Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 41 additions & 7 deletions src/app/api/stream/hls/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,6 @@ async function getMagnetUri(infohash: string): Promise<string> {
}

export async function GET(request: NextRequest): Promise<Response> {
// Subscription check
const { requireActiveSubscription } = await import('@/lib/subscription/guard');
const subscriptionError = await requireActiveSubscription(request);
if (subscriptionError) return subscriptionError;

const requestId = generateRequestId();
const reqLogger = logger.child({ requestId });

Expand All @@ -130,9 +125,24 @@ export async function GET(request: NextRequest): Promise<Response> {
return NextResponse.json({ error: 'Invalid fileIndex' }, { status: 400 });
}

// CORS headers required for iOS Safari's native HLS player.
// Safari's media engine fetches the playlist and segments from an internal context
// that may require CORS headers even for same-origin requests.
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Range, Content-Type',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range',
};

// Check for an existing active HLS session for this infohash+fileIndex.
// Safari's native HLS player re-fetches the playlist URL to discover new segments.
// We must return the updated playlist from the same FFmpeg session, not start a new one.
//
// NOTE: No subscription check for re-fetches. Safari's native HLS player makes
// periodic playlist requests from its internal media engine, which may NOT include
// session cookies. The initial request (new session below) is auth-gated.
// Rejecting a playlist re-fetch kills playback entirely.
const existing = findActiveSession(infohash, fileIndex);
if (existing) {
reqLogger.info('Reusing existing HLS session', { sessionId: existing.sessionId });
Expand All @@ -150,11 +160,17 @@ export async function GET(request: NextRequest): Promise<Response> {
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache, no-store',
'Access-Control-Allow-Origin': '*',
...corsHeaders,
},
});
}

// Subscription check — only for NEW sessions (initial playlist request).
// Re-fetches of existing sessions skip this (handled above).
const { requireActiveSubscription } = await import('@/lib/subscription/guard');
const subscriptionError = await requireActiveSubscription(request);
if (subscriptionError) return subscriptionError;

// No existing session — start a new one
const sessionId = randomUUID().slice(0, 8);
const hlsDir = getHlsDir(infohash, fileIndex, sessionId);
Expand Down Expand Up @@ -497,7 +513,7 @@ export async function GET(request: NextRequest): Promise<Response> {
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache, no-store',
'Access-Control-Allow-Origin': '*',
...corsHeaders,
},
});
} catch (error) {
Expand All @@ -508,3 +524,21 @@ export async function GET(request: NextRequest): Promise<Response> {
);
}
}

/**
* OPTIONS /api/stream/hls
* Handle CORS preflight requests for iOS Safari's native HLS player.
* Safari's media engine may send preflight requests when fetching playlists.
*/
export async function OPTIONS(): Promise<Response> {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Range, Content-Type',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range',
'Access-Control-Max-Age': '86400',
},
});
}
17 changes: 11 additions & 6 deletions src/components/media/media-player-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,12 @@ export function MediaPlayerModal({
const isStreamReady = isP2PStreaming ? isP2PReady : isServerStreamReady;
// Detect if this is an HLS stream (iOS/Safari path)
const isHLSStream = streamUrl?.includes('/api/stream/hls') ?? false;
// For HLS on iOS/Safari: mount the player as soon as the torrent has metadata
// (torrent.ready). We do NOT wait for the full fileReady threshold (20MB for video)
// because the HLS endpoint has its own buffering — FFmpeg reads from the local file
// and waits for data, then produces segments incrementally. Waiting for 20MB would
// cause an excessive delay or infinite loading spinner on slow torrents.
const isHLSTorrentReady = connectionStatus?.ready ?? false;
// Show play button when stream is ready but user hasn't clicked play yet
// On TV screens, skip the play button overlay - let autoplay handle it
// For HLS on iOS/Safari, skip the play button — HLS autoplay is more reliable
Expand All @@ -945,14 +951,13 @@ export function MediaPlayerModal({
// Show loading spinner when stream is not ready yet (for P2P, check WebTorrent status)
// WebTorrent status: 'idle' | 'loading' | 'buffering' | 'ready' | 'no-peers' | 'error'
// Also show loading when falling back from P2P to server streaming ('no-peers' status)
// For HLS: only wait for torrent metadata (ready), not full 20MB buffer (fileReady)
const showLoadingSpinner = isP2PStreaming
? (webTorrent.status === 'loading' || webTorrent.status === 'buffering' || webTorrent.status === 'no-peers') && !error
: !isServerStreamReady && !error;
// For HLS on iOS/Safari: only mount the VideoPlayer after SSE confirms stream readiness.
// The HLS endpoint blocks server-side waiting for FFmpeg segments, but if the torrent
// hasn't even connected to peers yet, Safari's ~60s media timeout can expire first.
// Waiting for SSE "ready" ensures data is flowing before hitting the HLS endpoint.
const shouldMountPlayer = isHLSStream ? isStreamReady : true;
: isHLSStream
? !isHLSTorrentReady && !error
: !isServerStreamReady && !error;
const shouldMountPlayer = isHLSStream ? isHLSTorrentReady : true;

// Show paywall if subscription expired
if (isOpen && !isPremium) {
Expand Down
45 changes: 37 additions & 8 deletions src/components/video/video-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,18 +174,21 @@ export function VideoPlayer({
nativeVideo.setAttribute('playsinline', '');
nativeVideo.setAttribute('webkit-playsinline', '');
nativeVideo.controls = true;
nativeVideo.autoplay = autoplay || !!options?.autoplay;
nativeVideo.preload = 'auto';
nativeVideo.style.width = '100%';
nativeVideo.style.height = '100%';
// CORS attribute is required so Safari's media engine can fetch HLS segments
// from our API endpoints (which return Access-Control-Allow-Origin: *)
nativeVideo.crossOrigin = 'anonymous';
if (poster) nativeVideo.poster = poster;

// Remove the video-js element we just created and use native instead
videoRef.current.removeChild(videoElement);
videoRef.current.appendChild(nativeVideo);

nativeVideo.src = videoSource.src;

// Attach event listeners BEFORE setting src so we don't miss early events
nativeVideo.addEventListener('loadedmetadata', () => {
console.log('[VideoPlayer] iOS HLS: loadedmetadata fired');
setIsLoading(false);
// Call onReady — the Player type is expected but modal handlers
// don't actually use the player reference, they just track ready state
Expand All @@ -200,16 +203,42 @@ export function VideoPlayer({
nativeVideo.addEventListener('error', () => {
const mediaError = nativeVideo.error;
const errorMessage = mediaError?.message || `Media error code ${mediaError?.code || 'unknown'}`;
console.error('[VideoPlayer] iOS HLS error:', errorMessage, 'code:', mediaError?.code);
setError(errorMessage);
onError?.(new Error(errorMessage));
});

// Try to play — catch autoplay blocks gracefully
nativeVideo.play().catch((playErr) => {
console.warn('[VideoPlayer] iOS autoplay blocked:', playErr.message);
// Don't treat autoplay block as error — user can tap play
// Track stalled/waiting events for debugging iOS playback issues
nativeVideo.addEventListener('stalled', () => {
console.warn('[VideoPlayer] iOS HLS: stalled event');
});
nativeVideo.addEventListener('waiting', () => {
console.warn('[VideoPlayer] iOS HLS: waiting for data');
});

// Set source and explicitly load — this triggers Safari to fetch the m3u8
nativeVideo.src = videoSource.src;
nativeVideo.load();

// Only attempt autoplay AFTER loadedmetadata fires to avoid iOS race conditions.
// iOS Safari rejects play() if called before the media engine has parsed the playlist.
if (autoplay || options?.autoplay) {
const playOnReady = () => {
nativeVideo.removeEventListener('loadedmetadata', playOnReady);
nativeVideo.play().catch((playErr) => {
console.warn('[VideoPlayer] iOS autoplay blocked:', playErr.message);
// Don't treat autoplay block as error — user can tap play via native controls
});
};
// If loadedmetadata already fired (unlikely but defensive), play immediately
if (nativeVideo.readyState >= 1) {
nativeVideo.play().catch((playErr) => {
console.warn('[VideoPlayer] iOS autoplay blocked:', playErr.message);
});
} else {
nativeVideo.addEventListener('loadedmetadata', playOnReady);
}
}

// Store ref for cleanup (no Video.js player in this path)
playerRef.current = null;
isNativePlayerRef.current = true;
Expand Down
Loading