diff --git a/assets/twitch-tunnel.js b/assets/twitch-tunnel.js index d21eaed1..31ae47d3 100644 --- a/assets/twitch-tunnel.js +++ b/assets/twitch-tunnel.js @@ -16,31 +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 - ); + window.addEventListener( + "message", (e) => Flutter.postMessage(JSON.stringify(e.data)), false + ); } + +function hookPlayer(player) { + + player.addEventListener(Twitch.Player.PLAYING, function() { + let qualities = player.getQualities(); + + qualities = qualities.map(q => + (typeof q === "object" && q.group) ? q.group : q + ); + + if (Flutter) { + Flutter.postMessage(JSON.stringify({ + event: "qualities_available", + qualities: qualities + })); + } + }); +} + +window.addEventListener("message", function init(e) { + const data = e.data; + if (data && data.namespace === "twitch-embed-player-proxy" && data.eventName === "PlayerReady") { + + hookPlayer(data.params.player); + window.removeEventListener("message", init); + } +}, false); diff --git a/lib/components/stream_preview.dart b/lib/components/stream_preview.dart index 337519bf..a7846a0b 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,16 +22,13 @@ 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; @@ -41,8 +37,8 @@ class _StreamPreviewState extends State { @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( @@ -60,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"); } @@ -77,26 +74,19 @@ 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 && 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)); + if (Platform.isIOS) { await _controller.runJavaScript( "window.action(window.Actions.SetMuted, ${model.volume == 0})"); @@ -105,37 +95,52 @@ 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, '160p')"); - } + await _controller.runJavaScript( + "window.action(window.Actions.SetQuality, '${model.isHighDefinition ? 'auto' : '160p'}')"); } }, - )); + )) + ..loadRequest(url); + } + + 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(); - // 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; } @@ -143,6 +148,7 @@ class _StreamPreviewState extends State { @override Widget build(BuildContext context) { + final model = Provider.of(context); return Stack(children: [ WebViewWidget(controller: _controller), if (_playerState == null || _playerState == "Idle") @@ -167,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), @@ -182,67 +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 - ? 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, '160p')"); - } - }, - color: Colors.white, - icon: Icon(model.isHighDefinition - ? Icons.hd - : Icons.sd)), - ], - ); - }, + 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 50fd553f..638da93b 100644 --- a/lib/models/stream_preview.dart +++ b/lib/models/stream_preview.dart @@ -6,9 +6,39 @@ class StreamPreviewModel extends ChangeNotifier { var _isHighDefinition = false; var _volume = 0; var _showBatteryPrompt = true; + var _quality = '160p'; + + List _availableQualities = []; + + bool _canSwitchQuality = false; + + String get quality => _quality; + + set quality(String value) { + _quality = value; + 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(); @@ -29,6 +59,10 @@ class StreamPreviewModel extends ChangeNotifier { } StreamPreviewModel.fromJson(Map json) { + if (json['quality'] != null) { + _quality = json['quality']; + } + if (json['isHighDefinition'] != null) { _isHighDefinition = json['isHighDefinition']; } @@ -38,11 +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 5b60b086..812d74be 100644 --- a/lib/screens/settings/chat_history.dart +++ b/lib/screens/settings/chat_history.dart @@ -6,6 +6,7 @@ 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'; final message1 = TwitchMessageModel( @@ -209,6 +210,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: model.availableQualities + .map>((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + ); + }, + ), + ], + ), + ), Padding( padding: const EdgeInsets.all(16), child: Column(