mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
feat: Add local Docker test environment setup with PowerShell script and Docker Compose.
This commit is contained in:
1 parent
435cdb46ed
commit
b39dd97e5a
8 files changed
+141
-27
No files matched your search
@@ -40,8 +40,11 @@ Handler createLiveStreamHandler(Future<PlaylistConfig?> Function(Request) getPla
|
||||
print('[Live] Incoming request for streamId: $streamId');
|
||||
|
||||
// Remove .ts extension if present
|
||||
// Remove extension if present (handle both .ts and .m3u8 just in case)
|
||||
if (streamId.endsWith('.ts')) {
|
||||
streamId = streamId.substring(0, streamId.length - 3);
|
||||
} else if (streamId.endsWith('.m3u8')) {
|
||||
streamId = streamId.substring(0, streamId.length - 5);
|
||||
}
|
||||
|
||||
final playlist = await getPlaylist(request);
|
||||
@@ -101,27 +104,34 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
|
||||
final playlist = await getPlaylist(request);
|
||||
if (playlist == null) return Response.internalServerError(body: 'No playlist');
|
||||
|
||||
// Clean up old session if exists
|
||||
// Check if session already exists
|
||||
if (_vodProcesses.containsKey(streamId)) {
|
||||
print('[VOD] Killing previous session for $streamId');
|
||||
_vodProcesses[streamId]?.kill();
|
||||
_vodProcesses.remove(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)
|
||||
}
|
||||
|
||||
// Prepare directory
|
||||
final streamDir = Directory('${_hlsTempDir.path}/$streamId');
|
||||
if (streamDir.existsSync()) {
|
||||
streamDir.deleteSync(recursive: true);
|
||||
}
|
||||
streamDir.createSync(recursive: true);
|
||||
|
||||
|
||||
// Build Upstream URL (Try MKV first, then generic)
|
||||
// Note: We don't know the extension here easily unless passed.
|
||||
// Xtream usually allows access via streamId.mkv or streamId.mp4 regardless of actual file
|
||||
// We'll trust the ID.
|
||||
final targetUrl = '${playlist.dns}/movie/${playlist.username}/${playlist.password}/$streamId.mkv';
|
||||
// Only start FFmpeg if not already running
|
||||
if (!_vodProcesses.containsKey(streamId)) {
|
||||
// Prepare directory (Only for new session)
|
||||
if (streamDir.existsSync()) {
|
||||
streamDir.deleteSync(recursive: true);
|
||||
}
|
||||
streamDir.createSync(recursive: true);
|
||||
|
||||
print('[VOD] Starting Transcode: $targetUrl');
|
||||
final targetUrl = '${playlist.dns}/movie/${playlist.username}/${playlist.password}/$streamId.mkv';
|
||||
print('[VOD] Starting Transcode: $targetUrl');
|
||||
|
||||
// Start FFmpeg
|
||||
final ffmpegArgs = [
|
||||
@@ -147,7 +157,9 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
|
||||
// HLS Output options
|
||||
'-f', 'hls',
|
||||
'-hls_time', '4',
|
||||
'-hls_list_size', '0', // Keep all segments
|
||||
'-hls_list_size', '0',
|
||||
'-hls_playlist_type', 'event', // Enable Event mode (VOD that grows)
|
||||
'-hls_allow_cache', '1',
|
||||
'-hls_segment_type', 'mpegts',
|
||||
'-hls_segment_filename', '${streamDir.path}/segment_%03d.ts',
|
||||
'-start_number', '0',
|
||||
@@ -156,18 +168,21 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
|
||||
'${streamDir.path}/playlist.m3u8'
|
||||
];
|
||||
|
||||
Process.start('ffmpeg', ffmpegArgs).then((process) {
|
||||
_vodProcesses[streamId] = process;
|
||||
|
||||
process.stderr.transform(utf8.decoder).listen((data) {
|
||||
if (data.contains('Error')) print('[FFmpeg $streamId] $data');
|
||||
});
|
||||
Process.start('ffmpeg', ffmpegArgs).then((process) {
|
||||
_vodProcesses[streamId] = process;
|
||||
|
||||
process.stderr.transform(utf8.decoder).listen((data) {
|
||||
// Log only major errors or startup info to keep logs clean(er)
|
||||
// or keep verbose if debugging
|
||||
print('[FFmpeg $streamId] $data');
|
||||
});
|
||||
|
||||
process.exitCode.then((code) {
|
||||
print('[VOD] FFmpeg exited with code $code');
|
||||
_vodProcesses.remove(streamId);
|
||||
process.exitCode.then((code) {
|
||||
print('[VOD] FFmpeg exited with code $code');
|
||||
_vodProcesses.remove(streamId);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for playlist to appear (max 10s)
|
||||
final playlistFile = File('${streamDir.path}/playlist.m3u8');
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
iptv-web:
|
||||
build: .
|
||||
ports:
|
||||
- "8089:8089"
|
||||
volumes:
|
||||
- xtremflow-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
xtremflow-data:
|
||||
@@ -31,7 +31,7 @@ class LiveChannel {
|
||||
}
|
||||
|
||||
String getStreamUrl(String dns, String username, String password) {
|
||||
return '$dns/live/$username/$password/$streamId.m3u8';
|
||||
return '$dns/live/$username/$password/$streamId.ts';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class Movie {
|
||||
final String categoryName;
|
||||
final String? containerExtension;
|
||||
final String? rating;
|
||||
final int? durationSecs;
|
||||
|
||||
const Movie({
|
||||
required this.streamId,
|
||||
@@ -52,6 +53,7 @@ class Movie {
|
||||
this.categoryName = '',
|
||||
this.containerExtension,
|
||||
this.rating,
|
||||
this.durationSecs,
|
||||
});
|
||||
|
||||
factory Movie.fromJson(Map<String, dynamic> json) {
|
||||
@@ -69,6 +71,7 @@ class Movie {
|
||||
categoryName: json['category_name']?.toString() ?? '',
|
||||
containerExtension: json['container_extension']?.toString() ?? 'mp4',
|
||||
rating: json['rating']?.toString(),
|
||||
durationSecs: int.tryParse(json['duration_secs']?.toString() ?? json['duration']?.toString() ?? '0'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class PlayerScreen extends ConsumerStatefulWidget {
|
||||
final String containerExtension;
|
||||
final List<Channel>? channels;
|
||||
final double? startTime;
|
||||
final Duration? duration;
|
||||
|
||||
const PlayerScreen({
|
||||
super.key,
|
||||
@@ -40,6 +41,7 @@ class PlayerScreen extends ConsumerStatefulWidget {
|
||||
this.containerExtension = 'mp4',
|
||||
this.channels,
|
||||
this.startTime,
|
||||
this.duration,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -133,6 +135,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
if (startTimeOverride != null) {
|
||||
playerSrc += '&t=$startTimeOverride';
|
||||
}
|
||||
|
||||
if (widget.duration != null) {
|
||||
playerSrc += '&duration=${widget.duration!.inSeconds}';
|
||||
}
|
||||
|
||||
// Register View Factory (Only needed for Standard Player, but safe to do always or conditionally)
|
||||
ui_web.platformViewRegistry.registerViewFactory(_viewId, (int viewId) {
|
||||
|
||||
@@ -280,6 +280,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
||||
playlist: widget.playlist,
|
||||
streamType: StreamType.series,
|
||||
containerExtension: episode.containerExtension ?? 'mkv',
|
||||
duration: episode.durationSecs != null ? Duration(seconds: episode.durationSecs!) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -161,6 +161,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
playlist: widget.playlist,
|
||||
streamType: StreamType.vod,
|
||||
containerExtension: movie.containerExtension ?? 'mp4',
|
||||
duration: movie.durationSecs != null ? Duration(seconds: movie.durationSecs!) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Stop existing containers if running
|
||||
Write-Host "Stopping any existing test containers..."
|
||||
docker-compose -f docker-compose.test.yml down
|
||||
|
||||
# Build and start
|
||||
Write-Host "Building and starting local test environment..."
|
||||
docker-compose -f docker-compose.test.yml up --build -d
|
||||
|
||||
# Check status
|
||||
Write-Host "Checking container status..."
|
||||
Start-Sleep -Seconds 5
|
||||
docker-compose -f docker-compose.test.yml ps
|
||||
|
||||
Write-Host "Application should be available at http://localhost:8089"
|
||||
+67
-4
@@ -109,6 +109,7 @@
|
||||
let streamUrl = urlParams.get('url');
|
||||
const startTime = parseFloat(urlParams.get('t') || '0');
|
||||
const streamType = urlParams.get('type') || ''; // 'live' or 'vod'
|
||||
const injectedDuration = parseFloat(urlParams.get('duration') || '0'); // Fix for infinite HLS duration
|
||||
|
||||
function log(msg) {
|
||||
console.log('[Player] ' + msg);
|
||||
@@ -142,12 +143,20 @@
|
||||
let lastReportedTime = 0;
|
||||
function reportPosition() {
|
||||
if (video.currentTime > 0 && !video.paused && video.readyState > 2) {
|
||||
if (Math.abs(video.currentTime - lastReportedTime) >= 5) {
|
||||
if (Math.abs(video.currentTime - lastReportedTime) >= 1) { // Report more frequently for smoother UI
|
||||
lastReportedTime = video.currentTime;
|
||||
|
||||
let duration = video.duration;
|
||||
// If video.duration is Infinity, NaN, or significantly smaller than injected (transcoding lag), use injected
|
||||
// We trust injected duration (from API) more than HLS internal duration for VOD
|
||||
if (!isFinite(duration) || isNaN(duration) || (injectedDuration > 0 && duration < injectedDuration * 0.9)) {
|
||||
duration = injectedDuration > 0 ? injectedDuration : 0;
|
||||
}
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'playback_position',
|
||||
currentTime: video.currentTime,
|
||||
duration: isFinite(video.duration) ? video.duration : 0
|
||||
duration: duration
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
@@ -156,17 +165,40 @@
|
||||
|
||||
// Report on pause and end
|
||||
video.addEventListener('pause', () => {
|
||||
let duration = video.duration;
|
||||
if (!isFinite(duration) || isNaN(duration)) {
|
||||
duration = injectedDuration > 0 ? injectedDuration : 0;
|
||||
}
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'playback_position',
|
||||
currentTime: video.currentTime,
|
||||
duration: isFinite(video.duration) ? video.duration : 0
|
||||
duration: duration
|
||||
}, '*');
|
||||
|
||||
// Also report status
|
||||
window.parent.postMessage({
|
||||
type: 'playback_status',
|
||||
status: 'paused'
|
||||
}, '*');
|
||||
});
|
||||
|
||||
video.addEventListener('ended', () => {
|
||||
let duration = video.duration;
|
||||
if (!isFinite(duration) || isNaN(duration)) {
|
||||
duration = injectedDuration > 0 ? injectedDuration : 0;
|
||||
}
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'playback_ended',
|
||||
duration: isFinite(video.duration) ? video.duration : 0
|
||||
duration: duration
|
||||
}, '*');
|
||||
});
|
||||
|
||||
video.addEventListener('playing', () => {
|
||||
window.parent.postMessage({
|
||||
type: 'playback_status',
|
||||
status: 'playing'
|
||||
}, '*');
|
||||
});
|
||||
|
||||
@@ -353,6 +385,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for messages from parent (Flutter)
|
||||
window.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (!data) return;
|
||||
|
||||
log('Received message: ' + data.type);
|
||||
|
||||
switch (data.type) {
|
||||
case 'play':
|
||||
video.play().catch(e => log('Play failed: ' + e));
|
||||
break;
|
||||
case 'pause':
|
||||
video.pause();
|
||||
break;
|
||||
case 'seek':
|
||||
const time = parseFloat(data.value);
|
||||
if (isFinite(time) && time >= 0) {
|
||||
video.currentTime = time;
|
||||
log('Seek to: ' + time);
|
||||
}
|
||||
break;
|
||||
case 'set_volume':
|
||||
const vol = parseFloat(data.value);
|
||||
if (isFinite(vol) && vol >= 0 && vol <= 1) {
|
||||
video.volume = vol;
|
||||
video.muted = vol === 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
initPlayer();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in new issue
Block a user