diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 5d646e4..8c20926 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -104,18 +104,49 @@ Handler createVodStreamHandler(Future Function(Request) getPlay final playlist = await getPlaylist(request); 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'); + + // 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) @@ -149,16 +180,16 @@ Handler createVodStreamHandler(Future Function(Request) getPlay '-pix_fmt', 'yuv420p', '-threads', '0', - // Audio: AAC (Browser compatible) + // Audio: AAC (Browser compatible, improved quality) '-c:a', 'aac', - '-b:a', '128k', + '-b:a', '192k', // Higher bitrate for better quality + '-ar', '48000', // Standard sample rate '-ac', '2', - // HLS Output options '-f', 'hls', '-hls_time', '4', '-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_segment_type', 'mpegts', '-hls_segment_filename', '${streamDir.path}/segment_%03d.ts', diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index 48a0d70..6c6b898 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -501,6 +501,62 @@ class XtreamService { } } + /// Get VOD (movie) info including duration + /// + /// Returns detailed movie information including duration in seconds + Future 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; + + // Try to get duration from movie_properties or info + final movieData = data['movie_data'] as Map?; + final info = data['info'] as Map?; + + // 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 /// /// Returns "Now" and "Next" program info diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index abbb27d..d73cb4f 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -149,9 +149,24 @@ class _MoviesTabState extends ConsumerState { return originalUrl; } - void _playMovie(Movie movie) { + Future _playMovie(Movie movie) async { 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( context, MaterialPageRoute( @@ -161,7 +176,7 @@ class _MoviesTabState extends ConsumerState { playlist: widget.playlist, streamType: StreamType.vod, containerExtension: movie.containerExtension ?? 'mp4', - duration: movie.durationSecs != null ? Duration(seconds: movie.durationSecs!) : null, + duration: movieDuration, ), ), ); diff --git a/web/player.html b/web/player.html index a87cd0a..6652827 100644 --- a/web/player.html +++ b/web/player.html @@ -147,10 +147,14 @@ 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; + // Priority: 1) HLS dynamic duration, 2) video.duration, 3) injectedDuration + const hlsDuration = window.currentHlsDuration || 0; + if (!isFinite(duration) || isNaN(duration) || duration < 1) { + 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({ @@ -268,6 +272,21 @@ 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) => { log('HLS Error: ' + data.type + ' - ' + data.details); if (data.fatal) {