mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-19 05:06:21 +02:00
feat: Implement Xtream Codes IPTV streaming, VOD transcoding, and content browsing UI with a dedicated web player.
This commit is contained in:
1 parent
6d69617e40
commit
3f9a88c09a
4 files changed
+142
-21
No files matched your search
@@ -104,19 +104,50 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
|
|||||||
final playlist = await getPlaylist(request);
|
final playlist = await getPlaylist(request);
|
||||||
if (playlist == null) return Response.internalServerError(body: 'No playlist');
|
if (playlist == null) return Response.internalServerError(body: 'No playlist');
|
||||||
|
|
||||||
// Check if session already exists
|
|
||||||
if (_vodProcesses.containsKey(streamId)) {
|
|
||||||
print('[VOD] Transcoding already in progress for $streamId');
|
|
||||||
|
|
||||||
// Allow existing process to continue
|
|
||||||
// Just fall through to serve the playlist
|
|
||||||
} else {
|
|
||||||
// Clean up ANY existing garbage from previous dead runs if process not tracked
|
|
||||||
// (Optional safety, or just rely on overwrite)
|
|
||||||
}
|
|
||||||
|
|
||||||
final streamDir = Directory('${_hlsTempDir.path}/$streamId');
|
final streamDir = Directory('${_hlsTempDir.path}/$streamId');
|
||||||
|
|
||||||
|
// Check if existing session is healthy
|
||||||
|
if (_vodProcesses.containsKey(streamId)) {
|
||||||
|
final existingProcess = _vodProcesses[streamId]!;
|
||||||
|
|
||||||
|
// Check if process is still running by checking if playlist exists and has content
|
||||||
|
final playlistFile = File('${streamDir.path}/playlist.m3u8');
|
||||||
|
if (playlistFile.existsSync()) {
|
||||||
|
final content = playlistFile.readAsStringSync();
|
||||||
|
// Check if playlist has segments (healthy transcoding)
|
||||||
|
if (content.contains('.ts')) {
|
||||||
|
print('[VOD] Reusing existing session for $streamId');
|
||||||
|
// Serve existing playlist
|
||||||
|
return Response.ok(
|
||||||
|
playlistFile.openRead(),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist missing or empty - process likely failed, clean up
|
||||||
|
print('[VOD] Cleaning up stale session for $streamId');
|
||||||
|
try {
|
||||||
|
existingProcess.kill();
|
||||||
|
} catch (e) {
|
||||||
|
// Process may already be dead
|
||||||
|
}
|
||||||
|
_vodProcesses.remove(streamId);
|
||||||
|
|
||||||
|
// Clean up directory
|
||||||
|
if (streamDir.existsSync()) {
|
||||||
|
try {
|
||||||
|
streamDir.deleteSync(recursive: true);
|
||||||
|
} catch (e) {
|
||||||
|
print('[VOD] Failed to clean directory: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Build Upstream URL (Try MKV first, then generic)
|
// Build Upstream URL (Try MKV first, then generic)
|
||||||
// Note: We don't know the extension here easily unless passed.
|
// Note: We don't know the extension here easily unless passed.
|
||||||
@@ -149,16 +180,16 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
|
|||||||
'-pix_fmt', 'yuv420p',
|
'-pix_fmt', 'yuv420p',
|
||||||
'-threads', '0',
|
'-threads', '0',
|
||||||
|
|
||||||
// Audio: AAC (Browser compatible)
|
// Audio: AAC (Browser compatible, improved quality)
|
||||||
'-c:a', 'aac',
|
'-c:a', 'aac',
|
||||||
'-b:a', '128k',
|
'-b:a', '192k', // Higher bitrate for better quality
|
||||||
|
'-ar', '48000', // Standard sample rate
|
||||||
'-ac', '2',
|
'-ac', '2',
|
||||||
|
|
||||||
// HLS Output options
|
|
||||||
'-f', 'hls',
|
'-f', 'hls',
|
||||||
'-hls_time', '4',
|
'-hls_time', '4',
|
||||||
'-hls_list_size', '0',
|
'-hls_list_size', '0',
|
||||||
'-hls_playlist_type', 'event', // Enable Event mode (VOD that grows)
|
'-hls_playlist_type', 'event', // Event type allows immediate playback while transcoding
|
||||||
'-hls_allow_cache', '1',
|
'-hls_allow_cache', '1',
|
||||||
'-hls_segment_type', 'mpegts',
|
'-hls_segment_type', 'mpegts',
|
||||||
'-hls_segment_filename', '${streamDir.path}/segment_%03d.ts',
|
'-hls_segment_filename', '${streamDir.path}/segment_%03d.ts',
|
||||||
|
|||||||
@@ -501,6 +501,62 @@ class XtreamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get VOD (movie) info including duration
|
||||||
|
///
|
||||||
|
/// Returns detailed movie information including duration in seconds
|
||||||
|
Future<int?> getVodDuration(String vodId) async {
|
||||||
|
if (_currentPlaylist == null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
|
||||||
|
queryParameters: {
|
||||||
|
'username': _currentPlaylist!.username,
|
||||||
|
'password': _currentPlaylist!.password,
|
||||||
|
'action': 'get_vod_info',
|
||||||
|
'vod_id': vodId,
|
||||||
|
},
|
||||||
|
options: Options(extra: _cacheOptions.toExtra()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Try to get duration from movie_properties or info
|
||||||
|
final movieData = data['movie_data'] as Map<String, dynamic>?;
|
||||||
|
final info = data['info'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
// Duration can be in different formats: seconds (int), "HH:MM:SS", or minutes
|
||||||
|
String? durationStr = movieData?['duration']?.toString() ??
|
||||||
|
info?['duration']?.toString() ??
|
||||||
|
info?['duration_secs']?.toString();
|
||||||
|
|
||||||
|
if (durationStr == null || durationStr.isEmpty) return null;
|
||||||
|
|
||||||
|
// Parse duration - could be "01:30:00" format or seconds
|
||||||
|
if (durationStr.contains(':')) {
|
||||||
|
// HH:MM:SS format
|
||||||
|
final parts = durationStr.split(':');
|
||||||
|
if (parts.length == 3) {
|
||||||
|
final hours = int.tryParse(parts[0]) ?? 0;
|
||||||
|
final minutes = int.tryParse(parts[1]) ?? 0;
|
||||||
|
final seconds = int.tryParse(parts[2]) ?? 0;
|
||||||
|
return hours * 3600 + minutes * 60 + seconds;
|
||||||
|
} else if (parts.length == 2) {
|
||||||
|
// MM:SS format
|
||||||
|
final minutes = int.tryParse(parts[0]) ?? 0;
|
||||||
|
final seconds = int.tryParse(parts[1]) ?? 0;
|
||||||
|
return minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as seconds directly
|
||||||
|
return int.tryParse(durationStr);
|
||||||
|
} catch (e) {
|
||||||
|
// Duration is optional, don't fail
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get short EPG for a specific stream
|
/// Get short EPG for a specific stream
|
||||||
///
|
///
|
||||||
/// Returns "Now" and "Next" program info
|
/// Returns "Now" and "Next" program info
|
||||||
|
|||||||
@@ -149,9 +149,24 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
return originalUrl;
|
return originalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _playMovie(Movie movie) {
|
Future<void> _playMovie(Movie movie) async {
|
||||||
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
|
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
|
||||||
|
|
||||||
|
// Fetch actual duration from API if not available in movie data
|
||||||
|
Duration? movieDuration;
|
||||||
|
if (movie.durationSecs != null && movie.durationSecs! > 0) {
|
||||||
|
movieDuration = Duration(seconds: movie.durationSecs!);
|
||||||
|
} else {
|
||||||
|
// Try to fetch from API
|
||||||
|
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
||||||
|
final durationSecs = await service.getVodDuration(movie.streamId);
|
||||||
|
if (durationSecs != null && durationSecs > 0) {
|
||||||
|
movieDuration = Duration(seconds: durationSecs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -161,7 +176,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
playlist: widget.playlist,
|
playlist: widget.playlist,
|
||||||
streamType: StreamType.vod,
|
streamType: StreamType.vod,
|
||||||
containerExtension: movie.containerExtension ?? 'mp4',
|
containerExtension: movie.containerExtension ?? 'mp4',
|
||||||
duration: movie.durationSecs != null ? Duration(seconds: movie.durationSecs!) : null,
|
duration: movieDuration,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+23
-4
@@ -147,10 +147,14 @@
|
|||||||
lastReportedTime = video.currentTime;
|
lastReportedTime = video.currentTime;
|
||||||
|
|
||||||
let duration = video.duration;
|
let duration = video.duration;
|
||||||
// If video.duration is Infinity, NaN, or significantly smaller than injected (transcoding lag), use injected
|
// Priority: 1) HLS dynamic duration, 2) video.duration, 3) injectedDuration
|
||||||
// We trust injected duration (from API) more than HLS internal duration for VOD
|
const hlsDuration = window.currentHlsDuration || 0;
|
||||||
if (!isFinite(duration) || isNaN(duration) || (injectedDuration > 0 && duration < injectedDuration * 0.9)) {
|
if (!isFinite(duration) || isNaN(duration) || duration < 1) {
|
||||||
duration = injectedDuration > 0 ? injectedDuration : 0;
|
duration = hlsDuration > 0 ? hlsDuration : (injectedDuration > 0 ? injectedDuration : 0);
|
||||||
|
} else if (hlsDuration > duration) {
|
||||||
|
duration = hlsDuration; // Use HLS duration if larger (growing playlist)
|
||||||
|
} else if (injectedDuration > 0 && duration < injectedDuration * 0.9) {
|
||||||
|
duration = injectedDuration; // Fallback to injected if significantly larger
|
||||||
}
|
}
|
||||||
|
|
||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
@@ -268,6 +272,21 @@
|
|||||||
video.play().catch(e => log('Autoplay prevented: ' + e));
|
video.play().catch(e => log('Autoplay prevented: ' + e));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track growing duration as more segments are added (for EVENT playlists)
|
||||||
|
hls.on(Hls.Events.LEVEL_LOADED, (event, data) => {
|
||||||
|
const levelDetails = data.details;
|
||||||
|
if (levelDetails && levelDetails.totalduration) {
|
||||||
|
const currentLevelDuration = levelDetails.totalduration;
|
||||||
|
log('Level loaded, current duration: ' + currentLevelDuration);
|
||||||
|
|
||||||
|
// Update injectedDuration if HLS gives us a better value
|
||||||
|
// This allows the progress bar to grow as transcoding progresses
|
||||||
|
if (currentLevelDuration > injectedDuration) {
|
||||||
|
window.currentHlsDuration = currentLevelDuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||||
log('HLS Error: ' + data.type + ' - ' + data.details);
|
log('HLS Error: ' + data.type + ' - ' + data.details);
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
|
|||||||
Reference in new issue
Block a user