From b2298a9bf404738bd05fbb0595e38d031fff5285 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 14 Dec 2025 16:05:30 +0100 Subject: [PATCH] feat: Add initial IPTV player with HLS.js and mpegts.js support, including UI and parent communication. --- bin/api/hls_handler.dart | 76 ++++++++++++------- .../iptv/providers/settings_provider.dart | 16 ++++ lib/features/iptv/screens/player_screen.dart | 9 ++- .../iptv/services/xtream_service.dart | 36 ++++++--- .../iptv/widgets/streaming_settings_tab.dart | 14 ++++ web/player.html | 25 ++++-- 6 files changed, 127 insertions(+), 49 deletions(-) diff --git a/bin/api/hls_handler.dart b/bin/api/hls_handler.dart index 4f467f0..cbbc9f8 100644 --- a/bin/api/hls_handler.dart +++ b/bin/api/hls_handler.dart @@ -140,40 +140,39 @@ Handler createStreamInitHandler() { // Output format: HLS '-f', 'hls', - '-hls_time', '4', // 4 second segments - '-hls_list_size', '0', // Keep all segments - '-hls_flags', 'delete_segments+append_list', + '-hls_time', '4', + '-hls_list_size', '0', // Keep all segments in playlist + '-hls_playlist_type', 'event', // Allow appending new segments, keep old ones '-hls_segment_type', 'mpegts', '-hls_segment_filename', '${tempDir.path}/segment_%03d.ts', ]; - // Video: Copy or transcode - if (quality == 'high') { - ffmpegArgs.addAll(['-c:v', 'copy']); - } else { - ffmpegArgs.addAll([ - '-c:v', 'libx264', - '-preset', 'veryfast', - '-profile:v', 'baseline', - '-level', '3.0', - '-pix_fmt', 'yuv420p', - '-g', '48', - ]); + // Video: ALWAYS Transcode to ensure standard HLS/MPEG-TS timestamps + // Copying causes DEMUXER_ERROR if source stamps are weird (common in MKV) + ffmpegArgs.addAll([ + '-c:v', 'libx264', + '-preset', 'ultrafast', // Fast encoding for live/vod + '-tune', 'zerolatency', + '-profile:v', 'baseline', + '-level', '3.0', + '-pix_fmt', 'yuv420p', + '-g', '48', // 2s GOP + ]); - if (quality == 'low') { - ffmpegArgs.addAll([ - '-vf', 'scale=-2:480', - '-b:v', '800k', - '-maxrate', '800k', - '-bufsize', '1600k', - ]); - } else { - ffmpegArgs.addAll([ - '-b:v', '2000k', - '-maxrate', '2000k', - '-bufsize', '4000k', - ]); - } + if (quality == 'low') { + ffmpegArgs.addAll([ + '-vf', 'scale=-2:480', + '-b:v', '800k', + '-maxrate', '800k', + '-bufsize', '1600k', + ]); + } else { + // High/Medium (default) + ffmpegArgs.addAll([ + '-b:v', '2500k', + '-maxrate', '2500k', + '-bufsize', '5000k', + ]); } // Audio: Always transcode to AAC @@ -214,6 +213,25 @@ Handler createStreamInitHandler() { ); } + // CRITICAL: Wait for first segment to exist AND HAVE DATA > 5MB to be safe? + // No, 10KB is enough for header + some data + attempts = 0; + final firstSegment = File('${tempDir.path}/segment_000.ts'); + while (attempts < 100) { + if (await firstSegment.exists()) { + final len = await firstSegment.length(); + if (len > 10240) break; // Wait for > 10KB + } + await Future.delayed(const Duration(milliseconds: 100)); + attempts++; + } + + if (!await firstSegment.exists()) { + print('Warning: First segment not created yet for $streamId'); + } else { + print('First segment ready for $streamId (${await firstSegment.length()} bytes)'); + } + print('HLS playlist created for $streamId'); // Cleanup on process exit diff --git a/lib/features/iptv/providers/settings_provider.dart b/lib/features/iptv/providers/settings_provider.dart index 2d6bb14..2e19b8c 100644 --- a/lib/features/iptv/providers/settings_provider.dart +++ b/lib/features/iptv/providers/settings_provider.dart @@ -62,6 +62,7 @@ class _SettingsKeys { static const String showClock = 'show_clock'; static const String preferredAspectRatio = 'aspect_ratio'; static const String playerType = 'player_type'; + static const String forceVodTranscoding = 'force_vod_transcoding'; } /// Settings state for IPTV preferences with persistence @@ -84,6 +85,9 @@ class IptvSettings { final bool showClock; final String preferredAspectRatio; final PlayerType playerType; + + // VOD Settings + final bool forceVodTranscoding; const IptvSettings({ // Filters @@ -102,6 +106,8 @@ class IptvSettings { this.showClock = false, this.preferredAspectRatio = 'contain', this.playerType = PlayerType.standard, + // VOD defaults + this.forceVodTranscoding = false, }); IptvSettings copyWith({ @@ -118,6 +124,7 @@ class IptvSettings { bool? showClock, String? preferredAspectRatio, PlayerType? playerType, + bool? forceVodTranscoding, }) { return IptvSettings( liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter, @@ -133,6 +140,7 @@ class IptvSettings { showClock: showClock ?? this.showClock, preferredAspectRatio: preferredAspectRatio ?? this.preferredAspectRatio, playerType: playerType ?? this.playerType, + forceVodTranscoding: forceVodTranscoding ?? this.forceVodTranscoding, ); } @@ -298,6 +306,7 @@ class IptvSettingsNotifier extends StateNotifier { preferredAspectRatio: getValue(_SettingsKeys.preferredAspectRatio), playerType: remoteSettings[_SettingsKeys.playerType] != null ? PlayerType.values[remoteSettings[_SettingsKeys.playerType] as int] : null, + forceVodTranscoding: getValue(_SettingsKeys.forceVodTranscoding), ); } _initialized = true; @@ -328,6 +337,7 @@ class IptvSettingsNotifier extends StateNotifier { _SettingsKeys.showClock: state.showClock, _SettingsKeys.preferredAspectRatio: state.preferredAspectRatio, _SettingsKeys.playerType: state.playerType.index, + _SettingsKeys.forceVodTranscoding: state.forceVodTranscoding, }; print('[SettingsProvider] _saveToApi: Saving settings...'); @@ -422,6 +432,12 @@ class IptvSettingsNotifier extends StateNotifier { // Legacy methods void setCategoryFilter(String filter) => setLiveTvFilter(filter); void clearCategoryFilter() => clearLiveTvFilter(); + + // VOD Settings + void setForceVodTranscoding(bool value) { + state = state.copyWith(forceVodTranscoding: value); + _saveToApi(); + } } /// Provider for IPTV settings diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 09f9592..6e5732a 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -102,20 +102,23 @@ class _PlayerScreenState extends ConsumerState { final service = ref.read(xtreamServiceProvider(widget.playlist)); String streamUrl = ''; + // Get settings for VOD transcoding preference + final playerSettings = ref.read(iptvSettingsProvider); + final forceTranscoding = playerSettings.forceVodTranscoding; + // Generate Stream URL based on type if (widget.streamType == StreamType.live) { streamUrl = service.getLiveStreamUrl(currentStreamId); } else if (widget.streamType == StreamType.vod) { - streamUrl = service.getVodStreamUrl(currentStreamId, widget.containerExtension); + streamUrl = service.getVodStreamUrl(currentStreamId, widget.containerExtension, forceTranscoding: forceTranscoding); } else if (widget.streamType == StreamType.series) { - streamUrl = service.getSeriesStreamUrl(currentStreamId, widget.containerExtension); + streamUrl = service.getSeriesStreamUrl(currentStreamId, widget.containerExtension, forceTranscoding: forceTranscoding); } // Store URL for Lite Player _currentStreamUrl = streamUrl; final encodedUrl = Uri.encodeComponent(streamUrl); - final playerSettings = ref.read(iptvSettingsProvider); final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod'; var playerSrc = playerSettings.playerType == PlayerType.lite ? 'player_lite.html?url=$encodedUrl&type=$streamTypeParam' diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 6de5f5d..20ec6e9 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -69,27 +69,43 @@ class XtreamService { } /// Generate stream URL for VOD (movies) - String getVodStreamUrl(String streamId, String containerExtension) { + /// + /// [forceTranscoding] - If true, uses FFmpeg to transcode audio (guaranteed sound) + /// If false, uses direct URL (enables seeking but may lack audio on AC3/DTS) + String getVodStreamUrl(String streamId, String containerExtension, {bool forceTranscoding = false}) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - // Use FFmpeg HLS transcoding for guaranteed audio compatibility + seeking final url = '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; - final baseUrl = html.window.location.origin; - final encodedUrl = Uri.encodeComponent(url); - return '$baseUrl/api/stream/$streamId?url=$encodedUrl'; + if (forceTranscoding) { + // Use FFmpeg transcoding for guaranteed audio (no seeking) + final baseUrl = html.window.location.origin; + final encodedUrl = Uri.encodeComponent(url); + return '$baseUrl/api/stream/$streamId?url=$encodedUrl'; + } else { + // Direct URL for seeking (audio only if AAC/MP3) + return _wrapWithProxy(url); + } } /// Generate stream URL for series episodes - String getSeriesStreamUrl(String streamId, String containerExtension) { + /// + /// [forceTranscoding] - If true, uses FFmpeg to transcode audio (guaranteed sound) + /// If false, uses direct URL (enables seeking but may lack audio on AC3/DTS) + String getSeriesStreamUrl(String streamId, String containerExtension, {bool forceTranscoding = false}) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - // Use FFmpeg HLS transcoding for guaranteed audio compatibility + seeking final url = '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; - final baseUrl = html.window.location.origin; - final encodedUrl = Uri.encodeComponent(url); - return '$baseUrl/api/stream/$streamId?url=$encodedUrl'; + if (forceTranscoding) { + // Use FFmpeg transcoding for guaranteed audio (no seeking) + final baseUrl = html.window.location.origin; + final encodedUrl = Uri.encodeComponent(url); + return '$baseUrl/api/stream/$streamId?url=$encodedUrl'; + } else { + // Direct URL for seeking (audio only if AAC/MP3) + return _wrapWithProxy(url); + } } /// Authenticate and get server info diff --git a/lib/features/iptv/widgets/streaming_settings_tab.dart b/lib/features/iptv/widgets/streaming_settings_tab.dart index 3813540..968c66e 100644 --- a/lib/features/iptv/widgets/streaming_settings_tab.dart +++ b/lib/features/iptv/widgets/streaming_settings_tab.dart @@ -167,6 +167,20 @@ class StreamingSettingsTab extends ConsumerWidget { activeColor: Colors.blueAccent, onChanged: (v) => notifier.setPreferDirectPlay(v), ), + Divider(height: 1, color: Colors.white.withOpacity(0.1)), + SwitchListTile( + title: Text( + 'Forcer audio VOD (Films/Séries)', + style: GoogleFonts.roboto(fontSize: 14, color: Colors.white), + ), + subtitle: Text( + 'Active FFmpeg pour garantir le son\n⚠️ Désactive le seeking', + style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), + ), + value: settings.forceVodTranscoding, + activeColor: Colors.orangeAccent, + onChanged: (v) => notifier.setForceVodTranscoding(v), + ), ], ), const SizedBox(height: 16), diff --git a/web/player.html b/web/player.html index b4cda85..197d413 100644 --- a/web/player.html +++ b/web/player.html @@ -511,16 +511,27 @@ log('Stream URL: ' + streamUrl.substring(0, 80) + '...'); - // SPECIAL: /api/stream/ endpoint outputs MPEG-TS, use mpegts.js directly + // SPECIAL: /api/stream/ returns JSON with HLS playlist URL if (streamUrl.includes('/api/stream/')) { - log('Detected /api/stream/ endpoint - using MPEG-TS player for transcoded stream'); + log('Detected /api/stream/ - fetching HLS playlist...'); showLoading('Initializing stream (transcoding audio)...'); - if (mpegts.isSupported()) { - await playWithMpegTS(streamUrl); - return; - } else { - showError('MPEG-TS playback not supported on this device'); + try { + const response = await fetch(streamUrl); + const data = await response.json(); + + if (data.playlist) { + const playlistUrl = window.location.origin + data.playlist; + log('Playing HLS: ' + playlistUrl); + await playWithHLS(playlistUrl); + return; + } else { + showError('Invalid stream response'); + return; + } + } catch (err) { + log('Error: ' + err); + showError('Stream init failed'); return; } }