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
+131
-17
No files matched your search
@@ -40,8 +40,11 @@ Handler createLiveStreamHandler(Future<PlaylistConfig?> Function(Request) getPla
|
|||||||
print('[Live] Incoming request for streamId: $streamId');
|
print('[Live] Incoming request for streamId: $streamId');
|
||||||
|
|
||||||
// Remove .ts extension if present
|
// Remove .ts extension if present
|
||||||
|
// Remove extension if present (handle both .ts and .m3u8 just in case)
|
||||||
if (streamId.endsWith('.ts')) {
|
if (streamId.endsWith('.ts')) {
|
||||||
streamId = streamId.substring(0, streamId.length - 3);
|
streamId = streamId.substring(0, streamId.length - 3);
|
||||||
|
} else if (streamId.endsWith('.m3u8')) {
|
||||||
|
streamId = streamId.substring(0, streamId.length - 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
final playlist = await getPlaylist(request);
|
final playlist = await getPlaylist(request);
|
||||||
@@ -101,26 +104,33 @@ 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');
|
||||||
|
|
||||||
// Clean up old session if exists
|
// Check if session already exists
|
||||||
if (_vodProcesses.containsKey(streamId)) {
|
if (_vodProcesses.containsKey(streamId)) {
|
||||||
print('[VOD] Killing previous session for $streamId');
|
print('[VOD] Transcoding already in progress for $streamId');
|
||||||
_vodProcesses[streamId]?.kill();
|
|
||||||
_vodProcesses.remove(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');
|
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)
|
// 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.
|
||||||
// Xtream usually allows access via streamId.mkv or streamId.mp4 regardless of actual file
|
// Xtream usually allows access via streamId.mkv or streamId.mp4 regardless of actual file
|
||||||
// We'll trust the ID.
|
// 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');
|
print('[VOD] Starting Transcode: $targetUrl');
|
||||||
|
|
||||||
// Start FFmpeg
|
// Start FFmpeg
|
||||||
@@ -147,7 +157,9 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
|
|||||||
// HLS Output options
|
// HLS Output options
|
||||||
'-f', 'hls',
|
'-f', 'hls',
|
||||||
'-hls_time', '4',
|
'-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_type', 'mpegts',
|
||||||
'-hls_segment_filename', '${streamDir.path}/segment_%03d.ts',
|
'-hls_segment_filename', '${streamDir.path}/segment_%03d.ts',
|
||||||
'-start_number', '0',
|
'-start_number', '0',
|
||||||
@@ -160,7 +172,9 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
|
|||||||
_vodProcesses[streamId] = process;
|
_vodProcesses[streamId] = process;
|
||||||
|
|
||||||
process.stderr.transform(utf8.decoder).listen((data) {
|
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) {
|
process.exitCode.then((code) {
|
||||||
@@ -168,6 +182,7 @@ Handler createVodStreamHandler(Future<PlaylistConfig?> Function(Request) getPlay
|
|||||||
_vodProcesses.remove(streamId);
|
_vodProcesses.remove(streamId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for playlist to appear (max 10s)
|
// Wait for playlist to appear (max 10s)
|
||||||
final playlistFile = File('${streamDir.path}/playlist.m3u8');
|
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) {
|
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 categoryName;
|
||||||
final String? containerExtension;
|
final String? containerExtension;
|
||||||
final String? rating;
|
final String? rating;
|
||||||
|
final int? durationSecs;
|
||||||
|
|
||||||
const Movie({
|
const Movie({
|
||||||
required this.streamId,
|
required this.streamId,
|
||||||
@@ -52,6 +53,7 @@ class Movie {
|
|||||||
this.categoryName = '',
|
this.categoryName = '',
|
||||||
this.containerExtension,
|
this.containerExtension,
|
||||||
this.rating,
|
this.rating,
|
||||||
|
this.durationSecs,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Movie.fromJson(Map<String, dynamic> json) {
|
factory Movie.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -69,6 +71,7 @@ class Movie {
|
|||||||
categoryName: json['category_name']?.toString() ?? '',
|
categoryName: json['category_name']?.toString() ?? '',
|
||||||
containerExtension: json['container_extension']?.toString() ?? 'mp4',
|
containerExtension: json['container_extension']?.toString() ?? 'mp4',
|
||||||
rating: json['rating']?.toString(),
|
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 String containerExtension;
|
||||||
final List<Channel>? channels;
|
final List<Channel>? channels;
|
||||||
final double? startTime;
|
final double? startTime;
|
||||||
|
final Duration? duration;
|
||||||
|
|
||||||
const PlayerScreen({
|
const PlayerScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -40,6 +41,7 @@ class PlayerScreen extends ConsumerStatefulWidget {
|
|||||||
this.containerExtension = 'mp4',
|
this.containerExtension = 'mp4',
|
||||||
this.channels,
|
this.channels,
|
||||||
this.startTime,
|
this.startTime,
|
||||||
|
this.duration,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -134,6 +136,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
playerSrc += '&t=$startTimeOverride';
|
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)
|
// Register View Factory (Only needed for Standard Player, but safe to do always or conditionally)
|
||||||
ui_web.platformViewRegistry.registerViewFactory(_viewId, (int viewId) {
|
ui_web.platformViewRegistry.registerViewFactory(_viewId, (int viewId) {
|
||||||
final iframe = html.IFrameElement()
|
final iframe = html.IFrameElement()
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
playlist: widget.playlist,
|
playlist: widget.playlist,
|
||||||
streamType: StreamType.series,
|
streamType: StreamType.series,
|
||||||
containerExtension: episode.containerExtension ?? 'mkv',
|
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,
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
let streamUrl = urlParams.get('url');
|
||||||
const startTime = parseFloat(urlParams.get('t') || '0');
|
const startTime = parseFloat(urlParams.get('t') || '0');
|
||||||
const streamType = urlParams.get('type') || ''; // 'live' or 'vod'
|
const streamType = urlParams.get('type') || ''; // 'live' or 'vod'
|
||||||
|
const injectedDuration = parseFloat(urlParams.get('duration') || '0'); // Fix for infinite HLS duration
|
||||||
|
|
||||||
function log(msg) {
|
function log(msg) {
|
||||||
console.log('[Player] ' + msg);
|
console.log('[Player] ' + msg);
|
||||||
@@ -142,12 +143,20 @@
|
|||||||
let lastReportedTime = 0;
|
let lastReportedTime = 0;
|
||||||
function reportPosition() {
|
function reportPosition() {
|
||||||
if (video.currentTime > 0 && !video.paused && video.readyState > 2) {
|
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;
|
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({
|
window.parent.postMessage({
|
||||||
type: 'playback_position',
|
type: 'playback_position',
|
||||||
currentTime: video.currentTime,
|
currentTime: video.currentTime,
|
||||||
duration: isFinite(video.duration) ? video.duration : 0
|
duration: duration
|
||||||
}, '*');
|
}, '*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,17 +165,40 @@
|
|||||||
|
|
||||||
// Report on pause and end
|
// Report on pause and end
|
||||||
video.addEventListener('pause', () => {
|
video.addEventListener('pause', () => {
|
||||||
|
let duration = video.duration;
|
||||||
|
if (!isFinite(duration) || isNaN(duration)) {
|
||||||
|
duration = injectedDuration > 0 ? injectedDuration : 0;
|
||||||
|
}
|
||||||
|
|
||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
type: 'playback_position',
|
type: 'playback_position',
|
||||||
currentTime: video.currentTime,
|
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', () => {
|
video.addEventListener('ended', () => {
|
||||||
|
let duration = video.duration;
|
||||||
|
if (!isFinite(duration) || isNaN(duration)) {
|
||||||
|
duration = injectedDuration > 0 ? injectedDuration : 0;
|
||||||
|
}
|
||||||
|
|
||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
type: 'playback_ended',
|
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();
|
initPlayer();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in new issue
Block a user