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
|
package com.tommychow.frosty
|
||||||
|
|
||||||
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
|
import cl.puntito.simple_pip_mode.PipCallbackHelperActivityWrapper
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
|
||||||
class MainActivity: PipCallbackHelperActivityWrapper() {
|
class MainActivity: PipCallbackHelperActivityWrapper() {
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
flutterEngine.plugins.add(CookieExtractorPlugin())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ PODS:
|
||||||
- Flutter
|
- Flutter
|
||||||
- app_links (7.0.0):
|
- app_links (7.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- better_native_video_player (0.4.11):
|
||||||
|
- Flutter
|
||||||
- device_info_plus (0.0.1):
|
- device_info_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Firebase/CoreOnly (12.9.0):
|
- Firebase/CoreOnly (12.9.0):
|
||||||
|
|
@ -192,6 +194,7 @@ PODS:
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
|
- advanced_in_app_review (from `.symlinks/plugins/advanced_in_app_review/ios`)
|
||||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||||
|
- better_native_video_player (from `.symlinks/plugins/better_native_video_player/ios`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
||||||
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
|
||||||
|
|
@ -237,6 +240,8 @@ EXTERNAL SOURCES:
|
||||||
:path: ".symlinks/plugins/advanced_in_app_review/ios"
|
:path: ".symlinks/plugins/advanced_in_app_review/ios"
|
||||||
app_links:
|
app_links:
|
||||||
:path: ".symlinks/plugins/app_links/ios"
|
:path: ".symlinks/plugins/app_links/ios"
|
||||||
|
better_native_video_player:
|
||||||
|
:path: ".symlinks/plugins/better_native_video_player/ios"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||||
firebase_analytics:
|
firebase_analytics:
|
||||||
|
|
@ -273,6 +278,7 @@ EXTERNAL SOURCES:
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
|
advanced_in_app_review: fe8fd50b8670cb3dbe67e4c26ca643f7aea8dbb8
|
||||||
app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d
|
app_links: 6d01271b3907b0ee7325c5297c75d697c4226c4d
|
||||||
|
better_native_video_player: 88a6b9bf169428352c05b6ebec085c5263603282
|
||||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||||
Firebase: 065f2bb395062046623036d8e6dc857bc2521d56
|
Firebase: 065f2bb395062046623036d8e6dc857bc2521d56
|
||||||
firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa
|
firebase_analytics: b5c909bdc321fab0f9ddf8712f5cef79057e09fa
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
|
3985845DB766D700CF6E4C8E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E751BC99755AAC391DC0B29F /* Pods_Runner.framework */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
|
@ -38,6 +39,7 @@
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieExtractorPlugin.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
95D7E205EE04D01F6996ECFC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
|
@ -106,6 +108,7 @@
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
F1C00CE01A2B3C4D00E5F6A7 /* CookieExtractorPlugin.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
|
D4F5A60712340002ABCD5678 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
|
|
@ -304,6 +307,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
F1C00CE01A2B3C4D00E5F6A8 /* CookieExtractorPlugin.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import UIKit
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
CookieExtractorPlugin.register(with: self.registrar(forPlugin: "CookieExtractorPlugin")!)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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/>
|
<true/>
|
||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'package:frosty/models/followed_channel.dart';
|
||||||
import 'package:frosty/models/shared_chat_session.dart';
|
import 'package:frosty/models/shared_chat_session.dart';
|
||||||
import 'package:frosty/models/stream.dart';
|
import 'package:frosty/models/stream.dart';
|
||||||
import 'package:frosty/models/user.dart';
|
import 'package:frosty/models/user.dart';
|
||||||
|
import 'package:frosty/models/video.dart';
|
||||||
|
|
||||||
/// The Twitch service for making API calls.
|
/// The Twitch service for making API calls.
|
||||||
class TwitchApi extends BaseApiClient {
|
class TwitchApi extends BaseApiClient {
|
||||||
|
|
@ -205,6 +206,23 @@ class TwitchApi extends BaseApiClient {
|
||||||
return FollowedChannels.fromJson(data);
|
return FollowedChannels.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a [VideosTwitch] object containing past broadcasts for the given [userId].
|
||||||
|
Future<VideosTwitch> getVideos({
|
||||||
|
required String userId,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final queryParams = <String, String>{
|
||||||
|
'user_id': userId,
|
||||||
|
'first': '20',
|
||||||
|
'type': 'archive',
|
||||||
|
};
|
||||||
|
if (cursor != null) queryParams['after'] = cursor;
|
||||||
|
|
||||||
|
final data = await get<JsonMap>('/videos', queryParameters: queryParams);
|
||||||
|
|
||||||
|
return VideosTwitch.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
|
/// Returns a [StreamsTwitch] object that contains the list of streams under the given game/category ID.
|
||||||
Future<StreamsTwitch> getStreamsUnderCategory({
|
Future<StreamsTwitch> getStreamsUnderCategory({
|
||||||
required String gameId,
|
required String gameId,
|
||||||
|
|
|
||||||
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
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
// Check if this is a 401 Unauthorized error
|
// Check if this is a 401 Unauthorized error on a Twitch Helix/OAuth request
|
||||||
if (err.response?.statusCode == 401) {
|
if (err.response?.statusCode == 401 &&
|
||||||
|
_isTwitchHelixOrOAuth(err.requestOptions.uri)) {
|
||||||
// For token validation requests, let the error propagate so validateToken can handle it
|
// For token validation requests, let the error propagate so validateToken can handle it
|
||||||
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
if (err.requestOptions.uri.path.endsWith('/validate')) {
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
|
|
@ -89,4 +90,10 @@ class UnauthorizedInterceptor extends Interceptor {
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool _isDialogShowing = false;
|
static bool _isDialogShowing = false;
|
||||||
|
|
||||||
|
bool _isTwitchHelixOrOAuth(Uri uri) {
|
||||||
|
final url = uri.toString();
|
||||||
|
return url.startsWith('https://api.twitch.tv/helix') ||
|
||||||
|
url.startsWith('https://id.twitch.tv/oauth2');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,15 @@ import 'package:flutter/material.dart';
|
||||||
/// Twitch API client ID.
|
/// Twitch API client ID.
|
||||||
const clientId = String.fromEnvironment('CLIENT_ID');
|
const clientId = String.fromEnvironment('CLIENT_ID');
|
||||||
|
|
||||||
|
/// Twitch GQL client ID (web player, used for playback access tokens).
|
||||||
|
const twitchGqlClientId = 'kimne78kx3ncx6brgo4mv6wki5h1ko';
|
||||||
|
|
||||||
|
/// SharedPreferences key for the last-selected stream quality.
|
||||||
|
const kLastStreamQualityKey = 'last_stream_quality';
|
||||||
|
|
||||||
|
/// Twitch GQL mobile client ID (bypasses Client-Integrity checks, used for VOD chat).
|
||||||
|
const twitchGqlMobileClientId = 'kd1unb4b3q4t58fwlpcbzcbnm76a8fp';
|
||||||
|
|
||||||
/// Twitch API client secret.
|
/// Twitch API client secret.
|
||||||
const secret = String.fromEnvironment('SECRET');
|
const secret = String.fromEnvironment('SECRET');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import 'package:frosty/apis/ffz_api.dart';
|
||||||
import 'package:frosty/apis/seventv_api.dart';
|
import 'package:frosty/apis/seventv_api.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
import 'package:frosty/apis/twitch_auth_interceptor.dart';
|
import 'package:frosty/apis/twitch_auth_interceptor.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/apis/unauthorized_interceptor.dart';
|
import 'package:frosty/apis/unauthorized_interceptor.dart';
|
||||||
import 'package:frosty/cache_manager.dart';
|
import 'package:frosty/cache_manager.dart';
|
||||||
import 'package:frosty/firebase_options.dart';
|
import 'package:frosty/firebase_options.dart';
|
||||||
|
|
@ -104,6 +105,7 @@ void main() async {
|
||||||
final bttvApiService = BTTVApi(dioClient);
|
final bttvApiService = BTTVApi(dioClient);
|
||||||
final ffzApiService = FFZApi(dioClient);
|
final ffzApiService = FFZApi(dioClient);
|
||||||
final sevenTVApiService = SevenTVApi(dioClient);
|
final sevenTVApiService = SevenTVApi(dioClient);
|
||||||
|
final twitchGqlApiService = TwitchGqlApi(dioClient);
|
||||||
|
|
||||||
// Create global assets store (shared cache for global emotes/badges)
|
// Create global assets store (shared cache for global emotes/badges)
|
||||||
final globalAssetsStore = GlobalAssetsStore(
|
final globalAssetsStore = GlobalAssetsStore(
|
||||||
|
|
@ -137,6 +139,7 @@ void main() async {
|
||||||
Provider<BTTVApi>.value(value: bttvApiService),
|
Provider<BTTVApi>.value(value: bttvApiService),
|
||||||
Provider<FFZApi>.value(value: ffzApiService),
|
Provider<FFZApi>.value(value: ffzApiService),
|
||||||
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
Provider<SevenTVApi>.value(value: sevenTVApiService),
|
||||||
|
Provider<TwitchGqlApi>.value(value: twitchGqlApiService),
|
||||||
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
Provider<GlobalAssetsStore>.value(value: globalAssetsStore),
|
||||||
],
|
],
|
||||||
child: MyApp(firstRun: firstRun),
|
child: MyApp(firstRun: firstRun),
|
||||||
|
|
|
||||||
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/constants.dart';
|
||||||
import 'package:frosty/models/badges.dart';
|
import 'package:frosty/models/badges.dart';
|
||||||
import 'package:frosty/models/emotes.dart';
|
import 'package:frosty/models/emotes.dart';
|
||||||
|
import 'package:frosty/models/gql_chat_comment.dart';
|
||||||
import 'package:frosty/models/user.dart';
|
import 'package:frosty/models/user.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
|
|
@ -176,7 +177,9 @@ class IRCMessage {
|
||||||
size: badgeSize,
|
size: badgeSize,
|
||||||
color:
|
color:
|
||||||
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
|
Theme.of(context).iconTheme.color?.withValues(alpha: 0.5) ??
|
||||||
Colors.grey.withValues(alpha: 0.5),
|
Theme.of(context).colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1153,6 +1156,56 @@ class IRCMessage {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates an [IRCMessage] from a GQL VOD chat comment.
|
||||||
|
///
|
||||||
|
/// Maps GQL fields to IRC tags so [generateSpan] works identically
|
||||||
|
/// for both live and VOD chat messages.
|
||||||
|
factory IRCMessage.fromGqlComment(GqlChatComment comment) {
|
||||||
|
final tags = <String, String>{
|
||||||
|
'display-name': comment.commenterDisplayName ?? 'Deleted User',
|
||||||
|
'user-id': comment.commenterId ?? '',
|
||||||
|
'id': comment.id,
|
||||||
|
'tmi-sent-ts': '${comment.contentOffsetSeconds * 1000}',
|
||||||
|
if (comment.userColor != null) 'color': comment.userColor!,
|
||||||
|
if (comment.userBadges.isNotEmpty)
|
||||||
|
'badges': comment.userBadges
|
||||||
|
.map((b) => '${b.setId}/${b.version}')
|
||||||
|
.join(','),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reconstruct message text from fragments.
|
||||||
|
final messageText = comment.messageFragments
|
||||||
|
.map((f) => f.text ?? '')
|
||||||
|
.join();
|
||||||
|
|
||||||
|
// Build local emotes map from fragments that have Twitch emotes.
|
||||||
|
// generateSpan() will match these by name in the split words.
|
||||||
|
final localEmotes = <String, Emote>{};
|
||||||
|
for (final fragment in comment.messageFragments) {
|
||||||
|
if (fragment.emote != null && fragment.text != null) {
|
||||||
|
localEmotes[fragment.text!] = Emote(
|
||||||
|
name: fragment.text!,
|
||||||
|
zeroWidth: false,
|
||||||
|
url:
|
||||||
|
'$_twitchEmoteBaseUrl/${fragment.emote!.emoteId}$_emoteModeSuffix',
|
||||||
|
type: EmoteType.twitchSub,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return IRCMessage(
|
||||||
|
raw: '',
|
||||||
|
command: Command.privateMessage,
|
||||||
|
tags: tags,
|
||||||
|
user: comment.commenterLogin,
|
||||||
|
localEmotes: localEmotes.isNotEmpty ? localEmotes : null,
|
||||||
|
message: messageText,
|
||||||
|
split: messageText.split(' ').where((w) => w.isNotEmpty).toList(),
|
||||||
|
action: false,
|
||||||
|
mention: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
factory IRCMessage.createNotice({
|
factory IRCMessage.createNotice({
|
||||||
required String message,
|
required String message,
|
||||||
VoidCallback? actionCallback,
|
VoidCallback? actionCallback,
|
||||||
|
|
|
||||||
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_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_tabs_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_tabs.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/native_video.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
||||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||||
import 'package:frosty/screens/channel/video/video.dart';
|
import 'package:frosty/screens/channel/video/video.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_overlay.dart';
|
import 'package:frosty/screens/channel/video/video_overlay.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
import 'package:frosty/screens/channel/video/video_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/theme.dart';
|
import 'package:frosty/theme.dart';
|
||||||
|
|
@ -75,13 +78,23 @@ class _VideoChatState extends State<VideoChat>
|
||||||
primaryDisplayName: widget.userName,
|
primaryDisplayName: widget.userName,
|
||||||
);
|
);
|
||||||
|
|
||||||
late final VideoStore _videoStore = VideoStore(
|
late final VideoPlayerInterface _videoStore =
|
||||||
userLogin: widget.userLogin,
|
context.settingsStore.useNativePlayer
|
||||||
userId: widget.userId,
|
? NativeVideoStore(
|
||||||
twitchApi: context.twitchApi,
|
userLogin: widget.userLogin,
|
||||||
authStore: context.authStore,
|
userId: widget.userId,
|
||||||
settingsStore: context.settingsStore,
|
twitchApi: context.twitchApi,
|
||||||
);
|
twitchGqlApi: context.twitchGqlApi,
|
||||||
|
authStore: context.authStore,
|
||||||
|
settingsStore: context.settingsStore,
|
||||||
|
)
|
||||||
|
: VideoStore(
|
||||||
|
userLogin: widget.userLogin,
|
||||||
|
userId: widget.userId,
|
||||||
|
twitchApi: context.twitchApi,
|
||||||
|
authStore: context.authStore,
|
||||||
|
settingsStore: context.settingsStore,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -297,7 +310,12 @@ class _VideoChatState extends State<VideoChat>
|
||||||
|
|
||||||
final player = GestureDetector(
|
final player = GestureDetector(
|
||||||
onLongPress: _videoStore.handleToggleOverlay,
|
onLongPress: _videoStore.handleToggleOverlay,
|
||||||
child: Video(key: _videoKey, videoStore: _videoStore),
|
child: _videoStore is NativeVideoStore
|
||||||
|
? NativeVideo(nativeVideoStore: _videoStore)
|
||||||
|
: Video(
|
||||||
|
key: _videoKey,
|
||||||
|
videoStore: _videoStore as VideoStore,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final overlay = GestureDetector(
|
final overlay = GestureDetector(
|
||||||
|
|
@ -318,6 +336,10 @@ class _VideoChatState extends State<VideoChat>
|
||||||
},
|
},
|
||||||
child: Observer(
|
child: Observer(
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
|
if (_videoStore.loading) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
final videoOverlay = VideoOverlay(
|
final videoOverlay = VideoOverlay(
|
||||||
videoStore: _videoStore,
|
videoStore: _videoStore,
|
||||||
chatStore: _chatStore,
|
chatStore: _chatStore,
|
||||||
|
|
@ -349,6 +371,28 @@ class _VideoChatState extends State<VideoChat>
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
player,
|
player,
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _videoStore.loading ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: const IgnorePointer(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white38,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (_videoStore.settingsStore.showOverlay) overlay,
|
if (_videoStore.settingsStore.showOverlay) overlay,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -683,7 +727,7 @@ class _VideoChatState extends State<VideoChat>
|
||||||
if (settingsStore.showVideo) ...[
|
if (settingsStore.showVideo) ...[
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
child: Container(), // Placeholder for video space
|
child: SizedBox(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
Expanded(child: chat),
|
Expanded(child: chat),
|
||||||
|
|
|
||||||
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_modes.dart';
|
||||||
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||||
|
import 'package:frosty/screens/channel/vod/vod_list.dart';
|
||||||
import 'package:frosty/screens/settings/settings.dart';
|
import 'package:frosty/screens/settings/settings.dart';
|
||||||
import 'package:frosty/utils.dart';
|
import 'package:frosty/utils.dart';
|
||||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||||
|
|
@ -371,6 +372,20 @@ class _ChatDetailsState extends State<ChatDetails> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.video_library_rounded),
|
||||||
|
title: const Text('Past broadcasts'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => VodList(
|
||||||
|
userId: widget.chatStore.channelId,
|
||||||
|
displayName: widget.chatStore.displayName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.add_comment_rounded),
|
leading: const Icon(Icons.add_comment_rounded),
|
||||||
title: const Text('Add chat'),
|
title: const Text('Add chat'),
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ class ChatModes extends StatelessWidget {
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurfaceVariant,
|
).colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:frosty/models/badges.dart';
|
||||||
import 'package:frosty/models/emotes.dart';
|
import 'package:frosty/models/emotes.dart';
|
||||||
import 'package:frosty/models/events.dart';
|
import 'package:frosty/models/events.dart';
|
||||||
import 'package:frosty/models/irc.dart';
|
import 'package:frosty/models/irc.dart';
|
||||||
|
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
|
||||||
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
|
import 'package:frosty/screens/channel/chat/details/chat_details_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_assets_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
|
|
@ -22,7 +23,7 @@ part 'chat_store.g.dart';
|
||||||
/// The store and view-model for chat-related activities.
|
/// The store and view-model for chat-related activities.
|
||||||
class ChatStore = ChatStoreBase with _$ChatStore;
|
class ChatStore = ChatStoreBase with _$ChatStore;
|
||||||
|
|
||||||
abstract class ChatStoreBase with Store {
|
abstract class ChatStoreBase with Store implements ChatRenderContext {
|
||||||
/// The total maximum amount of messages in chat.
|
/// The total maximum amount of messages in chat.
|
||||||
static const _messageLimit = 5000;
|
static const _messageLimit = 5000;
|
||||||
|
|
||||||
|
|
@ -99,9 +100,11 @@ abstract class ChatStoreBase with Store {
|
||||||
final _messagesToRemove = (_messageLimit * 0.2).toInt();
|
final _messagesToRemove = (_messageLimit * 0.2).toInt();
|
||||||
|
|
||||||
/// The provided auth store to determine login status, get the token, and use the headers for requests.
|
/// The provided auth store to determine login status, get the token, and use the headers for requests.
|
||||||
|
@override
|
||||||
final AuthStore auth;
|
final AuthStore auth;
|
||||||
|
|
||||||
/// The provided setting store to account for any user-defined behaviors.
|
/// The provided setting store to account for any user-defined behaviors.
|
||||||
|
@override
|
||||||
final SettingsStore settings;
|
final SettingsStore settings;
|
||||||
|
|
||||||
/// The focus node for the textfield to allow for showing and hiding the keyboard/focus.
|
/// The focus node for the textfield to allow for showing and hiding the keyboard/focus.
|
||||||
|
|
@ -111,6 +114,7 @@ abstract class ChatStoreBase with Store {
|
||||||
final String channelName;
|
final String channelName;
|
||||||
|
|
||||||
/// The channel's ID for API requests.
|
/// The channel's ID for API requests.
|
||||||
|
@override
|
||||||
final String channelId;
|
final String channelId;
|
||||||
|
|
||||||
/// The channel's display name to show on widgets.
|
/// The channel's display name to show on widgets.
|
||||||
|
|
@ -153,6 +157,7 @@ abstract class ChatStoreBase with Store {
|
||||||
final ChatDetailsStore chatDetailsStore;
|
final ChatDetailsStore chatDetailsStore;
|
||||||
|
|
||||||
/// The assets store responsible for badges, emotes, and the emote menu.
|
/// The assets store responsible for badges, emotes, and the emote menu.
|
||||||
|
@override
|
||||||
final ChatAssetsStore assetsStore;
|
final ChatAssetsStore assetsStore;
|
||||||
|
|
||||||
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
|
/// Requested message to be sent by the user. Will only be sent on receipt of a USERNOTICE command.
|
||||||
|
|
@ -175,8 +180,10 @@ abstract class ChatStoreBase with Store {
|
||||||
final _pendingSevenTVCallbacks = <_DelayedCallback>[];
|
final _pendingSevenTVCallbacks = <_DelayedCallback>[];
|
||||||
|
|
||||||
/// The set of message IDs that have been revealed by the user (for deleted messages).
|
/// The set of message IDs that have been revealed by the user (for deleted messages).
|
||||||
|
@override
|
||||||
final revealedMessageIds = ObservableSet<String>();
|
final revealedMessageIds = ObservableSet<String>();
|
||||||
|
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
void revealMessage(String id) {
|
void revealMessage(String id) {
|
||||||
revealedMessageIds.add(id);
|
revealedMessageIds.add(id);
|
||||||
|
|
@ -1269,6 +1276,7 @@ abstract class ChatStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage].
|
/// Cancels the previous notification/timer and creates a new one with the provided [notificationMessage].
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
void updateNotification(String notificationMessage) {
|
void updateNotification(String notificationMessage) {
|
||||||
// Cancel the previous notification to prevent the notification from phasing in and out
|
// Cancel the previous notification to prevent the notification from phasing in and out
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:frosty/apis/twitch_api.dart';
|
||||||
import 'package:frosty/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/models/irc.dart';
|
import 'package:frosty/models/irc.dart';
|
||||||
import 'package:frosty/models/user.dart';
|
import 'package:frosty/models/user.dart';
|
||||||
|
import 'package:frosty/screens/channel/chat/chat_render_context.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
|
import 'package:frosty/screens/channel/chat/widgets/chat_user_modal.dart';
|
||||||
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
|
import 'package:frosty/screens/channel/chat/widgets/reply_thread.dart';
|
||||||
|
|
@ -15,7 +16,7 @@ import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class ChatMessage extends StatelessWidget {
|
class ChatMessage extends StatelessWidget {
|
||||||
final IRCMessage ircMessage;
|
final IRCMessage ircMessage;
|
||||||
final ChatStore chatStore;
|
final ChatRenderContext chatStore;
|
||||||
final bool isModal;
|
final bool isModal;
|
||||||
final bool showReplyHeader;
|
final bool showReplyHeader;
|
||||||
final bool isInReplyThread;
|
final bool isInReplyThread;
|
||||||
|
|
@ -51,6 +52,9 @@ class ChatMessage extends StatelessWidget {
|
||||||
// Ignore if the message is a recent message in the modal bottom sheet.
|
// Ignore if the message is a recent message in the modal bottom sheet.
|
||||||
if (isModal) return;
|
if (isModal) return;
|
||||||
|
|
||||||
|
// User modal requires live ChatStore (recent messages, blocking, etc.).
|
||||||
|
if (chatStore is! ChatStore) return;
|
||||||
|
|
||||||
// Ignore if long-pressing own username.
|
// Ignore if long-pressing own username.
|
||||||
if (ircMessage.user == null ||
|
if (ircMessage.user == null ||
|
||||||
ircMessage.user == chatStore.auth.user.details?.login) {
|
ircMessage.user == chatStore.auth.user.details?.login) {
|
||||||
|
|
@ -61,7 +65,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => ChatUserModal(
|
builder: (context) => ChatUserModal(
|
||||||
chatStore: chatStore,
|
chatStore: chatStore as ChatStore,
|
||||||
username: ircMessage.user!,
|
username: ircMessage.user!,
|
||||||
userId: ircMessage.tags['user-id']!,
|
userId: ircMessage.tags['user-id']!,
|
||||||
displayName: ircMessage.tags['display-name']!,
|
displayName: ircMessage.tags['display-name']!,
|
||||||
|
|
@ -73,6 +77,9 @@ class ChatMessage extends StatelessWidget {
|
||||||
// Ignore if the message is a recent message in the modal bottom sheet.
|
// Ignore if the message is a recent message in the modal bottom sheet.
|
||||||
if (isModal) return;
|
if (isModal) return;
|
||||||
|
|
||||||
|
// User modal requires live ChatStore.
|
||||||
|
if (chatStore is! ChatStore) return;
|
||||||
|
|
||||||
final twitchApi = context.read<TwitchApi>();
|
final twitchApi = context.read<TwitchApi>();
|
||||||
twitchApi.getUser(userLogin: nickname).then((user) {
|
twitchApi.getUser(userLogin: nickname).then((user) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|
@ -80,7 +87,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => ChatUserModal(
|
builder: (context) => ChatUserModal(
|
||||||
chatStore: chatStore,
|
chatStore: chatStore as ChatStore,
|
||||||
username: user.login,
|
username: user.login,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
|
|
@ -91,7 +98,7 @@ class ChatMessage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The store to use for notifications and emote menu in merged mode.
|
/// The store to use for notifications and emote menu in merged mode.
|
||||||
ChatStore get _effectiveInputStore => inputChatStore ?? chatStore;
|
ChatRenderContext get _effectiveInputStore => inputChatStore ?? chatStore;
|
||||||
|
|
||||||
Future<void> copyMessage() async {
|
Future<void> copyMessage() async {
|
||||||
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
|
await Clipboard.setData(ClipboardData(text: ircMessage.message ?? ''));
|
||||||
|
|
@ -144,53 +151,58 @@ class ChatMessage extends StatelessWidget {
|
||||||
leading: const Icon(Icons.copy),
|
leading: const Icon(Icons.copy),
|
||||||
title: const Text('Copy message'),
|
title: const Text('Copy message'),
|
||||||
),
|
),
|
||||||
ListTile(
|
if (chatStore is ChatStore) ...[
|
||||||
onTap: () async {
|
ListTile(
|
||||||
await copyMessage();
|
onTap: () async {
|
||||||
|
await copyMessage();
|
||||||
|
final liveChatStore = chatStore as ChatStore;
|
||||||
|
|
||||||
final effectiveChatDelay = chatStore.settings.effectiveChatDelay;
|
final effectiveChatDelay =
|
||||||
|
liveChatStore.settings.effectiveChatDelay;
|
||||||
|
|
||||||
final hasChatDelay =
|
final hasChatDelay =
|
||||||
chatStore.settings.showVideo && effectiveChatDelay > 0;
|
liveChatStore.settings.showVideo && effectiveChatDelay > 0;
|
||||||
|
|
||||||
if (hasChatDelay) {
|
if (hasChatDelay) {
|
||||||
_effectiveInputStore.updateNotification(
|
_effectiveInputStore.updateNotification(
|
||||||
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
|
'Chatting is disabled due to message delay (${effectiveChatDelay.toInt()}s)',
|
||||||
);
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In merged mode, switch to the source tab so the paste
|
||||||
|
// targets the correct channel's composer.
|
||||||
|
onActivateSourceTab?.call();
|
||||||
|
|
||||||
|
// Paste the copied message into the text controller
|
||||||
|
liveChatStore.textController.text = ircMessage.message ?? '';
|
||||||
|
liveChatStore.safeRequestFocus();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
return;
|
},
|
||||||
}
|
leading: const Icon(Icons.content_paste),
|
||||||
|
title: const Text('Copy message and paste'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
onTap: () {
|
||||||
|
final liveChatStore = chatStore as ChatStore;
|
||||||
|
// In merged mode, switch to the source tab so the reply
|
||||||
|
// targets the correct channel (reply-parent-msg-id is
|
||||||
|
// channel-scoped).
|
||||||
|
onActivateSourceTab?.call();
|
||||||
|
|
||||||
// In merged mode, switch to the source tab so the paste
|
liveChatStore.replyingToMessage = ircMessage;
|
||||||
// targets the correct channel's composer.
|
liveChatStore.safeRequestFocus();
|
||||||
onActivateSourceTab?.call();
|
|
||||||
|
|
||||||
// Paste the copied message into the text controller
|
|
||||||
chatStore.textController.text = ircMessage.message ?? '';
|
|
||||||
chatStore.safeRequestFocus();
|
|
||||||
if (context.mounted) {
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
},
|
||||||
},
|
leading: const Icon(Icons.reply),
|
||||||
leading: const Icon(Icons.content_paste),
|
title: const Text('Reply to message'),
|
||||||
title: const Text('Copy message and paste'),
|
),
|
||||||
),
|
],
|
||||||
ListTile(
|
|
||||||
onTap: () {
|
|
||||||
// In merged mode, switch to the source tab so the reply
|
|
||||||
// targets the correct channel (reply-parent-msg-id is
|
|
||||||
// channel-scoped).
|
|
||||||
onActivateSourceTab?.call();
|
|
||||||
|
|
||||||
chatStore.replyingToMessage = ircMessage;
|
|
||||||
chatStore.safeRequestFocus();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
leading: const Icon(Icons.reply),
|
|
||||||
title: const Text('Reply to message'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -255,14 +267,14 @@ class ChatMessage extends StatelessWidget {
|
||||||
color: messageHeaderTextColor,
|
color: messageHeaderTextColor,
|
||||||
);
|
);
|
||||||
messageHeader = GestureDetector(
|
messageHeader = GestureDetector(
|
||||||
onTap: isModal
|
onTap: isModal || chatStore is! ChatStore
|
||||||
? null
|
? null
|
||||||
: () => showModalBottomSheetWithProperFocus(
|
: () => showModalBottomSheetWithProperFocus(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return ReplyThread(
|
return ReplyThread(
|
||||||
selectedMessage: ircMessage,
|
selectedMessage: ircMessage,
|
||||||
chatStore: chatStore,
|
chatStore: chatStore as ChatStore,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -482,10 +494,14 @@ class ChatMessage extends StatelessWidget {
|
||||||
messageHeaderIcon = Icon(
|
messageHeaderIcon = Icon(
|
||||||
Icons.campaign_rounded,
|
Icons.campaign_rounded,
|
||||||
size: messageHeaderIconSize,
|
size: messageHeaderIconSize,
|
||||||
|
color: messageHeaderTextColor,
|
||||||
);
|
);
|
||||||
messageHeader = const Text(
|
messageHeader = Text(
|
||||||
'Announcement',
|
'Announcement',
|
||||||
style: TextStyle(fontWeight: messageHeaderFontWeight),
|
style: TextStyle(
|
||||||
|
fontWeight: messageHeaderFontWeight,
|
||||||
|
color: messageHeaderTextColor,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class ReplyThread extends StatelessWidget {
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$replyName: $replyBody',
|
'$replyName: $replyBody',
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Flexible(
|
Flexible(
|
||||||
|
|
|
||||||
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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
import 'package:frosty/screens/channel/chat/details/chat_users_list.dart';
|
||||||
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
import 'package:frosty/screens/channel/chat/stores/chat_store.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/native_video_store.dart';
|
||||||
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
import 'package:frosty/screens/channel/video/stream_info_bar.dart';
|
||||||
import 'package:frosty/screens/channel/video/video_store.dart';
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/theme.dart';
|
import 'package:frosty/theme.dart';
|
||||||
import 'package:frosty/utils.dart';
|
import 'package:frosty/utils.dart';
|
||||||
|
|
@ -21,7 +23,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
|
/// Creates a widget containing controls which enable interactions with an underlying [Video] widget.
|
||||||
class VideoOverlay extends StatelessWidget {
|
class VideoOverlay extends StatelessWidget {
|
||||||
final VideoStore videoStore;
|
final VideoPlayerInterface videoStore;
|
||||||
final ChatStore chatStore;
|
final ChatStore chatStore;
|
||||||
final SettingsStore settingsStore;
|
final SettingsStore settingsStore;
|
||||||
|
|
||||||
|
|
@ -108,7 +110,7 @@ class VideoOverlay extends StatelessWidget {
|
||||||
videoStore.setStreamQuality(quality);
|
videoStore.setStreamQuality(quality);
|
||||||
SharedPreferences.getInstance().then(
|
SharedPreferences.getInstance().then(
|
||||||
(prefs) => prefs.setString(
|
(prefs) => prefs.setString(
|
||||||
'last_stream_quality',
|
kLastStreamQualityKey,
|
||||||
quality,
|
quality,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -259,7 +261,6 @@ class VideoOverlay extends StatelessWidget {
|
||||||
displayName: chatStore.displayName,
|
displayName: chatStore.displayName,
|
||||||
showUptime: false,
|
showUptime: false,
|
||||||
showViewerCount: false,
|
showViewerCount: false,
|
||||||
showOfflineIndicator: false,
|
|
||||||
textColor: surfaceColor,
|
textColor: surfaceColor,
|
||||||
isOffline: true,
|
isOffline: true,
|
||||||
isInSharedChatMode:
|
isInSharedChatMode:
|
||||||
|
|
@ -344,7 +345,10 @@ class VideoOverlay extends StatelessWidget {
|
||||||
if (videoStore.settingsStore.fullScreen &&
|
if (videoStore.settingsStore.fullScreen &&
|
||||||
context.isLandscape)
|
context.isLandscape)
|
||||||
chatOverlayButton,
|
chatOverlayButton,
|
||||||
if (!Platform.isIOS || isIPad()) videoSettingsButton,
|
if (!Platform.isIOS ||
|
||||||
|
isIPad() ||
|
||||||
|
videoStore is NativeVideoStore)
|
||||||
|
videoSettingsButton,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
|
|
|
||||||
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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/models/channel.dart';
|
import 'package:frosty/models/channel.dart';
|
||||||
import 'package:frosty/models/stream.dart';
|
import 'package:frosty/models/stream.dart';
|
||||||
|
import 'package:frosty/screens/channel/video/video_player_interface.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
|
|
@ -20,7 +22,7 @@ part 'video_store.g.dart';
|
||||||
|
|
||||||
class VideoStore = VideoStoreBase with _$VideoStore;
|
class VideoStore = VideoStoreBase with _$VideoStore;
|
||||||
|
|
||||||
abstract class VideoStoreBase with Store {
|
abstract class VideoStoreBase with Store implements VideoPlayerInterface {
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
|
||||||
/// The userlogin of the current channel.
|
/// The userlogin of the current channel.
|
||||||
|
|
@ -31,6 +33,7 @@ abstract class VideoStoreBase with Store {
|
||||||
|
|
||||||
final AuthStore authStore;
|
final AuthStore authStore;
|
||||||
|
|
||||||
|
@override
|
||||||
final SettingsStore settingsStore;
|
final SettingsStore settingsStore;
|
||||||
|
|
||||||
/// The [SimplePip] instance used for initiating PiP on Android.
|
/// The [SimplePip] instance used for initiating PiP on Android.
|
||||||
|
|
@ -86,7 +89,7 @@ abstract class VideoStoreBase with Store {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final lastStreamQuality = prefs.getString('last_stream_quality');
|
final lastStreamQuality = prefs.getString(kLastStreamQualityKey);
|
||||||
if (lastStreamQuality == null) return;
|
if (lastStreamQuality == null) return;
|
||||||
setStreamQuality(lastStreamQuality);
|
setStreamQuality(lastStreamQuality);
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +105,7 @@ abstract class VideoStoreBase with Store {
|
||||||
..addJavaScriptChannel(
|
..addJavaScriptChannel(
|
||||||
'VideoPlaying',
|
'VideoPlaying',
|
||||||
onMessageReceived: (message) {
|
onMessageReceived: (message) {
|
||||||
|
_loading = false;
|
||||||
_paused = false;
|
_paused = false;
|
||||||
if (Platform.isAndroid) pip.setIsPlaying(true);
|
if (Platform.isAndroid) pip.setIsPlaying(true);
|
||||||
},
|
},
|
||||||
|
|
@ -169,6 +173,9 @@ abstract class VideoStoreBase with Store {
|
||||||
/// Disposes the latency settings reaction.
|
/// Disposes the latency settings reaction.
|
||||||
ReactionDisposer? _disposeLatencySettingsReaction;
|
ReactionDisposer? _disposeLatencySettingsReaction;
|
||||||
|
|
||||||
|
@readonly
|
||||||
|
var _loading = true;
|
||||||
|
|
||||||
/// If the video is currently paused.
|
/// If the video is currently paused.
|
||||||
///
|
///
|
||||||
/// Does not pause or play the video, only used for rendering state of the overlay.
|
/// Does not pause or play the video, only used for rendering state of the overlay.
|
||||||
|
|
@ -195,6 +202,7 @@ abstract class VideoStoreBase with Store {
|
||||||
int _streamQualityIndex = 0;
|
int _streamQualityIndex = 0;
|
||||||
|
|
||||||
// The current stream quality string
|
// The current stream quality string
|
||||||
|
@override
|
||||||
String get streamQuality =>
|
String get streamQuality =>
|
||||||
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
|
_availableStreamQualities.elementAtOrNull(_streamQualityIndex) ?? 'Auto';
|
||||||
|
|
||||||
|
|
@ -343,6 +351,7 @@ abstract class VideoStoreBase with Store {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
Future<void> updateStreamQualities() async {
|
Future<void> updateStreamQualities() async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -382,6 +391,7 @@ abstract class VideoStoreBase with Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
Future<void> setStreamQuality(String newStreamQuality) async {
|
Future<void> setStreamQuality(String newStreamQuality) async {
|
||||||
final indexOfStreamQuality = _availableStreamQualities.indexOf(
|
final indexOfStreamQuality = _availableStreamQualities.indexOf(
|
||||||
|
|
@ -842,6 +852,7 @@ abstract class VideoStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called whenever the video/overlay is tapped.
|
/// Called whenever the video/overlay is tapped.
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
void handleVideoTap() {
|
void handleVideoTap() {
|
||||||
if (_isInPipMode) {
|
if (_isInPipMode) {
|
||||||
|
|
@ -918,6 +929,7 @@ abstract class VideoStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles app resume event for immediate stream info refresh in chat-only mode.
|
/// Handles app resume event for immediate stream info refresh in chat-only mode.
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
void handleAppResume() {
|
void handleAppResume() {
|
||||||
// Only refresh immediately in chat-only mode
|
// Only refresh immediately in chat-only mode
|
||||||
|
|
@ -930,6 +942,7 @@ abstract class VideoStoreBase with Store {
|
||||||
///
|
///
|
||||||
/// If the stream is offline, fetches channel information to show offline details.
|
/// If the stream is offline, fetches channel information to show offline details.
|
||||||
/// Set [forceUpdate] to true to bypass the rate limiting check.
|
/// Set [forceUpdate] to true to bypass the rate limiting check.
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
Future<void> updateStreamInfo({bool forceUpdate = false}) async {
|
Future<void> updateStreamInfo({bool forceUpdate = false}) async {
|
||||||
if (_streamInfoRequest != null) {
|
if (_streamInfoRequest != null) {
|
||||||
|
|
@ -967,6 +980,7 @@ abstract class VideoStoreBase with Store {
|
||||||
_offlineChannelInfo = null;
|
_offlineChannelInfo = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_overlayTimer?.cancel();
|
_overlayTimer?.cancel();
|
||||||
|
_loading = false;
|
||||||
_streamInfo = null;
|
_streamInfo = null;
|
||||||
_paused = true;
|
_paused = true;
|
||||||
|
|
||||||
|
|
@ -987,6 +1001,7 @@ abstract class VideoStoreBase with Store {
|
||||||
/// Handles the toggle overlay options.
|
/// Handles the toggle overlay options.
|
||||||
///
|
///
|
||||||
/// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay.
|
/// The toggle overlay option allows switching between the custom and Twitch's overlay by long-pressing the overlay.
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
void handleToggleOverlay() {
|
void handleToggleOverlay() {
|
||||||
if (settingsStore.toggleableOverlay) {
|
if (settingsStore.toggleableOverlay) {
|
||||||
|
|
@ -1004,9 +1019,11 @@ abstract class VideoStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refreshes the stream webview and updates the stream info.
|
/// Refreshes the stream webview and updates the stream info.
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
Future<void> handleRefresh() async {
|
Future<void> handleRefresh() async {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
_loading = true;
|
||||||
_paused = true;
|
_paused = true;
|
||||||
_firstTimeSettingQuality = true;
|
_firstTimeSettingQuality = true;
|
||||||
_isInPipMode = false;
|
_isInPipMode = false;
|
||||||
|
|
@ -1028,6 +1045,7 @@ abstract class VideoStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play or pause the video depending on the current state of [_paused].
|
/// Play or pause the video depending on the current state of [_paused].
|
||||||
|
@override
|
||||||
void handlePausePlay() {
|
void handlePausePlay() {
|
||||||
try {
|
try {
|
||||||
if (_paused) {
|
if (_paused) {
|
||||||
|
|
@ -1048,6 +1066,7 @@ abstract class VideoStoreBase with Store {
|
||||||
///
|
///
|
||||||
/// On Android, this will utilize the native Android PiP API.
|
/// On Android, this will utilize the native Android PiP API.
|
||||||
/// On iOS, this will utilize the web picture-in-picture API.
|
/// On iOS, this will utilize the web picture-in-picture API.
|
||||||
|
@override
|
||||||
void requestPictureInPicture() {
|
void requestPictureInPicture() {
|
||||||
try {
|
try {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
|
|
@ -1067,6 +1086,7 @@ abstract class VideoStoreBase with Store {
|
||||||
/// If not in PiP mode, enters PiP mode.
|
/// If not in PiP mode, enters PiP mode.
|
||||||
/// If already in PiP mode on iOS, exits PiP mode.
|
/// If already in PiP mode on iOS, exits PiP mode.
|
||||||
/// On Android, always enters PiP mode (no programmatic exit or state tracking).
|
/// On Android, always enters PiP mode (no programmatic exit or state tracking).
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
void togglePictureInPicture() {
|
void togglePictureInPicture() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1088,6 +1108,7 @@ abstract class VideoStoreBase with Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
@action
|
@action
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Disable auto PiP when leaving so that we don't enter PiP on other screens.
|
// Disable auto PiP when leaving so that we don't enter PiP on other screens.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,26 @@ part of 'video_store.dart';
|
||||||
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
|
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic, no_leading_underscores_for_local_identifiers
|
||||||
|
|
||||||
mixin _$VideoStore on VideoStoreBase, Store {
|
mixin _$VideoStore on VideoStoreBase, Store {
|
||||||
|
late final _$_loadingAtom = Atom(
|
||||||
|
name: 'VideoStoreBase._loading',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get loading {
|
||||||
|
_$_loadingAtom.reportRead();
|
||||||
|
return super._loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get _loading => loading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _loading(bool value) {
|
||||||
|
_$_loadingAtom.reportWrite(value, super._loading, () {
|
||||||
|
super._loading = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$_pausedAtom = Atom(
|
late final _$_pausedAtom = Atom(
|
||||||
name: 'VideoStoreBase._paused',
|
name: 'VideoStoreBase._paused',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
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(
|
NavigationDestination(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.favorite_border_rounded,
|
Icons.favorite_border_rounded,
|
||||||
color: _homeStore.selectedIndex == 0
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
? theme.colorScheme.onSurface
|
.withValues(alpha: 0.5),
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
Icons.favorite_rounded,
|
Icons.favorite_rounded,
|
||||||
|
|
@ -180,11 +179,8 @@ class _HomeState extends State<Home> {
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.arrow_upward_rounded,
|
Icons.arrow_upward_rounded,
|
||||||
color:
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
_homeStore.selectedIndex ==
|
.withValues(alpha: 0.5),
|
||||||
(_authStore.isLoggedIn ? 1 : 0)
|
|
||||||
? theme.colorScheme.onSurface
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
Icons.arrow_upward_rounded,
|
Icons.arrow_upward_rounded,
|
||||||
|
|
@ -196,11 +192,8 @@ class _HomeState extends State<Home> {
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.search_rounded,
|
Icons.search_rounded,
|
||||||
color:
|
color: theme.colorScheme.onSurfaceVariant
|
||||||
_homeStore.selectedIndex ==
|
.withValues(alpha: 0.5),
|
||||||
(_authStore.isLoggedIn ? 2 : 1)
|
|
||||||
? theme.colorScheme.onSurface
|
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
selectedIcon: Icon(
|
selectedIcon: Icon(
|
||||||
Icons.search_rounded,
|
Icons.search_rounded,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:frosty/screens/onboarding/login_webview.dart';
|
||||||
import 'package:frosty/screens/settings/account/account_options.dart';
|
import 'package:frosty/screens/settings/account/account_options.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
import 'package:frosty/utils/modal_bottom_sheet.dart';
|
||||||
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
import 'package:frosty/widgets/profile_picture.dart';
|
import 'package:frosty/widgets/profile_picture.dart';
|
||||||
|
|
||||||
class ProfileCard extends StatelessWidget {
|
class ProfileCard extends StatelessWidget {
|
||||||
|
|
@ -39,12 +40,69 @@ class ProfileCard extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (authStore.isLoggedIn && authStore.user.details != null) {
|
if (authStore.isLoggedIn && authStore.user.details != null) {
|
||||||
|
final hasToken = authStore.gqlToken != null;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: ProfilePicture(
|
leading: ProfilePicture(
|
||||||
userLogin: authStore.user.details!.login,
|
userLogin: authStore.user.details!.login,
|
||||||
radius: 12,
|
radius: 12,
|
||||||
),
|
),
|
||||||
title: Text(authStore.user.details!.displayName),
|
title: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => FrostyDialog(
|
||||||
|
title: 'Web session',
|
||||||
|
message: hasToken
|
||||||
|
? 'Your Twitch web session is linked. When using the native player, ads will be avoided on channels where you have a subscription or Twitch Turbo.'
|
||||||
|
: 'Your Twitch web session is not linked. Log in again to avoid ads when using the native player on channels where you have a subscription or Twitch Turbo.',
|
||||||
|
actions: [
|
||||||
|
if (!hasToken)
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
if (!hasToken)
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => LoginWebView(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Log in'),
|
||||||
|
),
|
||||||
|
if (hasToken)
|
||||||
|
TextButton(
|
||||||
|
onPressed: Navigator.of(context).pop,
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
text: authStore.user.details!.displayName,
|
||||||
|
children: [
|
||||||
|
const WidgetSpan(child: SizedBox(width: 6)),
|
||||||
|
WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.middle,
|
||||||
|
child: Icon(
|
||||||
|
hasToken
|
||||||
|
? Icons.check_circle_outline_rounded
|
||||||
|
: Icons.info_outline_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: hasToken ? Colors.green : Colors.amber,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
trailing: const Icon(Icons.chevron_right_rounded),
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
onTap: () => _showAccountOptionsModalBottomSheet(context),
|
onTap: () => _showAccountOptionsModalBottomSheet(context),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,347 +1,369 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_mobx/flutter_mobx.dart';
|
import 'package:flutter_mobx/flutter_mobx.dart';
|
||||||
import 'package:frosty/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
|
import 'package:frosty/screens/settings/widgets/settings_list_select.dart';
|
||||||
import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
|
import 'package:frosty/screens/settings/widgets/settings_list_slider.dart';
|
||||||
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
import 'package:frosty/screens/settings/widgets/settings_list_switch.dart';
|
||||||
import 'package:frosty/screens/settings/widgets/settings_muted_words.dart';
|
import 'package:frosty/screens/settings/widgets/settings_muted_words.dart';
|
||||||
import 'package:frosty/utils/context_extensions.dart';
|
import 'package:frosty/utils/context_extensions.dart';
|
||||||
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
import 'package:frosty/widgets/frosty_cached_network_image.dart';
|
||||||
import 'package:frosty/widgets/section_header.dart';
|
import 'package:frosty/widgets/section_header.dart';
|
||||||
import 'package:frosty/widgets/settings_page_layout.dart';
|
import 'package:frosty/widgets/settings_page_layout.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class ChatSettings extends StatefulWidget {
|
class ChatSettings extends StatelessWidget {
|
||||||
final SettingsStore settingsStore;
|
final SettingsStore settingsStore;
|
||||||
|
|
||||||
const ChatSettings({super.key, required this.settingsStore});
|
const ChatSettings({super.key, required this.settingsStore});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatSettings> createState() => _ChatSettingsState();
|
Widget build(BuildContext context) {
|
||||||
}
|
return Observer(
|
||||||
|
builder: (context) => SettingsPageLayout(
|
||||||
class _ChatSettingsState extends State<ChatSettings> {
|
children: [
|
||||||
var showPreview = false;
|
const SectionHeader('General', isFirst: true),
|
||||||
|
SettingsListSwitch(
|
||||||
@override
|
title: 'Keep screen on',
|
||||||
Widget build(BuildContext context) {
|
subtitle: const Text(
|
||||||
final settingsStore = widget.settingsStore;
|
'Prevents the screen from sleeping while a channel is open.',
|
||||||
|
),
|
||||||
return Observer(
|
value: settingsStore.keepScreenAwake,
|
||||||
builder: (context) => SettingsPageLayout(
|
onChanged: (newValue) =>
|
||||||
children: [
|
settingsStore.keepScreenAwake = newValue,
|
||||||
const SectionHeader('Message sizing', isFirst: true),
|
),
|
||||||
ExpansionTile(
|
SettingsListSwitch(
|
||||||
title: const Text('Preview'),
|
title: 'Autocomplete',
|
||||||
children: [
|
subtitle: const Text(
|
||||||
Container(
|
'Shows matching emotes and mentions while typing.',
|
||||||
width: double.infinity,
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
value: settingsStore.autocomplete,
|
||||||
child: DefaultTextStyle(
|
onChanged: (newValue) =>
|
||||||
style: DefaultTextStyle.of(
|
settingsStore.autocomplete = newValue,
|
||||||
context,
|
),
|
||||||
).style.copyWith(fontSize: settingsStore.fontSize),
|
SettingsListSwitch(
|
||||||
child: Column(
|
title: 'Load recent messages',
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
subtitle: Text.rich(
|
||||||
children: [
|
TextSpan(
|
||||||
Text.rich(
|
text:
|
||||||
TextSpan(
|
'Loads historical recent messages in chat through a third-party API service at ',
|
||||||
children: [
|
children: [
|
||||||
WidgetSpan(
|
TextSpan(
|
||||||
alignment: PlaceholderAlignment.middle,
|
text: 'https://recent-messages.robotty.de/',
|
||||||
child: FrostyCachedNetworkImage(
|
style: TextStyle(
|
||||||
imageUrl:
|
color: context.colorScheme.primary,
|
||||||
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
|
decoration: TextDecoration.underline,
|
||||||
height:
|
decorationColor: context.colorScheme.primary,
|
||||||
defaultBadgeSize * settingsStore.badgeScale,
|
),
|
||||||
width:
|
recognizer: TapGestureRecognizer()
|
||||||
defaultBadgeSize * settingsStore.badgeScale,
|
..onTap = () => launchUrl(
|
||||||
),
|
Uri.parse(
|
||||||
),
|
'https://recent-messages.robotty.de/',
|
||||||
const TextSpan(text: ' Badge and emote preview. '),
|
),
|
||||||
WidgetSpan(
|
mode: settingsStore.launchUrlExternal
|
||||||
alignment: PlaceholderAlignment.middle,
|
? LaunchMode.externalApplication
|
||||||
child: FrostyCachedNetworkImage(
|
: LaunchMode.inAppBrowserView,
|
||||||
imageUrl:
|
),
|
||||||
'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
|
),
|
||||||
height:
|
],
|
||||||
defaultEmoteSize * settingsStore.emoteScale,
|
),
|
||||||
width:
|
),
|
||||||
defaultEmoteSize * settingsStore.emoteScale,
|
value: settingsStore.showRecentMessages,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
),
|
settingsStore.showRecentMessages = newValue,
|
||||||
],
|
),
|
||||||
),
|
const SectionHeader('Message sizing'),
|
||||||
textScaler: settingsStore.messageScale.textScaler,
|
ExpansionTile(
|
||||||
),
|
title: const Text('Preview'),
|
||||||
SizedBox(height: settingsStore.messageSpacing),
|
children: [
|
||||||
Text(
|
Container(
|
||||||
'Hello! Here\'s a text preview.',
|
width: double.infinity,
|
||||||
textScaler: settingsStore.messageScale.textScaler,
|
padding: const EdgeInsets.all(16),
|
||||||
),
|
child: DefaultTextStyle(
|
||||||
SizedBox(height: settingsStore.messageSpacing),
|
style: DefaultTextStyle.of(
|
||||||
Text(
|
context,
|
||||||
'And another for spacing without an emote!',
|
).style.copyWith(fontSize: settingsStore.fontSize),
|
||||||
textScaler: settingsStore.messageScale.textScaler,
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
],
|
children: [
|
||||||
),
|
Text.rich(
|
||||||
),
|
TextSpan(
|
||||||
),
|
children: [
|
||||||
],
|
WidgetSpan(
|
||||||
),
|
alignment: PlaceholderAlignment.middle,
|
||||||
SettingsListSlider(
|
child: FrostyCachedNetworkImage(
|
||||||
title: 'Badge scale',
|
imageUrl:
|
||||||
trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x',
|
'https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3',
|
||||||
value: settingsStore.badgeScale,
|
height:
|
||||||
min: 0.25,
|
defaultBadgeSize *
|
||||||
max: 3.0,
|
settingsStore.badgeScale,
|
||||||
divisions: 11,
|
width:
|
||||||
onChanged: (newValue) => settingsStore.badgeScale = newValue,
|
defaultBadgeSize *
|
||||||
),
|
settingsStore.badgeScale,
|
||||||
SettingsListSlider(
|
),
|
||||||
title: 'Emote scale',
|
),
|
||||||
trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x',
|
const TextSpan(
|
||||||
value: settingsStore.emoteScale,
|
text: ' Badge and emote preview. ',
|
||||||
min: 0.25,
|
),
|
||||||
max: 3.0,
|
WidgetSpan(
|
||||||
divisions: 11,
|
alignment: PlaceholderAlignment.middle,
|
||||||
onChanged: (newValue) => settingsStore.emoteScale = newValue,
|
child: FrostyCachedNetworkImage(
|
||||||
),
|
imageUrl:
|
||||||
SettingsListSlider(
|
'https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/3.0',
|
||||||
title: 'Message scale',
|
height:
|
||||||
trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x',
|
defaultEmoteSize *
|
||||||
value: settingsStore.messageScale,
|
settingsStore.emoteScale,
|
||||||
min: 0.5,
|
width:
|
||||||
max: 2.0,
|
defaultEmoteSize *
|
||||||
divisions: 6,
|
settingsStore.emoteScale,
|
||||||
onChanged: (newValue) => settingsStore.messageScale = newValue,
|
),
|
||||||
),
|
),
|
||||||
SettingsListSlider(
|
],
|
||||||
title: 'Message spacing',
|
),
|
||||||
trailing: '${settingsStore.messageSpacing.toStringAsFixed(0)}px',
|
textScaler: settingsStore.messageScale.textScaler,
|
||||||
value: settingsStore.messageSpacing,
|
),
|
||||||
max: 30.0,
|
SizedBox(height: settingsStore.messageSpacing),
|
||||||
divisions: 15,
|
Text(
|
||||||
onChanged: (newValue) => settingsStore.messageSpacing = newValue,
|
'Hello! Here\'s a text preview.',
|
||||||
),
|
textScaler: settingsStore.messageScale.textScaler,
|
||||||
SettingsListSlider(
|
),
|
||||||
title: 'Font size',
|
SizedBox(height: settingsStore.messageSpacing),
|
||||||
trailing: settingsStore.fontSize.toInt().toString(),
|
Text(
|
||||||
value: settingsStore.fontSize,
|
'And another for spacing without an emote!',
|
||||||
min: 5,
|
textScaler: settingsStore.messageScale.textScaler,
|
||||||
max: 20,
|
),
|
||||||
divisions: 15,
|
],
|
||||||
onChanged: (newValue) => settingsStore.fontSize = newValue,
|
),
|
||||||
),
|
),
|
||||||
const SectionHeader('Message appearance'),
|
),
|
||||||
SettingsListSwitch(
|
],
|
||||||
title: 'Show deleted messages',
|
),
|
||||||
subtitle: const Text(
|
SettingsListSlider(
|
||||||
'Restores the original message of deleted messages.',
|
title: 'Badge scale',
|
||||||
),
|
trailing: '${settingsStore.badgeScale.toStringAsFixed(2)}x',
|
||||||
value: settingsStore.showDeletedMessages,
|
value: settingsStore.badgeScale,
|
||||||
onChanged: (newValue) =>
|
min: 0.25,
|
||||||
settingsStore.showDeletedMessages = newValue,
|
max: 3.0,
|
||||||
),
|
divisions: 11,
|
||||||
SettingsListSwitch(
|
onChanged: (newValue) => settingsStore.badgeScale = newValue,
|
||||||
title: 'Show message dividers',
|
),
|
||||||
value: settingsStore.showChatMessageDividers,
|
SettingsListSlider(
|
||||||
onChanged: (newValue) =>
|
title: 'Emote scale',
|
||||||
settingsStore.showChatMessageDividers = newValue,
|
trailing: '${settingsStore.emoteScale.toStringAsFixed(2)}x',
|
||||||
),
|
value: settingsStore.emoteScale,
|
||||||
SettingsListSelect(
|
min: 0.25,
|
||||||
title: 'Message timestamps',
|
max: 3.0,
|
||||||
selectedOption: timestampNames[settingsStore.timestampType.index],
|
divisions: 11,
|
||||||
options: timestampNames,
|
onChanged: (newValue) => settingsStore.emoteScale = newValue,
|
||||||
onChanged: (newValue) => settingsStore.timestampType =
|
),
|
||||||
TimestampType.values[timestampNames.indexOf(newValue)],
|
SettingsListSlider(
|
||||||
),
|
title: 'Message scale',
|
||||||
const SectionHeader('Delay and latency'),
|
trailing: '${settingsStore.messageScale.toStringAsFixed(2)}x',
|
||||||
SettingsListSwitch(
|
value: settingsStore.messageScale,
|
||||||
title: 'Sync message delay and stream latency',
|
min: 0.5,
|
||||||
value: settingsStore.autoSyncChatDelay,
|
max: 2.0,
|
||||||
onChanged: (newValue) => settingsStore.autoSyncChatDelay = newValue,
|
divisions: 6,
|
||||||
),
|
onChanged: (newValue) => settingsStore.messageScale = newValue,
|
||||||
if (!settingsStore.autoSyncChatDelay)
|
),
|
||||||
SettingsListSlider(
|
SettingsListSlider(
|
||||||
title: 'Message delay',
|
title: 'Message spacing',
|
||||||
trailing: '${settingsStore.chatDelay.toInt()} seconds',
|
trailing:
|
||||||
subtitle:
|
'${settingsStore.messageSpacing.toStringAsFixed(0)}px',
|
||||||
'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
|
value: settingsStore.messageSpacing,
|
||||||
value: settingsStore.chatDelay,
|
max: 30.0,
|
||||||
max: 30.0,
|
divisions: 15,
|
||||||
divisions: 30,
|
onChanged: (newValue) =>
|
||||||
onChanged: (newValue) => settingsStore.chatDelay = newValue,
|
settingsStore.messageSpacing = newValue,
|
||||||
),
|
),
|
||||||
const SectionHeader('Alerts'),
|
SettingsListSlider(
|
||||||
SettingsListSwitch(
|
title: 'Font size',
|
||||||
title: 'Highlight first time chatters',
|
trailing: settingsStore.fontSize.toInt().toString(),
|
||||||
value: settingsStore.highlightFirstTimeChatter,
|
value: settingsStore.fontSize,
|
||||||
onChanged: (newValue) =>
|
min: 5,
|
||||||
settingsStore.highlightFirstTimeChatter = newValue,
|
max: 20,
|
||||||
),
|
divisions: 15,
|
||||||
SettingsListSwitch(
|
onChanged: (newValue) => settingsStore.fontSize = newValue,
|
||||||
title: 'Show notices',
|
),
|
||||||
subtitle: const Text(
|
const SectionHeader('Message appearance'),
|
||||||
'Shows notices such as subs and re-subs, announcements, and raids.',
|
SettingsListSwitch(
|
||||||
),
|
title: 'Show deleted messages',
|
||||||
value: settingsStore.showUserNotices,
|
subtitle: const Text(
|
||||||
onChanged: (newValue) => settingsStore.showUserNotices = newValue,
|
'Restores the original message of deleted messages.',
|
||||||
),
|
),
|
||||||
const SectionHeader('Layout'),
|
value: settingsStore.showDeletedMessages,
|
||||||
SettingsListSwitch(
|
onChanged: (newValue) =>
|
||||||
title: 'Move emote menu button left',
|
settingsStore.showDeletedMessages = newValue,
|
||||||
subtitle: const Text(
|
),
|
||||||
'Places the emote menu button on the left side to avoid accidental presses.',
|
SettingsListSwitch(
|
||||||
),
|
title: 'Show message dividers',
|
||||||
value: settingsStore.emoteMenuButtonOnLeft,
|
value: settingsStore.showChatMessageDividers,
|
||||||
onChanged: (newValue) =>
|
onChanged: (newValue) =>
|
||||||
settingsStore.emoteMenuButtonOnLeft = newValue,
|
settingsStore.showChatMessageDividers = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSelect(
|
||||||
title: 'Persist chat tabs',
|
title: 'Timestamps',
|
||||||
subtitle: const Text(
|
selectedOption:
|
||||||
'Secondary chat tabs are remembered when switching channels.',
|
timestampNames[settingsStore.timestampType.index],
|
||||||
),
|
options: timestampNames,
|
||||||
value: settingsStore.persistChatTabs,
|
onChanged: (newValue) => settingsStore.timestampType =
|
||||||
onChanged: (newValue) {
|
TimestampType.values[timestampNames.indexOf(newValue)],
|
||||||
settingsStore.persistChatTabs = newValue;
|
),
|
||||||
if (!newValue) {
|
const SectionHeader('Delay'),
|
||||||
settingsStore.secondaryTabs = [];
|
SettingsListSwitch(
|
||||||
}
|
title: 'Auto-sync chat delay',
|
||||||
},
|
value: settingsStore.autoSyncChatDelay,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
const SectionHeader('Landscape mode'),
|
settingsStore.autoSyncChatDelay = newValue,
|
||||||
SettingsListSwitch(
|
),
|
||||||
title: 'Move chat left',
|
if (!settingsStore.autoSyncChatDelay)
|
||||||
value: settingsStore.landscapeChatLeftSide,
|
SettingsListSlider(
|
||||||
onChanged: (newValue) =>
|
title: 'Chat delay',
|
||||||
settingsStore.landscapeChatLeftSide = newValue,
|
trailing: '${settingsStore.chatDelay.toInt()} seconds',
|
||||||
),
|
subtitle:
|
||||||
SettingsListSwitch(
|
'Adds a delay before each message is rendered in chat. ${Platform.isIOS ? '15 seconds is recommended for iOS.' : ''}',
|
||||||
title: 'Force vertical chat',
|
value: settingsStore.chatDelay,
|
||||||
subtitle: const Text(
|
max: 30.0,
|
||||||
'Intended for tablets and other larger displays.',
|
divisions: 30,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
value: settingsStore.landscapeForceVerticalChat,
|
settingsStore.chatDelay = newValue,
|
||||||
onChanged: (newValue) =>
|
),
|
||||||
settingsStore.landscapeForceVerticalChat = newValue,
|
const SectionHeader('Alerts'),
|
||||||
),
|
SettingsListSwitch(
|
||||||
SettingsListSelect(
|
title: 'Highlight first-time chatters',
|
||||||
title: 'Fill notch side',
|
value: settingsStore.highlightFirstTimeChatter,
|
||||||
subtitle:
|
onChanged: (newValue) =>
|
||||||
'Overrides and fills the available space in devices with a display notch.',
|
settingsStore.highlightFirstTimeChatter = newValue,
|
||||||
selectedOption:
|
),
|
||||||
landscapeCutoutNames[settingsStore.landscapeCutout.index],
|
SettingsListSwitch(
|
||||||
options: landscapeCutoutNames,
|
title: 'Show notices',
|
||||||
onChanged: (newValue) => settingsStore.landscapeCutout =
|
subtitle: const Text(
|
||||||
LandscapeCutoutType.values[landscapeCutoutNames.indexOf(
|
'Shows notices such as subs, announcements, and raids.',
|
||||||
newValue,
|
),
|
||||||
)],
|
value: settingsStore.showUserNotices,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
SettingsListSlider(
|
settingsStore.showUserNotices = newValue,
|
||||||
title: 'Chat overlay opacity',
|
),
|
||||||
trailing:
|
const SectionHeader('Layout'),
|
||||||
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
|
SettingsListSwitch(
|
||||||
subtitle:
|
title: 'Emote menu on left',
|
||||||
'Sets the opacity (transparency) of the overlay chat in fullscreen mode.',
|
subtitle: const Text(
|
||||||
value: settingsStore.fullScreenChatOverlayOpacity,
|
'Places the emote menu button on the left side.',
|
||||||
divisions: 10,
|
),
|
||||||
onChanged: (newValue) =>
|
value: settingsStore.emoteMenuButtonOnLeft,
|
||||||
settingsStore.fullScreenChatOverlayOpacity = newValue,
|
onChanged: (newValue) =>
|
||||||
),
|
settingsStore.emoteMenuButtonOnLeft = newValue,
|
||||||
const SectionHeader('Muted keywords'),
|
),
|
||||||
SettingsMutedWords(settingsStore: settingsStore),
|
SettingsListSwitch(
|
||||||
SettingsListSwitch(
|
title: 'Remember chat tabs',
|
||||||
title: 'Match whole words',
|
subtitle: const Text(
|
||||||
subtitle: const Text(
|
'Secondary chat tabs are kept when switching channels.',
|
||||||
'Only matches whole words instead of partial matches.',
|
),
|
||||||
),
|
value: settingsStore.persistChatTabs,
|
||||||
value: settingsStore.matchWholeWord,
|
onChanged: (newValue) {
|
||||||
onChanged: (newValue) => settingsStore.matchWholeWord = newValue,
|
settingsStore.persistChatTabs = newValue;
|
||||||
),
|
if (!newValue) {
|
||||||
const SectionHeader('Autocomplete'),
|
settingsStore.secondaryTabs = [];
|
||||||
SettingsListSwitch(
|
}
|
||||||
title: 'Show autocomplete bar',
|
},
|
||||||
subtitle: const Text(
|
),
|
||||||
'Shows a bar containing matching emotes and mentions while typing.',
|
const SectionHeader('Landscape'),
|
||||||
),
|
SettingsListSwitch(
|
||||||
value: settingsStore.autocomplete,
|
title: 'Chat on left side',
|
||||||
onChanged: (newValue) => settingsStore.autocomplete = newValue,
|
value: settingsStore.landscapeChatLeftSide,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
const SectionHeader('Emotes and badges'),
|
settingsStore.landscapeChatLeftSide = newValue,
|
||||||
SettingsListSwitch(
|
),
|
||||||
title: 'Show Twitch emotes',
|
SettingsListSwitch(
|
||||||
value: settingsStore.showTwitchEmotes,
|
title: 'Force vertical chat',
|
||||||
onChanged: (newValue) => settingsStore.showTwitchEmotes = newValue,
|
subtitle: const Text(
|
||||||
),
|
'Intended for tablets and larger displays.',
|
||||||
SettingsListSwitch(
|
),
|
||||||
title: 'Show Twitch badges',
|
value: settingsStore.landscapeForceVerticalChat,
|
||||||
value: settingsStore.showTwitchBadges,
|
onChanged: (newValue) =>
|
||||||
onChanged: (newValue) => settingsStore.showTwitchBadges = newValue,
|
settingsStore.landscapeForceVerticalChat = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSelect(
|
||||||
title: 'Show 7TV emotes',
|
title: 'Notch fill',
|
||||||
value: settingsStore.show7TVEmotes,
|
subtitle:
|
||||||
onChanged: (newValue) => settingsStore.show7TVEmotes = newValue,
|
'Fills the display cutout area on the selected side.',
|
||||||
),
|
selectedOption:
|
||||||
SettingsListSwitch(
|
landscapeCutoutNames[settingsStore.landscapeCutout.index],
|
||||||
title: 'Show BTTV emotes',
|
options: landscapeCutoutNames,
|
||||||
value: settingsStore.showBTTVEmotes,
|
onChanged: (newValue) => settingsStore.landscapeCutout =
|
||||||
onChanged: (newValue) => settingsStore.showBTTVEmotes = newValue,
|
LandscapeCutoutType.values[landscapeCutoutNames.indexOf(
|
||||||
),
|
newValue,
|
||||||
SettingsListSwitch(
|
)],
|
||||||
title: 'Show BTTV badges',
|
),
|
||||||
value: settingsStore.showBTTVBadges,
|
SettingsListSlider(
|
||||||
onChanged: (newValue) => settingsStore.showBTTVBadges = newValue,
|
title: 'Overlay chat opacity',
|
||||||
),
|
trailing:
|
||||||
SettingsListSwitch(
|
'${(settingsStore.fullScreenChatOverlayOpacity * 100).toStringAsFixed(0)}%',
|
||||||
title: 'Show FFZ emotes',
|
subtitle:
|
||||||
value: settingsStore.showFFZEmotes,
|
'Opacity of the chat overlay in fullscreen mode.',
|
||||||
onChanged: (newValue) => settingsStore.showFFZEmotes = newValue,
|
value: settingsStore.fullScreenChatOverlayOpacity,
|
||||||
),
|
divisions: 10,
|
||||||
SettingsListSwitch(
|
onChanged: (newValue) =>
|
||||||
title: 'Show FFZ badges',
|
settingsStore.fullScreenChatOverlayOpacity = newValue,
|
||||||
value: settingsStore.showFFZBadges,
|
),
|
||||||
onChanged: (newValue) => settingsStore.showFFZBadges = newValue,
|
const SectionHeader('Filtering'),
|
||||||
),
|
SettingsMutedWords(settingsStore: settingsStore),
|
||||||
const SectionHeader('Recent messages'),
|
SettingsListSwitch(
|
||||||
SettingsListSwitch(
|
title: 'Match whole words',
|
||||||
title: 'Show historical recent messages',
|
subtitle: const Text(
|
||||||
subtitle: Text.rich(
|
'Only matches whole words instead of partial matches.',
|
||||||
TextSpan(
|
),
|
||||||
text:
|
value: settingsStore.matchWholeWord,
|
||||||
'Loads historical recent messages in chat through a third-party API service at ',
|
onChanged: (newValue) =>
|
||||||
children: [
|
settingsStore.matchWholeWord = newValue,
|
||||||
TextSpan(
|
),
|
||||||
text: 'https://recent-messages.robotty.de/',
|
const SectionHeader('Emotes and badges'),
|
||||||
style: TextStyle(
|
SettingsListSwitch(
|
||||||
color: context.colorScheme.primary,
|
title: 'Twitch emotes',
|
||||||
decoration: TextDecoration.underline,
|
value: settingsStore.showTwitchEmotes,
|
||||||
decorationColor: context.colorScheme.primary,
|
onChanged: (newValue) =>
|
||||||
),
|
settingsStore.showTwitchEmotes = newValue,
|
||||||
recognizer: TapGestureRecognizer()
|
),
|
||||||
..onTap = () => launchUrl(
|
SettingsListSwitch(
|
||||||
Uri.parse('https://recent-messages.robotty.de/'),
|
title: 'Twitch badges',
|
||||||
mode: settingsStore.launchUrlExternal
|
value: settingsStore.showTwitchBadges,
|
||||||
? LaunchMode.externalApplication
|
onChanged: (newValue) =>
|
||||||
: LaunchMode.inAppBrowserView,
|
settingsStore.showTwitchBadges = newValue,
|
||||||
),
|
),
|
||||||
),
|
SettingsListSwitch(
|
||||||
],
|
title: '7TV emotes',
|
||||||
),
|
value: settingsStore.show7TVEmotes,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
value: settingsStore.showRecentMessages,
|
settingsStore.show7TVEmotes = newValue,
|
||||||
onChanged: (newValue) =>
|
),
|
||||||
settingsStore.showRecentMessages = newValue,
|
SettingsListSwitch(
|
||||||
),
|
title: 'BTTV emotes',
|
||||||
],
|
value: settingsStore.showBTTVEmotes,
|
||||||
),
|
onChanged: (newValue) =>
|
||||||
);
|
settingsStore.showBTTVEmotes = newValue,
|
||||||
}
|
),
|
||||||
}
|
SettingsListSwitch(
|
||||||
|
title: 'BTTV badges',
|
||||||
|
value: settingsStore.showBTTVBadges,
|
||||||
|
onChanged: (newValue) =>
|
||||||
|
settingsStore.showBTTVBadges = newValue,
|
||||||
|
),
|
||||||
|
SettingsListSwitch(
|
||||||
|
title: 'FFZ emotes',
|
||||||
|
value: settingsStore.showFFZEmotes,
|
||||||
|
onChanged: (newValue) =>
|
||||||
|
settingsStore.showFFZEmotes = newValue,
|
||||||
|
),
|
||||||
|
SettingsListSwitch(
|
||||||
|
title: 'FFZ badges',
|
||||||
|
value: settingsStore.showFFZBadges,
|
||||||
|
onChanged: (newValue) =>
|
||||||
|
settingsStore.showFFZBadges = newValue,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,12 @@ class GeneralSettings extends StatelessWidget {
|
||||||
AccentColorSetting(settingsStore: settingsStore),
|
AccentColorSetting(settingsStore: settingsStore),
|
||||||
const SectionHeader('Stream card'),
|
const SectionHeader('Stream card'),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Use large stream card',
|
title: 'Large stream cards',
|
||||||
value: settingsStore.largeStreamCard,
|
value: settingsStore.largeStreamCard,
|
||||||
onChanged: (newValue) => settingsStore.largeStreamCard = newValue,
|
onChanged: (newValue) => settingsStore.largeStreamCard = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Show thumbnail',
|
title: 'Show thumbnails',
|
||||||
value: settingsStore.showThumbnails,
|
value: settingsStore.showThumbnails,
|
||||||
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
onChanged: (newValue) => settingsStore.showThumbnails = newValue,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:frosty/apis/twitch_api.dart';
|
||||||
import 'package:frosty/constants.dart';
|
import 'package:frosty/constants.dart';
|
||||||
import 'package:frosty/main.dart';
|
import 'package:frosty/main.dart';
|
||||||
import 'package:frosty/screens/settings/stores/user_store.dart';
|
import 'package:frosty/screens/settings/stores/user_store.dart';
|
||||||
|
import 'package:frosty/services/cookie_extractor.dart';
|
||||||
import 'package:frosty/widgets/frosty_dialog.dart';
|
import 'package:frosty/widgets/frosty_dialog.dart';
|
||||||
import 'package:mobx/mobx.dart';
|
import 'package:mobx/mobx.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
@ -27,6 +28,9 @@ abstract class AuthBase with Store {
|
||||||
/// The shared_preferences key for the user token.
|
/// The shared_preferences key for the user token.
|
||||||
static const _userTokenKey = 'user_token';
|
static const _userTokenKey = 'user_token';
|
||||||
|
|
||||||
|
/// The secure storage key for the GQL web cookie token.
|
||||||
|
static const _gqlTokenKey = 'gql_token';
|
||||||
|
|
||||||
/// The Twitch API service for making requests.
|
/// The Twitch API service for making requests.
|
||||||
final TwitchApi twitchApi;
|
final TwitchApi twitchApi;
|
||||||
|
|
||||||
|
|
@ -49,6 +53,10 @@ abstract class AuthBase with Store {
|
||||||
@readonly
|
@readonly
|
||||||
String? _token;
|
String? _token;
|
||||||
|
|
||||||
|
/// Web cookie token extracted from the login WebView for GQL ad-free playback.
|
||||||
|
@readonly
|
||||||
|
String? _gqlToken;
|
||||||
|
|
||||||
/// Whether the user is logged in or not.
|
/// Whether the user is logged in or not.
|
||||||
@readonly
|
@readonly
|
||||||
var _isLoggedIn = false;
|
var _isLoggedIn = false;
|
||||||
|
|
@ -83,6 +91,7 @@ abstract class AuthBase with Store {
|
||||||
// When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true".
|
// When redirected to the redirect_uri, there will be another redirect to "https://www.twitch.tv/?no-reload=true".
|
||||||
// Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself.
|
// Checking for this will ensure that the user has automatically logged in to Twitch on the WebView itself.
|
||||||
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
|
if (request.url == 'https://www.twitch.tv/?no-reload=true') {
|
||||||
|
_extractGqlToken();
|
||||||
if (routeAfter != null) {
|
if (routeAfter != null) {
|
||||||
navigatorKey.currentState?.pop();
|
navigatorKey.currentState?.pop();
|
||||||
navigatorKey.currentState?.push(
|
navigatorKey.currentState?.push(
|
||||||
|
|
@ -211,12 +220,27 @@ abstract class AuthBase with Store {
|
||||||
|
|
||||||
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi);
|
AuthBase({required this.twitchApi}) : user = UserStore(twitchApi: twitchApi);
|
||||||
|
|
||||||
|
Future<void> _extractGqlToken() async {
|
||||||
|
try {
|
||||||
|
final token = await CookieExtractor.extractTwitchAuthToken();
|
||||||
|
if (token != null) {
|
||||||
|
runInAction(() {
|
||||||
|
_gqlToken = token;
|
||||||
|
});
|
||||||
|
await _storage.write(key: _gqlTokenKey, value: token);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('GQL token extraction failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize by retrieving a token if it does not already exist.
|
/// Initialize by retrieving a token if it does not already exist.
|
||||||
@action
|
@action
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
try {
|
try {
|
||||||
// Read and set the currently stored user token, if any.
|
// Read and set the currently stored user token, if any.
|
||||||
_token = await _storage.read(key: _userTokenKey);
|
_token = await _storage.read(key: _userTokenKey);
|
||||||
|
_gqlToken = await _storage.read(key: _gqlTokenKey);
|
||||||
|
|
||||||
// If the token does not exist, get the default token.
|
// If the token does not exist, get the default token.
|
||||||
// Otherwise, log in.
|
// Otherwise, log in.
|
||||||
|
|
@ -293,9 +317,11 @@ abstract class AuthBase with Store {
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
try {
|
try {
|
||||||
_stopReconnectLoop();
|
_stopReconnectLoop();
|
||||||
// Delete the existing user token.
|
// Delete the existing user token and GQL token.
|
||||||
await _storage.delete(key: _userTokenKey);
|
await _storage.delete(key: _userTokenKey);
|
||||||
|
await _storage.delete(key: _gqlTokenKey);
|
||||||
_token = null;
|
_token = null;
|
||||||
|
_gqlToken = null;
|
||||||
|
|
||||||
// Clear the user info.
|
// Clear the user info.
|
||||||
user.dispose();
|
user.dispose();
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,26 @@ mixin _$AuthStore on AuthBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$_gqlTokenAtom = Atom(
|
||||||
|
name: 'AuthBase._gqlToken',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
String? get gqlToken {
|
||||||
|
_$_gqlTokenAtom.reportRead();
|
||||||
|
return super._gqlToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get _gqlToken => gqlToken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set _gqlToken(String? value) {
|
||||||
|
_$_gqlTokenAtom.reportWrite(value, super._gqlToken, () {
|
||||||
|
super._gqlToken = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$_isLoggedInAtom = Atom(
|
late final _$_isLoggedInAtom = Atom(
|
||||||
name: 'AuthBase._isLoggedIn',
|
name: 'AuthBase._isLoggedIn',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,8 @@ abstract class _SettingsStoreBase with Store {
|
||||||
static const defaultShowVideo = true;
|
static const defaultShowVideo = true;
|
||||||
static const defaultDefaultToHighestQuality = false;
|
static const defaultDefaultToHighestQuality = false;
|
||||||
static const defaultUseTextureRendering = true;
|
static const defaultUseTextureRendering = true;
|
||||||
static const defaultKeepScreenAwake = true;
|
static const defaultUseNativePlayer = false;
|
||||||
|
|
||||||
// Overlay defaults
|
|
||||||
static const defaultShowOverlay = true;
|
static const defaultShowOverlay = true;
|
||||||
static const defaultToggleableOverlay = false;
|
static const defaultToggleableOverlay = false;
|
||||||
static const defaultShowLatency = false;
|
static const defaultShowLatency = false;
|
||||||
|
|
@ -85,9 +84,9 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var useTextureRendering = defaultUseTextureRendering;
|
var useTextureRendering = defaultUseTextureRendering;
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultKeepScreenAwake)
|
@JsonKey(defaultValue: defaultUseNativePlayer)
|
||||||
@observable
|
@observable
|
||||||
var keepScreenAwake = defaultKeepScreenAwake;
|
var useNativePlayer = defaultUseNativePlayer;
|
||||||
|
|
||||||
// Overlay options
|
// Overlay options
|
||||||
@JsonKey(defaultValue: defaultShowOverlay)
|
@JsonKey(defaultValue: defaultShowOverlay)
|
||||||
|
|
@ -107,7 +106,7 @@ abstract class _SettingsStoreBase with Store {
|
||||||
showVideo = defaultShowVideo;
|
showVideo = defaultShowVideo;
|
||||||
defaultToHighestQuality = defaultDefaultToHighestQuality;
|
defaultToHighestQuality = defaultDefaultToHighestQuality;
|
||||||
useTextureRendering = defaultUseTextureRendering;
|
useTextureRendering = defaultUseTextureRendering;
|
||||||
keepScreenAwake = defaultKeepScreenAwake;
|
useNativePlayer = defaultUseNativePlayer;
|
||||||
|
|
||||||
showOverlay = defaultShowOverlay;
|
showOverlay = defaultShowOverlay;
|
||||||
toggleableOverlay = defaultToggleableOverlay;
|
toggleableOverlay = defaultToggleableOverlay;
|
||||||
|
|
@ -115,44 +114,25 @@ abstract class _SettingsStoreBase with Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Chat Settings
|
// * Chat Settings
|
||||||
// Message sizing defaults
|
|
||||||
|
// -- Messages --
|
||||||
|
// Sizing defaults
|
||||||
static const defaultBadgeScale = 1.0;
|
static const defaultBadgeScale = 1.0;
|
||||||
static const defaultEmoteScale = 1.0;
|
static const defaultEmoteScale = 1.0;
|
||||||
static const defaultMessageScale = 1.0;
|
static const defaultMessageScale = 1.0;
|
||||||
static const defaultMessageSpacing = 8.0;
|
static const defaultMessageSpacing = 8.0;
|
||||||
static const defaultFontSize = 12.0;
|
static const defaultFontSize = 12.0;
|
||||||
|
|
||||||
// Message appearance defaults
|
// Appearance defaults
|
||||||
static const defaultShowDeletedMessages = false;
|
static const defaultShowDeletedMessages = false;
|
||||||
static const defaultShowChatMessageDividers = false;
|
static const defaultShowChatMessageDividers = false;
|
||||||
static const defaultTimestampType = TimestampType.disabled;
|
static const defaultTimestampType = TimestampType.disabled;
|
||||||
|
|
||||||
// Delay defaults
|
|
||||||
static const defaultAutoSyncChatDelay = false;
|
|
||||||
static const defaultChatDelay = 0.0;
|
|
||||||
|
|
||||||
// Alert defaults
|
// Alert defaults
|
||||||
static const defaultHighlightFirstTimeChatter = true;
|
static const defaultHighlightFirstTimeChatter = true;
|
||||||
static const defaultShowUserNotices = true;
|
static const defaultShowUserNotices = true;
|
||||||
|
|
||||||
// Layout defaults
|
// -- Emotes & Badges --
|
||||||
static const defaultEmoteMenuButtonOnLeft = false;
|
|
||||||
|
|
||||||
// Landscape mode defaults
|
|
||||||
static const defaultLandscapeChatLeftSide = false;
|
|
||||||
static const defaultLandscapeForceVerticalChat = false;
|
|
||||||
static const defaultLandscapeCutout = LandscapeCutoutType.none;
|
|
||||||
static const defaultChatWidth = 0.2;
|
|
||||||
static const defaultFullScreenChatOverlayOpacity = 0.5;
|
|
||||||
|
|
||||||
// mute words defaults
|
|
||||||
static const defaultMutedWords = <String>[];
|
|
||||||
static const defaultMatchWholeWord = true;
|
|
||||||
|
|
||||||
// Autocomplete defaults
|
|
||||||
static const defaultAutocomplete = true;
|
|
||||||
|
|
||||||
// Emotes and badges defaults
|
|
||||||
static const defaultShowTwitchEmotes = true;
|
static const defaultShowTwitchEmotes = true;
|
||||||
static const defaultShowTwitchBadges = true;
|
static const defaultShowTwitchBadges = true;
|
||||||
static const defaultShow7TVEmotes = true;
|
static const defaultShow7TVEmotes = true;
|
||||||
|
|
@ -161,14 +141,26 @@ abstract class _SettingsStoreBase with Store {
|
||||||
static const defaultShowFFZEmotes = true;
|
static const defaultShowFFZEmotes = true;
|
||||||
static const defaultShowFFZBadges = true;
|
static const defaultShowFFZBadges = true;
|
||||||
|
|
||||||
// Recent messages defaults
|
// -- Layout --
|
||||||
static const defaultShowRecentMessages = false;
|
static const defaultEmoteMenuButtonOnLeft = false;
|
||||||
|
|
||||||
// Chat tabs defaults
|
|
||||||
static const defaultPersistChatTabs = true;
|
static const defaultPersistChatTabs = true;
|
||||||
static const defaultSecondaryTabs = <PersistedChatTab>[];
|
static const defaultSecondaryTabs = <PersistedChatTab>[];
|
||||||
|
static const defaultLandscapeChatLeftSide = false;
|
||||||
|
static const defaultLandscapeForceVerticalChat = false;
|
||||||
|
static const defaultLandscapeCutout = LandscapeCutoutType.none;
|
||||||
|
static const defaultChatWidth = 0.2;
|
||||||
|
static const defaultFullScreenChatOverlayOpacity = 0.5;
|
||||||
|
|
||||||
// Message sizing options
|
// -- Behavior --
|
||||||
|
static const defaultKeepScreenAwake = true;
|
||||||
|
static const defaultAutocomplete = true;
|
||||||
|
static const defaultShowRecentMessages = false;
|
||||||
|
static const defaultAutoSyncChatDelay = false;
|
||||||
|
static const defaultChatDelay = 0.0;
|
||||||
|
static const defaultMutedWords = <String>[];
|
||||||
|
static const defaultMatchWholeWord = true;
|
||||||
|
|
||||||
|
// Messages options
|
||||||
@JsonKey(defaultValue: defaultBadgeScale)
|
@JsonKey(defaultValue: defaultBadgeScale)
|
||||||
@observable
|
@observable
|
||||||
var badgeScale = defaultBadgeScale;
|
var badgeScale = defaultBadgeScale;
|
||||||
|
|
@ -189,7 +181,6 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var fontSize = defaultFontSize;
|
var fontSize = defaultFontSize;
|
||||||
|
|
||||||
// Message appearance options
|
|
||||||
@JsonKey(defaultValue: defaultShowDeletedMessages)
|
@JsonKey(defaultValue: defaultShowDeletedMessages)
|
||||||
@observable
|
@observable
|
||||||
var showDeletedMessages = defaultShowDeletedMessages;
|
var showDeletedMessages = defaultShowDeletedMessages;
|
||||||
|
|
@ -205,24 +196,6 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var timestampType = defaultTimestampType;
|
var timestampType = defaultTimestampType;
|
||||||
|
|
||||||
// Delay options
|
|
||||||
@JsonKey(defaultValue: defaultAutoSyncChatDelay)
|
|
||||||
@observable
|
|
||||||
var autoSyncChatDelay = defaultAutoSyncChatDelay;
|
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultChatDelay)
|
|
||||||
@observable
|
|
||||||
var chatDelay = defaultChatDelay;
|
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
|
||||||
@observable
|
|
||||||
var syncedChatDelay = defaultChatDelay;
|
|
||||||
|
|
||||||
@computed
|
|
||||||
double get effectiveChatDelay =>
|
|
||||||
autoSyncChatDelay ? syncedChatDelay : chatDelay;
|
|
||||||
|
|
||||||
// Alert options
|
|
||||||
@JsonKey(defaultValue: defaultHighlightFirstTimeChatter)
|
@JsonKey(defaultValue: defaultHighlightFirstTimeChatter)
|
||||||
@observable
|
@observable
|
||||||
var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
|
var highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
|
||||||
|
|
@ -231,38 +204,7 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var showUserNotices = defaultShowUserNotices;
|
var showUserNotices = defaultShowUserNotices;
|
||||||
|
|
||||||
// Layout options
|
// Emotes & Badges options
|
||||||
@JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft)
|
|
||||||
@observable
|
|
||||||
var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
|
|
||||||
|
|
||||||
// Landscape mode options
|
|
||||||
@JsonKey(defaultValue: defaultLandscapeChatLeftSide)
|
|
||||||
@observable
|
|
||||||
var landscapeChatLeftSide = defaultLandscapeChatLeftSide;
|
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultLandscapeForceVerticalChat)
|
|
||||||
@observable
|
|
||||||
var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
|
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultLandscapeCutout)
|
|
||||||
@observable
|
|
||||||
var landscapeCutout = defaultLandscapeCutout;
|
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultChatWidth)
|
|
||||||
@observable
|
|
||||||
var chatWidth = defaultChatWidth;
|
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity)
|
|
||||||
@observable
|
|
||||||
var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
|
|
||||||
|
|
||||||
// Autocomplete options
|
|
||||||
@JsonKey(defaultValue: defaultAutocomplete)
|
|
||||||
@observable
|
|
||||||
var autocomplete = defaultAutocomplete;
|
|
||||||
|
|
||||||
// Emotes and badges
|
|
||||||
@JsonKey(defaultValue: defaultShowTwitchEmotes)
|
@JsonKey(defaultValue: defaultShowTwitchEmotes)
|
||||||
@observable
|
@observable
|
||||||
var showTwitchEmotes = defaultShowTwitchEmotes;
|
var showTwitchEmotes = defaultShowTwitchEmotes;
|
||||||
|
|
@ -291,12 +233,11 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var showFFZBadges = defaultShowFFZBadges;
|
var showFFZBadges = defaultShowFFZBadges;
|
||||||
|
|
||||||
// Recent messages
|
// Layout options
|
||||||
@JsonKey(defaultValue: defaultShowRecentMessages)
|
@JsonKey(defaultValue: defaultEmoteMenuButtonOnLeft)
|
||||||
@observable
|
@observable
|
||||||
var showRecentMessages = defaultShowRecentMessages;
|
var emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
|
||||||
|
|
||||||
// Chat tabs
|
|
||||||
@JsonKey(defaultValue: defaultPersistChatTabs)
|
@JsonKey(defaultValue: defaultPersistChatTabs)
|
||||||
@observable
|
@observable
|
||||||
var persistChatTabs = defaultPersistChatTabs;
|
var persistChatTabs = defaultPersistChatTabs;
|
||||||
|
|
@ -305,6 +246,55 @@ abstract class _SettingsStoreBase with Store {
|
||||||
@observable
|
@observable
|
||||||
var secondaryTabs = defaultSecondaryTabs;
|
var secondaryTabs = defaultSecondaryTabs;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultLandscapeChatLeftSide)
|
||||||
|
@observable
|
||||||
|
var landscapeChatLeftSide = defaultLandscapeChatLeftSide;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultLandscapeForceVerticalChat)
|
||||||
|
@observable
|
||||||
|
var landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultLandscapeCutout)
|
||||||
|
@observable
|
||||||
|
var landscapeCutout = defaultLandscapeCutout;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultChatWidth)
|
||||||
|
@observable
|
||||||
|
var chatWidth = defaultChatWidth;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultFullScreenChatOverlayOpacity)
|
||||||
|
@observable
|
||||||
|
var fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
|
||||||
|
|
||||||
|
// Behavior options
|
||||||
|
@JsonKey(defaultValue: defaultKeepScreenAwake)
|
||||||
|
@observable
|
||||||
|
var keepScreenAwake = defaultKeepScreenAwake;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultAutocomplete)
|
||||||
|
@observable
|
||||||
|
var autocomplete = defaultAutocomplete;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultShowRecentMessages)
|
||||||
|
@observable
|
||||||
|
var showRecentMessages = defaultShowRecentMessages;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultAutoSyncChatDelay)
|
||||||
|
@observable
|
||||||
|
var autoSyncChatDelay = defaultAutoSyncChatDelay;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: defaultChatDelay)
|
||||||
|
@observable
|
||||||
|
var chatDelay = defaultChatDelay;
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@observable
|
||||||
|
var syncedChatDelay = defaultChatDelay;
|
||||||
|
|
||||||
|
@computed
|
||||||
|
double get effectiveChatDelay =>
|
||||||
|
autoSyncChatDelay ? syncedChatDelay : chatDelay;
|
||||||
|
|
||||||
@JsonKey(defaultValue: defaultMutedWords)
|
@JsonKey(defaultValue: defaultMutedWords)
|
||||||
@observable
|
@observable
|
||||||
List<String> mutedWords = defaultMutedWords;
|
List<String> mutedWords = defaultMutedWords;
|
||||||
|
|
@ -315,36 +305,19 @@ abstract class _SettingsStoreBase with Store {
|
||||||
|
|
||||||
@action
|
@action
|
||||||
void resetChatSettings() {
|
void resetChatSettings() {
|
||||||
|
// Messages
|
||||||
badgeScale = defaultBadgeScale;
|
badgeScale = defaultBadgeScale;
|
||||||
emoteScale = defaultEmoteScale;
|
emoteScale = defaultEmoteScale;
|
||||||
messageScale = defaultMessageScale;
|
messageScale = defaultMessageScale;
|
||||||
messageSpacing = defaultMessageSpacing;
|
messageSpacing = defaultMessageSpacing;
|
||||||
fontSize = defaultFontSize;
|
fontSize = defaultFontSize;
|
||||||
|
|
||||||
showDeletedMessages = defaultShowDeletedMessages;
|
showDeletedMessages = defaultShowDeletedMessages;
|
||||||
showChatMessageDividers = defaultShowChatMessageDividers;
|
showChatMessageDividers = defaultShowChatMessageDividers;
|
||||||
timestampType = defaultTimestampType;
|
timestampType = defaultTimestampType;
|
||||||
|
|
||||||
autoSyncChatDelay = defaultAutoSyncChatDelay;
|
|
||||||
chatDelay = defaultChatDelay;
|
|
||||||
syncedChatDelay = defaultChatDelay;
|
|
||||||
|
|
||||||
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
|
highlightFirstTimeChatter = defaultHighlightFirstTimeChatter;
|
||||||
showUserNotices = defaultShowUserNotices;
|
showUserNotices = defaultShowUserNotices;
|
||||||
|
|
||||||
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
|
// Emotes & Badges
|
||||||
|
|
||||||
landscapeChatLeftSide = defaultLandscapeChatLeftSide;
|
|
||||||
landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
|
|
||||||
landscapeCutout = defaultLandscapeCutout;
|
|
||||||
chatWidth = defaultChatWidth;
|
|
||||||
fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
|
|
||||||
|
|
||||||
mutedWords = defaultMutedWords;
|
|
||||||
matchWholeWord = defaultMatchWholeWord;
|
|
||||||
|
|
||||||
autocomplete = defaultAutocomplete;
|
|
||||||
|
|
||||||
showTwitchEmotes = defaultShowTwitchEmotes;
|
showTwitchEmotes = defaultShowTwitchEmotes;
|
||||||
showTwitchBadges = defaultShowTwitchBadges;
|
showTwitchBadges = defaultShowTwitchBadges;
|
||||||
show7TVEmotes = defaultShow7TVEmotes;
|
show7TVEmotes = defaultShow7TVEmotes;
|
||||||
|
|
@ -353,10 +326,25 @@ abstract class _SettingsStoreBase with Store {
|
||||||
showFFZEmotes = defaultShowFFZEmotes;
|
showFFZEmotes = defaultShowFFZEmotes;
|
||||||
showFFZBadges = defaultShowFFZBadges;
|
showFFZBadges = defaultShowFFZBadges;
|
||||||
|
|
||||||
showRecentMessages = defaultShowRecentMessages;
|
// Layout
|
||||||
|
emoteMenuButtonOnLeft = defaultEmoteMenuButtonOnLeft;
|
||||||
persistChatTabs = defaultPersistChatTabs;
|
persistChatTabs = defaultPersistChatTabs;
|
||||||
secondaryTabs = defaultSecondaryTabs;
|
secondaryTabs = defaultSecondaryTabs;
|
||||||
|
landscapeChatLeftSide = defaultLandscapeChatLeftSide;
|
||||||
|
landscapeForceVerticalChat = defaultLandscapeForceVerticalChat;
|
||||||
|
landscapeCutout = defaultLandscapeCutout;
|
||||||
|
chatWidth = defaultChatWidth;
|
||||||
|
fullScreenChatOverlayOpacity = defaultFullScreenChatOverlayOpacity;
|
||||||
|
|
||||||
|
// Behavior
|
||||||
|
keepScreenAwake = defaultKeepScreenAwake;
|
||||||
|
autocomplete = defaultAutocomplete;
|
||||||
|
showRecentMessages = defaultShowRecentMessages;
|
||||||
|
autoSyncChatDelay = defaultAutoSyncChatDelay;
|
||||||
|
chatDelay = defaultChatDelay;
|
||||||
|
syncedChatDelay = defaultChatDelay;
|
||||||
|
mutedWords = defaultMutedWords;
|
||||||
|
matchWholeWord = defaultMatchWholeWord;
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Other settings
|
// * Other settings
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ SettingsStore _$SettingsStoreFromJson(
|
||||||
..showVideo = json['showVideo'] as bool? ?? true
|
..showVideo = json['showVideo'] as bool? ?? true
|
||||||
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
||||||
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
||||||
..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true
|
..useNativePlayer = json['useNativePlayer'] as bool? ?? false
|
||||||
..showOverlay = json['showOverlay'] as bool? ?? true
|
..showOverlay = json['showOverlay'] as bool? ?? true
|
||||||
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
|
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
|
||||||
..showLatency = json['showLatency'] as bool? ?? false
|
..showLatency = json['showLatency'] as bool? ?? false
|
||||||
|
|
@ -41,12 +41,23 @@ SettingsStore _$SettingsStoreFromJson(
|
||||||
unknownValue: TimestampType.disabled,
|
unknownValue: TimestampType.disabled,
|
||||||
) ??
|
) ??
|
||||||
TimestampType.disabled
|
TimestampType.disabled
|
||||||
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
|
|
||||||
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
|
|
||||||
..highlightFirstTimeChatter =
|
..highlightFirstTimeChatter =
|
||||||
json['highlightFirstTimeChatter'] as bool? ?? true
|
json['highlightFirstTimeChatter'] as bool? ?? true
|
||||||
..showUserNotices = json['showUserNotices'] as bool? ?? true
|
..showUserNotices = json['showUserNotices'] as bool? ?? true
|
||||||
|
..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true
|
||||||
|
..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true
|
||||||
|
..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true
|
||||||
|
..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true
|
||||||
|
..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true
|
||||||
|
..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true
|
||||||
|
..showFFZBadges = json['showFFZBadges'] as bool? ?? true
|
||||||
..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false
|
..emoteMenuButtonOnLeft = json['emoteMenuButtonOnLeft'] as bool? ?? false
|
||||||
|
..persistChatTabs = json['persistChatTabs'] as bool? ?? true
|
||||||
|
..secondaryTabs =
|
||||||
|
(json['secondaryTabs'] as List<dynamic>?)
|
||||||
|
?.map((e) => PersistedChatTab.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[]
|
||||||
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
|
..landscapeChatLeftSide = json['landscapeChatLeftSide'] as bool? ?? false
|
||||||
..landscapeForceVerticalChat =
|
..landscapeForceVerticalChat =
|
||||||
json['landscapeForceVerticalChat'] as bool? ?? false
|
json['landscapeForceVerticalChat'] as bool? ?? false
|
||||||
|
|
@ -59,21 +70,11 @@ SettingsStore _$SettingsStoreFromJson(
|
||||||
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2
|
..chatWidth = (json['chatWidth'] as num?)?.toDouble() ?? 0.2
|
||||||
..fullScreenChatOverlayOpacity =
|
..fullScreenChatOverlayOpacity =
|
||||||
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
|
(json['fullScreenChatOverlayOpacity'] as num?)?.toDouble() ?? 0.5
|
||||||
|
..keepScreenAwake = json['keepScreenAwake'] as bool? ?? true
|
||||||
..autocomplete = json['autocomplete'] as bool? ?? true
|
..autocomplete = json['autocomplete'] as bool? ?? true
|
||||||
..showTwitchEmotes = json['showTwitchEmotes'] as bool? ?? true
|
|
||||||
..showTwitchBadges = json['showTwitchBadges'] as bool? ?? true
|
|
||||||
..show7TVEmotes = json['show7TVEmotes'] as bool? ?? true
|
|
||||||
..showBTTVEmotes = json['showBTTVEmotes'] as bool? ?? true
|
|
||||||
..showBTTVBadges = json['showBTTVBadges'] as bool? ?? true
|
|
||||||
..showFFZEmotes = json['showFFZEmotes'] as bool? ?? true
|
|
||||||
..showFFZBadges = json['showFFZBadges'] as bool? ?? true
|
|
||||||
..showRecentMessages = json['showRecentMessages'] as bool? ?? false
|
..showRecentMessages = json['showRecentMessages'] as bool? ?? false
|
||||||
..persistChatTabs = json['persistChatTabs'] as bool? ?? true
|
..autoSyncChatDelay = json['autoSyncChatDelay'] as bool? ?? false
|
||||||
..secondaryTabs =
|
..chatDelay = (json['chatDelay'] as num?)?.toDouble() ?? 0.0
|
||||||
(json['secondaryTabs'] as List<dynamic>?)
|
|
||||||
?.map((e) => PersistedChatTab.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList() ??
|
|
||||||
[]
|
|
||||||
..mutedWords =
|
..mutedWords =
|
||||||
(json['mutedWords'] as List<dynamic>?)
|
(json['mutedWords'] as List<dynamic>?)
|
||||||
?.map((e) => e as String)
|
?.map((e) => e as String)
|
||||||
|
|
@ -101,7 +102,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
||||||
'showVideo': instance.showVideo,
|
'showVideo': instance.showVideo,
|
||||||
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
||||||
'useTextureRendering': instance.useTextureRendering,
|
'useTextureRendering': instance.useTextureRendering,
|
||||||
'keepScreenAwake': instance.keepScreenAwake,
|
'useNativePlayer': instance.useNativePlayer,
|
||||||
'showOverlay': instance.showOverlay,
|
'showOverlay': instance.showOverlay,
|
||||||
'toggleableOverlay': instance.toggleableOverlay,
|
'toggleableOverlay': instance.toggleableOverlay,
|
||||||
'showLatency': instance.showLatency,
|
'showLatency': instance.showLatency,
|
||||||
|
|
@ -113,17 +114,8 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
||||||
'showDeletedMessages': instance.showDeletedMessages,
|
'showDeletedMessages': instance.showDeletedMessages,
|
||||||
'showChatMessageDividers': instance.showChatMessageDividers,
|
'showChatMessageDividers': instance.showChatMessageDividers,
|
||||||
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
|
'timestampType': _$TimestampTypeEnumMap[instance.timestampType]!,
|
||||||
'autoSyncChatDelay': instance.autoSyncChatDelay,
|
|
||||||
'chatDelay': instance.chatDelay,
|
|
||||||
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
|
'highlightFirstTimeChatter': instance.highlightFirstTimeChatter,
|
||||||
'showUserNotices': instance.showUserNotices,
|
'showUserNotices': instance.showUserNotices,
|
||||||
'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft,
|
|
||||||
'landscapeChatLeftSide': instance.landscapeChatLeftSide,
|
|
||||||
'landscapeForceVerticalChat': instance.landscapeForceVerticalChat,
|
|
||||||
'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!,
|
|
||||||
'chatWidth': instance.chatWidth,
|
|
||||||
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
|
|
||||||
'autocomplete': instance.autocomplete,
|
|
||||||
'showTwitchEmotes': instance.showTwitchEmotes,
|
'showTwitchEmotes': instance.showTwitchEmotes,
|
||||||
'showTwitchBadges': instance.showTwitchBadges,
|
'showTwitchBadges': instance.showTwitchBadges,
|
||||||
'show7TVEmotes': instance.show7TVEmotes,
|
'show7TVEmotes': instance.show7TVEmotes,
|
||||||
|
|
@ -131,9 +123,19 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
||||||
'showBTTVBadges': instance.showBTTVBadges,
|
'showBTTVBadges': instance.showBTTVBadges,
|
||||||
'showFFZEmotes': instance.showFFZEmotes,
|
'showFFZEmotes': instance.showFFZEmotes,
|
||||||
'showFFZBadges': instance.showFFZBadges,
|
'showFFZBadges': instance.showFFZBadges,
|
||||||
'showRecentMessages': instance.showRecentMessages,
|
'emoteMenuButtonOnLeft': instance.emoteMenuButtonOnLeft,
|
||||||
'persistChatTabs': instance.persistChatTabs,
|
'persistChatTabs': instance.persistChatTabs,
|
||||||
'secondaryTabs': instance.secondaryTabs,
|
'secondaryTabs': instance.secondaryTabs,
|
||||||
|
'landscapeChatLeftSide': instance.landscapeChatLeftSide,
|
||||||
|
'landscapeForceVerticalChat': instance.landscapeForceVerticalChat,
|
||||||
|
'landscapeCutout': _$LandscapeCutoutTypeEnumMap[instance.landscapeCutout]!,
|
||||||
|
'chatWidth': instance.chatWidth,
|
||||||
|
'fullScreenChatOverlayOpacity': instance.fullScreenChatOverlayOpacity,
|
||||||
|
'keepScreenAwake': instance.keepScreenAwake,
|
||||||
|
'autocomplete': instance.autocomplete,
|
||||||
|
'showRecentMessages': instance.showRecentMessages,
|
||||||
|
'autoSyncChatDelay': instance.autoSyncChatDelay,
|
||||||
|
'chatDelay': instance.chatDelay,
|
||||||
'mutedWords': instance.mutedWords,
|
'mutedWords': instance.mutedWords,
|
||||||
'matchWholeWord': instance.matchWholeWord,
|
'matchWholeWord': instance.matchWholeWord,
|
||||||
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
|
'shareCrashLogsAndAnalytics': instance.shareCrashLogsAndAnalytics,
|
||||||
|
|
@ -325,21 +327,21 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$keepScreenAwakeAtom = Atom(
|
late final _$useNativePlayerAtom = Atom(
|
||||||
name: '_SettingsStoreBase.keepScreenAwake',
|
name: '_SettingsStoreBase.useNativePlayer',
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get keepScreenAwake {
|
bool get useNativePlayer {
|
||||||
_$keepScreenAwakeAtom.reportRead();
|
_$useNativePlayerAtom.reportRead();
|
||||||
return super.keepScreenAwake;
|
return super.useNativePlayer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
set keepScreenAwake(bool value) {
|
set useNativePlayer(bool value) {
|
||||||
_$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () {
|
_$useNativePlayerAtom.reportWrite(value, super.useNativePlayer, () {
|
||||||
super.keepScreenAwake = value;
|
super.useNativePlayer = value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,60 +547,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$autoSyncChatDelayAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.autoSyncChatDelay',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get autoSyncChatDelay {
|
|
||||||
_$autoSyncChatDelayAtom.reportRead();
|
|
||||||
return super.autoSyncChatDelay;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set autoSyncChatDelay(bool value) {
|
|
||||||
_$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () {
|
|
||||||
super.autoSyncChatDelay = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$chatDelayAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.chatDelay',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get chatDelay {
|
|
||||||
_$chatDelayAtom.reportRead();
|
|
||||||
return super.chatDelay;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set chatDelay(double value) {
|
|
||||||
_$chatDelayAtom.reportWrite(value, super.chatDelay, () {
|
|
||||||
super.chatDelay = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$syncedChatDelayAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.syncedChatDelay',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get syncedChatDelay {
|
|
||||||
_$syncedChatDelayAtom.reportRead();
|
|
||||||
return super.syncedChatDelay;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set syncedChatDelay(double value) {
|
|
||||||
_$syncedChatDelayAtom.reportWrite(value, super.syncedChatDelay, () {
|
|
||||||
super.syncedChatDelay = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$highlightFirstTimeChatterAtom = Atom(
|
late final _$highlightFirstTimeChatterAtom = Atom(
|
||||||
name: '_SettingsStoreBase.highlightFirstTimeChatter',
|
name: '_SettingsStoreBase.highlightFirstTimeChatter',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -639,148 +587,6 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$emoteMenuButtonOnLeftAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.emoteMenuButtonOnLeft',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get emoteMenuButtonOnLeft {
|
|
||||||
_$emoteMenuButtonOnLeftAtom.reportRead();
|
|
||||||
return super.emoteMenuButtonOnLeft;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set emoteMenuButtonOnLeft(bool value) {
|
|
||||||
_$emoteMenuButtonOnLeftAtom.reportWrite(
|
|
||||||
value,
|
|
||||||
super.emoteMenuButtonOnLeft,
|
|
||||||
() {
|
|
||||||
super.emoteMenuButtonOnLeft = value;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$landscapeChatLeftSideAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.landscapeChatLeftSide',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get landscapeChatLeftSide {
|
|
||||||
_$landscapeChatLeftSideAtom.reportRead();
|
|
||||||
return super.landscapeChatLeftSide;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set landscapeChatLeftSide(bool value) {
|
|
||||||
_$landscapeChatLeftSideAtom.reportWrite(
|
|
||||||
value,
|
|
||||||
super.landscapeChatLeftSide,
|
|
||||||
() {
|
|
||||||
super.landscapeChatLeftSide = value;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$landscapeForceVerticalChatAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.landscapeForceVerticalChat',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get landscapeForceVerticalChat {
|
|
||||||
_$landscapeForceVerticalChatAtom.reportRead();
|
|
||||||
return super.landscapeForceVerticalChat;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set landscapeForceVerticalChat(bool value) {
|
|
||||||
_$landscapeForceVerticalChatAtom.reportWrite(
|
|
||||||
value,
|
|
||||||
super.landscapeForceVerticalChat,
|
|
||||||
() {
|
|
||||||
super.landscapeForceVerticalChat = value;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$landscapeCutoutAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.landscapeCutout',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
LandscapeCutoutType get landscapeCutout {
|
|
||||||
_$landscapeCutoutAtom.reportRead();
|
|
||||||
return super.landscapeCutout;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set landscapeCutout(LandscapeCutoutType value) {
|
|
||||||
_$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () {
|
|
||||||
super.landscapeCutout = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$chatWidthAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.chatWidth',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get chatWidth {
|
|
||||||
_$chatWidthAtom.reportRead();
|
|
||||||
return super.chatWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set chatWidth(double value) {
|
|
||||||
_$chatWidthAtom.reportWrite(value, super.chatWidth, () {
|
|
||||||
super.chatWidth = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$fullScreenChatOverlayOpacityAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.fullScreenChatOverlayOpacity',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get fullScreenChatOverlayOpacity {
|
|
||||||
_$fullScreenChatOverlayOpacityAtom.reportRead();
|
|
||||||
return super.fullScreenChatOverlayOpacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set fullScreenChatOverlayOpacity(double value) {
|
|
||||||
_$fullScreenChatOverlayOpacityAtom.reportWrite(
|
|
||||||
value,
|
|
||||||
super.fullScreenChatOverlayOpacity,
|
|
||||||
() {
|
|
||||||
super.fullScreenChatOverlayOpacity = value;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$autocompleteAtom = Atom(
|
|
||||||
name: '_SettingsStoreBase.autocomplete',
|
|
||||||
context: context,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get autocomplete {
|
|
||||||
_$autocompleteAtom.reportRead();
|
|
||||||
return super.autocomplete;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set autocomplete(bool value) {
|
|
||||||
_$autocompleteAtom.reportWrite(value, super.autocomplete, () {
|
|
||||||
super.autocomplete = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
late final _$showTwitchEmotesAtom = Atom(
|
late final _$showTwitchEmotesAtom = Atom(
|
||||||
name: '_SettingsStoreBase.showTwitchEmotes',
|
name: '_SettingsStoreBase.showTwitchEmotes',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -907,22 +713,26 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$showRecentMessagesAtom = Atom(
|
late final _$emoteMenuButtonOnLeftAtom = Atom(
|
||||||
name: '_SettingsStoreBase.showRecentMessages',
|
name: '_SettingsStoreBase.emoteMenuButtonOnLeft',
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get showRecentMessages {
|
bool get emoteMenuButtonOnLeft {
|
||||||
_$showRecentMessagesAtom.reportRead();
|
_$emoteMenuButtonOnLeftAtom.reportRead();
|
||||||
return super.showRecentMessages;
|
return super.emoteMenuButtonOnLeft;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
set showRecentMessages(bool value) {
|
set emoteMenuButtonOnLeft(bool value) {
|
||||||
_$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () {
|
_$emoteMenuButtonOnLeftAtom.reportWrite(
|
||||||
super.showRecentMessages = value;
|
value,
|
||||||
});
|
super.emoteMenuButtonOnLeft,
|
||||||
|
() {
|
||||||
|
super.emoteMenuButtonOnLeft = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
late final _$persistChatTabsAtom = Atom(
|
late final _$persistChatTabsAtom = Atom(
|
||||||
|
|
@ -961,6 +771,216 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final _$landscapeChatLeftSideAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.landscapeChatLeftSide',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get landscapeChatLeftSide {
|
||||||
|
_$landscapeChatLeftSideAtom.reportRead();
|
||||||
|
return super.landscapeChatLeftSide;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set landscapeChatLeftSide(bool value) {
|
||||||
|
_$landscapeChatLeftSideAtom.reportWrite(
|
||||||
|
value,
|
||||||
|
super.landscapeChatLeftSide,
|
||||||
|
() {
|
||||||
|
super.landscapeChatLeftSide = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$landscapeForceVerticalChatAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.landscapeForceVerticalChat',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get landscapeForceVerticalChat {
|
||||||
|
_$landscapeForceVerticalChatAtom.reportRead();
|
||||||
|
return super.landscapeForceVerticalChat;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set landscapeForceVerticalChat(bool value) {
|
||||||
|
_$landscapeForceVerticalChatAtom.reportWrite(
|
||||||
|
value,
|
||||||
|
super.landscapeForceVerticalChat,
|
||||||
|
() {
|
||||||
|
super.landscapeForceVerticalChat = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$landscapeCutoutAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.landscapeCutout',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
LandscapeCutoutType get landscapeCutout {
|
||||||
|
_$landscapeCutoutAtom.reportRead();
|
||||||
|
return super.landscapeCutout;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set landscapeCutout(LandscapeCutoutType value) {
|
||||||
|
_$landscapeCutoutAtom.reportWrite(value, super.landscapeCutout, () {
|
||||||
|
super.landscapeCutout = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$chatWidthAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.chatWidth',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get chatWidth {
|
||||||
|
_$chatWidthAtom.reportRead();
|
||||||
|
return super.chatWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set chatWidth(double value) {
|
||||||
|
_$chatWidthAtom.reportWrite(value, super.chatWidth, () {
|
||||||
|
super.chatWidth = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$fullScreenChatOverlayOpacityAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.fullScreenChatOverlayOpacity',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get fullScreenChatOverlayOpacity {
|
||||||
|
_$fullScreenChatOverlayOpacityAtom.reportRead();
|
||||||
|
return super.fullScreenChatOverlayOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set fullScreenChatOverlayOpacity(double value) {
|
||||||
|
_$fullScreenChatOverlayOpacityAtom.reportWrite(
|
||||||
|
value,
|
||||||
|
super.fullScreenChatOverlayOpacity,
|
||||||
|
() {
|
||||||
|
super.fullScreenChatOverlayOpacity = value;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$keepScreenAwakeAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.keepScreenAwake',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get keepScreenAwake {
|
||||||
|
_$keepScreenAwakeAtom.reportRead();
|
||||||
|
return super.keepScreenAwake;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set keepScreenAwake(bool value) {
|
||||||
|
_$keepScreenAwakeAtom.reportWrite(value, super.keepScreenAwake, () {
|
||||||
|
super.keepScreenAwake = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$autocompleteAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.autocomplete',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get autocomplete {
|
||||||
|
_$autocompleteAtom.reportRead();
|
||||||
|
return super.autocomplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set autocomplete(bool value) {
|
||||||
|
_$autocompleteAtom.reportWrite(value, super.autocomplete, () {
|
||||||
|
super.autocomplete = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$showRecentMessagesAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.showRecentMessages',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get showRecentMessages {
|
||||||
|
_$showRecentMessagesAtom.reportRead();
|
||||||
|
return super.showRecentMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set showRecentMessages(bool value) {
|
||||||
|
_$showRecentMessagesAtom.reportWrite(value, super.showRecentMessages, () {
|
||||||
|
super.showRecentMessages = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$autoSyncChatDelayAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.autoSyncChatDelay',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get autoSyncChatDelay {
|
||||||
|
_$autoSyncChatDelayAtom.reportRead();
|
||||||
|
return super.autoSyncChatDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set autoSyncChatDelay(bool value) {
|
||||||
|
_$autoSyncChatDelayAtom.reportWrite(value, super.autoSyncChatDelay, () {
|
||||||
|
super.autoSyncChatDelay = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$chatDelayAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.chatDelay',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get chatDelay {
|
||||||
|
_$chatDelayAtom.reportRead();
|
||||||
|
return super.chatDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set chatDelay(double value) {
|
||||||
|
_$chatDelayAtom.reportWrite(value, super.chatDelay, () {
|
||||||
|
super.chatDelay = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
late final _$syncedChatDelayAtom = Atom(
|
||||||
|
name: '_SettingsStoreBase.syncedChatDelay',
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get syncedChatDelay {
|
||||||
|
_$syncedChatDelayAtom.reportRead();
|
||||||
|
return super.syncedChatDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
set syncedChatDelay(double value) {
|
||||||
|
_$syncedChatDelayAtom.reportWrite(value, super.syncedChatDelay, () {
|
||||||
|
super.syncedChatDelay = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
late final _$mutedWordsAtom = Atom(
|
late final _$mutedWordsAtom = Atom(
|
||||||
name: '_SettingsStoreBase.mutedWords',
|
name: '_SettingsStoreBase.mutedWords',
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -1165,7 +1185,7 @@ launchUrlExternal: ${launchUrlExternal},
|
||||||
showVideo: ${showVideo},
|
showVideo: ${showVideo},
|
||||||
defaultToHighestQuality: ${defaultToHighestQuality},
|
defaultToHighestQuality: ${defaultToHighestQuality},
|
||||||
useTextureRendering: ${useTextureRendering},
|
useTextureRendering: ${useTextureRendering},
|
||||||
keepScreenAwake: ${keepScreenAwake},
|
useNativePlayer: ${useNativePlayer},
|
||||||
showOverlay: ${showOverlay},
|
showOverlay: ${showOverlay},
|
||||||
toggleableOverlay: ${toggleableOverlay},
|
toggleableOverlay: ${toggleableOverlay},
|
||||||
showLatency: ${showLatency},
|
showLatency: ${showLatency},
|
||||||
|
|
@ -1177,18 +1197,8 @@ fontSize: ${fontSize},
|
||||||
showDeletedMessages: ${showDeletedMessages},
|
showDeletedMessages: ${showDeletedMessages},
|
||||||
showChatMessageDividers: ${showChatMessageDividers},
|
showChatMessageDividers: ${showChatMessageDividers},
|
||||||
timestampType: ${timestampType},
|
timestampType: ${timestampType},
|
||||||
autoSyncChatDelay: ${autoSyncChatDelay},
|
|
||||||
chatDelay: ${chatDelay},
|
|
||||||
syncedChatDelay: ${syncedChatDelay},
|
|
||||||
highlightFirstTimeChatter: ${highlightFirstTimeChatter},
|
highlightFirstTimeChatter: ${highlightFirstTimeChatter},
|
||||||
showUserNotices: ${showUserNotices},
|
showUserNotices: ${showUserNotices},
|
||||||
emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
|
|
||||||
landscapeChatLeftSide: ${landscapeChatLeftSide},
|
|
||||||
landscapeForceVerticalChat: ${landscapeForceVerticalChat},
|
|
||||||
landscapeCutout: ${landscapeCutout},
|
|
||||||
chatWidth: ${chatWidth},
|
|
||||||
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
|
|
||||||
autocomplete: ${autocomplete},
|
|
||||||
showTwitchEmotes: ${showTwitchEmotes},
|
showTwitchEmotes: ${showTwitchEmotes},
|
||||||
showTwitchBadges: ${showTwitchBadges},
|
showTwitchBadges: ${showTwitchBadges},
|
||||||
show7TVEmotes: ${show7TVEmotes},
|
show7TVEmotes: ${show7TVEmotes},
|
||||||
|
|
@ -1196,9 +1206,20 @@ showBTTVEmotes: ${showBTTVEmotes},
|
||||||
showBTTVBadges: ${showBTTVBadges},
|
showBTTVBadges: ${showBTTVBadges},
|
||||||
showFFZEmotes: ${showFFZEmotes},
|
showFFZEmotes: ${showFFZEmotes},
|
||||||
showFFZBadges: ${showFFZBadges},
|
showFFZBadges: ${showFFZBadges},
|
||||||
showRecentMessages: ${showRecentMessages},
|
emoteMenuButtonOnLeft: ${emoteMenuButtonOnLeft},
|
||||||
persistChatTabs: ${persistChatTabs},
|
persistChatTabs: ${persistChatTabs},
|
||||||
secondaryTabs: ${secondaryTabs},
|
secondaryTabs: ${secondaryTabs},
|
||||||
|
landscapeChatLeftSide: ${landscapeChatLeftSide},
|
||||||
|
landscapeForceVerticalChat: ${landscapeForceVerticalChat},
|
||||||
|
landscapeCutout: ${landscapeCutout},
|
||||||
|
chatWidth: ${chatWidth},
|
||||||
|
fullScreenChatOverlayOpacity: ${fullScreenChatOverlayOpacity},
|
||||||
|
keepScreenAwake: ${keepScreenAwake},
|
||||||
|
autocomplete: ${autocomplete},
|
||||||
|
showRecentMessages: ${showRecentMessages},
|
||||||
|
autoSyncChatDelay: ${autoSyncChatDelay},
|
||||||
|
chatDelay: ${chatDelay},
|
||||||
|
syncedChatDelay: ${syncedChatDelay},
|
||||||
mutedWords: ${mutedWords},
|
mutedWords: ${mutedWords},
|
||||||
matchWholeWord: ${matchWholeWord},
|
matchWholeWord: ${matchWholeWord},
|
||||||
shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics},
|
shareCrashLogsAndAnalytics: ${shareCrashLogsAndAnalytics},
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,20 @@ class VideoSettings extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
const SectionHeader('Player', isFirst: true),
|
const SectionHeader('Player', isFirst: true),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Enable video',
|
title: 'Show video player',
|
||||||
value: settingsStore.showVideo,
|
value: settingsStore.showVideo,
|
||||||
onChanged: (newValue) => settingsStore.showVideo = newValue,
|
onChanged: (newValue) => settingsStore.showVideo = newValue,
|
||||||
),
|
),
|
||||||
|
if (settingsStore.showVideo)
|
||||||
|
SettingsListSwitch(
|
||||||
|
title: 'Native player (experimental)',
|
||||||
|
subtitle: const Text(
|
||||||
|
'More performant video player with auto picture-in-picture and quality selection.',
|
||||||
|
),
|
||||||
|
value: settingsStore.useNativePlayer,
|
||||||
|
onChanged: (newValue) =>
|
||||||
|
settingsStore.useNativePlayer = newValue,
|
||||||
|
),
|
||||||
if (!Platform.isIOS || isIPad())
|
if (!Platform.isIOS || isIPad())
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Default to highest quality',
|
title: 'Default to highest quality',
|
||||||
|
|
@ -41,14 +51,6 @@ class VideoSettings extends StatelessWidget {
|
||||||
onChanged: (newValue) =>
|
onChanged: (newValue) =>
|
||||||
settingsStore.useTextureRendering = newValue,
|
settingsStore.useTextureRendering = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
|
||||||
title: 'Keep screen awake',
|
|
||||||
subtitle: const Text(
|
|
||||||
'Prevents the screen from sleeping while a channel is open.',
|
|
||||||
),
|
|
||||||
value: settingsStore.keepScreenAwake,
|
|
||||||
onChanged: (newValue) => settingsStore.keepScreenAwake = newValue,
|
|
||||||
),
|
|
||||||
const SectionHeader('Overlay'),
|
const SectionHeader('Overlay'),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Use custom video overlay',
|
title: 'Use custom video overlay',
|
||||||
|
|
@ -59,12 +61,13 @@ class VideoSettings extends StatelessWidget {
|
||||||
onChanged: (newValue) => settingsStore.showOverlay = newValue,
|
onChanged: (newValue) => settingsStore.showOverlay = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Long-press player to toggle overlay',
|
title: 'Toggle overlay on long-press',
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'Allows switching between Twitch\'s overlay and the custom overlay.',
|
'Switch between Twitch\'s overlay and the custom overlay.',
|
||||||
),
|
),
|
||||||
value: settingsStore.toggleableOverlay,
|
value: settingsStore.toggleableOverlay,
|
||||||
onChanged: (newValue) => settingsStore.toggleableOverlay = newValue,
|
onChanged: (newValue) =>
|
||||||
|
settingsStore.toggleableOverlay = newValue,
|
||||||
),
|
),
|
||||||
SettingsListSwitch(
|
SettingsListSwitch(
|
||||||
title: 'Show latency',
|
title: 'Show latency',
|
||||||
|
|
|
||||||
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(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:frosty/apis/bttv_api.dart';
|
||||||
import 'package:frosty/apis/ffz_api.dart';
|
import 'package:frosty/apis/ffz_api.dart';
|
||||||
import 'package:frosty/apis/seventv_api.dart';
|
import 'package:frosty/apis/seventv_api.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
|
import 'package:frosty/apis/twitch_gql_api.dart';
|
||||||
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
import 'package:frosty/screens/settings/stores/auth_store.dart';
|
||||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||||
import 'package:frosty/stores/global_assets_store.dart';
|
import 'package:frosty/stores/global_assets_store.dart';
|
||||||
|
|
@ -71,6 +72,9 @@ extension ContextExtensions on BuildContext {
|
||||||
/// Gets TwitchApi from provider
|
/// Gets TwitchApi from provider
|
||||||
TwitchApi get twitchApi => read<TwitchApi>();
|
TwitchApi get twitchApi => read<TwitchApi>();
|
||||||
|
|
||||||
|
/// Gets TwitchGqlApi from provider
|
||||||
|
TwitchGqlApi get twitchGqlApi => read<TwitchGqlApi>();
|
||||||
|
|
||||||
/// Gets AuthStore from provider
|
/// Gets AuthStore from provider
|
||||||
AuthStore get authStore => read<AuthStore>();
|
AuthStore get authStore => read<AuthStore>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,11 @@ class ExpandableSectionHeader extends StatelessWidget {
|
||||||
child: Text(
|
child: Text(
|
||||||
text.toUpperCase(),
|
text.toUpperCase(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: fontSize ?? 13,
|
fontSize: fontSize ?? 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w500,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.8,
|
||||||
color: context.colorScheme.onSurface.withValues(
|
color: context.colorScheme.onSurfaceVariant.withValues(
|
||||||
alpha: 0.6,
|
alpha: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -65,7 +65,9 @@ class ExpandableSectionHeader extends StatelessWidget {
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.keyboard_arrow_down,
|
Icons.keyboard_arrow_down,
|
||||||
color: context.colorScheme.onSurfaceVariant,
|
color: context.colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,12 @@ class SectionHeader extends StatelessWidget {
|
||||||
Text(
|
Text(
|
||||||
text.toUpperCase(),
|
text.toUpperCase(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: fontSize ?? 13,
|
fontSize: fontSize ?? 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w500,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.8,
|
||||||
color: context.colorScheme.onSurfaceVariant,
|
color: context.colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class _SettingsPageLayoutState extends State<SettingsPageLayout> {
|
||||||
|
|
||||||
// Responsive values based on orientation
|
// Responsive values based on orientation
|
||||||
final listPadding = EdgeInsets.only(
|
final listPadding = EdgeInsets.only(
|
||||||
top: isPortrait ? 116 : 92,
|
top: isPortrait ? 124 : 100,
|
||||||
bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0,
|
bottom: widget.hasBottomPadding ? context.safePaddingBottom + 8 : 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
41
pubspec.lock
41
pubspec.lock
|
|
@ -97,6 +97,15 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
version: "2.13.0"
|
||||||
|
better_native_video_player:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "."
|
||||||
|
ref: frosty-patches
|
||||||
|
resolved-ref: "92b2c025b368225c4ee75db567f561be78c5ce45"
|
||||||
|
url: "https://github.com/tommyxchow/flutter-native-video-player.git"
|
||||||
|
source: git
|
||||||
|
version: "0.4.11"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -181,10 +190,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -313,6 +322,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
dismissible_page:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dismissible_page
|
||||||
|
sha256: "5b2316f770fe83583f770df1f6505cb19102081c5971979806e77f2e507a9958"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
extended_text_field:
|
extended_text_field:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -449,6 +466,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
floating:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: floating
|
||||||
|
sha256: e51ce1dbcab3ea83da0ad1b07f2091b99c0e8680184c780a000567f41234d454
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.0"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -772,18 +797,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.19"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1242,10 +1267,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.10"
|
version: "0.7.7"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ dependencies:
|
||||||
app_links: ^7.0.0
|
app_links: ^7.0.0
|
||||||
extended_text_field: ^16.0.2
|
extended_text_field: ^16.0.2
|
||||||
native_device_orientation: ^2.1.0
|
native_device_orientation: ^2.1.0
|
||||||
|
better_native_video_player:
|
||||||
|
git:
|
||||||
|
url: https://github.com/tommyxchow/flutter-native-video-player.git
|
||||||
|
ref: frosty-patches
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:frosty/apis/base_api_client.dart';
|
import 'package:frosty/apis/base_api_client.dart';
|
||||||
import 'package:frosty/apis/twitch_api.dart';
|
import 'package:frosty/apis/twitch_api.dart';
|
||||||
import 'package:frosty/models/emotes.dart';
|
import 'package:frosty/models/emotes.dart';
|
||||||
|
import 'package:frosty/models/video.dart';
|
||||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||||
|
|
||||||
import '../fixtures/api_responses.dart';
|
import '../fixtures/api_responses.dart';
|
||||||
|
|
@ -389,4 +390,63 @@ void main() {
|
||||||
expect(emotes[2].type, EmoteType.twitchChannel);
|
expect(emotes[2].type, EmoteType.twitchChannel);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('getVideos', () {
|
||||||
|
test('returns videos for a user', () async {
|
||||||
|
dioAdapter.onGet(
|
||||||
|
'https://api.twitch.tv/helix/videos',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': [
|
||||||
|
{
|
||||||
|
'id': '12345',
|
||||||
|
'stream_id': '67890',
|
||||||
|
'user_id': '111',
|
||||||
|
'user_login': 'testuser',
|
||||||
|
'user_name': 'TestUser',
|
||||||
|
'title': 'Test VOD',
|
||||||
|
'created_at': '2024-01-15T10:30:00Z',
|
||||||
|
'published_at': '2024-01-15T10:30:00Z',
|
||||||
|
'duration': '3h26m15s',
|
||||||
|
'thumbnail_url': 'https://cdn/thumb.jpg',
|
||||||
|
'view_count': 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'pagination': {'cursor': 'next_page'},
|
||||||
|
}),
|
||||||
|
queryParameters: {
|
||||||
|
'user_id': '111',
|
||||||
|
'first': '20',
|
||||||
|
'type': 'archive',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final videos = await api.getVideos(userId: '111');
|
||||||
|
|
||||||
|
expect(videos.data, hasLength(1));
|
||||||
|
expect(videos.data.first.id, '12345');
|
||||||
|
expect(videos.data.first.title, 'Test VOD');
|
||||||
|
expect(videos.data.first.duration, '3h26m15s');
|
||||||
|
expect(videos.pagination['cursor'], 'next_page');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes cursor for pagination', () async {
|
||||||
|
dioAdapter.onGet(
|
||||||
|
'https://api.twitch.tv/helix/videos',
|
||||||
|
(server) => server.reply(200, {
|
||||||
|
'data': <Map<String, dynamic>>[],
|
||||||
|
'pagination': <String, String>{},
|
||||||
|
}),
|
||||||
|
queryParameters: {
|
||||||
|
'user_id': '111',
|
||||||
|
'first': '20',
|
||||||
|
'type': 'archive',
|
||||||
|
'after': 'page2',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final videos = await api.getVideos(userId: '111', cursor: 'page2');
|
||||||
|
|
||||||
|
expect(videos.data, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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