From f8172fc04fede4241257e588228d7835b550baad Mon Sep 17 00:00:00 2001 From: SputNikPlop <100245448+SputNikPlop@users.noreply.github.com> Date: Sun, 25 May 2025 01:00:33 -0700 Subject: [PATCH 1/4] fix: quality selector --- lib/components/stream_preview.dart | 4 +-- lib/models/stream_preview.dart | 21 +++++++++++++++ lib/screens/settings/chat_history.dart | 36 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/lib/components/stream_preview.dart b/lib/components/stream_preview.dart index 233e5653e..6f92a4c5c 100644 --- a/lib/components/stream_preview.dart +++ b/lib/components/stream_preview.dart @@ -110,7 +110,7 @@ class _StreamPreviewState extends State { "window.action(window.Actions.SetQuality, 'auto')"); } else { await _controller.runJavaScript( - "window.action(window.Actions.SetQuality, '160p')"); + "window.action(window.Actions.SetQuality, ${jsonEncode(model.quality)}"); } } }, @@ -233,7 +233,7 @@ class _StreamPreviewState extends State { "window.action(window.Actions.SetQuality, 'auto')"); } else { await _controller.runJavaScript( - "window.action(window.Actions.SetQuality, '160p')"); + "window.action(window.Actions.SetQuality, ${jsonEncode(model.quality)})"); } }, color: Colors.white, diff --git a/lib/models/stream_preview.dart b/lib/models/stream_preview.dart index 50fd553fe..d7fcd6406 100644 --- a/lib/models/stream_preview.dart +++ b/lib/models/stream_preview.dart @@ -6,6 +6,22 @@ class StreamPreviewModel extends ChangeNotifier { var _isHighDefinition = false; var _volume = 0; var _showBatteryPrompt = true; + var _quality = '160p'; + + static const List supportedQualities = [ + '160p', + '360p', + '480p', + '720p', + '1080p', + ]; + + String get quality => _quality; + + set quality(String value) { + _quality = value; + notifyListeners(); + } bool get isHighDefinition => _isHighDefinition; @@ -29,6 +45,10 @@ class StreamPreviewModel extends ChangeNotifier { } StreamPreviewModel.fromJson(Map json) { + if (json['quality'] != null) { + _quality = json['quality']; + } + if (json['isHighDefinition'] != null) { _isHighDefinition = json['isHighDefinition']; } @@ -44,5 +64,6 @@ class StreamPreviewModel extends ChangeNotifier { 'isHighDefinition': _isHighDefinition, 'volume': _volume, 'showBatteryPrompt': _showBatteryPrompt, + 'quality': _quality }; } diff --git a/lib/screens/settings/chat_history.dart b/lib/screens/settings/chat_history.dart index 5b60b0867..770dc402c 100644 --- a/lib/screens/settings/chat_history.dart +++ b/lib/screens/settings/chat_history.dart @@ -8,6 +8,8 @@ import 'package:rtchat/models/messages/twitch/message.dart'; import 'package:rtchat/models/messages/twitch/user.dart'; import 'package:rtchat/models/style.dart'; +import '../../models/stream_preview.dart'; + final message1 = TwitchMessageModel( messageId: "placeholder1", author: const TwitchUserModel(userId: 'muxfd', login: 'muxfd'), @@ -209,6 +211,40 @@ class ChatHistoryScreen extends StatelessWidget { ], ), ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Stream Preview Quality", + style: DefaultTextStyle.of(context).style.copyWith( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + )), + Consumer( + builder: (context, model, child) { + return DropdownButton( + value: model.quality, + icon: const Icon(Icons.arrow_drop_down), + underline: Container(height: 1, color: Colors.grey), + onChanged: (String? newValue) { + if (newValue != null) { + model.quality = newValue; + } + }, + items: StreamPreviewModel.supportedQualities + .map>((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + ); + }, + ), + ], + ), + ), Padding( padding: const EdgeInsets.all(16), child: Column( From 5730adbaee2f06ce868a9840e6fafce84656bf18 Mon Sep 17 00:00:00 2001 From: SputNikPlop <100245448+SputNikPlop@users.noreply.github.com> Date: Fri, 30 May 2025 09:51:50 -0700 Subject: [PATCH 2/4] fix: tweaks --- assets/twitch-tunnel.js | 38 +++++++++ lib/components/stream_preview.dart | 108 ++++++++++++++++++------- lib/screens/settings/chat_history.dart | 3 +- 3 files changed, 120 insertions(+), 29 deletions(-) diff --git a/assets/twitch-tunnel.js b/assets/twitch-tunnel.js index d21eaed1c..d52019b1f 100644 --- a/assets/twitch-tunnel.js +++ b/assets/twitch-tunnel.js @@ -37,6 +37,44 @@ window.action = function(eventName, params) { ); } + +window.detectPlayerCapabilities = function() { + // Wait for player to be available + const checkPlayer = setInterval(() => { + if (window.player && typeof player.getQualities === 'function') { + clearInterval(checkPlayer); + + try { + // Get available qualities + const qualities = player.getQualities().map(q => q.group); + + // Send to Flutter + if (window.Flutter) { + Flutter.postMessage(JSON.stringify({ + type: 'playerCapabilities', + qualities: qualities, + currentQuality: player.getQuality() + })); + } + } catch (e) { + console.error('Quality detection failed:', e); + } + } + }, 500); +}; + +// Initialize when Twitch player is ready +if (typeof Twitch !== 'undefined') { + Twitch.Player.READY && Twitch.Player.READY(() => { + window.detectPlayerCapabilities(); + }); +} + +// Also check when our iframe loads +window.addEventListener('load', () => { + window.detectPlayerCapabilities(); +}); + if (Flutter) { window.addEventListener( "message", diff --git a/lib/components/stream_preview.dart b/lib/components/stream_preview.dart index c03066897..4b592aa97 100644 --- a/lib/components/stream_preview.dart +++ b/lib/components/stream_preview.dart @@ -38,6 +38,10 @@ class _StreamPreviewState extends State { String? _playerState; Timer? _promptTimer; + List _availableQualities = []; + bool _hasQualityOptions = false; + Timer? _qualityCheckTimer; + @override void initState() { super.initState(); @@ -81,10 +85,23 @@ class _StreamPreviewState extends State { ..addJavaScriptChannel("Flutter", onMessageReceived: (message) { try { final data = jsonDecode(message.message); - if (data is Map && data.containsKey('params')) { - final params = data['params']; - if (params is Map && mounted) { - setState(() => _playerState = params["playback"]); + if (data is Map) { + if (data['type'] == 'playerCapabilities') { + if (mounted) { + setState(() { + _availableQualities = + List.from(data['qualities'] ?? []); + _hasQualityOptions = _availableQualities.length > 1; + }); + } + return; + } + + if (data.containsKey('params')) { + final params = data['params']; + if (params is Map && mounted) { + setState(() => _playerState = params["playback"]); + } } } } catch (e, st) { @@ -95,8 +112,10 @@ class _StreamPreviewState extends State { onPageFinished: (url) async { await _controller.runJavaScript( await rootBundle.loadString('assets/twitch-tunnel.js')); + // wait a second for twitch to catch up. await Future.delayed(const Duration(seconds: 1)); + if (Platform.isIOS) { await _controller.runJavaScript( "window.action(window.Actions.SetMuted, ${model.volume == 0})"); @@ -105,16 +124,15 @@ class _StreamPreviewState extends State { .runJavaScript("window.action(window.Actions.SetMuted, false)"); await _controller.runJavaScript( "window.action(window.Actions.SetVolume, ${model.volume / 100})"); - if (model.isHighDefinition) { - await _controller.runJavaScript( - "window.action(window.Actions.SetQuality, 'auto')"); - } else { - await _controller.runJavaScript( - "window.action(window.Actions.SetQuality, ${jsonEncode(model.quality)}"); - } } }, )); + + _qualityCheckTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (mounted) { + _controller.runJavaScript('window.detectPlayerCapabilities()'); + } + }); } @override @@ -122,6 +140,7 @@ class _StreamPreviewState extends State { super.dispose(); _promptTimer?.cancel(); + _qualityCheckTimer?.cancel(); // on iOS, the webview is not disposed when the widget is disposed. // this causes audio to keep playing even when the widget is closed. @@ -141,6 +160,35 @@ class _StreamPreviewState extends State { } } + Future _setQuality(StreamPreviewModel model) async { + if (!_hasQualityOptions) return; + + if (model.isHighDefinition) { + if (_availableQualities.contains('auto')) { + await _controller + .runJavaScript("window.action(window.Actions.SetQuality, 'auto')"); + } else { + final hdOptions = _availableQualities + .where((q) => q.contains('720') || q.contains('1080')) + .toList(); + if (hdOptions.isNotEmpty) { + await _controller.runJavaScript( + "window.action(window.Actions.SetQuality, '${hdOptions.last}')"); + } + } + } else { + final sdOptions = _availableQualities + .where( + (q) => !q.contains('720') && !q.contains('1080') && q != 'auto') + .toList(); + + final qualityToUse = sdOptions.isNotEmpty ? sdOptions.first : '360p'; + + await _controller.runJavaScript( + "window.action(window.Actions.SetQuality, '$qualityToUse')"); + } + } + @override Widget build(BuildContext context) { return Stack(children: [ @@ -223,23 +271,29 @@ class _StreamPreviewState extends State { // SetQuality doesn't seem to work on ios so we don't show the button. if (!Platform.isIOS) IconButton( - onPressed: !_isOverlayActive - ? null - : () async { - model.isHighDefinition = - !model.isHighDefinition; - if (model.isHighDefinition) { - await _controller.runJavaScript( - "window.action(window.Actions.SetQuality, 'auto')"); - } else { - await _controller.runJavaScript( - "window.action(window.Actions.SetQuality, ${jsonEncode(model.quality)})"); - } - }, + onPressed: + !_isOverlayActive || !_hasQualityOptions + ? null + : () async { + model.isHighDefinition = + !model.isHighDefinition; + await _setQuality(model); + }, color: Colors.white, - icon: Icon(model.isHighDefinition - ? Icons.hd - : Icons.sd)), + icon: Stack( + children: [ + Icon(model.isHighDefinition + ? Icons.hd + : Icons.sd), + if (!_hasQualityOptions) + const Positioned( + right: 0, + bottom: 0, + child: Icon(Icons.block, + size: 12, color: Colors.red), + ) + ], + )), ], ); }, diff --git a/lib/screens/settings/chat_history.dart b/lib/screens/settings/chat_history.dart index 770dc402c..51bd8b1a4 100644 --- a/lib/screens/settings/chat_history.dart +++ b/lib/screens/settings/chat_history.dart @@ -6,10 +6,9 @@ import 'package:rtchat/models/messages.dart'; import 'package:rtchat/models/messages/twitch/emote.dart'; import 'package:rtchat/models/messages/twitch/message.dart'; import 'package:rtchat/models/messages/twitch/user.dart'; +import 'package:rtchat/models/stream_preview.dart'; import 'package:rtchat/models/style.dart'; -import '../../models/stream_preview.dart'; - final message1 = TwitchMessageModel( messageId: "placeholder1", author: const TwitchUserModel(userId: 'muxfd', login: 'muxfd'), From fedc932ac994738b176540bfa6bd2248a2ebcefa Mon Sep 17 00:00:00 2001 From: SputNikPlop <100245448+SputNikPlop@users.noreply.github.com> Date: Fri, 30 May 2025 21:53:37 -0700 Subject: [PATCH 3/4] fix: tweaks --- assets/twitch-tunnel.js | 96 ++++------ lib/components/stream_preview.dart | 256 ++++++++++--------------- lib/models/stream_preview.dart | 37 +++- lib/screens/settings/chat_history.dart | 5 +- 4 files changed, 173 insertions(+), 221 deletions(-) diff --git a/assets/twitch-tunnel.js b/assets/twitch-tunnel.js index d52019b1f..31ae47d34 100644 --- a/assets/twitch-tunnel.js +++ b/assets/twitch-tunnel.js @@ -16,69 +16,57 @@ document.body.appendChild(ifr); window.parent = ifr.contentWindow; window.Actions = { - DisableCaptions: 0, - EnableCaptions: 1, - Pause: 2, - Play: 3, - Seek: 4, - SetChannel: 5, - SetChannelID: 6, - SetCollection: 7, - SetQuality: 8, - SetVideo: 9, - SetMuted: 10, - SetVolume: 11, + DisableCaptions: 0, + EnableCaptions: 1, + Pause: 2, + Play: 3, + Seek: 4, + SetChannel: 5, + SetChannelID: 6, + SetCollection: 7, + SetQuality: 8, + SetVideo: 9, + SetMuted: 10, + SetVolume: 11, }; window.action = function(eventName, params) { - ifr.contentWindow.postMessage( - { eventName, params, namespace: "twitch-embed-player-proxy" }, - "*" - ); + ifr.contentWindow.postMessage({ + eventName, + params, + namespace: "twitch-embed-player-proxy" + }, "*"); +}; + +if (Flutter) { + window.addEventListener( + "message", (e) => Flutter.postMessage(JSON.stringify(e.data)), false + ); } +function hookPlayer(player) { -window.detectPlayerCapabilities = function() { - // Wait for player to be available - const checkPlayer = setInterval(() => { - if (window.player && typeof player.getQualities === 'function') { - clearInterval(checkPlayer); + player.addEventListener(Twitch.Player.PLAYING, function() { + let qualities = player.getQualities(); - try { - // Get available qualities - const qualities = player.getQualities().map(q => q.group); + qualities = qualities.map(q => + (typeof q === "object" && q.group) ? q.group : q + ); - // Send to Flutter - if (window.Flutter) { - Flutter.postMessage(JSON.stringify({ - type: 'playerCapabilities', - qualities: qualities, - currentQuality: player.getQuality() - })); + if (Flutter) { + Flutter.postMessage(JSON.stringify({ + event: "qualities_available", + qualities: qualities + })); } - } catch (e) { - console.error('Quality detection failed:', e); - } - } - }, 500); -}; - -// Initialize when Twitch player is ready -if (typeof Twitch !== 'undefined') { - Twitch.Player.READY && Twitch.Player.READY(() => { - window.detectPlayerCapabilities(); - }); + }); } -// Also check when our iframe loads -window.addEventListener('load', () => { - window.detectPlayerCapabilities(); -}); +window.addEventListener("message", function init(e) { + const data = e.data; + if (data && data.namespace === "twitch-embed-player-proxy" && data.eventName === "PlayerReady") { -if (Flutter) { - window.addEventListener( - "message", - (e) => Flutter.postMessage(JSON.stringify(e.data)), - false - ); -} + hookPlayer(data.params.player); + window.removeEventListener("message", init); + } +}, false); diff --git a/lib/components/stream_preview.dart b/lib/components/stream_preview.dart index 4b592aa97..a7846a0b3 100644 --- a/lib/components/stream_preview.dart +++ b/lib/components/stream_preview.dart @@ -15,7 +15,6 @@ import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; class StreamPreview extends StatefulWidget { const StreamPreview({super.key, required this.channel}); - final Channel channel; @override @@ -23,30 +22,23 @@ class StreamPreview extends StatefulWidget { } extension Embed on Channel { - Uri get embedUri { - return Uri.parse( - 'https://chat.rtirl.com/embed?provider=$provider&channelId=$channelId'); - } + Uri get embedUri => Uri.parse( + 'https://chat.rtirl.com/embed?provider=$provider&channelId=$channelId'); } class _StreamPreviewState extends State { late WebViewController _controller; late Uri url; - var _isOverlayActive = false; Timer? _overlayTimer; String? _playerState; Timer? _promptTimer; - List _availableQualities = []; - bool _hasQualityOptions = false; - Timer? _qualityCheckTimer; - @override void initState() { super.initState(); - final model = Provider.of(context, listen: false); + if (model.showBatteryPrompt) { _promptTimer = Timer(const Duration(minutes: 5), () { ScaffoldMessenger.of(context).showSnackBar(SnackBar( @@ -64,16 +56,17 @@ class _StreamPreviewState extends State { } url = widget.channel.embedUri; - if (WebViewPlatform.instance is WebKitWebViewPlatform) { _controller = WebViewController.fromPlatformCreationParams( - WebKitWebViewControllerCreationParams( - allowsInlineMediaPlayback: true, - mediaTypesRequiringUserAction: const {}, - )); + WebKitWebViewControllerCreationParams( + allowsInlineMediaPlayback: true, + mediaTypesRequiringUserAction: const {}, + ), + ); } else if (WebViewPlatform.instance is AndroidWebViewPlatform) { _controller = WebViewController.fromPlatformCreationParams( - AndroidWebViewControllerCreationParams()); + AndroidWebViewControllerCreationParams(), + ); } else { throw UnsupportedError("Unsupported platform"); } @@ -81,37 +74,15 @@ class _StreamPreviewState extends State { _controller ..setJavaScriptMode(JavaScriptMode.unrestricted) ..enableZoom(false) - ..loadRequest(url) - ..addJavaScriptChannel("Flutter", onMessageReceived: (message) { - try { - final data = jsonDecode(message.message); - if (data is Map) { - if (data['type'] == 'playerCapabilities') { - if (mounted) { - setState(() { - _availableQualities = - List.from(data['qualities'] ?? []); - _hasQualityOptions = _availableQualities.length > 1; - }); - } - return; - } - - if (data.containsKey('params')) { - final params = data['params']; - if (params is Map && mounted) { - setState(() => _playerState = params["playback"]); - } - } - } - } catch (e, st) { - FirebaseCrashlytics.instance.recordError(e, st); - } - }) + ..addJavaScriptChannel( + 'Flutter', + onMessageReceived: _onJsMessage, + ) ..setNavigationDelegate(NavigationDelegate( - onPageFinished: (url) async { + onPageFinished: (uri) async { await _controller.runJavaScript( - await rootBundle.loadString('assets/twitch-tunnel.js')); + await rootBundle.loadString('assets/twitch-tunnel.js'), + ); // wait a second for twitch to catch up. await Future.delayed(const Duration(seconds: 1)); @@ -124,73 +95,60 @@ class _StreamPreviewState extends State { .runJavaScript("window.action(window.Actions.SetMuted, false)"); await _controller.runJavaScript( "window.action(window.Actions.SetVolume, ${model.volume / 100})"); + await _controller.runJavaScript( + "window.action(window.Actions.SetQuality, '${model.isHighDefinition ? 'auto' : '160p'}')"); } }, - )); + )) + ..loadRequest(url); + } - _qualityCheckTimer = Timer.periodic(const Duration(seconds: 10), (timer) { - if (mounted) { - _controller.runJavaScript('window.detectPlayerCapabilities()'); + void _onJsMessage(JavaScriptMessage message) { + try { + final data = jsonDecode(message.message) as Map; + if (data.containsKey('params') && + data['params'] is Map && + data['params']['playback'] != null) { + setState(() => _playerState = data['params']['playback'] as String); } - }); + if (data['event'] == 'qualities_available') { + final model = Provider.of(context, listen: false); + final List quals = + List.from(data['qualities'] as List); + model.availableQualities = quals; + model.canSwitchQuality = quals.length > 1; + } + } catch (e, st) { + FirebaseCrashlytics.instance.recordError(e, st); + } } @override void dispose() { - super.dispose(); - _promptTimer?.cancel(); - _qualityCheckTimer?.cancel(); - // on iOS, the webview is not disposed when the widget is disposed. // this causes audio to keep playing even when the widget is closed. // therefore, we load a blank page to silence the audio. + if (Platform.isIOS) { _controller.loadHtmlString(" "); } + super.dispose(); } @override void didUpdateWidget(StreamPreview oldWidget) { super.didUpdateWidget(oldWidget); final newUrl = widget.channel.embedUri; - if (url != newUrl) { + if (newUrl != url) { _controller.loadRequest(newUrl); url = newUrl; } } - Future _setQuality(StreamPreviewModel model) async { - if (!_hasQualityOptions) return; - - if (model.isHighDefinition) { - if (_availableQualities.contains('auto')) { - await _controller - .runJavaScript("window.action(window.Actions.SetQuality, 'auto')"); - } else { - final hdOptions = _availableQualities - .where((q) => q.contains('720') || q.contains('1080')) - .toList(); - if (hdOptions.isNotEmpty) { - await _controller.runJavaScript( - "window.action(window.Actions.SetQuality, '${hdOptions.last}')"); - } - } - } else { - final sdOptions = _availableQualities - .where( - (q) => !q.contains('720') && !q.contains('1080') && q != 'auto') - .toList(); - - final qualityToUse = sdOptions.isNotEmpty ? sdOptions.first : '360p'; - - await _controller.runJavaScript( - "window.action(window.Actions.SetQuality, '$qualityToUse')"); - } - } - @override Widget build(BuildContext context) { + final model = Provider.of(context); return Stack(children: [ WebViewWidget(controller: _controller), if (_playerState == null || _playerState == "Idle") @@ -215,13 +173,9 @@ class _StreamPreviewState extends State { _overlayTimer = Timer(const Duration(seconds: 3), () { _overlayTimer = null; if (!mounted) return; - setState(() { - _isOverlayActive = false; - }); - }); - setState(() { - _isOverlayActive = true; + setState(() => _isOverlayActive = false); }); + setState(() => _isOverlayActive = true); }, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), @@ -230,73 +184,59 @@ class _StreamPreviewState extends State { color: Colors.black.withValues(alpha: 0.4), child: Padding( padding: const EdgeInsets.all(8), - child: Consumer( - builder: (context, model, child) { - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - onPressed: !_isOverlayActive - ? null - : () async { - if (Platform.isIOS) { - // SetVolume doesn't seem to work on ios so we use SetMuted instead and toggle between 0 and 100. - model.volume = - model.volume == 0 ? 100 : 0; - await _controller.runJavaScript( - "window.action(window.Actions.SetMuted, ${model.volume == 0})"); - return; - } - if (model.volume == 0) { - model.volume = 100; - } else if (model.volume == 100) { - model.volume = 33; - } else { - model.volume = 0; - } - await _controller.runJavaScript( - "window.action(window.Actions.SetMuted, false)"); - await _controller.runJavaScript( - "window.action(window.Actions.SetVolume, ${model.volume / 100})"); - }, - color: Colors.white, - icon: Icon( - model.volume == 0 - ? Icons.volume_mute - : model.volume == 100 - ? Icons.volume_up - : Icons.volume_down, - )), - // SetQuality doesn't seem to work on ios so we don't show the button. - if (!Platform.isIOS) - IconButton( - onPressed: - !_isOverlayActive || !_hasQualityOptions - ? null - : () async { - model.isHighDefinition = - !model.isHighDefinition; - await _setQuality(model); - }, - color: Colors.white, - icon: Stack( - children: [ - Icon(model.isHighDefinition - ? Icons.hd - : Icons.sd), - if (!_hasQualityOptions) - const Positioned( - right: 0, - bottom: 0, - child: Icon(Icons.block, - size: 12, color: Colors.red), - ) - ], - )), - ], - ); - }, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + onPressed: !_isOverlayActive + ? null + : () async { + if (Platform.isIOS) { + // SetVolume doesn't seem to work on ios so we use SetMuted instead and toggle between 0 and 100. + model.volume = model.volume == 0 ? 100 : 0; + await _controller.runJavaScript( + "window.action(window.Actions.SetMuted, ${model.volume == 0})"); + return; + } + model.volume = (model.volume == 0) + ? 100 + : (model.volume == 100) + ? 33 + : 0; + await _controller.runJavaScript( + "window.action(window.Actions.SetMuted, false)"); + await _controller.runJavaScript( + "window.action(window.Actions.SetVolume, ${model.volume / 100})"); + }, + color: Colors.white, + icon: Icon( + model.volume == 0 + ? Icons.volume_mute + : model.volume == 100 + ? Icons.volume_up + : Icons.volume_down, + ), + ), + // SetQuality doesn't seem to work on ios so we don't show the button. + if (!Platform.isIOS) + IconButton( + onPressed: (!_isOverlayActive || + !model.canSwitchQuality) + ? null + : () async { + model.isHighDefinition = + !model.isHighDefinition; + final target = + model.isHighDefinition ? 'auto' : '160p'; + await _controller.runJavaScript( + "window.action(window.Actions.SetQuality, '$target')"); + }, + color: Colors.white, + icon: Icon( + model.isHighDefinition ? Icons.hd : Icons.sd, + ), + ), + ], ), ), ), diff --git a/lib/models/stream_preview.dart b/lib/models/stream_preview.dart index d7fcd6406..638da93bc 100644 --- a/lib/models/stream_preview.dart +++ b/lib/models/stream_preview.dart @@ -8,13 +8,9 @@ class StreamPreviewModel extends ChangeNotifier { var _showBatteryPrompt = true; var _quality = '160p'; - static const List supportedQualities = [ - '160p', - '360p', - '480p', - '720p', - '1080p', - ]; + List _availableQualities = []; + + bool _canSwitchQuality = false; String get quality => _quality; @@ -23,8 +19,26 @@ class StreamPreviewModel extends ChangeNotifier { notifyListeners(); } + set availableQualities(List qualities) { + _availableQualities = qualities; + + _canSwitchQuality = qualities.length > 1; + notifyListeners(); + } + + set canSwitchQuality(bool value) { + if (_canSwitchQuality != value) { + _canSwitchQuality = value; + notifyListeners(); + } + } + bool get isHighDefinition => _isHighDefinition; + List get availableQualities => List.unmodifiable(_availableQualities); + + bool get canSwitchQuality => _canSwitchQuality; + set isHighDefinition(bool value) { _isHighDefinition = value; notifyListeners(); @@ -58,12 +72,21 @@ class StreamPreviewModel extends ChangeNotifier { if (json['showBatteryPrompt'] != null) { _showBatteryPrompt = json['showBatteryPrompt']; } + + if (json['availableQualities'] != null) { + final list = json['availableQualities'] as List; + _availableQualities = list.map((e) => e.toString()).toList(); + _canSwitchQuality = + json['canSwitchQuality'] as bool? ?? (_availableQualities.length > 1); + } } Map toJson() => { 'isHighDefinition': _isHighDefinition, 'volume': _volume, 'showBatteryPrompt': _showBatteryPrompt, + 'availableQualities': _availableQualities, + 'canSwitchQuality': _canSwitchQuality, 'quality': _quality }; } diff --git a/lib/screens/settings/chat_history.dart b/lib/screens/settings/chat_history.dart index 51bd8b1a4..de0484817 100644 --- a/lib/screens/settings/chat_history.dart +++ b/lib/screens/settings/chat_history.dart @@ -6,9 +6,10 @@ import 'package:rtchat/models/messages.dart'; import 'package:rtchat/models/messages/twitch/emote.dart'; import 'package:rtchat/models/messages/twitch/message.dart'; import 'package:rtchat/models/messages/twitch/user.dart'; -import 'package:rtchat/models/stream_preview.dart'; import 'package:rtchat/models/style.dart'; +import '../../models/stream_preview.dart'; + final message1 = TwitchMessageModel( messageId: "placeholder1", author: const TwitchUserModel(userId: 'muxfd', login: 'muxfd'), @@ -231,7 +232,7 @@ class ChatHistoryScreen extends StatelessWidget { model.quality = newValue; } }, - items: StreamPreviewModel.supportedQualities + items: model.availableQualities .map>((String value) { return DropdownMenuItem( value: value, From 779a6bdac4dcf2aab1e4df3b5e35b35d47e5ff00 Mon Sep 17 00:00:00 2001 From: SputNikPlop <100245448+SputNikPlop@users.noreply.github.com> Date: Fri, 30 May 2025 21:54:39 -0700 Subject: [PATCH 4/4] fix: package --- lib/screens/settings/chat_history.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/screens/settings/chat_history.dart b/lib/screens/settings/chat_history.dart index de0484817..812d74bef 100644 --- a/lib/screens/settings/chat_history.dart +++ b/lib/screens/settings/chat_history.dart @@ -6,10 +6,9 @@ import 'package:rtchat/models/messages.dart'; import 'package:rtchat/models/messages/twitch/emote.dart'; import 'package:rtchat/models/messages/twitch/message.dart'; import 'package:rtchat/models/messages/twitch/user.dart'; +import 'package:rtchat/models/stream_preview.dart'; import 'package:rtchat/models/style.dart'; -import '../../models/stream_preview.dart'; - final message1 = TwitchMessageModel( messageId: "placeholder1", author: const TwitchUserModel(userId: 'muxfd', login: 'muxfd'),