diff --git a/data/media.db b/data/media.db index 7491e10..3412255 100644 Binary files a/data/media.db and b/data/media.db differ diff --git a/src/app/api/transcode/[jobId]/route.ts b/src/app/api/transcode/[jobId]/route.ts index ddb66bd..279b2f2 100644 --- a/src/app/api/transcode/[jobId]/route.ts +++ b/src/app/api/transcode/[jobId]/route.ts @@ -6,6 +6,16 @@ export const dynamic = 'force-dynamic'; export async function DELETE(_request: Request, { params }: { params: Promise<{ jobId: string }> }) { const { jobId } = await params; - await transcodeOrchestrator.kill(jobId, 'client closed'); + // markKilled() synchronously removes the job from the in-memory map so that + // segment/playlist routes return 404 immediately. The FFmpeg process is then + // terminated in the background so this response returns in <10ms. + const proc = transcodeOrchestrator.markKilled(jobId); + if (proc) { + transcodeOrchestrator.killProcess(proc).catch(() => {}); + } else { + // Job not found via markKilled (may have already been cleaned up) — still + // try the normal kill path in case state differs. + transcodeOrchestrator.kill(jobId, 'client closed').catch(() => {}); + } return NextResponse.json({ success: true }); } diff --git a/src/app/api/transcode/[jobId]/seg/[name]/route.ts b/src/app/api/transcode/[jobId]/seg/[name]/route.ts index 47e8635..7e48481 100644 --- a/src/app/api/transcode/[jobId]/seg/[name]/route.ts +++ b/src/app/api/transcode/[jobId]/seg/[name]/route.ts @@ -6,6 +6,18 @@ import { segmentPath } from '@/lib/transcode/playlist'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +async function waitForSegment(filePath: string, timeoutMs = 5000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (fs.existsSync(filePath)) { + const stat = fs.statSync(filePath); + if (stat.size > 0) return true; + } + await new Promise(resolve => setTimeout(resolve, 150)); + } + return false; +} + export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string; name: string }> }) { const { jobId, name } = await params; const job = transcodeOrchestrator.get(jobId); @@ -15,7 +27,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ job } const resolvedPath = segmentPath(job.outDir, name); - if (!resolvedPath || !fs.existsSync(resolvedPath)) { + if (!resolvedPath || !(await waitForSegment(resolvedPath))) { return new Response(null, { status: 404 }); } diff --git a/src/components/artplayer-wrapper.tsx b/src/components/artplayer-wrapper.tsx index 2bc8f82..045c882 100644 --- a/src/components/artplayer-wrapper.tsx +++ b/src/components/artplayer-wrapper.tsx @@ -48,6 +48,8 @@ export default function ArtPlayerWrapper({ const containerRef = useRef(null); const playerRef = useRef(null); const hlsInstanceRef = useRef(null); // Store HLS instance for cleanup + const formatOverrideRef = useRef(formatOverride); + const mediaElementsRef = useRef>(new Set()); const [format, setFormat] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -61,6 +63,102 @@ export default function ArtPlayerWrapper({ const [localBookmarkCount, setLocalBookmarkCount] = useState(bookmarkCount); const [localAvgRating, setLocalAvgRating] = useState(avgRating); const hlsErrorHandlerRef = useRef(null); + const hlsShuttingDownRef = useRef(false); // Blocks HLS error-recovery after teardown + + const cloneQualities = (qualities: VideoFormat['qualities']) => ( + qualities?.map(quality => ({ ...quality })) || [] + ); + + const resetArtPlayerContainer = () => { + if (!containerRef.current) return; + const el = containerRef.current as unknown as Record; + for (const prop of ['$control_option', '$video', '$player', '$art']) { + try { delete el[prop]; } catch (_) { /* non-configurable */ } + } + }; + + const stopTrackedMedia = (clearSource: boolean) => { + mediaElementsRef.current.forEach(videoEl => { + videoEl.pause(); + if (clearSource) { + videoEl.removeAttribute('src'); + videoEl.load(); + } + }); + + containerRef.current?.querySelectorAll('video, audio').forEach(mediaEl => { + mediaEl.pause(); + if (clearSource) { + mediaEl.removeAttribute('src'); + mediaEl.load(); + } + }); + + if (clearSource) { + document.querySelectorAll('video, audio').forEach(mediaEl => { + mediaEl.pause(); + mediaEl.removeAttribute('src'); + mediaEl.load(); + }); + } + }; + + const releasePlayer = () => { + // Mark HLS as shutting down BEFORE stopping/destroying so that any + // in-flight error callbacks (NETWORK_ERROR → startLoad recovery) are blocked. + hlsShuttingDownRef.current = true; + + // ──── Step 1: Immediately pause all media to stop audio output ──── + // Only pause here — do NOT remove src or call load() yet, because + // HLS.js still owns the MediaSource on the