feat: Add initial IPTV player with HLS.js and mpegts.js support, including UI and parent communication.

This commit is contained in:
Michael committed 2025-12-14 16:05:30 +01:00
1 parent 0c087e0b34
commit 41c4ab05aa
6 files changed
+127 -49

No files matched your search

+47 -29
View File
@@ -140,40 +140,39 @@ Handler createStreamInitHandler() {
// Output format: HLS // Output format: HLS
'-f', 'hls', '-f', 'hls',
'-hls_time', '4', // 4 second segments '-hls_time', '4',
'-hls_list_size', '0', // Keep all segments '-hls_list_size', '0', // Keep all segments in playlist
'-hls_flags', 'delete_segments+append_list', '-hls_playlist_type', 'event', // Allow appending new segments, keep old ones
'-hls_segment_type', 'mpegts', '-hls_segment_type', 'mpegts',
'-hls_segment_filename', '${tempDir.path}/segment_%03d.ts', '-hls_segment_filename', '${tempDir.path}/segment_%03d.ts',
]; ];
// Video: Copy or transcode // Video: ALWAYS Transcode to ensure standard HLS/MPEG-TS timestamps
if (quality == 'high') { // Copying causes DEMUXER_ERROR if source stamps are weird (common in MKV)
ffmpegArgs.addAll(['-c:v', 'copy']); ffmpegArgs.addAll([
} else { '-c:v', 'libx264',
ffmpegArgs.addAll([ '-preset', 'ultrafast', // Fast encoding for live/vod
'-c:v', 'libx264', '-tune', 'zerolatency',
'-preset', 'veryfast', '-profile:v', 'baseline',
'-profile:v', 'baseline', '-level', '3.0',
'-level', '3.0', '-pix_fmt', 'yuv420p',
'-pix_fmt', 'yuv420p', '-g', '48', // 2s GOP
'-g', '48', ]);
]);
if (quality == 'low') { if (quality == 'low') {
ffmpegArgs.addAll([ ffmpegArgs.addAll([
'-vf', 'scale=-2:480', '-vf', 'scale=-2:480',
'-b:v', '800k', '-b:v', '800k',
'-maxrate', '800k', '-maxrate', '800k',
'-bufsize', '1600k', '-bufsize', '1600k',
]); ]);
} else { } else {
ffmpegArgs.addAll([ // High/Medium (default)
'-b:v', '2000k', ffmpegArgs.addAll([
'-maxrate', '2000k', '-b:v', '2500k',
'-bufsize', '4000k', '-maxrate', '2500k',
]); '-bufsize', '5000k',
} ]);
} }
// Audio: Always transcode to AAC // 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'); print('HLS playlist created for $streamId');
// Cleanup on process exit // Cleanup on process exit
@@ -62,6 +62,7 @@ class _SettingsKeys {
static const String showClock = 'show_clock'; static const String showClock = 'show_clock';
static const String preferredAspectRatio = 'aspect_ratio'; static const String preferredAspectRatio = 'aspect_ratio';
static const String playerType = 'player_type'; static const String playerType = 'player_type';
static const String forceVodTranscoding = 'force_vod_transcoding';
} }
/// Settings state for IPTV preferences with persistence /// Settings state for IPTV preferences with persistence
@@ -85,6 +86,9 @@ class IptvSettings {
final String preferredAspectRatio; final String preferredAspectRatio;
final PlayerType playerType; final PlayerType playerType;
// VOD Settings
final bool forceVodTranscoding;
const IptvSettings({ const IptvSettings({
// Filters // Filters
this.liveTvCategoryFilter = '', this.liveTvCategoryFilter = '',
@@ -102,6 +106,8 @@ class IptvSettings {
this.showClock = false, this.showClock = false,
this.preferredAspectRatio = 'contain', this.preferredAspectRatio = 'contain',
this.playerType = PlayerType.standard, this.playerType = PlayerType.standard,
// VOD defaults
this.forceVodTranscoding = false,
}); });
IptvSettings copyWith({ IptvSettings copyWith({
@@ -118,6 +124,7 @@ class IptvSettings {
bool? showClock, bool? showClock,
String? preferredAspectRatio, String? preferredAspectRatio,
PlayerType? playerType, PlayerType? playerType,
bool? forceVodTranscoding,
}) { }) {
return IptvSettings( return IptvSettings(
liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter, liveTvCategoryFilter: liveTvCategoryFilter ?? this.liveTvCategoryFilter,
@@ -133,6 +140,7 @@ class IptvSettings {
showClock: showClock ?? this.showClock, showClock: showClock ?? this.showClock,
preferredAspectRatio: preferredAspectRatio ?? this.preferredAspectRatio, preferredAspectRatio: preferredAspectRatio ?? this.preferredAspectRatio,
playerType: playerType ?? this.playerType, playerType: playerType ?? this.playerType,
forceVodTranscoding: forceVodTranscoding ?? this.forceVodTranscoding,
); );
} }
@@ -298,6 +306,7 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
preferredAspectRatio: getValue<String>(_SettingsKeys.preferredAspectRatio), preferredAspectRatio: getValue<String>(_SettingsKeys.preferredAspectRatio),
playerType: remoteSettings[_SettingsKeys.playerType] != null playerType: remoteSettings[_SettingsKeys.playerType] != null
? PlayerType.values[remoteSettings[_SettingsKeys.playerType] as int] : null, ? PlayerType.values[remoteSettings[_SettingsKeys.playerType] as int] : null,
forceVodTranscoding: getValue<bool>(_SettingsKeys.forceVodTranscoding),
); );
} }
_initialized = true; _initialized = true;
@@ -328,6 +337,7 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
_SettingsKeys.showClock: state.showClock, _SettingsKeys.showClock: state.showClock,
_SettingsKeys.preferredAspectRatio: state.preferredAspectRatio, _SettingsKeys.preferredAspectRatio: state.preferredAspectRatio,
_SettingsKeys.playerType: state.playerType.index, _SettingsKeys.playerType: state.playerType.index,
_SettingsKeys.forceVodTranscoding: state.forceVodTranscoding,
}; };
print('[SettingsProvider] _saveToApi: Saving settings...'); print('[SettingsProvider] _saveToApi: Saving settings...');
@@ -422,6 +432,12 @@ class IptvSettingsNotifier extends StateNotifier<IptvSettings> {
// Legacy methods // Legacy methods
void setCategoryFilter(String filter) => setLiveTvFilter(filter); void setCategoryFilter(String filter) => setLiveTvFilter(filter);
void clearCategoryFilter() => clearLiveTvFilter(); void clearCategoryFilter() => clearLiveTvFilter();
// VOD Settings
void setForceVodTranscoding(bool value) {
state = state.copyWith(forceVodTranscoding: value);
_saveToApi();
}
} }
/// Provider for IPTV settings /// Provider for IPTV settings
+6 -3
View File
@@ -102,20 +102,23 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(xtreamServiceProvider(widget.playlist));
String streamUrl = ''; String streamUrl = '';
// Get settings for VOD transcoding preference
final playerSettings = ref.read(iptvSettingsProvider);
final forceTranscoding = playerSettings.forceVodTranscoding;
// Generate Stream URL based on type // Generate Stream URL based on type
if (widget.streamType == StreamType.live) { if (widget.streamType == StreamType.live) {
streamUrl = service.getLiveStreamUrl(currentStreamId); streamUrl = service.getLiveStreamUrl(currentStreamId);
} else if (widget.streamType == StreamType.vod) { } 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) { } 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 // Store URL for Lite Player
_currentStreamUrl = streamUrl; _currentStreamUrl = streamUrl;
final encodedUrl = Uri.encodeComponent(streamUrl); final encodedUrl = Uri.encodeComponent(streamUrl);
final playerSettings = ref.read(iptvSettingsProvider);
final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod'; final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod';
var playerSrc = playerSettings.playerType == PlayerType.lite var playerSrc = playerSettings.playerType == PlayerType.lite
? 'player_lite.html?url=$encodedUrl&type=$streamTypeParam' ? 'player_lite.html?url=$encodedUrl&type=$streamTypeParam'
+26 -10
View File
@@ -69,27 +69,43 @@ class XtreamService {
} }
/// Generate stream URL for VOD (movies) /// 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'); 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 url = '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
final baseUrl = html.window.location.origin; if (forceTranscoding) {
final encodedUrl = Uri.encodeComponent(url); // Use FFmpeg transcoding for guaranteed audio (no seeking)
return '$baseUrl/api/stream/$streamId?url=$encodedUrl'; 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 /// 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'); 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 url = '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension';
final baseUrl = html.window.location.origin; if (forceTranscoding) {
final encodedUrl = Uri.encodeComponent(url); // Use FFmpeg transcoding for guaranteed audio (no seeking)
return '$baseUrl/api/stream/$streamId?url=$encodedUrl'; 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 /// Authenticate and get server info
@@ -167,6 +167,20 @@ class StreamingSettingsTab extends ConsumerWidget {
activeColor: Colors.blueAccent, activeColor: Colors.blueAccent,
onChanged: (v) => notifier.setPreferDirectPlay(v), 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), const SizedBox(height: 16),
+18 -7
View File
@@ -511,16 +511,27 @@
log('Stream URL: ' + streamUrl.substring(0, 80) + '...'); 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/')) { 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)...'); showLoading('Initializing stream (transcoding audio)...');
if (mpegts.isSupported()) { try {
await playWithMpegTS(streamUrl); const response = await fetch(streamUrl);
return; const data = await response.json();
} else {
showError('MPEG-TS playback not supported on this device'); 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; return;
} }
} }