Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
38 changes: 38 additions & 0 deletions assets/twitch-tunnel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this exists?

image

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I tried some new js, let me know if that works bit of a patch work in me understanding whats going on

Twitch.Player.READY && Twitch.Player.READY(() => {
window.detectPlayerCapabilities();
});
}

// Also check when our iframe loads
window.addEventListener('load', () => {
window.detectPlayerCapabilities();
});

if (Flutter) {
window.addEventListener(
"message",
Expand Down
108 changes: 81 additions & 27 deletions lib/components/stream_preview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ class _StreamPreviewState extends State<StreamPreview> {
String? _playerState;
Timer? _promptTimer;

List<String> _availableQualities = [];
bool _hasQualityOptions = false;
Timer? _qualityCheckTimer;

@override
void initState() {
super.initState();
Expand Down Expand Up @@ -81,10 +85,23 @@ class _StreamPreviewState extends State<StreamPreview> {
..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<String>.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) {
Expand All @@ -95,8 +112,10 @@ class _StreamPreviewState extends State<StreamPreview> {
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})");
Expand All @@ -105,23 +124,23 @@ 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')");
}
}
},
));

_qualityCheckTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
if (mounted) {
_controller.runJavaScript('window.detectPlayerCapabilities()');
}
});
}

@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.
Expand All @@ -141,6 +160,35 @@ class _StreamPreviewState extends State<StreamPreview> {
}
}

Future<void> _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: [
Expand Down Expand Up @@ -223,23 +271,29 @@ class _StreamPreviewState extends State<StreamPreview> {
// 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')");
}
},
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),
)
],
)),
],
);
},
Expand Down
21 changes: 21 additions & 0 deletions lib/models/stream_preview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ class StreamPreviewModel extends ChangeNotifier {
var _isHighDefinition = false;
var _volume = 0;
var _showBatteryPrompt = true;
var _quality = '160p';

static const List<String> supportedQualities = [
'160p',
'360p',
'480p',
'720p',
'1080p',
];

String get quality => _quality;

set quality(String value) {
_quality = value;
notifyListeners();
}

bool get isHighDefinition => _isHighDefinition;

Expand All @@ -29,6 +45,10 @@ class StreamPreviewModel extends ChangeNotifier {
}

StreamPreviewModel.fromJson(Map<String, dynamic> json) {
if (json['quality'] != null) {
_quality = json['quality'];
}

if (json['isHighDefinition'] != null) {
_isHighDefinition = json['isHighDefinition'];
}
Expand All @@ -44,5 +64,6 @@ class StreamPreviewModel extends ChangeNotifier {
'isHighDefinition': _isHighDefinition,
'volume': _volume,
'showBatteryPrompt': _showBatteryPrompt,
'quality': _quality
};
}
35 changes: 35 additions & 0 deletions lib/screens/settings/chat_history.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<StreamPreviewModel>(
builder: (context, model, child) {
return DropdownButton<String>(
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<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
);
},
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
Expand Down