feat: Add local Docker test environment setup with PowerShell script and Docker Compose.

This commit is contained in:
Michael committed 2025-12-15 20:56:17 +01:00
1 parent 435cdb46ed
commit b39dd97e5a
8 files changed
+131 -17

No files matched your search

+27 -12
View File
@@ -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,26 +104,33 @@ 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);
final targetUrl = '${playlist.dns}/movie/${playlist.username}/${playlist.password}/$streamId.mkv';
print('[VOD] Starting Transcode: $targetUrl');
// Start FFmpeg
@@ -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',
@@ -160,7 +172,9 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
_vodProcesses[streamId] = process;
process.stderr.transform(utf8.decoder).listen((data) {
if (data.contains('Error')) print('[FFmpeg $streamId] $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) {
@@ -168,6 +182,7 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
_vodProcesses.remove(streamId);
});
});
}
// Wait for playlist to appear (max 10s)
final playlistFile = File('${streamDir.path}/playlist.m3u8');
+11
View File
@@ -0,0 +1,11 @@
services:
iptv-web:
build: .
ports:
- "8089:8089"
volumes:
- xtremflow-data:/app/data
restart: unless-stopped
volumes:
xtremflow-data:
+4 -1
View File
@@ -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
@@ -134,6 +136,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
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) {
final iframe = html.IFrameElement()
@@ -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,
),
),
);
+14
View File
@@ -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
View File
@@ -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>