Add multi-resolution playback support
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../services/chat_service.dart';
|
||||
import '../widgets/web_stream_player.dart';
|
||||
|
||||
@@ -24,7 +26,7 @@ class PlayerPage extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
_PlayerPageState createState() => _PlayerPageState();
|
||||
State<PlayerPage> createState() => _PlayerPageState();
|
||||
}
|
||||
|
||||
class _PlayerPageState extends State<PlayerPage> {
|
||||
@@ -42,11 +44,13 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
bool _controlsVisible = true;
|
||||
int _playerVersion = 0;
|
||||
String _selectedResolution = 'Source';
|
||||
List<String> _availableResolutions = const ['Source'];
|
||||
Timer? _controlsHideTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPlaybackOptions();
|
||||
if (!kIsWeb) {
|
||||
_initializePlayer();
|
||||
}
|
||||
@@ -55,9 +59,8 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
}
|
||||
|
||||
Future<void> _initializePlayer() async {
|
||||
_controller = VideoPlayerController.networkUrl(
|
||||
Uri.parse(widget.playbackUrl),
|
||||
);
|
||||
final playbackUrl = _currentPlaybackUrl();
|
||||
_controller = VideoPlayerController.networkUrl(Uri.parse(playbackUrl));
|
||||
try {
|
||||
await _controller!.initialize();
|
||||
_controller!.play();
|
||||
@@ -81,6 +84,55 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _currentPlaybackUrl() {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final quality = _selectedResolution == 'Source'
|
||||
? null
|
||||
: _selectedResolution.toLowerCase();
|
||||
return settings.playbackUrl(widget.roomId, quality: quality);
|
||||
}
|
||||
|
||||
Future<void> _loadPlaybackOptions() async {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final auth = context.read<AuthProvider>();
|
||||
final api = ApiService(settings, auth.token);
|
||||
|
||||
try {
|
||||
final response = await api.getPlaybackOptions(widget.roomId);
|
||||
if (response.statusCode != 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final rawQualities =
|
||||
(data['qualities'] as List<dynamic>? ?? const ['source'])
|
||||
.map((item) => item.toString().trim().toLowerCase())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
final normalized = <String>['Source'];
|
||||
for (final quality in rawQualities) {
|
||||
if (quality == 'source') {
|
||||
continue;
|
||||
}
|
||||
normalized.add(quality);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_availableResolutions = normalized.toSet().toList();
|
||||
if (!_availableResolutions.contains(_selectedResolution)) {
|
||||
_selectedResolution = 'Source';
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Keep source-only playback when the capability probe fails.
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeChat() {
|
||||
final settings = context.read<SettingsProvider>();
|
||||
final auth = context.read<AuthProvider>();
|
||||
@@ -141,6 +193,8 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
await _loadPlaybackOptions();
|
||||
|
||||
setState(() {
|
||||
_isRefreshing = true;
|
||||
_isError = false;
|
||||
@@ -223,22 +277,30 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
|
||||
Future<void> _selectResolution() async {
|
||||
_showControls();
|
||||
await _loadPlaybackOptions();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final nextResolution = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
const options = ['Source', '720p', '480p'];
|
||||
final available = _availableResolutions.toSet();
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const ListTile(
|
||||
ListTile(
|
||||
title: Text('Playback Resolution'),
|
||||
subtitle: Text(
|
||||
'Current backend only provides the source stream. Lower resolutions are reserved for future multi-bitrate output.',
|
||||
available.length > 1
|
||||
? 'Select an available transcoded stream.'
|
||||
: 'Only the source stream is available right now.',
|
||||
),
|
||||
),
|
||||
...options.map((option) {
|
||||
final enabled = option == 'Source';
|
||||
final enabled = available.contains(option);
|
||||
return ListTile(
|
||||
enabled: enabled,
|
||||
leading: Icon(
|
||||
@@ -249,7 +311,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
title: Text(option),
|
||||
subtitle: enabled
|
||||
? const Text('Available now')
|
||||
: const Text('Requires backend transcoding support'),
|
||||
: const Text('Waiting for backend transcoding output'),
|
||||
onTap: enabled ? () => Navigator.pop(context, option) : null,
|
||||
);
|
||||
}),
|
||||
@@ -376,7 +438,7 @@ class _PlayerPageState extends State<PlayerPage> {
|
||||
: kIsWeb
|
||||
? WebStreamPlayer(
|
||||
key: ValueKey('web-player-$_playerVersion'),
|
||||
streamUrl: widget.playbackUrl,
|
||||
streamUrl: _currentPlaybackUrl(),
|
||||
)
|
||||
: _controller != null && _controller!.value.isInitialized
|
||||
? AspectRatio(
|
||||
|
||||
@@ -307,11 +307,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
"Version: 1.0.0-beta3.5",
|
||||
"Version: 1.0.0-beta4.1",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
Text(
|
||||
"Author: Highground-Soft & Minimax",
|
||||
"Author: Highground-Soft",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
|
||||
@@ -62,12 +62,26 @@ class SettingsProvider with ChangeNotifier {
|
||||
return "rtmp://${uri.host}:1935/live";
|
||||
}
|
||||
|
||||
String playbackUrl(String roomId) {
|
||||
String playbackUrl(String roomId, {String? quality}) {
|
||||
final uri = Uri.parse(_baseUrl);
|
||||
final normalizedQuality = quality?.trim().toLowerCase();
|
||||
|
||||
if (kIsWeb) {
|
||||
return uri.replace(path: '/live/$roomId').toString();
|
||||
return uri
|
||||
.replace(
|
||||
path: '/live/$roomId',
|
||||
queryParameters:
|
||||
normalizedQuality == null || normalizedQuality.isEmpty
|
||||
? null
|
||||
: {'quality': normalizedQuality},
|
||||
)
|
||||
.toString();
|
||||
}
|
||||
return "$rtmpUrl/$roomId";
|
||||
|
||||
if (normalizedQuality == null || normalizedQuality.isEmpty) {
|
||||
return "$rtmpUrl/$roomId";
|
||||
}
|
||||
return "$rtmpUrl/$roomId/$normalizedQuality";
|
||||
}
|
||||
|
||||
ThemeMode _themeModeFromString(String value) {
|
||||
|
||||
@@ -43,11 +43,24 @@ class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<http.Response> changePassword(String oldPassword, String newPassword) async {
|
||||
Future<http.Response> getPlaybackOptions(String roomId) async {
|
||||
return await http.get(
|
||||
Uri.parse("${settings.baseUrl}/api/rooms/$roomId/playback-options"),
|
||||
headers: _headers,
|
||||
);
|
||||
}
|
||||
|
||||
Future<http.Response> changePassword(
|
||||
String oldPassword,
|
||||
String newPassword,
|
||||
) async {
|
||||
return await http.post(
|
||||
Uri.parse("${settings.baseUrl}/api/user/change-password"),
|
||||
headers: _headers,
|
||||
body: jsonEncode({"old_password": oldPassword, "new_password": newPassword}),
|
||||
body: jsonEncode({
|
||||
"old_password": oldPassword,
|
||||
"new_password": newPassword,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user