Browse Source

Harden HLS streaming and MSE recovery

Fixes multiple HLS playback issues across transcoder and Movie player flows. The transcoder now forces forward-slash HLS output paths so ffmpeg writes init.mp4 into the correct session directory on Windows, and adds NVENC `-forced-idr 1` to keep segment boundaries aligned with forced keyframes for responsive seeking.

On the frontend, the custom HLS MSE player now better mirrors native HLS behavior (playlist-length seekable range), adds segment retry/timeout logic, safer cursor retargeting on seek, append/trim coordination, and a stall watchdog with gap jumping and bounded recovery before surfacing failure. Movie pages now handle MSE stream-loss events explicitly, stop old media immediately when switching streams, and only show failure toasts after retry limits are exhausted.
Toby Chui 5 days ago
parent
commit
a0bdedbc44

+ 2 - 1
src/mediaServer.go

@@ -51,7 +51,8 @@ func mediaServer_init() {
 		//whose container extension merely looks playable
 		//whose container extension merely looks playable
 		http.HandleFunc("/media/probe/", mediaServer.ServeMediaProbe)
 		http.HandleFunc("/media/probe/", mediaServer.ServeMediaProbe)
 
 
-		//HLS output, for clients that require byte-range-able media (Safari / iOS)
+		//HLS output, for clients that require byte-range-able media (Safari / iOS);
+		//other browsers may opt in and play it through Media Source
 		http.HandleFunc("/media/hls/", mediaServer.ServeHLSPlaylist)
 		http.HandleFunc("/media/hls/", mediaServer.ServeHLSPlaylist)
 		http.HandleFunc(mediaserver.HLSSegmentEndpoint, mediaServer.ServeHLSSegment)
 		http.HandleFunc(mediaserver.HLSSegmentEndpoint, mediaServer.ServeHLSSegment)
 		http.HandleFunc("/media/duration/", mediaServer.GetAudioDuration)
 		http.HandleFunc("/media/duration/", mediaServer.GetAudioDuration)

+ 5 - 1
src/mod/media/mediaserver/hls.go

@@ -6,10 +6,14 @@ package mediaserver
 	HLS delivery endpoints for transcoded video.
 	HLS delivery endpoints for transcoded video.
 
 
 	This module adds support for HLS based streaming to the media server.
 	This module adds support for HLS based streaming to the media server.
+	Safari (and every browser on iOS) needs it, since it will not play the
+	unbounded MP4 response of /media/transcode. Other browsers default to that
+	MP4 stream and can opt into HLS, which they play through Media Source
+	(web/script/hlsmse.js) because the segments are fragmented MP4.
 
 
 	Two endpoints make up the format:
 	Two endpoints make up the format:
 	  /media/hls/          ?file=<vpath>[&res=][&start=]  -> the .m3u8 playlist
 	  /media/hls/          ?file=<vpath>[&res=][&start=]  -> the .m3u8 playlist
-	  /media/hls/segment   ?sid=<session>&name=<segment>  -> one .ts segment
+	  /media/hls/segment   ?sid=<session>&name=<segment>  -> one fMP4 segment, or the init segment
 
 
 	The playlist request creates (or joins) a transcode session; every segment
 	The playlist request creates (or joins) a transcode session; every segment
 	line inside it points back at the segment endpoint carrying that session id.
 	line inside it points back at the segment endpoint carrying that session id.

+ 17 - 4
src/mod/media/transcoder/hls.go

@@ -194,7 +194,7 @@ func (s *HLSSession) stop() {
 }
 }
 
 
 // validHLSSegmentName reports whether name matches the segment naming this
 // validHLSSegmentName reports whether name matches the segment naming this
-// package generates ("seg00000.ts"), rejecting anything containing a path
+// package generates ("seg00000.m4s", plus the fixed init segment name), rejecting anything containing a path
 // separator, "..", or unexpected characters.
 // separator, "..", or unexpected characters.
 func validHLSSegmentName(name string) bool {
 func validHLSSegmentName(name string) bool {
 	// The fMP4 initialisation segment is fetched through the same endpoint as
 	// The fMP4 initialisation segment is fetched through the same endpoint as
@@ -269,7 +269,8 @@ func buildHLSArgs(inputFile string, dir string, resolution TranscodeOutputResolu
 
 
 	// Take the first video and, if present, the first audio track. Without this
 	// Take the first video and, if present, the first audio track. Without this
 	// a file carrying extra streams (subtitles, attachments, second audio) can
 	// a file carrying extra streams (subtitles, attachments, second audio) can
-	// fail to mux into MPEG-TS.
+	// fail to mux into the fMP4 segments, or put a track the player does not
+	// expect into them.
 	args = append(args, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
 	args = append(args, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
 
 
 	if vf != "" {
 	if vf != "" {
@@ -298,12 +299,24 @@ func buildHLSArgs(inputFile string, dir string, resolution TranscodeOutputResolu
 		"-hls_segment_type", "fmp4",
 		"-hls_segment_type", "fmp4",
 		"-hls_fmp4_init_filename", HLSInitSegmentName,
 		"-hls_fmp4_init_filename", HLSInitSegmentName,
 		"-hls_base_url", segmentBaseURL,
 		"-hls_base_url", segmentBaseURL,
-		"-hls_segment_filename", filepath.Join(dir, hlsSegmentPattern),
-		filepath.Join(dir, hlsPlaylistName),
+		"-hls_segment_filename", hlsOutputPath(dir, hlsSegmentPattern),
+		hlsOutputPath(dir, hlsPlaylistName),
 	)
 	)
 	return args, nil
 	return args, nil
 }
 }
 
 
+// hlsOutputPath builds an output path for the HLS muxer with forward slashes.
+//
+// ffmpeg places the fMP4 init segment next to the playlist by cutting the
+// playlist path at its last '/', and only '/'. Given a native Windows path
+// (backslashes) it finds no directory, so init.mp4 lands in ffmpeg's working
+// directory instead of the session's, every session overwrites the same file,
+// and the player's first request - the init segment - 404s. ffmpeg on Windows
+// accepts forward slashes, and on every other OS this changes nothing.
+func hlsOutputPath(dir string, name string) string {
+	return filepath.ToSlash(filepath.Join(dir, name))
+}
+
 // resolutionHeight maps a requested output resolution to an ffmpeg scale
 // resolutionHeight maps a requested output resolution to an ffmpeg scale
 // height, returning an error for anything unrecognised.
 // height, returning an error for anything unrecognised.
 func resolutionHeight(resolution TranscodeOutputResolution) (string, error) {
 func resolutionHeight(resolution TranscodeOutputResolution) (string, error) {

+ 20 - 1
src/mod/media/transcoder/hls_test.go

@@ -223,9 +223,20 @@ func TestBuildHLSArgs(t *testing.T) {
 		if strings.Contains(joined, "-ss ") {
 		if strings.Contains(joined, "-ss ") {
 			t.Errorf("start time 0 should not add -ss\ngot: %v", args)
 			t.Errorf("start time 0 should not add -ss\ngot: %v", args)
 		}
 		}
-		if last := args[len(args)-1]; last != filepath.Join(dir, hlsPlaylistName) {
+		if last := args[len(args)-1]; last != filepath.ToSlash(filepath.Join(dir, hlsPlaylistName)) {
 			t.Errorf("playlist path = %q, want it last and inside the session dir", last)
 			t.Errorf("playlist path = %q, want it last and inside the session dir", last)
 		}
 		}
+		// ffmpeg derives the init segment's directory from the playlist path
+		// by its last '/', so a backslash there sends init.mp4 elsewhere
+		segmentFlag := indexOf(args, "-hls_segment_filename")
+		if segmentFlag == -1 {
+			t.Fatalf("args missing -hls_segment_filename\ngot: %v", args)
+		}
+		for _, path := range []string{args[segmentFlag+1], args[len(args)-1]} {
+			if strings.Contains(path, `\`) {
+				t.Errorf("output path %q must use forward slashes", path)
+			}
+		}
 	})
 	})
 
 
 	t.Run("hardware encoder", func(t *testing.T) {
 	t.Run("hardware encoder", func(t *testing.T) {
@@ -595,6 +606,14 @@ func TestGetOrCreateSupersedesOnSeek(t *testing.T) {
 	if !strings.Contains(string(playlist), hlsSegmentSuffix) {
 	if !strings.Contains(string(playlist), hlsSegmentSuffix) {
 		t.Error("the playlist lists no media segment")
 		t.Error("the playlist lists no media segment")
 	}
 	}
+	//The segment endpoint serves the init segment from the session directory;
+	//on Windows ffmpeg used to drop it into its working directory instead
+	if _, err := second.SegmentPath(HLSInitSegmentName); err != nil {
+		t.Fatalf("init segment name rejected: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(second.Dir, HLSInitSegmentName)); err != nil {
+		t.Errorf("the init segment is not in the session directory: %v", err)
+	}
 }
 }
 
 
 // generateTestVideo writes a short, deterministic clip with ffmpeg for the
 // generateTestVideo writes a short, deterministic clip with ffmpeg for the

+ 4 - 1
src/mod/media/transcoder/hwaccel_linux.go

@@ -21,7 +21,10 @@ func candidateHWProfiles() []*hwEncoderProfile {
 			Name:        "NVIDIA NVENC",
 			Name:        "NVIDIA NVENC",
 			Codec:       "h264_nvenc",
 			Codec:       "h264_nvenc",
 			ScaleFilter: nv12ScaleFilter,
 			ScaleFilter: nv12ScaleFilter,
-			EncodeArgs:  []string{"-preset", "fast"},
+			// -forced-idr: NVENC turns -force_key_frames into plain I-frames,
+			// which the HLS muxer cannot cut on, so segments fall back to its
+			// 250-frame GOP (~10s) and every seek has to wait for one.
+			EncodeArgs: []string{"-preset", "fast", "-forced-idr", "1"},
 		},
 		},
 		{
 		{
 			Name:     "Intel/AMD VAAPI",
 			Name:     "Intel/AMD VAAPI",

+ 15 - 0
src/mod/media/transcoder/hwaccel_test.go

@@ -157,3 +157,18 @@ func TestGetHWEncoderProfile_Cached(t *testing.T) {
 		t.Errorf("getHWEncoderProfile() returned different results across calls: %v vs %v", first, second)
 		t.Errorf("getHWEncoderProfile() returned different results across calls: %v vs %v", first, second)
 	}
 	}
 }
 }
+
+// TestNVENCProfilesForceIDR guards the HLS segment length: without -forced-idr
+// NVENC ignores -force_key_frames for segmenting purposes and cuts ~10s
+// segments at its default GOP. Platforms with no NVENC candidate pass trivially.
+func TestNVENCProfilesForceIDR(t *testing.T) {
+	for _, profile := range candidateHWProfiles() {
+		if profile.Codec != "h264_nvenc" {
+			continue
+		}
+		i := indexOf(profile.EncodeArgs, "-forced-idr")
+		if i == -1 || i+1 >= len(profile.EncodeArgs) || profile.EncodeArgs[i+1] != "1" {
+			t.Errorf("%s EncodeArgs = %v, want -forced-idr 1", profile.Name, profile.EncodeArgs)
+		}
+	}
+}

+ 4 - 1
src/mod/media/transcoder/hwaccel_windows.go

@@ -20,7 +20,10 @@ func candidateHWProfiles() []*hwEncoderProfile {
 			Name:        "NVIDIA NVENC",
 			Name:        "NVIDIA NVENC",
 			Codec:       "h264_nvenc",
 			Codec:       "h264_nvenc",
 			ScaleFilter: nv12ScaleFilter,
 			ScaleFilter: nv12ScaleFilter,
-			EncodeArgs:  []string{"-preset", "fast"},
+			// -forced-idr: NVENC turns -force_key_frames into plain I-frames,
+			// which the HLS muxer cannot cut on, so segments fall back to its
+			// 250-frame GOP (~10s) and every seek has to wait for one.
+			EncodeArgs: []string{"-preset", "fast", "-forced-idr", "1"},
 		},
 		},
 		{
 		{
 			Name:        "Intel Quick Sync (QSV)",
 			Name:        "Intel Quick Sync (QSV)",

+ 47 - 5
src/web/Movie/backend/common.js

@@ -43,10 +43,22 @@ var SCRIPT_CLEAR_INDEX        = BACKEND_PATH + "clearIndex.js";
 //        is a finite, seekable file, so WebKit plays it and seeking inside the
 //        is a finite, seekable file, so WebKit plays it and seeking inside the
 //        transcoded window no longer restarts the stream.
 //        transcoded window no longer restarts the stream.
 //
 //
-// "auto" picks hls on WebKit and mp4 everywhere else, so nothing changes for
-// browsers that were already working.
+// "auto" picks hls on WebKit and mp4 everywhere else (Firefox, Chrome, Edge on
+// Windows and Linux). HLS exists for WebKit's sake and is not the better choice
+// where MP4 works: the MP4 transcode writes into the response, so the client's
+// reading pace holds ffmpeg back and closing the player ends it, while an HLS
+// session writes the whole film to temp disk as fast as the CPU allows and
+// outlives the player until it is reaped. Other browsers can still choose HLS
+// in settings; they play it through Media Source (see nativeHLSSupported).
 var STREAM_MODE_KEY = "movie_stream_mode";
 var STREAM_MODE_KEY = "movie_stream_mode";
 
 
+// Fired on the <video> element when a Media Source HLS player loses a stream
+// it had already bound (session reaped, segments failing, playback stuck),
+// with event.detail.reason saying why. Pages treat it like the element's own
+// 'error' event, which is how native HLS reports the same thing, and reopen
+// the stream where it stopped.
+var STREAM_LOST_EVENT = "transcodestreamlost";
+
 // Identifies this player to the HLS endpoint. Seeking outside the transcoded
 // Identifies this player to the HLS endpoint. Seeking outside the transcoded
 // window restarts the transcode at a new offset, and the server has no other
 // window restarts the transcode at a new offset, and the server has no other
 // way to tell that the previous one is finished with: the MP4 stream dies with
 // way to tell that the previous one is finished with: the MP4 stream dies with
@@ -229,7 +241,9 @@ function playlistErrorMessage(body, status) {
 // A playlist is checked before the player is pointed at it, which makes the
 // A playlist is checked before the player is pointed at it, which makes the
 // attachment asynchronous: onReady fires once the stream is actually bound, and
 // attachment asynchronous: onReady fires once the stream is actually bound, and
 // is where the caller should call play(). onError reports a stream that never
 // is where the caller should call play(). onError reports a stream that never
-// became playable, with the server's own explanation.
+// became playable, with the server's own explanation. A stream that fails after
+// it was bound is reported on the element instead: its 'error' event for native
+// playback, STREAM_LOST_EVENT for the Media Source player.
 function attachTranscodeStream(videoEl, url, onError, onReady) {
 function attachTranscodeStream(videoEl, url, onError, onReady) {
     detachTranscodeStream(videoEl);
     detachTranscodeStream(videoEl);
 
 
@@ -258,15 +272,33 @@ function attachTranscodeStream(videoEl, url, onError, onReady) {
         };
         };
     } else if (window.MovieHLS && window.MovieHLS.isSupported()) {
     } else if (window.MovieHLS && window.MovieHLS.isSupported()) {
         bind = function () {
         bind = function () {
-            videoEl._mseInstance = window.MovieHLS.attach(videoEl, url, {
-                onError: function (reason, err) { fail(reason, err); }
+            var instance = window.MovieHLS.attach(videoEl, url, {
+                onError: function (reason, err) {
+                    // A player already replaced by a newer stream is not news
+                    if (videoEl._mseInstance !== instance) { return; }
+                    videoEl.dispatchEvent(new CustomEvent(STREAM_LOST_EVENT, {
+                        detail: { reason: reason, error: err }
+                    }));
+                }
             });
             });
+            videoEl._mseInstance = instance;
             ready();
             ready();
         };
         };
     } else {
     } else {
         return false;
         return false;
     }
     }
 
 
+    // Stop the stream being replaced now, not when the new one binds. Tearing
+    // down its player does not stop the element: it plays on through whatever
+    // it had buffered while the page already counts time from the new offset,
+    // so after a jump to 2:20 the clock runs 2:20, 2:21, 2:22 over the old
+    // picture, then snaps back to 2:20 when the new stream arrives. The gap is
+    // the preflight below, which waits for the server to start ffmpeg at the
+    // new offset - seconds, not milliseconds. An emptied element shows nothing
+    // (the page keeps its freeze frame over it) and holds its clock at zero,
+    // which is exactly the new offset.
+    stopElementMedia(videoEl);
+
     // A seek made while the previous playlist is still being fetched must not
     // A seek made while the previous playlist is still being fetched must not
     // be overtaken by that older answer.
     // be overtaken by that older answer.
     var generation = (videoEl._streamGeneration || 0) + 1;
     var generation = (videoEl._streamGeneration || 0) + 1;
@@ -279,6 +311,16 @@ function attachTranscodeStream(videoEl, url, onError, onReady) {
     return true;
     return true;
 }
 }
 
 
+// Empty the element so nothing keeps playing from its old source. Removing the
+// attribute (rather than setting src to "") matters: an empty src is an invalid
+// source and fires 'error', which the pages would treat as a lost stream. The
+// load() rejects a pending play() with AbortError, which playVideo ignores.
+function stopElementMedia(videoEl) {
+    if (!videoEl.hasAttribute("src")) { return; }
+    videoEl.removeAttribute("src");
+    videoEl.load();
+}
+
 // Release whichever player is currently bound to the element. Always call this
 // Release whichever player is currently bound to the element. Always call this
 // before pointing a <video> somewhere new, or an MSE attachment keeps feeding
 // before pointing a <video> somewhere new, or an MSE attachment keeps feeding
 // segments into an element that has moved on.
 // segments into an element that has moved on.

+ 17 - 4
src/web/Movie/embedded.html

@@ -1074,7 +1074,9 @@ function transcodeSeekTo(seconds) {
 }
 }
 
 
 // Whether `pos` (absolute time) falls inside what the current stream has
 // Whether `pos` (absolute time) falls inside what the current stream has
-// already produced and can therefore be reached without reloading.
+// already produced and can therefore be reached without reloading. seekable
+// spans every segment the playlist lists, for native HLS and for the Media
+// Source player alike (it publishes the playlist's length as the duration).
 function seekWithinTranscodedWindow(videoEl, pos) {
 function seekWithinTranscodedWindow(videoEl, pos) {
     var relative = pos - transcodeSeekOffset;
     var relative = pos - transcodeSeekOffset;
     if (relative < 0) { return false; }
     if (relative < 0) { return false; }
@@ -1090,15 +1092,19 @@ function seekWithinTranscodedWindow(videoEl, pos) {
 // player: reaped after a long pause, retired when the same player asked for a
 // player: reaped after a long pause, retired when the same player asked for a
 // different offset, or lost with a restart. The next segment then 404s and the
 // different offset, or lost with a restart. The next segment then 404s and the
 // element reports a decode error, which leaves a black frame and no way back.
 // element reports a decode error, which leaves a black frame and no way back.
-// Re-open the stream where it stopped instead.
+// Re-open the stream where it stopped instead. The Media Source player reports
+// the same failures as STREAM_LOST_EVENT rather than an element error.
+//
+// Returns whether a recovery was started, so a caller holding a reason for the
+// failure can show it once recovery has given up.
 var streamRecoveryAttempts = 0;
 var streamRecoveryAttempts = 0;
 var STREAM_RECOVERY_LIMIT  = 3;   // consecutive tries before giving up
 var STREAM_RECOVERY_LIMIT  = 3;   // consecutive tries before giving up
 
 
 function recoverTranscodeStream() {
 function recoverTranscodeStream() {
-    if (!isTranscodedVideo || !usingHLS() || !currentFile) { return; }
+    if (!isTranscodedVideo || !usingHLS() || !currentFile) { return false; }
     // A file that genuinely cannot be transcoded fails every time; retrying it
     // A file that genuinely cannot be transcoded fails every time; retrying it
     // forever would replace one dead player with a flickering one.
     // forever would replace one dead player with a flickering one.
-    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return; }
+    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return false; }
     streamRecoveryAttempts++;
     streamRecoveryAttempts++;
 
 
     var resumeAt = effectivePlaybackTime();
     var resumeAt = effectivePlaybackTime();
@@ -1107,6 +1113,7 @@ function recoverTranscodeStream() {
     attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, resumeAt),
     attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, resumeAt),
         function (reason) { hideSeekFreeze(); showToast(reason); },
         function (reason) { hideSeekFreeze(); showToast(reason); },
         function () { playVideo(vid); });
         function () { playVideo(vid); });
+    return true;
 }
 }
 
 
 // Current playback position in whole-file terms (transcode streams restart at 0)
 // Current playback position in whole-file terms (transcode streams restart at 0)
@@ -1374,6 +1381,12 @@ function initVideoControls() {
         hideSeekFreeze();
         hideSeekFreeze();
         recoverTranscodeStream();
         recoverTranscodeStream();
     });
     });
+    $(vid).on(STREAM_LOST_EVENT, function (e) {
+        hideSeekFreeze();
+        if (recoverTranscodeStream()) { return; }
+        var detail = (e.originalEvent && e.originalEvent.detail) || {};
+        showToast(detail.reason || 'The HLS stream stopped and could not be reopened.');
+    });
 
 
     // Auto-hide controls on mouse movement
     // Auto-hide controls on mouse movement
     $('#player-wrap').on('mousemove touchstart', showControls);
     $('#player-wrap').on('mousemove touchstart', showControls);

+ 18 - 5
src/web/Movie/index.html

@@ -2680,7 +2680,9 @@ function transcodeSeekTo(seconds) {
 }
 }
 
 
 // Whether `pos` (absolute time) falls inside what the current stream has
 // Whether `pos` (absolute time) falls inside what the current stream has
-// already produced and can therefore be reached without reloading.
+// already produced and can therefore be reached without reloading. seekable
+// spans every segment the playlist lists, for native HLS and for the Media
+// Source player alike (it publishes the playlist's length as the duration).
 function seekWithinTranscodedWindow(vid, pos) {
 function seekWithinTranscodedWindow(vid, pos) {
     var relative = pos - transcodeSeekOffset;
     var relative = pos - transcodeSeekOffset;
     if (relative < 0) { return false; }
     if (relative < 0) { return false; }
@@ -2696,16 +2698,20 @@ function seekWithinTranscodedWindow(vid, pos) {
 // player: reaped after a long pause, retired when the same player asked for a
 // player: reaped after a long pause, retired when the same player asked for a
 // different offset, or lost with a restart. The next segment then 404s and the
 // different offset, or lost with a restart. The next segment then 404s and the
 // element reports a decode error, which leaves a black frame and no way back.
 // element reports a decode error, which leaves a black frame and no way back.
-// Re-open the stream where it stopped instead.
+// Re-open the stream where it stopped instead. The Media Source player reports
+// the same failures as STREAM_LOST_EVENT rather than an element error.
+//
+// Returns whether a recovery was started, so a caller holding a reason for the
+// failure can show it once recovery has given up.
 var streamRecoveryAttempts = 0;
 var streamRecoveryAttempts = 0;
 var STREAM_RECOVERY_LIMIT  = 3;   // consecutive tries before giving up
 var STREAM_RECOVERY_LIMIT  = 3;   // consecutive tries before giving up
 
 
 function recoverTranscodeStream() {
 function recoverTranscodeStream() {
-    if (!isTranscodedVideo || !usingHLS()) { return; }
-    if (playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
+    if (!isTranscodedVideo || !usingHLS()) { return false; }
+    if (playingIndex < 0 || !currentEpisodes[playingIndex]) { return false; }
     // A file that genuinely cannot be transcoded fails every time; retrying it
     // A file that genuinely cannot be transcoded fails every time; retrying it
     // forever would replace one dead player with a flickering one.
     // forever would replace one dead player with a flickering one.
-    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return; }
+    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return false; }
     streamRecoveryAttempts++;
     streamRecoveryAttempts++;
 
 
     var vid      = document.getElementById('main-video');
     var vid      = document.getElementById('main-video');
@@ -2715,6 +2721,7 @@ function recoverTranscodeStream() {
     attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, resumeAt),
     attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, resumeAt),
         function (reason) { hideSeekFreeze(); showToast(reason); },
         function (reason) { hideSeekFreeze(); showToast(reason); },
         function () { playVideo(vid); });
         function () { playVideo(vid); });
+    return true;
 }
 }
 
 
 // Current playback position in whole-file terms (transcode streams restart at 0)
 // Current playback position in whole-file terms (transcode streams restart at 0)
@@ -3906,6 +3913,12 @@ function initVideoControls() {
         hideSeekFreeze();
         hideSeekFreeze();
         recoverTranscodeStream();
         recoverTranscodeStream();
     });
     });
+    $(vid).on(STREAM_LOST_EVENT, function (e) {
+        hideSeekFreeze();
+        if (recoverTranscodeStream()) { return; }
+        var detail = (e.originalEvent && e.originalEvent.detail) || {};
+        showToast(detail.reason || 'The HLS stream stopped and could not be reopened.');
+    });
 
 
     // Auto-hide controls
     // Auto-hide controls
     $('#video-container').on('mousemove touchstart', function () { showControls(); });
     $('#video-container').on('mousemove touchstart', function () { showControls(); });

+ 310 - 32
src/web/script/hlsmse.js

@@ -18,6 +18,23 @@
       • Codec detection read from the init segment, so the SourceBuffer is
       • Codec detection read from the init segment, so the SourceBuffer is
         created with the stream's real profile rather than a guess
         created with the stream's real profile rather than a guess
 
 
+    Behaving like Safari's native player
+      The pages using this treat it as a drop-in for native HLS, so it keeps
+      the same contract: video.seekable spans every segment the playlist lists
+      (not just what has been downloaded), which is how a page tells a seek it
+      can make in place from one that needs the transcode restarted.
+
+    Recovery
+      • A segment download that fails on the network or with a server error
+        is retried with backoff; a 404/403 is not, since it means the server
+        session is gone and only reopening the stream helps.
+      • A watchdog steps over small holes between segments, and points the
+        download cursor back at the playhead if its media is neither buffered
+        nor on the way.
+      • Anything it cannot recover from is reported once through
+        options.onError, after which the player stops. The caller is expected
+        to reopen the stream (the Movie app restarts the transcode there).
+
     Not supported (by design — the server never produces them)
     Not supported (by design — the server never produces them)
       • Master playlists / multiple variants / bitrate switching
       • Master playlists / multiple variants / bitrate switching
       • MPEG-TS segments, encryption, discontinuities, subtitle renditions
       • MPEG-TS segments, encryption, discontinuities, subtitle renditions
@@ -29,6 +46,23 @@
 var BUFFER_AHEAD_SECONDS = 30;
 var BUFFER_AHEAD_SECONDS = 30;
 // How much already-played media to keep before trimming it out of the buffer.
 // How much already-played media to keep before trimming it out of the buffer.
 var BUFFER_BEHIND_SECONDS = 30;
 var BUFFER_BEHIND_SECONDS = 30;
+// Retries after a failed segment download, the first waiting RETRY_DELAY_MS
+// and each one after that twice as long as the one before.
+var SEGMENT_RETRIES = 3;
+var RETRY_DELAY_MS = 500;
+// A download with no answer by then is abandoned and retried. A server
+// segment is a few seconds of video, so this only trips on a hung connection.
+var SEGMENT_TIMEOUT_MS = 20000;
+// How often the stall watchdog looks at the element.
+var STALL_CHECK_MS = 1000;
+// The largest hole between buffered ranges the watchdog skips over. Holes this
+// small come from audio and video starting a few frames apart at a segment
+// boundary; they never fill in, and some browsers will not play across them.
+var GAP_TOLERANCE_SECONDS = 1;
+// How long playback may sit stuck on media the playlist already lists before
+// the player gives up and reports it. Waiting on a transcode that has not yet
+// produced the next segment never counts towards this.
+var STALL_FAIL_MS = 30000;
 // Fallback codecs when the init segment cannot be parsed: the server always
 // Fallback codecs when the init segment cannot be parsed: the server always
 // encodes H.264 High + AAC-LC, so this is the right shape even if the exact
 // encodes H.264 High + AAC-LC, so this is the right shape even if the exact
 // profile digits differ.
 // profile digits differ.
@@ -159,19 +193,40 @@ function Player(videoEl, playlistURL, options) {
     this.playlistURL = playlistURL;
     this.playlistURL = playlistURL;
     this.options = options || {};
     this.options = options || {};
     this.destroyed = false;
     this.destroyed = false;
+    this.failed = false;
 
 
     this.mediaSource = null;
     this.mediaSource = null;
     this.sourceBuffer = null;
     this.sourceBuffer = null;
     this.playlist = null;
     this.playlist = null;
     this.initBuffer = null;
     this.initBuffer = null;
-    this.nextIndex = 0;
-    this.appending = false;
     this.refreshTimer = null;
     this.refreshTimer = null;
     this.objectURL = null;
     this.objectURL = null;
 
 
+    // Download cursor. nextIndex is the next segment to fetch; a segment in
+    // flight is recorded separately, so a seek landing mid-download can move
+    // the cursor without the finished download moving it again.
+    this.nextIndex = 0;
+    this.appending = false;
+    this.inflightIndex = -1;
+    this.inflightAbort = null;
+    // Bumped every time the cursor is moved by something other than a
+    // download completing. A download started under an older generation is
+    // discarded when it lands.
+    this.cursorGeneration = 0;
+    // A segment that arrived while the buffer was busy (removing, or the
+    // previous append still running), appended on the next updateend.
+    this.pendingAppend = null;
+
+    // Stall watchdog state
+    this.stallTicks = 0;
+    this.healedIndex = -1;
+    this.watchdogTimer = null;
+
     this._onSourceOpen = this._onSourceOpen.bind(this);
     this._onSourceOpen = this._onSourceOpen.bind(this);
     this._pump = this._pump.bind(this);
     this._pump = this._pump.bind(this);
     this._onSeeking = this._onSeeking.bind(this);
     this._onSeeking = this._onSeeking.bind(this);
+    this._onUpdateEnd = this._onUpdateEnd.bind(this);
+    this._checkStall = this._checkStall.bind(this);
 
 
     this.mediaSource = new global.MediaSource();
     this.mediaSource = new global.MediaSource();
     this.objectURL = URL.createObjectURL(this.mediaSource);
     this.objectURL = URL.createObjectURL(this.mediaSource);
@@ -179,10 +234,16 @@ function Player(videoEl, playlistURL, options) {
     this.video.addEventListener('timeupdate', this._pump);
     this.video.addEventListener('timeupdate', this._pump);
     this.video.addEventListener('seeking', this._onSeeking);
     this.video.addEventListener('seeking', this._onSeeking);
     this.video.src = this.objectURL;
     this.video.src = this.objectURL;
+    this.watchdogTimer = setInterval(this._checkStall, STALL_CHECK_MS);
 }
 }
 
 
+// Report an unrecoverable failure, once. The player stops working after this:
+// the caller is expected to tear it down and reopen the stream.
 Player.prototype._fail = function (reason, err) {
 Player.prototype._fail = function (reason, err) {
-    if (this.destroyed) { return; }
+    if (this.destroyed || this.failed) { return; }
+    this.failed = true;
+    clearInterval(this.watchdogTimer);
+    clearTimeout(this.refreshTimer);
     if (typeof this.options.onError === 'function') { this.options.onError(reason, err); }
     if (typeof this.options.onError === 'function') { this.options.onError(reason, err); }
 };
 };
 
 
@@ -206,10 +267,11 @@ Player.prototype._onSourceOpen = function () {
             }
             }
 
 
             self.sourceBuffer = self.mediaSource.addSourceBuffer(mime);
             self.sourceBuffer = self.mediaSource.addSourceBuffer(mime);
-            self.sourceBuffer.addEventListener('updateend', self._pump);
+            self.sourceBuffer.addEventListener('updateend', self._onUpdateEnd);
             self.sourceBuffer.addEventListener('error', function () {
             self.sourceBuffer.addEventListener('error', function () {
                 self._fail('The browser rejected a media segment');
                 self._fail('The browser rejected a media segment');
             });
             });
+            self._syncDuration();
             self._append(buffer);
             self._append(buffer);
         });
         });
     }).catch(function (err) {
     }).catch(function (err) {
@@ -217,11 +279,66 @@ Player.prototype._onSourceOpen = function () {
     });
     });
 };
 };
 
 
-Player.prototype._fetch = function (url) {
-    return fetch(url, { credentials: 'same-origin' }).then(function (response) {
-        if (!response.ok) { throw new Error('HTTP ' + response.status + ' for ' + url); }
-        return response.arrayBuffer();
-    });
+Player.prototype._onUpdateEnd = function () {
+    if (this.destroyed) { return; }
+    if (this.pendingAppend) {
+        var buffer = this.pendingAppend;
+        this.pendingAppend = null;
+        this._append(buffer);
+        return;
+    }
+    // Setting the duration is refused while the buffer is updating, so a
+    // playlist refresh that arrived meanwhile is applied here
+    this._syncDuration();
+    this._pump();
+};
+
+// Fetch one segment (or the init segment), retrying transient failures.
+//
+// isCurrent is checked before every retry; once it returns false (a seek has
+// moved the cursor elsewhere) the download is abandoned instead of retried.
+Player.prototype._fetch = function (url, isCurrent) {
+    var self = this;
+    var attempt = 0;
+
+    function tryOnce() {
+        var controller = global.AbortController ? new global.AbortController() : null;
+        var timedOut = false;
+        var timer = null;
+        if (controller) {
+            self.inflightAbort = controller;
+            timer = setTimeout(function () { timedOut = true; controller.abort(); }, SEGMENT_TIMEOUT_MS);
+        }
+
+        return fetch(url, { credentials: 'same-origin', signal: controller ? controller.signal : undefined })
+            .then(function (response) {
+                if (!response.ok) {
+                    var httpErr = new Error('HTTP ' + response.status + ' for ' + url);
+                    // 404/403 mean the session behind this stream is gone or
+                    // was never ours: asking again cannot change the answer
+                    httpErr.retryable = response.status >= 500;
+                    throw httpErr;
+                }
+                return response.arrayBuffer();
+            })
+            .then(function (buffer) {
+                clearTimeout(timer);
+                return buffer;
+            }, function (err) {
+                clearTimeout(timer);
+                if (self.destroyed || (isCurrent && !isCurrent())) { throw err; }
+                // An abort that was not the timeout came from a seek or teardown
+                if (err && err.name === 'AbortError' && !timedOut) { throw err; }
+                if ((err && err.retryable === false) || attempt >= SEGMENT_RETRIES) { throw err; }
+                var wait = RETRY_DELAY_MS * Math.pow(2, attempt);
+                attempt++;
+                return new Promise(function (resolve) { setTimeout(resolve, wait); }).then(function () {
+                    if (self.destroyed || (isCurrent && !isCurrent())) { throw err; }
+                    return tryOnce();
+                });
+            });
+    }
+    return tryOnce();
 };
 };
 
 
 Player.prototype._loadPlaylist = function () {
 Player.prototype._loadPlaylist = function () {
@@ -234,6 +351,7 @@ Player.prototype._loadPlaylist = function () {
         .then(function (text) {
         .then(function (text) {
             if (self.destroyed) { return; }
             if (self.destroyed) { return; }
             self.playlist = parsePlaylist(text, self.playlistURL);
             self.playlist = parsePlaylist(text, self.playlistURL);
+            self._syncDuration();
             self._scheduleRefresh();
             self._scheduleRefresh();
         });
         });
 };
 };
@@ -243,9 +361,15 @@ Player.prototype._loadPlaylist = function () {
 Player.prototype._scheduleRefresh = function () {
 Player.prototype._scheduleRefresh = function () {
     var self = this;
     var self = this;
     clearTimeout(this.refreshTimer);
     clearTimeout(this.refreshTimer);
-    if (this.destroyed || !this.playlist || this.playlist.ended) { return; }
-
-    var wait = Math.max(1000, (this.playlist.targetDuration || 4) * 500);
+    if (this.destroyed || this.failed || !this.playlist || this.playlist.ended) { return; }
+
+    // Half a segment, but never more than two seconds. The page decides whether
+    // a seek can be served in place from how far this playlist reaches, and
+    // the first copy lists a single segment (the server answers as soon as one
+    // exists). With a hardware encoder that ignores forced keyframes, segments
+    // run to 10s or more, and a stale playlist turns seeks into needless
+    // transcode restarts.
+    var wait = Math.max(1000, Math.min(2000, (this.playlist.targetDuration || 4) * 500));
     this.refreshTimer = setTimeout(function () {
     this.refreshTimer = setTimeout(function () {
         if (self.destroyed) { return; }
         if (self.destroyed) { return; }
         self._loadPlaylist().then(function () { self._pump(); })
         self._loadPlaylist().then(function () { self._pump(); })
@@ -253,6 +377,29 @@ Player.prototype._scheduleRefresh = function () {
     }, wait);
     }, wait);
 };
 };
 
 
+// Publish the playlist's length as the media duration.
+//
+// Media Source reports seekable as 0..duration, and left alone the duration
+// only ever reaches the end of what has been appended — about half a minute
+// past the playhead. A page asking "has the transcode got this far?" would
+// then be told no for segments that exist, and restart ffmpeg for nothing.
+// Using the playlist's length gives the same answer Safari's native player
+// does for an EVENT playlist: everything the transcode has produced so far.
+Player.prototype._syncDuration = function () {
+    var ms = this.mediaSource;
+    if (!ms || ms.readyState !== 'open' || !this.playlist) { return; }
+    // Refused while an append or removal is running; retried on updateend
+    if (this.sourceBuffer && this.sourceBuffer.updating) { return; }
+
+    var target = this.playlist.duration;
+    if (!(target > 0)) { return; }
+    // Only ever grow it. Shrinking below the buffered media is refused, and a
+    // finished stream's endOfStream() settles it a fraction under the
+    // playlist's rounded total, which is not worth fighting over.
+    if (!isNaN(ms.duration) && target <= ms.duration + 0.5) { return; }
+    try { ms.duration = target; } catch (e) { /* retried on the next refresh */ }
+};
+
 Player.prototype._bufferedAhead = function () {
 Player.prototype._bufferedAhead = function () {
     var buffered = this.video.buffered;
     var buffered = this.video.buffered;
     var time = this.video.currentTime;
     var time = this.video.currentTime;
@@ -264,6 +411,28 @@ Player.prototype._bufferedAhead = function () {
     return 0;
     return 0;
 };
 };
 
 
+// Whether there is media to play at `time`, with a little room after it.
+Player.prototype._isBufferedAt = function (time) {
+    var buffered = this.video.buffered;
+    for (var i = 0; i < buffered.length; i++) {
+        if (buffered.start(i) <= time && time < buffered.end(i) - 0.1) { return true; }
+    }
+    return false;
+};
+
+// Whether a segment's whole span is already in the buffer. The edges are
+// allowed some slack: audio and video rarely start and end on exactly the
+// segment's nominal boundary.
+Player.prototype._isSegmentBuffered = function (segment) {
+    var buffered = this.video.buffered;
+    var start = segment.start + 0.2;
+    var end = segment.start + segment.duration - 0.2;
+    for (var i = 0; i < buffered.length; i++) {
+        if (buffered.start(i) <= start && end <= buffered.end(i)) { return true; }
+    }
+    return false;
+};
+
 Player.prototype._segmentIndexForTime = function (time) {
 Player.prototype._segmentIndexForTime = function (time) {
     var segments = this.playlist ? this.playlist.segments : [];
     var segments = this.playlist ? this.playlist.segments : [];
     for (var i = 0; i < segments.length; i++) {
     for (var i = 0; i < segments.length; i++) {
@@ -272,31 +441,50 @@ Player.prototype._segmentIndexForTime = function (time) {
     return segments.length;
     return segments.length;
 };
 };
 
 
+// Point the download cursor at `index`, abandoning whatever download is in
+// flight. The abandoned download's result is dropped when it lands (see the
+// generation check in _pump), so it can neither be appended out of turn nor
+// move the cursor off the segment that is now wanted.
+Player.prototype._retarget = function (index) {
+    this.cursorGeneration++;
+    if (this.inflightAbort) {
+        try { this.inflightAbort.abort(); } catch (e) {}
+        this.inflightAbort = null;
+    }
+    this.appending = false;
+    this.inflightIndex = -1;
+    this.nextIndex = index;
+    this._pump();
+};
+
 Player.prototype._onSeeking = function () {
 Player.prototype._onSeeking = function () {
     if (this.destroyed || !this.playlist || !this.sourceBuffer) { return; }
     if (this.destroyed || !this.playlist || !this.sourceBuffer) { return; }
     var target = this.video.currentTime;
     var target = this.video.currentTime;
 
 
     // Already buffered around the target: let the browser play it.
     // Already buffered around the target: let the browser play it.
-    var buffered = this.video.buffered;
-    for (var i = 0; i < buffered.length; i++) {
-        if (buffered.start(i) <= target && target < buffered.end(i) - 0.1) { return; }
-    }
+    if (this._isBufferedAt(target)) { return; }
 
 
-    // Otherwise restart the append cursor at the segment covering the target.
-    this.nextIndex = this._segmentIndexForTime(target);
-    this._pump();
+    // Otherwise restart the download cursor at the segment covering the target.
+    this._retarget(this._segmentIndexForTime(target));
 };
 };
 
 
 // Drive downloads: keep a window buffered ahead of the playhead, and trim what
 // Drive downloads: keep a window buffered ahead of the playhead, and trim what
 // is far behind so a long session does not grow without bound.
 // is far behind so a long session does not grow without bound.
 Player.prototype._pump = function () {
 Player.prototype._pump = function () {
     var self = this;
     var self = this;
-    if (this.destroyed || !this.sourceBuffer || this.sourceBuffer.updating || this.appending) { return; }
+    if (this.destroyed || this.failed || !this.sourceBuffer || this.sourceBuffer.updating || this.appending) { return; }
     if (!this.playlist) { return; }
     if (!this.playlist) { return; }
 
 
     this._trimBehind();
     this._trimBehind();
 
 
-    if (this.nextIndex >= this.playlist.segments.length) {
+    // After a backward seek the cursor walks forward over segments that are
+    // still buffered from before; fetching those again only costs bandwidth.
+    var segments = this.playlist.segments;
+    while (this.nextIndex < segments.length && this._isSegmentBuffered(segments[this.nextIndex])) {
+        this.nextIndex++;
+    }
+
+    if (this.nextIndex >= segments.length) {
         if (this.playlist.ended && this.mediaSource.readyState === 'open') {
         if (this.playlist.ended && this.mediaSource.readyState === 'open') {
             try { this.mediaSource.endOfStream(); } catch (e) {}
             try { this.mediaSource.endOfStream(); } catch (e) {}
         }
         }
@@ -304,53 +492,143 @@ Player.prototype._pump = function () {
     }
     }
     if (this._bufferedAhead() > BUFFER_AHEAD_SECONDS) { return; }
     if (this._bufferedAhead() > BUFFER_AHEAD_SECONDS) { return; }
 
 
-    var segment = this.playlist.segments[this.nextIndex];
+    var index = this.nextIndex;
+    var generation = this.cursorGeneration;
+    var isCurrent = function () { return generation === self.cursorGeneration; };
+
     this.appending = true;
     this.appending = true;
-    this._fetch(segment.url).then(function (buffer) {
+    this.inflightIndex = index;
+    this._fetch(segments[index].url, isCurrent).then(function (buffer) {
+        // A seek moved the cursor while this was downloading. It has already
+        // cleared the in-flight state and started the download it wants.
+        if (!isCurrent()) { return; }
         self.appending = false;
         self.appending = false;
+        self.inflightIndex = -1;
+        self.inflightAbort = null;
         if (self.destroyed || !self.sourceBuffer) { return; }
         if (self.destroyed || !self.sourceBuffer) { return; }
-        self.nextIndex++;
+        self.nextIndex = index + 1;
         self._append(buffer);
         self._append(buffer);
     }).catch(function (err) {
     }).catch(function (err) {
+        if (!isCurrent()) { return; }
         self.appending = false;
         self.appending = false;
+        self.inflightIndex = -1;
+        self.inflightAbort = null;
         self._fail('A media segment failed to load', err);
         self._fail('A media segment failed to load', err);
     });
     });
 };
 };
 
 
 Player.prototype._append = function (buffer) {
 Player.prototype._append = function (buffer) {
     if (this.destroyed || !this.sourceBuffer) { return; }
     if (this.destroyed || !this.sourceBuffer) { return; }
+    if (this.sourceBuffer.updating) {
+        // A removal started by the trim is still running; append once it ends
+        this.pendingAppend = buffer;
+        return;
+    }
     try {
     try {
         this.sourceBuffer.appendBuffer(new Uint8Array(buffer));
         this.sourceBuffer.appendBuffer(new Uint8Array(buffer));
     } catch (err) {
     } catch (err) {
-        // A full buffer is recoverable: drop what is behind and retry once.
-        if (err && err.name === 'QuotaExceededError') {
-            this._trimBehind(true);
-            try { this.sourceBuffer.appendBuffer(new Uint8Array(buffer)); return; } catch (e) {}
+        // A full buffer is recoverable: drop what is behind and retry once the
+        // removal has finished.
+        if (err && err.name === 'QuotaExceededError' && this._trimBehind(true)) {
+            this.pendingAppend = buffer;
+            return;
         }
         }
         this._fail('The browser rejected a media segment', err);
         this._fail('The browser rejected a media segment', err);
     }
     }
 };
 };
 
 
+// Remove media far behind the playhead. Returns true when a removal was
+// started (the buffer is then updating until the next updateend).
 Player.prototype._trimBehind = function (aggressive) {
 Player.prototype._trimBehind = function (aggressive) {
-    if (!this.sourceBuffer || this.sourceBuffer.updating) { return; }
+    if (!this.sourceBuffer || this.sourceBuffer.updating) { return false; }
     var keep = aggressive ? 5 : BUFFER_BEHIND_SECONDS;
     var keep = aggressive ? 5 : BUFFER_BEHIND_SECONDS;
     var cutoff = this.video.currentTime - keep;
     var cutoff = this.video.currentTime - keep;
-    if (cutoff <= 0) { return; }
+    if (cutoff <= 0) { return false; }
 
 
     var buffered = this.sourceBuffer.buffered;
     var buffered = this.sourceBuffer.buffered;
-    if (!buffered.length) { return; }
+    if (!buffered.length) { return false; }
     if (buffered.start(0) < cutoff) {
     if (buffered.start(0) < cutoff) {
-        try { this.sourceBuffer.remove(buffered.start(0), cutoff); } catch (e) {}
+        try { this.sourceBuffer.remove(buffered.start(0), cutoff); return true; } catch (e) {}
+    }
+    return false;
+};
+
+// Step over a small hole just ahead of the playhead. Returns true if it moved
+// the playhead.
+Player.prototype._jumpGap = function (time) {
+    if (this._isBufferedAt(time)) { return false; }
+    var buffered = this.video.buffered;
+    for (var i = 0; i < buffered.length; i++) {
+        var start = buffered.start(i);
+        if (start > time && start - time <= GAP_TOLERANCE_SECONDS) {
+            this.video.currentTime = start + 0.05;
+            return true;
+        }
+    }
+    return false;
+};
+
+// Runs every STALL_CHECK_MS. Playback that cannot move on is nudged past a
+// small hole first, then has its missing media fetched again, and is only
+// reported as failed once neither has helped for STALL_FAIL_MS.
+Player.prototype._checkStall = function () {
+    if (this.destroyed || this.failed || !this.sourceBuffer || !this.playlist) { return; }
+    var video = this.video;
+
+    // HAVE_FUTURE_DATA or better: the element can move on by itself
+    if (video.ended || video.readyState >= 3) {
+        this.stallTicks = 0;
+        this.healedIndex = -1;
+        return;
+    }
+    this.stallTicks++;
+    // One quiet tick is normal: the next segment is often mid-append
+    if (this.stallTicks < 2) { return; }
+
+    var time = video.currentTime;
+    if (this._jumpGap(time)) { return; }
+
+    var index = this._segmentIndexForTime(time);
+    if (index >= this.playlist.segments.length) {
+        // Waiting on the transcode to produce the next segment. Nothing is
+        // wrong on this side, so none of this counts as a stall.
+        this.stallTicks = 0;
+        return;
+    }
+
+    // The media for the playhead is neither buffered nor being downloaded.
+    // Whatever left the cursor elsewhere, bring it back — once per segment,
+    // so a segment that genuinely cannot fill the hole does not loop.
+    var onItsWay = (this.appending && this.inflightIndex === index) ||
+        this.pendingAppend !== null || this.sourceBuffer.updating;
+    if (!onItsWay && !this._isBufferedAt(time) && this.healedIndex !== index) {
+        this.healedIndex = index;
+        this._retarget(index);
+        return;
+    }
+
+    // A download in flight has its own timeout and retries, and a paused
+    // viewer is not waiting on anything
+    if (onItsWay || video.paused) { return; }
+    if (this.stallTicks * STALL_CHECK_MS >= STALL_FAIL_MS) {
+        this._fail('Playback stalled and could not recover');
     }
     }
 };
 };
 
 
 Player.prototype.destroy = function () {
 Player.prototype.destroy = function () {
     this.destroyed = true;
     this.destroyed = true;
     clearTimeout(this.refreshTimer);
     clearTimeout(this.refreshTimer);
+    clearInterval(this.watchdogTimer);
     this.video.removeEventListener('timeupdate', this._pump);
     this.video.removeEventListener('timeupdate', this._pump);
     this.video.removeEventListener('seeking', this._onSeeking);
     this.video.removeEventListener('seeking', this._onSeeking);
+    if (this.inflightAbort) {
+        try { this.inflightAbort.abort(); } catch (e) {}
+        this.inflightAbort = null;
+    }
+    this.pendingAppend = null;
 
 
     if (this.sourceBuffer) {
     if (this.sourceBuffer) {
+        this.sourceBuffer.removeEventListener('updateend', this._onUpdateEnd);
         try { this.sourceBuffer.abort(); } catch (e) {}
         try { this.sourceBuffer.abort(); } catch (e) {}
         this.sourceBuffer = null;
         this.sourceBuffer = null;
     }
     }