Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 48 additions & 22 deletions assets/twitch-tunnel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
210 changes: 102 additions & 108 deletions lib/components/stream_preview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,20 @@ import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';

class StreamPreview extends StatefulWidget {
const StreamPreview({super.key, required this.channel});

final Channel channel;

@override
State<StreamPreview> createState() => _StreamPreviewState();
}

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<StreamPreview> {
late WebViewController _controller;
late Uri url;

var _isOverlayActive = false;
Timer? _overlayTimer;
String? _playerState;
Expand All @@ -41,8 +37,8 @@ class _StreamPreviewState extends State<StreamPreview> {
@override
void initState() {
super.initState();

final model = Provider.of<StreamPreviewModel>(context, listen: false);

if (model.showBatteryPrompt) {
_promptTimer = Timer(const Duration(minutes: 5), () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
Expand All @@ -60,43 +56,37 @@ class _StreamPreviewState extends State<StreamPreview> {
}

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");
}

_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})");
Expand All @@ -105,44 +95,60 @@ class _StreamPreviewState extends State<StreamPreview> {
.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<String, dynamic>;
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<StreamPreviewModel>(context, listen: false);
final List<String> quals =
List<String>.from(data['qualities'] as List<dynamic>);
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;
}
}

@override
Widget build(BuildContext context) {
final model = Provider.of<StreamPreviewModel>(context);
return Stack(children: [
WebViewWidget(controller: _controller),
if (_playerState == null || _playerState == "Idle")
Expand All @@ -167,13 +173,9 @@ class _StreamPreviewState extends State<StreamPreview> {
_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),
Expand All @@ -182,67 +184,59 @@ class _StreamPreviewState extends State<StreamPreview> {
color: Colors.black.withValues(alpha: 0.4),
child: Padding(
padding: const EdgeInsets.all(8),
child: Consumer<StreamPreviewModel>(
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,
),
),
],
),
),
),
Expand Down
Loading
Loading