From b39dd97e5acb481f1dbac0d6018a268d1078a9ba Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 15 Dec 2025 20:56:17 +0100 Subject: [PATCH] feat: Add local Docker test environment setup with PowerShell script and Docker Compose. --- bin/api/streaming_handler.dart | 59 +++++++++------ docker-compose.test.yml | 11 +++ lib/features/iptv/models/xtream_models.dart | 5 +- lib/features/iptv/screens/player_screen.dart | 6 ++ .../iptv/screens/series_detail_screen.dart | 1 + lib/features/iptv/widgets/movies_tab.dart | 1 + test_docker_local.ps1 | 14 ++++ web/player.html | 71 +++++++++++++++++-- 8 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 docker-compose.test.yml create mode 100644 test_docker_local.ps1 diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index eb545ba..5d646e4 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -40,8 +40,11 @@ Handler createLiveStreamHandler(Future 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 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 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 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'); diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..21ba5cd --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,11 @@ +services: + iptv-web: + build: . + ports: + - "8089:8089" + volumes: + - xtremflow-data:/app/data + restart: unless-stopped + +volumes: + xtremflow-data: diff --git a/lib/features/iptv/models/xtream_models.dart b/lib/features/iptv/models/xtream_models.dart index 6f41e20..cab3fcc 100644 --- a/lib/features/iptv/models/xtream_models.dart +++ b/lib/features/iptv/models/xtream_models.dart @@ -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 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'), ); } diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index d83221e..19df898 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -30,6 +30,7 @@ class PlayerScreen extends ConsumerStatefulWidget { final String containerExtension; final List? 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 { 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) { diff --git a/lib/features/iptv/screens/series_detail_screen.dart b/lib/features/iptv/screens/series_detail_screen.dart index 2a1aeb9..d5f8255 100644 --- a/lib/features/iptv/screens/series_detail_screen.dart +++ b/lib/features/iptv/screens/series_detail_screen.dart @@ -280,6 +280,7 @@ class _SeriesDetailScreenState extends ConsumerState { playlist: widget.playlist, streamType: StreamType.series, containerExtension: episode.containerExtension ?? 'mkv', + duration: episode.durationSecs != null ? Duration(seconds: episode.durationSecs!) : null, ), ), ); diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 86bd081..abbb27d 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -161,6 +161,7 @@ class _MoviesTabState extends ConsumerState { playlist: widget.playlist, streamType: StreamType.vod, containerExtension: movie.containerExtension ?? 'mp4', + duration: movie.durationSecs != null ? Duration(seconds: movie.durationSecs!) : null, ), ), ); diff --git a/test_docker_local.ps1 b/test_docker_local.ps1 new file mode 100644 index 0000000..f6da75f --- /dev/null +++ b/test_docker_local.ps1 @@ -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" diff --git a/web/player.html b/web/player.html index 61b49c8..a87cd0a 100644 --- a/web/player.html +++ b/web/player.html @@ -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();