mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 13:38:05 +02:00
feat: Add initial IPTV player with HLS.js and mpegts.js support, including UI and parent communication.
This commit is contained in:
1 parent
0c087e0b34
commit
41c4ab05aa
6 files changed
+127
-49
No files matched your search
+47
-29
@@ -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
|
||||||
@@ -84,6 +85,9 @@ class IptvSettings {
|
|||||||
final bool showClock;
|
final bool showClock;
|
||||||
final String preferredAspectRatio;
|
final String preferredAspectRatio;
|
||||||
final PlayerType playerType;
|
final PlayerType playerType;
|
||||||
|
|
||||||
|
// VOD Settings
|
||||||
|
final bool forceVodTranscoding;
|
||||||
|
|
||||||
const IptvSettings({
|
const IptvSettings({
|
||||||
// Filters
|
// Filters
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user