diff --git a/bin/server.dart b/bin/server.dart index acd27a5..917c351 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -24,7 +24,7 @@ void main(List args) async { final parser = ArgParser() ..addOption('port', abbr: 'p', defaultsTo: '8089') ..addOption('path', defaultsTo: '/app/web'); - + final result = parser.parse(args); final port = int.parse(result['port']); final webPath = result['path']; @@ -45,17 +45,23 @@ void main(List args) async { // Auth endpoints (no auth middleware) - full path including /api/ ..mount('/api/auth', authHandler.router) // Playlists endpoints (with auth middleware) - ..mount('/api/playlists', Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler(playlistsHandler.router.call)) + ..mount( + '/api/playlists', + Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(playlistsHandler.router.call)) // Users endpoints (with auth middleware) - ..mount('/api/users', Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler(usersHandler.router.call)) + ..mount( + '/api/users', + Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(usersHandler.router.call)) // Settings endpoints (with auth middleware) - ..mount('/api/settings', Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler(settingsHandler.router.call)); + ..mount( + '/api/settings', + Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(settingsHandler.router.call)); // Initialize Cleanup Service final cleanupService = CleanupService(); @@ -64,31 +70,33 @@ void main(List args) async { // Target local logs/cache if they exist cleanupService.addTarget(Directory('/app/data/logs')); cleanupService.addTarget(Directory('/app/data/tmp')); - + cleanupService.start(); // Admin Routes (protected) - apiRouter.mount('/api/admin', Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler((Request request) { - final router = Router(); - - // POST /api/admin/purge - router.post('/purge', (Request req) async { - final user = req.context['user'] as User?; - if (user == null || !user.isAdmin) { - return Response.forbidden(jsonEncode({'error': 'Admin access required'})); - } - - final result = await cleanupService.runCleanup(); - return Response.ok( - jsonEncode(result), - headers: {'content-type': 'application/json'}, - ); - }); - - return router(request); - }),); + apiRouter.mount( + '/api/admin', + Pipeline().addMiddleware(authMiddleware(db)).addHandler((Request request) { + final router = Router(); + + // POST /api/admin/purge + router.post('/purge', (Request req) async { + final user = req.context['user'] as User?; + if (user == null || !user.isAdmin) { + return Response.forbidden( + jsonEncode({'error': 'Admin access required'})); + } + + final result = await cleanupService.runCleanup(); + return Response.ok( + jsonEncode(result), + headers: {'content-type': 'application/json'}, + ); + }); + + return router(request); + }), + ); // Create handlers // Create static handler @@ -101,46 +109,46 @@ void main(List args) async { // Wrap static handler to enforce cache policies Handler staticHandler = (Request request) async { final response = await baseStaticHandler(request); - + // Disable cache for entry points to ensure updates are seen immediately final path = request.url.path; - if (path.isEmpty || - path == 'index.html' || - path.endsWith('.js') || // Disable cache for ALL JS files (main.dart.js, bootstrap, etc) - path.endsWith('.json')) { // version.json etc + if (path.isEmpty || + path == 'index.html' || + path.endsWith( + '.js') || // Disable cache for ALL JS files (main.dart.js, bootstrap, etc) + path.endsWith('.json')) { + // version.json etc return response.change(headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', 'Pragma': 'no-cache', 'Expires': '0', }); } - + // Allow aggressive caching for hashed assets (images, compiled JS) // Flutter web builds usually hash main.dart.js, but let's be safe with 1 day return response.change(headers: { - 'Cache-Control': 'public, max-age=86400', + 'Cache-Control': 'public, max-age=86400', }); }; // Main handler with API proxy and FFmpeg streaming final handler = Cascade() - .add(_createApiHandler(apiRouter)) - .add(_createStreamHandler()) // FFmpeg streaming endpoint - .add(_createXtreamProxyHandler()) - // .add(_createHlsHandler()) // REMOVED: HLS obsolete with direct stream - .add(staticHandler) - .handler; - - + .add(_createApiHandler(apiRouter)) + .add(_createStreamHandler()) // FFmpeg streaming endpoint + .add(_createXtreamProxyHandler()) + // .add(_createHlsHandler()) // REMOVED: HLS obsolete with direct stream + .add(staticHandler) + .handler; // Add middleware final pipeline = Pipeline() - .addMiddleware(logRequests()) - .addMiddleware(securityHeadersMiddleware()) // Basic Headers - .addMiddleware(honeypotMiddleware()) // Trap Bots - .addMiddleware(rateLimitMiddleware()) // Anti-DoS - .addMiddleware(_corsMiddleware()) - .addHandler(handler); + .addMiddleware(logRequests()) + .addMiddleware(securityHeadersMiddleware()) // Basic Headers + .addMiddleware(honeypotMiddleware()) // Trap Bots + .addMiddleware(rateLimitMiddleware()) // Anti-DoS + .addMiddleware(_corsMiddleware()) + .addHandler(handler); // Start server final server = await shelf_io.serve( @@ -154,7 +162,7 @@ void main(List args) async { print('REST API available at: /api/auth/* and /api/playlists/*'); print('Xtream proxy available at: /api/xtream/*'); print('FFmpeg streaming available at: /api/stream/*'); - + // Clean expired sessions periodically (every hour) Timer.periodic(const Duration(hours: 1), (_) { db.cleanExpiredSessions(); @@ -166,12 +174,12 @@ void main(List args) async { Handler _createApiHandler(Router apiRouter) { return (Request request) async { final path = request.url.path; - + // Only handle /api/* requests (excluding /api/xtream) if (path.startsWith('api/') && !path.startsWith('api/xtream/')) { return apiRouter(request); } - + return Response.notFound('Not found'); }; } @@ -213,12 +221,12 @@ Handler _createXtreamProxyHandler() { // Format: /api/xtream/http://server:port/path // Note: URL might be URL-encoded (e.g., http%3A%2F%2F...) String apiPath = path.substring('api/xtream/'.length); - + // Decode URL if it's encoded (http%3A%2F%2F -> http://) if (apiPath.startsWith('http%3A') || apiPath.startsWith('https%3A')) { apiPath = Uri.decodeComponent(apiPath); } - + // The client will send the full URL after /api/xtream/ if (!apiPath.startsWith('http://') && !apiPath.startsWith('https://')) { return Response.badRequest( @@ -236,19 +244,20 @@ Handler _createXtreamProxyHandler() { fullUrl = '$fullUrl?${request.url.query}'; } } - + final targetUrl = Uri.parse(fullUrl); - final baseUrl = '${targetUrl.scheme}://${targetUrl.host}${targetUrl.hasPort ? ':${targetUrl.port}' : ''}'; - + final baseUrl = + '${targetUrl.scheme}://${targetUrl.host}${targetUrl.hasPort ? ':${targetUrl.port}' : ''}'; + // Check if this is a video file that needs streaming final lowerPath = targetUrl.path.toLowerCase(); - final isVideoFile = lowerPath.endsWith('.mp4') || - lowerPath.endsWith('.mkv') || - lowerPath.endsWith('.avi') || - lowerPath.endsWith('.ts') || - lowerPath.endsWith('.m4v') || - lowerPath.contains('/movie/') || - lowerPath.contains('/series/'); + final isVideoFile = lowerPath.endsWith('.mp4') || + lowerPath.endsWith('.mkv') || + lowerPath.endsWith('.avi') || + lowerPath.endsWith('.ts') || + lowerPath.endsWith('.m4v') || + lowerPath.contains('/movie/') || + lowerPath.contains('/series/'); print('Proxying request to: $targetUrl (video streaming: $isVideoFile)'); @@ -271,37 +280,39 @@ Handler _createXtreamProxyHandler() { http.Response response; if (request.method == 'GET') { response = await http.get(targetUrl, headers: proxyHeaders).timeout( - const Duration(seconds: 30), - onTimeout: () => http.Response('Request timeout', 504), - ); + const Duration(seconds: 30), + onTimeout: () => http.Response('Request timeout', 504), + ); } else if (request.method == 'POST') { final body = await request.readAsString(); - response = await http.post(targetUrl, headers: proxyHeaders, body: body).timeout( - const Duration(seconds: 30), - onTimeout: () => http.Response('Request timeout', 504), - ); + response = await http + .post(targetUrl, headers: proxyHeaders, body: body) + .timeout( + const Duration(seconds: 30), + onTimeout: () => http.Response('Request timeout', 504), + ); } else { return Response(405, body: 'Method not allowed'); } // Check if this is an M3U8/HLS playlist that needs URL rewriting final contentType = response.headers['content-type'] ?? ''; - final isM3u8 = fullUrl.endsWith('.m3u8') || - contentType.contains('application/vnd.apple.mpegurl') || - contentType.contains('application/x-mpegurl') || - contentType.contains('audio/mpegurl'); + final isM3u8 = fullUrl.endsWith('.m3u8') || + contentType.contains('application/vnd.apple.mpegurl') || + contentType.contains('application/x-mpegurl') || + contentType.contains('audio/mpegurl'); if (isM3u8 && response.statusCode == 200) { // Rewrite URLs in M3U8 playlist to go through proxy final rewrittenBody = _rewriteM3u8Urls( - response.body, - baseUrl, + response.body, + baseUrl, targetUrl.path, request.requestedUri.origin, ); - + print('Rewrote M3U8 playlist URLs for: $targetUrl'); - + return Response( response.statusCode, body: rewrittenBody, @@ -316,7 +327,8 @@ Handler _createXtreamProxyHandler() { response.statusCode, body: response.bodyBytes, headers: { - 'content-type': response.headers['content-type'] ?? 'application/json', + 'content-type': + response.headers['content-type'] ?? 'application/json', 'access-control-allow-origin': '*', // Forward cache headers from upstream if (response.headers.containsKey('cache-control')) @@ -345,7 +357,7 @@ Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { final maxRedirects = 5; var currentUrl = targetUrl; final cookies = {}; // Cookie Jar for redirects - + print('Stream Request: $targetUrl'); if (rangeHeader != null) print('Client Requested Range: $rangeHeader'); @@ -355,18 +367,19 @@ Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { // Increase timeouts for large video files client.connectionTimeout = const Duration(seconds: 60); client.idleTimeout = const Duration(minutes: 5); - + final req = await client.getUrl(currentUrl); req.followRedirects = false; // We handle redirects manually - + req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18'); req.headers.set('Accept', '*/*'); req.headers.set('Connection', 'keep-alive'); req.headers.set('Accept-Encoding', 'identity'); - + // Apply collected cookies if (cookies.isNotEmpty) { - final cookieHeader = cookies.entries.map((e) => '${e.key}=${e.value}').join('; '); + final cookieHeader = + cookies.entries.map((e) => '${e.key}=${e.value}').join('; '); req.headers.set(HttpHeaders.cookieHeader, cookieHeader); } @@ -377,12 +390,12 @@ Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { } req.headers.set(HttpHeaders.rangeHeader, rangeHeader); } - + client.autoUncompress = false; - + final response = await req.close(); print('Upstream Response: ${response.statusCode} (Redirect $i)'); - + // Collect Cookies from response response.cookies.forEach((cookie) { cookies[cookie.name] = cookie.value; @@ -398,10 +411,10 @@ Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { continue; // Loop to next request } } - + // Get content type final contentType = response.headers.contentType?.mimeType ?? 'video/mp4'; - + // Build response headers final responseHeaders = { 'content-type': contentType, @@ -410,74 +423,78 @@ Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { 'cache-control': 'no-cache', 'connection': 'keep-alive', }; - + // Checks for 206 vs 200 if (response.statusCode == 200 && rangeHeader != null) { - print('WARNING: Upstream ignored Range header and returned 200. Seeking might fail.'); + print( + 'WARNING: Upstream ignored Range header and returned 200. Seeking might fail.'); } else if (response.statusCode == 206) { print('SUCCESS: Upstream returned 206 Partial Content.'); } - + // Add Content-Length if available if (response.contentLength > 0) { responseHeaders['content-length'] = response.contentLength.toString(); } - + // Add Content-Range if this is a partial response (206) - final contentRange = response.headers.value(HttpHeaders.contentRangeHeader); + final contentRange = + response.headers.value(HttpHeaders.contentRangeHeader); if (contentRange != null) { responseHeaders[HttpHeaders.contentRangeHeader] = contentRange; } - + // Propagate Accept-Ranges responseHeaders[HttpHeaders.acceptRangesHeader] = 'bytes'; - + // Return appropriate status code return Response( response.statusCode, body: response, headers: responseHeaders, ); - } catch (e) { print('Video streaming exception: $e'); if (i == maxRedirects - 1) { - return Response.internalServerError( - body: jsonEncode({'error': 'Video streaming error', 'message': e.toString()}), - headers: {'content-type': 'application/json'}, - ); + return Response.internalServerError( + body: jsonEncode( + {'error': 'Video streaming error', 'message': e.toString()}), + headers: {'content-type': 'application/json'}, + ); } } } - + return Response.internalServerError(body: 'Too many redirects'); } /// Rewrite URLs in M3U8 playlist to go through the proxy -/// +/// /// Handles both relative and absolute URLs in HLS playlists -String _rewriteM3u8Urls(String m3u8Content, String baseUrl, String originalPath, String proxyOrigin) { +String _rewriteM3u8Urls(String m3u8Content, String baseUrl, String originalPath, + String proxyOrigin) { final lines = m3u8Content.split('\n'); final rewrittenLines = []; - + // Get directory path for relative URL resolution final pathSegments = originalPath.split('/'); pathSegments.removeLast(); // Remove filename final basePath = pathSegments.join('/'); - + for (final line in lines) { final trimmedLine = line.trim(); - + // Skip empty lines and comments (except URI in comments) if (trimmedLine.isEmpty) { rewrittenLines.add(line); continue; } - + // Handle lines that contain URLs (not starting with #, or EXT-X-KEY/EXT-X-MAP with URI) if (!trimmedLine.startsWith('#')) { // This is a segment URL - final rewrittenUrl = _rewriteUrl(trimmedLine, baseUrl, basePath, proxyOrigin); + final rewrittenUrl = + _rewriteUrl(trimmedLine, baseUrl, basePath, proxyOrigin); rewrittenLines.add(rewrittenUrl); } else if (trimmedLine.contains('URI="')) { // Handle EXT-X-KEY, EXT-X-MAP, etc. with URI attribute @@ -494,14 +511,15 @@ String _rewriteM3u8Urls(String m3u8Content, String baseUrl, String originalPath, rewrittenLines.add(line); } } - + return rewrittenLines.join('\n'); } /// Rewrite a single URL to go through the proxy -String _rewriteUrl(String url, String baseUrl, String basePath, String proxyOrigin) { +String _rewriteUrl( + String url, String baseUrl, String basePath, String proxyOrigin) { String fullUrl; - + if (url.startsWith('http://') || url.startsWith('https://')) { // Already absolute URL fullUrl = url; @@ -512,7 +530,7 @@ String _rewriteUrl(String url, String baseUrl, String basePath, String proxyOrig // Relative path, relative to current directory fullUrl = '$baseUrl$basePath/$url'; } - + // Wrap with proxy return '$proxyOrigin/api/xtream/$fullUrl'; } @@ -525,7 +543,7 @@ String _rewriteUrl(String url, String baseUrl, String basePath, String proxyOrig final Map _activeStreams = {}; /// Create handler for FFmpeg streaming -/// +/// /// This endpoint starts an FFmpeg process that connects to the IPTV server /// with proper headers and transcodes the stream to local HLS files. Handler _createStreamHandler() { @@ -543,14 +561,15 @@ Handler _createStreamHandler() { if (pathParts.length < 3) { return Response.badRequest(body: 'Invalid stream path'); } - + final streamId = pathParts[2]; - + // Get parameters final iptvUrl = request.url.queryParameters['url']; final quality = request.url.queryParameters['quality'] ?? 'high'; - final start = request.url.queryParameters['start']; // Start time in seconds - + final start = + request.url.queryParameters['start']; // Start time in seconds + if (iptvUrl == null || iptvUrl.isEmpty) { return Response.badRequest(body: 'Missing url parameter'); } @@ -561,27 +580,37 @@ Handler _createStreamHandler() { // Setup FFmpeg arguments for fMP4 piping // Determine if it's VOD or Live final isVod = streamId.startsWith('vod_'); // Or use extension check - + final ffmpegArgs = [ '-hide_banner', - '-loglevel', 'error', // Minimize logs, only errors + '-loglevel', 'warning', // Show warnings too for debugging long streams '-user_agent', 'VLC/3.0.18 LibVLC/3.0.18', - '-headers', 'Accept: */*\r\nConnection: keep-alive\r\n', - - // Input resilience flags + '-headers', + 'Accept: */*\r\nConnection: keep-alive\r\nIcy-MetaData: 0\r\n', + + // ENHANCED Input resilience flags for LONG streaming '-reconnect', '1', '-reconnect_streamed', '1', - '-reconnect_delay_max', '10', + '-reconnect_delay_max', + '30', // Increased to 30s max delay between reconnects '-reconnect_on_network_error', '1', '-reconnect_on_http_error', '4xx,5xx', - '-rw_timeout', '15000000', // 15s timeout - + '-reconnect_at_eof', + '1', // Reconnect at stream end (some streams reset) + '-rw_timeout', '30000000', // 30s timeout (doubled) + '-timeout', '60000000', // 60s global timeout + + // Keep TCP connection alive + '-tcp_nodelay', '1', + // Probing settings to ensure stream detection - '-analyzeduration', '20000000', // 20s - '-probesize', '10000000', // 10MB - + '-analyzeduration', '30000000', // 30s (increased) + '-probesize', '20000000', // 20MB (doubled) + '-fflags', + '+discardcorrupt', // Skip corrupted frames instead of failing + '-i', iptvUrl, - + // Output format: MPEG-TS for streaming (compatible with mpegts.js) '-f', 'mpegts', ]; @@ -614,21 +643,28 @@ Handler _createStreamHandler() { if (quality == 'low') { // Low: ~480p, 800k ffmpegArgs.addAll([ - '-vf', 'scale=-2:480', - '-b:v', '800k', - '-maxrate', '800k', - '-bufsize', '1600k', + '-vf', + 'scale=-2:480', + '-b:v', + '800k', + '-maxrate', + '800k', + '-bufsize', + '1600k', ]); } else { // Medium: Original res (or cap at 720p?), 2500k ffmpegArgs.addAll([ - '-b:v', '2500k', - '-maxrate', '2500k', - '-bufsize', '5000k', + '-b:v', + '2500k', + '-maxrate', + '2500k', + '-bufsize', + '5000k', ]); } } - + // Audio: Transcode to AAC is SAFEST for browsers // (Many IPTV streams typically use MP2/AC3 which browsers hate) ffmpegArgs.addAll([ @@ -636,22 +672,22 @@ Handler _createStreamHandler() { '-b:a', '128k', '-ar', '44100', '-ac', '2', - + // Fix for MPEG-TS timestamps '-fflags', '+genpts', - + // Output to stdout pipe 'pipe:1', ]); // Start the process final process = await Process.start('ffmpeg', ffmpegArgs); - + // Manage process lifecycle // We use a StreamController to pipe stdout to the response // AND detect when the client disconnects to kill the process. final controller = StreamController>(); - + // Pipe stdout to controller process.stdout.listen( (data) { @@ -690,16 +726,14 @@ Handler _createStreamHandler() { 'Connection': 'keep-alive', }, ); - } catch (e, stackTrace) { print('Stream setup error: $e'); print(stackTrace); return Response.internalServerError( - body: jsonEncode({'error': 'Stream setup error', 'message': e.toString()}), + body: jsonEncode( + {'error': 'Stream setup error', 'message': e.toString()}), headers: {'content-type': 'application/json'}, ); } }; } - - diff --git a/web/player.html b/web/player.html index 6dc3064..b2a1bc5 100644 --- a/web/player.html +++ b/web/player.html @@ -315,29 +315,30 @@ return false; } - // Optimized HLS.js configuration for stable live streaming + // Optimized HLS.js configuration for LONG stable live streaming const hls = new Hls({ - // Increase buffer for smoother playback - maxBufferLength: 30, // Max 30 seconds of buffer - maxMaxBufferLength: 60, // Allow up to 60 seconds in stressed conditions - maxBufferSize: 60 * 1000000, // 60MB max buffer size - maxBufferHole: 0.5, // Tolerate 0.5s gaps in buffer + // INCREASED buffer for longer playback stability + maxBufferLength: 60, // Max 60 seconds of buffer (doubled) + maxMaxBufferLength: 120, // Allow up to 2 minutes in stressed conditions + maxBufferSize: 120 * 1000000, // 120MB max buffer size + maxBufferHole: 1.0, // Tolerate 1s gaps in buffer - // Low latency settings (balanced) - liveSyncDurationCount: 3, // Sync to 3 segments behind live - liveMaxLatencyDurationCount: 6, // Max 6 segments behind before seeking + // Live streaming settings (more tolerant) + liveSyncDurationCount: 4, // Sync to 4 segments behind live + liveMaxLatencyDurationCount: 10, // Max 10 segments behind before seeking liveDurationInfinity: true, // Treat live streams as infinite + liveBackBufferLength: 60, // Keep 60s of back buffer - // Faster recovery from errors - manifestLoadingTimeOut: 15000, // 15s timeout for manifest - manifestLoadingMaxRetry: 4, // Retry manifest 4 times - manifestLoadingRetryDelay: 1000, // 1s between retries - levelLoadingTimeOut: 15000, // 15s for level playlist - levelLoadingMaxRetry: 4, - levelLoadingRetryDelay: 1000, - fragLoadingTimeOut: 20000, // 20s for fragments - fragLoadingMaxRetry: 6, // Retry fragments 6 times - fragLoadingRetryDelay: 1000, + // MORE AGGRESSIVE recovery from errors + manifestLoadingTimeOut: 30000, // 30s timeout for manifest + manifestLoadingMaxRetry: 10, // Retry manifest 10 times + manifestLoadingRetryDelay: 2000, // 2s between retries + levelLoadingTimeOut: 30000, // 30s for level playlist + levelLoadingMaxRetry: 10, + levelLoadingRetryDelay: 2000, + fragLoadingTimeOut: 60000, // 60s for fragments (long timeout) + fragLoadingMaxRetry: 10, // Retry fragments 10 times + fragLoadingRetryDelay: 2000, // Better quality selection startLevel: -1, // Auto-select start level @@ -353,12 +354,15 @@ }); window.hlsInstance = hls; // Expose for external control (audio tracks) + let hlsRetryCount = 0; + const maxHlsRetries = 3; hls.loadSource(url); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => { log('HLS manifest parsed, starting playback'); + hlsRetryCount = 0; // Reset retry counter on success hideLoading(); video.play().catch(e => { log('Autoplay prevented: ' + e); @@ -366,7 +370,7 @@ }); }); - // Better error recovery + // IMPROVED error recovery with automatic restart hls.on(Hls.Events.ERROR, (event, data) => { log('HLS Error: ' + data.type + ' - ' + data.details); @@ -374,15 +378,38 @@ switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: log('Network error, attempting recovery...'); - hls.startLoad(); // Try to recover network errors + // Try startLoad first + hls.startLoad(); + // If still failing after 5s, try full restart + setTimeout(() => { + if (video.paused || video.readyState < 2) { + if (hlsRetryCount < maxHlsRetries) { + hlsRetryCount++; + log('Auto-restarting stream (attempt ' + hlsRetryCount + ')...'); + showLoading('Reconnecting...'); + hls.destroy(); + playWithHLS(url); + } else { + showError('Connection lost - tap to retry'); + } + } + }, 5000); break; case Hls.ErrorTypes.MEDIA_ERROR: log('Media error, attempting recovery...'); hls.recoverMediaError(); // Try to recover media errors break; default: - showError('HLS Error: ' + data.details); - hls.destroy(); + if (hlsRetryCount < maxHlsRetries) { + hlsRetryCount++; + log('Fatal error, restarting stream (attempt ' + hlsRetryCount + ')...'); + showLoading('Restarting stream...'); + hls.destroy(); + setTimeout(() => playWithHLS(url), 2000); + } else { + showError('Stream error: ' + data.details); + hls.destroy(); + } break; } } @@ -391,6 +418,10 @@ return true; } + let mpegtsRetryCount = 0; + const maxMpegtsRetries = 3; + let currentMpegtsPlayer = null; + async function playWithMpegTS(url) { log('Trying mpegts.js with: ' + url); showLoading('Loading MPEG-TS stream...'); @@ -404,6 +435,13 @@ mpegts.getLoggingControl().enableError = true; } + // Cleanup previous player if exists + if (currentMpegtsPlayer) { + try { + currentMpegtsPlayer.destroy(); + } catch (e) { } + } + const player = mpegts.createPlayer({ type: 'mpegts', isLive: true, @@ -411,19 +449,30 @@ }, { enableWorker: true, enableStashBuffer: true, - stashInitialSize: 384 * 1024, + stashInitialSize: 1024 * 1024, // 1MB initial stash (increased) autoCleanupSourceBuffer: true, - // Make the player more resilient to timestamp gaps without spamming + autoCleanupMaxBackwardDuration: 120, // Keep 2 minutes back buffer + autoCleanupMinBackwardDuration: 60, // Min 1 minute + // Make the player more resilient to timestamp gaps fixAudioTimestampGap: true, - lazyLoadMaxDuration: 3 * 60, + lazyLoadMaxDuration: 10 * 60, // 10 minutes lazy load (increased) seekType: 'range', + // Longer timeouts for stability + deferLoadAfterSourceOpen: false, + liveBufferLatencyChasing: true, + liveBufferLatencyMaxLatency: 30, // Max 30s latency + liveBufferLatencyMinRemain: 5, // Min 5s buffer }); + currentMpegtsPlayer = player; + window.mpegtsPlayer = player; // Expose for debugging + player.attachMediaElement(video); player.load(); video.addEventListener('canplay', () => { log('MPEG-TS stream ready'); + mpegtsRetryCount = 0; // Reset on success hideLoading(); video.play().catch(e => { log('Autoplay prevented: ' + e); @@ -432,12 +481,38 @@ }); video.addEventListener('error', (e) => { - showError('Video error: ' + (video.error?.message || 'Unknown error')); - }); + const errorMsg = video.error?.message || 'Unknown error'; + log('Video element error: ' + errorMsg); + // Don't show error immediately, try recovery first + if (mpegtsRetryCount < maxMpegtsRetries) { + mpegtsRetryCount++; + log('Attempting stream restart (attempt ' + mpegtsRetryCount + ')...'); + showLoading('Reconnecting...'); + setTimeout(() => { + player.destroy(); + playWithMpegTS(url); + }, 3000); + } else { + showError('Video error: ' + errorMsg); + } + }, { once: true }); player.on(mpegts.Events.ERROR, (errType, errDetail, errInfo) => { log('mpegts error: ' + errType + ' - ' + errDetail); - showError('Stream error: ' + errDetail); + // Auto-retry on network errors + if (errType === mpegts.ErrorTypes.NETWORK_ERROR && mpegtsRetryCount < maxMpegtsRetries) { + mpegtsRetryCount++; + log('Network error, restarting stream (attempt ' + mpegtsRetryCount + ')...'); + showLoading('Reconnecting...'); + setTimeout(() => { + player.destroy(); + playWithMpegTS(url); + }, 3000); + } else if (mpegtsRetryCount >= maxMpegtsRetries) { + showError('Connection lost - tap to retry'); + } else { + showError('Stream error: ' + errDetail); + } }); return true;