From 4a829d316b21e4d4cf6317715107a0d00eac3b38 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 7 Dec 2025 15:44:22 +0100 Subject: [PATCH] feat: Add mobile settings tab UI with user profile, appearance, and streaming options --- bin/server.dart | 10 ++ lib/features/iptv/screens/player_screen.dart | 159 ++++++++++++++++-- .../iptv/widgets/mobile_settings_tab.dart | 117 +++++++++++++ web/player.html | 7 + 4 files changed, 281 insertions(+), 12 deletions(-) diff --git a/bin/server.dart b/bin/server.dart index 3c7269a..78bde4e 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -429,6 +429,7 @@ Handler _createStreamHandler() { // Get parameters final iptvUrl = request.url.queryParameters['url']; final quality = request.url.queryParameters['quality'] ?? 'high'; + final start = request.url.queryParameters['start']; // Start time in seconds if (iptvUrl == null || iptvUrl.isEmpty) { return Response.badRequest(body: 'Missing url parameter'); @@ -465,6 +466,15 @@ Handler _createStreamHandler() { '-f', 'mpegts', ]; + // Add seeking support if start time is provided + if (start != null && start.isNotEmpty) { + // Insert -ss before -i for faster seeking (input seeking) + final inputIndex = ffmpegArgs.indexOf('-i'); + if (inputIndex != -1) { + ffmpegArgs.insertAll(inputIndex, ['-ss', start]); + } + } + // Video: Transcode based on quality // High = Copy (efficient), Low/Medium = Transcode to H.264 (compatible) if (quality == 'high') { diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 3de5d60..418e0e8 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -58,7 +58,10 @@ class _PlayerScreenState extends ConsumerState { bool _showControls = false; bool _isPlaying = true; double _currentPosition = 0; + bool _isPlaying = true; + double _currentPosition = 0; double _totalDuration = 1; + double _volume = 1.0; Timer? _controlsTimer; StreamSubscription? _messageSubscription; @@ -172,7 +175,42 @@ class _PlayerScreenState extends ConsumerState { setState(() { _currentPosition = value; }); - _sendMessage({'type': 'seek', 'value': value}); + + // Check if we are using server-side transcoding for VOD/Series + // If so, we must reload the player because we can't seek in a piped stream directly via JS + final settings = ref.read(iptvSettingsProvider); + final isTranscoding = widget.streamType != StreamType.live && settings.streamQuality != StreamQuality.high; + + if (isTranscoding) { + debugPrint('Seeking in transcoded VOD - reloading player at ${value.round()}s'); + // Reload player with new start time + // This will trigger _initializePlayer with the current position effectively + // But _initializePlayer reads from _currentPosition? No, it reads from provider OR starts at 0. + // We need to force it to start at 'value'. + // Best way is to just call _initializePlayer which builds the URL. + // But _initializePlayer checks logic. + + // Actually _initializePlayer uses: startTime = positions.getPosition(_contentId); + // So if we save the position first, it might pick it up? + // Let's pass the seek time explicitly to a reload method or updating state. + + // Simplest: Update the provider, then re-init. + ref.read(playbackPositionsProvider.notifier).savePosition(_contentId, value, _totalDuration); + _initializePlayer(startTimeOverride: value); + + } else { + // Direct play or Live - standard seeking + _sendMessage({'type': 'seek', 'value': value}); + } + + _onHover(); + } + + void _setVolume(double value) { + setState(() { + _volume = value; + }); + _sendMessage({'type': 'set_volume', 'value': value}); _onHover(); } @@ -199,7 +237,7 @@ class _PlayerScreenState extends ConsumerState { Future.microtask(() => _initializePlayer()); } - Future _initializePlayer() async { + Future _initializePlayer({double? startTimeOverride}) async { try { setState(() { _isLoading = true; @@ -265,12 +303,55 @@ class _PlayerScreenState extends ConsumerState { hlsUrl = streamEndpoint; } else { - // For VOD and Series - use direct proxy streaming with Range support - final streamUrl = widget.streamType == StreamType.vod - ? xtreamService.getVodStreamUrl(currentStreamId, widget.containerExtension) - : xtreamService.getSeriesStreamUrl(currentStreamId, widget.containerExtension); - hlsUrl = streamUrl; - debugPrint('PlayerScreen: Direct playback URL: $hlsUrl'); + // For VOD and Series + + // CHECK QUALITY SETTING + // If Quality is NOT High, we force transcoding to ensure audio compatibility (AAC) + // This solves "No Sound" issues with AC3/DTS on mobile/web. + final settings = ref.read(iptvSettingsProvider); + final useTranscoding = settings.streamQuality != StreamQuality.high; + + if (useTranscoding) { + setState(() { + _statusMessage = 'Starting VOD Transcoding (Audio Fix)...'; + }); + + final baseUrl = html.window.location.origin; + final vodStreamUrl = widget.streamType == StreamType.vod + ? xtreamService.getVodStreamUrl(currentStreamId, widget.containerExtension) + : xtreamService.getSeriesStreamUrl(currentStreamId, widget.containerExtension); + + final encodedUrl = Uri.encodeComponent(vodStreamUrl); + final qualityParam = switch (settings.streamQuality) { + StreamQuality.low => 'low', + StreamQuality.medium => 'medium', + StreamQuality.high => 'high', + }; + + // Calculate start time for seeking support + // If override provided (seeking), use it. Else check provider resume. + double startSeconds = startTimeOverride ?? 0; + if (startTimeOverride == null && _contentId.isNotEmpty) { + final positions = ref.read(playbackPositionsProvider); + final saved = positions.getPosition(_contentId); + if (saved > 0) startSeconds = saved; + } + + // Construct URL with start param + // Note: We force extension to .ts for mpegts.js compatibility + final streamEndpoint = '$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&start=${startSeconds.round()}&ext=.ts'; + + debugPrint('PlayerScreen: Streaming Transcoded VOD: $streamEndpoint'); + hlsUrl = streamEndpoint; + + } else { + // Direct Play (High Quality) - Original behavior + final streamUrl = widget.streamType == StreamType.vod + ? xtreamService.getVodStreamUrl(currentStreamId, widget.containerExtension) + : xtreamService.getSeriesStreamUrl(currentStreamId, widget.containerExtension); + hlsUrl = streamUrl; + debugPrint('PlayerScreen: Direct playback URL (High Quality): $hlsUrl'); + } } setState(() { @@ -278,8 +359,8 @@ class _PlayerScreenState extends ConsumerState { }); // Get resume position for VOD/Series - double startTime = 0; - if (widget.streamType != StreamType.live && _contentId.isNotEmpty) { + double startTime = startTimeOverride ?? 0; + if (startTime <= 0 && widget.streamType != StreamType.live && _contentId.isNotEmpty) { final positions = ref.read(playbackPositionsProvider); startTime = positions.getPosition(_contentId); if (startTime > 0) { @@ -287,9 +368,19 @@ class _PlayerScreenState extends ConsumerState { } } - // Register the HTML view with an iframe pointing to our player.html + // If we are Transcoding VOD, the start time is handled by the server (burned into stream) + // So we should NOT pass startTime to player.html, otherwise it might try to seek + // on a stream that already starts at X. + // EXCEPTION: If player.html receives an MPEG-TS stream that has timestamps starting at X, + // setting currentTime might be redundant or needed depending on player impl. + // Safest: If Transcoding, don't pass startTime to player.html (server handles it). + // If Direct Play, pass startTime. + + final settings = ref.read(iptvSettingsProvider); + final isTranscoding = widget.streamType != StreamType.live && settings.streamQuality != StreamQuality.high; + final encodedHlsUrl = Uri.encodeComponent(hlsUrl); - final playerUrl = startTime > 0 + final playerUrl = (!isTranscoding && startTime > 0) ? 'player.html?url=$encodedHlsUrl&startTime=${startTime.toStringAsFixed(0)}' : 'player.html?url=$encodedHlsUrl'; @@ -618,6 +709,50 @@ class _PlayerScreenState extends ConsumerState { // Hover Controls Layer if (_showControls) ...[ + // Volume Slider (Left Side) + Positioned( + left: 24, + top: 0, + bottom: 0, + child: Center( + child: Container( + height: 200, + width: 40, + decoration: BoxDecoration( + color: Colors.black45, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + children: [ + const Icon(Icons.volume_up, color: Colors.white, size: 20), + const SizedBox(height: 8), + Expanded( + child: RotatedBox( + quarterTurns: 3, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 4, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + thumbColor: AppColors.primary, + overlayColor: AppColors.primary.withOpacity(0.2), + ), + child: Slider( + value: _volume, + onChanged: _setVolume, + ), + ), + ), + ), + ], + ), + ), + ), + ), + // Play/Pause Button (Centered) Positioned( top: 0, diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart index 6f62d57..5ee5af6 100644 --- a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -137,6 +137,69 @@ class _MobileSettingsTabState extends ConsumerState { onChanged: (_) => themeNotifier.toggleTheme(), activeColor: AppColors.primary, ), + const Divider(height: 1), + SwitchListTile( + title: const Text('Show Clock', style: TextStyle(color: AppColors.textPrimary)), + secondary: const Icon(Icons.access_time, color: AppColors.primary), + value: settings.showClock, + onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setShowClock(val), + activeColor: AppColors.primary, + ), + ], + ), + ), + const SizedBox(height: 24), + + // Streaming Settings + _SectionHeader(title: 'Streaming'), + Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + _DropdownSetting( + label: 'Quality', + icon: Icons.high_quality, + value: settings.streamQuality, + items: StreamQuality.values, + labelBuilder: (v) => v.name.toUpperCase(), + onChanged: (v) { + if (v != null) ref.read(iptvSettingsProvider.notifier).setStreamQuality(v); + }, + ), + const Divider(height: 1), + _DropdownSetting( + label: 'Buffer Size', + icon: Icons.memory, + value: settings.bufferSize, + items: BufferSize.values, + labelBuilder: (v) => v.name.toUpperCase(), + onChanged: (v) { + if (v != null) ref.read(iptvSettingsProvider.notifier).setBufferSize(v); + }, + ), + const Divider(height: 1), + _DropdownSetting( + label: 'Timeout', + icon: Icons.timer, + value: settings.connectionTimeout, + items: ConnectionTimeout.values, + labelBuilder: (v) => '${v.name.toUpperCase()} (${ref.read(iptvSettingsProvider).timeoutSeconds}s)', + onChanged: (v) { + if (v != null) ref.read(iptvSettingsProvider.notifier).setConnectionTimeout(v); + }, + ), + const Divider(height: 1), + SwitchListTile( + title: const Text('Auto Reconnect', style: TextStyle(color: AppColors.textPrimary)), + secondary: const Icon(Icons.refresh, color: AppColors.primary), + value: settings.autoReconnect, + onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setAutoReconnect(val), + activeColor: AppColors.primary, + ), ], ), ), @@ -316,3 +379,57 @@ class _SettingsButton extends StatelessWidget { ); } } + +class _DropdownSetting extends StatelessWidget { + final String label; + final IconData icon; + final T value; + final List items; + final String Function(T) labelBuilder; + final ValueChanged onChanged; + + const _DropdownSetting({ + required this.label, + required this.icon, + required this.value, + required this.items, + required this.labelBuilder, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Row( + children: [ + Icon(icon, color: AppColors.primary), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + ), + ), + ), + DropdownButton( + value: value, + underline: const SizedBox(), + dropdownColor: AppColors.surfaceVariant, + icon: const Icon(Icons.arrow_drop_down, color: AppColors.textSecondary), + style: const TextStyle(color: AppColors.textPrimary), + items: items.map((item) { + return DropdownMenuItem( + value: item, + child: Text(labelBuilder(item)), + ); + }).toList(), + onChanged: onChanged, + ), + ], + ), + ); + } +} diff --git a/web/player.html b/web/player.html index d5d0e1b..01a4125 100644 --- a/web/player.html +++ b/web/player.html @@ -198,6 +198,13 @@ if (isFinite(data.value)) { video.currentTime = data.value; } + } else if (data.type === 'set_volume') { + // value is 0.0 to 1.0 + log('Setting volume: ' + data.value); + if (isFinite(data.value)) { + video.volume = Math.max(0, Math.min(1, data.value)); + video.muted = (data.value === 0); + } } });