Ver código fonte

Add codec-aware playback and fMP4 HLS support

Switch HLS output from MPEG-TS to fragmented MP4, enabling direct MediaSource appending without a demuxer. Add a /media/probe/ endpoint that reports actual codecs and whether a file can be direct-played, replacing the unreliable extension-based check that caused HEVC and 10-bit H.264 files to fail with decode errors. Add hlsmse.js, a lightweight MSE player for Firefox/Chrome that makes hls.js optional. Fix 10-bit transcodes producing High 10 streams browsers cannot decode by adding -pix_fmt yuv420p. Rewrite the fMP4 playlist init segment URI onto the segment endpoint (ffmpeg always writes it as a bare filename).
Toby Chui 6 dias atrás
pai
commit
d23775e273

+ 4 - 0
src/mediaServer.go

@@ -47,6 +47,10 @@ func mediaServer_init() {
 		//ffmpeg installed. allow transcode
 		http.HandleFunc("/media/transcode/", mediaServer.ServeVideoWithTranscode)
 		http.HandleFunc("/media/transcode/audio/", mediaServer.ServeAudioWithTranscode)
+		//Codec probe, so the player can tell a directly playable file from one
+		//whose container extension merely looks playable
+		http.HandleFunc("/media/probe/", mediaServer.ServeMediaProbe)
+
 		//HLS output, for clients that require byte-range-able media (Safari / iOS)
 		http.HandleFunc("/media/hls/", mediaServer.ServeHLSPlaylist)
 		http.HandleFunc(mediaserver.HLSSegmentEndpoint, mediaServer.ServeHLSSegment)

+ 48 - 3
src/mod/media/mediaserver/hls.go

@@ -16,6 +16,7 @@ package mediaserver
 */
 
 import (
+	"encoding/json"
 	"net/http"
 	"os"
 	"path/filepath"
@@ -64,6 +65,40 @@ func startTimeFromRequest(r *http.Request) float64 {
 	return startTime
 }
 
+// ServeMediaProbe reports the codecs a file contains and whether a mainstream
+// browser can play it without transcoding.
+//
+// The player needs this because a container extension says nothing about
+// decodability: an .mp4 holding HEVC or 10-bit H.264 looks directly playable
+// and then fails with a bare decode error.
+func (s *Instance) ServeMediaProbe(w http.ResponseWriter, r *http.Request) {
+	targetFsh, _, realFilepath, err := s.ValidateSourceFile(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	//Native check on purpose: ffprobe has to read the file directly, and
+	//buffering a remote file in full just to read its header is not worth it.
+	if targetFsh.RequireBuffer || !filesystem.FileExists(realFilepath) {
+		utils.SendErrorResponse(w, "codec probe not supported for this file system")
+		return
+	}
+
+	info, err := transcoder.ProbeMediaCodecs(realFilepath)
+	if err != nil {
+		s.options.Logger.PrintAndLog("Media Server",
+			"Codec probe failed for "+filepath.Base(realFilepath), err)
+		utils.SendErrorResponse(w, "could not probe media codecs")
+		return
+	}
+
+	js, _ := json.Marshal(info)
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Cache-Control", "private, max-age=3600")
+	w.Write(js)
+}
+
 // ServeHLSPlaylist starts (or joins) an HLS transcode of the requested file and
 // returns its playlist once the first segment is ready.
 func (s *Instance) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request) {
@@ -98,10 +133,19 @@ func (s *Instance) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	//Served from memory rather than with ServeFile because the init segment URI
+	//has to be rewritten onto the segment endpoint before the client sees it.
+	playlist, err := s.hlsManager.ReadPlaylist(session)
+	if err != nil {
+		s.options.Logger.PrintAndLog("Media Server", "Unable to read HLS playlist", err)
+		utils.SendErrorResponse(w, "Unable to read the transcode playlist")
+		return
+	}
+
 	//The playlist grows as the transcode advances, so it must never be cached.
 	w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
 	w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
-	http.ServeFile(w, r, session.PlaylistPath())
+	w.Write(playlist)
 }
 
 // ServeHLSSegment serves one segment of a running HLS session. Segments are
@@ -147,8 +191,9 @@ func (s *Instance) ServeHLSSegment(w http.ResponseWriter, r *http.Request) {
 	}
 
 	//A segment never changes once the playlist lists it, so it is safe to cache
-	//for the lifetime of the session.
-	w.Header().Set("Content-Type", "video/mp2t")
+	//for the lifetime of the session. Segments are fragmented MP4 (including the
+	//init segment), which is what MediaSource can append without demuxing.
+	w.Header().Set("Content-Type", "video/mp4")
 	w.Header().Set("Cache-Control", "private, max-age=3600")
 	http.ServeFile(w, r, segmentPath)
 }

+ 62 - 6
src/mod/media/transcoder/hls.go

@@ -46,10 +46,16 @@ const (
 	// count (and request rate) stays sane on a feature-length file.
 	hlsSegmentSeconds = 4
 
-	hlsPlaylistName    = "index.m3u8"
-	hlsSegmentPattern  = "seg%05d.ts"
-	hlsSegmentPrefix   = "seg"
-	hlsSegmentSuffix   = ".ts"
+	hlsPlaylistName   = "index.m3u8"
+	hlsSegmentPattern = "seg%05d.m4s"
+	hlsSegmentPrefix  = "seg"
+	hlsSegmentSuffix  = ".m4s"
+
+	// HLSInitSegmentName is the fragmented-MP4 initialisation segment every
+	// fMP4 playlist points at with #EXT-X-MAP. Exported because the handler
+	// serving playlists has to rewrite that URI onto the segment endpoint.
+	HLSInitSegmentName = "init.mp4"
+
 	hlsWorkingDirName  = "hls"
 	hlsIdleTimeout     = 5 * time.Minute
 	hlsMaxSessions     = 8
@@ -139,6 +145,12 @@ func (s *HLSSession) stop() {
 // package generates ("seg00000.ts"), rejecting anything containing a path
 // separator, "..", or unexpected characters.
 func validHLSSegmentName(name string) bool {
+	// The fMP4 initialisation segment is fetched through the same endpoint as
+	// the media segments, so it has to be accepted here too. Matching the exact
+	// constant keeps the guarantee that only generated names resolve.
+	if name == HLSInitSegmentName {
+		return true
+	}
 	if !strings.HasPrefix(name, hlsSegmentPrefix) || !strings.HasSuffix(name, hlsSegmentSuffix) {
 		return false
 	}
@@ -191,7 +203,9 @@ func buildHLSArgs(inputFile string, dir string, resolution TranscodeOutputResolu
 		if height != "" {
 			vf = "scale=-1:" + height
 		}
-		videoCodecArgs = []string{"-vcodec", "libx264", "-preset", "superfast"}
+		// See TranscodeAndStream: without this a 10-bit source yields a High 10
+		// stream that most browsers cannot decode.
+		videoCodecArgs = []string{"-vcodec", "libx264", "-preset", "superfast", "-pix_fmt", "yuv420p"}
 	}
 
 	if startTime > 0.001 {
@@ -225,7 +239,12 @@ func buildHLSArgs(inputFile string, dir string, resolution TranscodeOutputResolu
 		"-hls_list_size", "0", // keep every segment in the playlist so seeking back works
 		"-hls_playlist_type", "event",
 		"-hls_flags", "independent_segments",
-		"-hls_segment_type", "mpegts",
+		// Fragmented MP4 rather than MPEG-TS. Safari plays either, but fMP4
+		// segments can be appended straight into a MediaSource buffer, which is
+		// what lets Firefox and Chrome play this stream without a third-party
+		// library to demux transport-stream packets first.
+		"-hls_segment_type", "fmp4",
+		"-hls_fmp4_init_filename", HLSInitSegmentName,
 		"-hls_base_url", segmentBaseURL,
 		"-hls_segment_filename", filepath.Join(dir, hlsSegmentPattern),
 		filepath.Join(dir, hlsPlaylistName),
@@ -444,6 +463,43 @@ func (m *HLSManager) segmentBaseURL(sessionID string) string {
 	return m.segmentEndpoint + "?sid=" + sessionID + "&name="
 }
 
+// ReadPlaylist returns the session's playlist with the #EXT-X-MAP init segment
+// URI rewritten onto the segment endpoint.
+//
+// -hls_base_url only rewrites media segment URIs; ffmpeg always writes the
+// init segment as a bare filename. Left alone it would resolve relative to the
+// playlist URL (/media/hls?file=…) and 404, so the rewrite happens here rather
+// than leaving every client to work it out.
+func (m *HLSManager) ReadPlaylist(session *HLSSession) ([]byte, error) {
+	content, err := os.ReadFile(session.PlaylistPath())
+	if err != nil {
+		return nil, err
+	}
+	return rewritePlaylistInitURI(content, m.segmentBaseURL(session.ID)+HLSInitSegmentName), nil
+}
+
+// rewritePlaylistInitURI replaces the URI inside an #EXT-X-MAP tag. Split out
+// from the file read so the substitution can be unit-tested directly.
+func rewritePlaylistInitURI(playlist []byte, initURL string) []byte {
+	lines := strings.Split(string(playlist), "\n")
+	for i, line := range lines {
+		if !strings.HasPrefix(strings.TrimSpace(line), "#EXT-X-MAP:") {
+			continue
+		}
+		start := strings.Index(line, `URI="`)
+		if start < 0 {
+			continue
+		}
+		start += len(`URI="`)
+		end := strings.Index(line[start:], `"`)
+		if end < 0 {
+			continue
+		}
+		lines[i] = line[:start] + initURL + line[start+end:]
+	}
+	return []byte(strings.Join(lines, "\n"))
+}
+
 // WaitForPlaylist blocks until the session's playlist lists at least one
 // segment, so the player is never handed an empty playlist to choke on.
 func (s *HLSSession) WaitForPlaylist(timeout time.Duration) error {

+ 80 - 14
src/mod/media/transcoder/hls_test.go

@@ -22,18 +22,22 @@ func TestValidHLSSegmentName(t *testing.T) {
 		name string
 		want bool
 	}{
-		{"seg00000.ts", true},
-		{"seg00123.ts", true},
-		{"seg1.ts", true},
+		{"seg00000.m4s", true},
+		{"seg00123.m4s", true},
+		{"seg1.m4s", true},
+		{"init.mp4", true}, // the fMP4 init segment goes through the same endpoint
 		{"", false},
-		{"seg.ts", false},
+		{"seg.m4s", false},
 		{"index.m3u8", false},
-		{"seg00000.ts.bak", false},
-		{"other00000.ts", false},
-		{"seg0000a.ts", false},
-		{"../seg00000.ts", false},
-		{"seg/00000.ts", false},
-		{"seg00000.ts/../../passwd", false},
+		{"seg00000.ts", false}, // the old MPEG-TS naming is no longer generated
+		{"seg00000.m4s.bak", false},
+		{"other00000.m4s", false},
+		{"seg0000a.m4s", false},
+		{"init.mp4.bak", false},
+		{"../init.mp4", false},
+		{"../seg00000.m4s", false},
+		{"seg/00000.m4s", false},
+		{"seg00000.m4s/../../passwd", false},
 		{"..", false},
 		{"../passwd", false},
 	}
@@ -56,13 +60,75 @@ func TestSegmentPathRejectsTraversal(t *testing.T) {
 		t.Error("SegmentPath accepted a traversal name, want error")
 	}
 
-	got, err := session.SegmentPath("seg00007.ts")
+	got, err := session.SegmentPath("seg00007.m4s")
 	if err != nil {
 		t.Fatalf("SegmentPath(valid) returned error: %v", err)
 	}
-	if want := filepath.Join(session.Dir, "seg00007.ts"); got != want {
+	if want := filepath.Join(session.Dir, "seg00007.m4s"); got != want {
 		t.Errorf("SegmentPath = %q, want %q", got, want)
 	}
+
+	// The init segment must resolve inside the session directory too
+	got, err = session.SegmentPath(HLSInitSegmentName)
+	if err != nil {
+		t.Fatalf("SegmentPath(init) returned error: %v", err)
+	}
+	if want := filepath.Join(session.Dir, HLSInitSegmentName); got != want {
+		t.Errorf("SegmentPath(init) = %q, want %q", got, want)
+	}
+}
+
+// TestRewritePlaylistInitURI verifies the #EXT-X-MAP URI is redirected onto the
+// segment endpoint. ffmpeg writes it as a bare filename, which would otherwise
+// resolve against the playlist URL and 404.
+func TestRewritePlaylistInitURI(t *testing.T) {
+	const initURL = "/media/hls/segment?sid=abc&name=init.mp4"
+
+	playlist := "#EXTM3U\n" +
+		"#EXT-X-VERSION:7\n" +
+		"#EXT-X-MAP:URI=\"init.mp4\"\n" +
+		"#EXTINF:4.000000,\n" +
+		"/media/hls/segment?sid=abc&name=seg00000.m4s\n"
+
+	got := string(rewritePlaylistInitURI([]byte(playlist), initURL))
+
+	if !strings.Contains(got, "#EXT-X-MAP:URI=\""+initURL+"\"") {
+		t.Errorf("init URI was not rewritten, got:\n%s", got)
+	}
+	if strings.Contains(got, "URI=\"init.mp4\"") {
+		t.Error("the bare init filename survived the rewrite")
+	}
+	// Media segment lines must be left exactly as ffmpeg wrote them
+	if !strings.Contains(got, "/media/hls/segment?sid=abc&name=seg00000.m4s") {
+		t.Error("a media segment URI was altered by the rewrite")
+	}
+}
+
+// TestRewritePlaylistInitURI_NoMapTag verifies a playlist without an init tag
+// (an MPEG-TS playlist, or a header written before the first segment) passes
+// through untouched.
+func TestRewritePlaylistInitURI_NoMapTag(t *testing.T) {
+	playlist := "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:4\n"
+	if got := string(rewritePlaylistInitURI([]byte(playlist), "/x")); got != playlist {
+		t.Errorf("playlist without EXT-X-MAP was modified:\n%s", got)
+	}
+}
+
+// TestRewritePlaylistInitURI_Malformed verifies a truncated tag cannot panic or
+// corrupt the playlist.
+func TestRewritePlaylistInitURI_Malformed(t *testing.T) {
+	cases := []string{
+		"#EXT-X-MAP:\n",
+		"#EXT-X-MAP:URI=\n",
+		"#EXT-X-MAP:URI=\"unterminated\n",
+		"#EXT-X-MAP:URI=''\n",
+	}
+	for _, tc := range cases {
+		out := string(rewritePlaylistInitURI([]byte(tc), "/x"))
+		if out == "" {
+			t.Errorf("rewrite emptied the playlist for %q", tc)
+		}
+	}
 }
 
 // TestHLSSessionKey verifies that only identical transcodes share a key.
@@ -238,7 +304,7 @@ func TestPlaylistHasSegment(t *testing.T) {
 	}
 
 	withSegment := filepath.Join(dir, "ready.m3u8")
-	body := "#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:4.000,\n/media/hls/segment?sid=x&name=seg00000.ts\n"
+	body := "#EXTM3U\n#EXT-X-VERSION:7\n#EXTINF:4.000,\n/media/hls/segment?sid=x&name=seg00000.m4s\n"
 	if err := os.WriteFile(withSegment, []byte(body), 0644); err != nil {
 		t.Fatalf("writing ready playlist: %v", err)
 	}
@@ -281,7 +347,7 @@ func TestHLSManagerDiscardsStaleSessions(t *testing.T) {
 	if err := os.MkdirAll(stale, 0755); err != nil {
 		t.Fatalf("seeding a stale session directory: %v", err)
 	}
-	if err := os.WriteFile(filepath.Join(stale, "seg00000.ts"), []byte("x"), 0644); err != nil {
+	if err := os.WriteFile(filepath.Join(stale, "seg00000.m4s"), []byte("x"), 0644); err != nil {
 		t.Fatalf("seeding a stale segment: %v", err)
 	}
 

+ 160 - 0
src/mod/media/transcoder/probe.go

@@ -0,0 +1,160 @@
+package transcoder
+
+/*
+	Probe.go
+
+	Reports what codecs a media file actually contains.
+
+	A container extension says nothing about decodability: an .mp4 may hold
+	HEVC, AV1 or 10-bit H.264, none of which most browsers can decode. Choosing
+	direct playback from the extension alone is what makes such a file fail with
+	a bare decode error instead of being transcoded. This lets the caller ask
+	first.
+*/
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"os/exec"
+	"strings"
+	"time"
+)
+
+const codecProbeTimeout = 30 * time.Second
+
+// MediaCodecInfo describes the primary video and audio streams of a file.
+type MediaCodecInfo struct {
+	VideoCodec   string `json:"videoCodec"`   // h264, hevc, vp9, av1, …
+	VideoProfile string `json:"videoProfile"` // "High", "Main 10", …
+	PixelFormat  string `json:"pixelFormat"`  // yuv420p, yuv420p10le, …
+	AudioCodec   string `json:"audioCodec"`   // aac, opus, ac3, …
+	Width        int    `json:"width"`
+	Height       int    `json:"height"`
+
+	// DirectPlay is the server's verdict on whether a mainstream browser can
+	// decode this without transcoding. The client still has the final say via
+	// canPlayType, but this catches the common cases up front.
+	DirectPlay bool `json:"directPlay"`
+	// Reason explains a false verdict, for logs and diagnostics.
+	Reason string `json:"reason,omitempty"`
+}
+
+// browserVideoCodecs are the video codecs a current mainstream browser can be
+// expected to decode. HEVC is deliberately absent: Safari plays it, but Firefox
+// has no support at all and Chrome's is platform-dependent, so treating it as
+// playable is what produced decode failures.
+var browserVideoCodecs = map[string]bool{
+	"h264": true,
+	"vp8":  true,
+	"vp9":  true,
+	"av1":  true,
+}
+
+// browserAudioCodecs are the audio codecs safe to hand a browser directly.
+var browserAudioCodecs = map[string]bool{
+	"aac": true, "mp3": true, "opus": true, "vorbis": true, "flac": true,
+	"": true, // a file with no audio track is fine
+}
+
+// tenBitPixelFormats are the high-depth formats browsers generally refuse for
+// H.264. Even where the codec is supported, 10-bit H.264 (High 10) is not.
+func isHighBitDepth(pixFmt string) bool {
+	f := strings.ToLower(pixFmt)
+	return strings.Contains(f, "10le") || strings.Contains(f, "10be") ||
+		strings.Contains(f, "12le") || strings.Contains(f, "12be") ||
+		strings.Contains(f, "p010") || strings.Contains(f, "16le")
+}
+
+// ProbeMediaCodecs inspects a file and reports its primary streams.
+func ProbeMediaCodecs(inputFile string) (*MediaCodecInfo, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), codecProbeTimeout)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, "ffprobe",
+		"-v", "quiet",
+		"-print_format", "json",
+		"-show_streams",
+		inputFile,
+	)
+	output, err := cmd.Output()
+	if err != nil {
+		if ctx.Err() == context.DeadlineExceeded {
+			return nil, errors.New("codec probe timed out")
+		}
+		return nil, fmt.Errorf("ffprobe failed: %w", err)
+	}
+	return parseMediaCodecs(output)
+}
+
+// parseMediaCodecs maps ffprobe output onto a playability verdict. Separated
+// from the exec call so the rules can be unit-tested without ffmpeg present.
+func parseMediaCodecs(probeJSON []byte) (*MediaCodecInfo, error) {
+	var parsed struct {
+		Streams []struct {
+			CodecName string `json:"codec_name"`
+			CodecType string `json:"codec_type"`
+			Profile   string `json:"profile"`
+			PixFmt    string `json:"pix_fmt"`
+			Width     int    `json:"width"`
+			Height    int    `json:"height"`
+			// An attached cover image is a video stream by codec_type; its
+			// disposition is what tells it apart from the real picture.
+			Disposition map[string]int `json:"disposition"`
+		} `json:"streams"`
+	}
+	if err := json.Unmarshal(probeJSON, &parsed); err != nil {
+		return nil, fmt.Errorf("could not parse ffprobe output: %w", err)
+	}
+
+	info := &MediaCodecInfo{}
+	haveVideo := false
+	haveAudio := false
+
+	for i := range parsed.Streams {
+		s := &parsed.Streams[i]
+		switch strings.ToLower(s.CodecType) {
+		case "video":
+			// Skip embedded cover art, which would otherwise be mistaken for
+			// the video track and reported as an mjpeg still.
+			if s.Disposition["attached_pic"] == 1 {
+				continue
+			}
+			if haveVideo {
+				continue
+			}
+			haveVideo = true
+			info.VideoCodec = strings.ToLower(s.CodecName)
+			info.VideoProfile = s.Profile
+			info.PixelFormat = s.PixFmt
+			info.Width = s.Width
+			info.Height = s.Height
+		case "audio":
+			if haveAudio {
+				continue
+			}
+			haveAudio = true
+			info.AudioCodec = strings.ToLower(s.CodecName)
+		}
+	}
+
+	if !haveVideo {
+		info.DirectPlay = false
+		info.Reason = "no video stream"
+		return info, nil
+	}
+
+	switch {
+	case !browserVideoCodecs[info.VideoCodec]:
+		info.Reason = "video codec " + info.VideoCodec + " is not broadly supported by browsers"
+	case info.VideoCodec == "h264" && isHighBitDepth(info.PixelFormat):
+		info.Reason = "10-bit H.264 is not decodable in most browsers"
+	case !browserAudioCodecs[info.AudioCodec]:
+		info.Reason = "audio codec " + info.AudioCodec + " is not broadly supported by browsers"
+	default:
+		info.DirectPlay = true
+	}
+
+	return info, nil
+}

+ 187 - 0
src/mod/media/transcoder/probe_test.go

@@ -0,0 +1,187 @@
+package transcoder
+
+import (
+	"strings"
+	"testing"
+)
+
+// TestParseMediaCodecs_DirectPlayable covers the files that should stream
+// straight to the browser with no transcode.
+func TestParseMediaCodecs_DirectPlayable(t *testing.T) {
+	cases := []struct {
+		name  string
+		probe string
+	}{
+		{"h264 + aac mp4", `{"streams":[
+			{"codec_type":"video","codec_name":"h264","profile":"High","pix_fmt":"yuv420p","width":1920,"height":1080},
+			{"codec_type":"audio","codec_name":"aac"}]}`},
+		{"vp9 + opus webm", `{"streams":[
+			{"codec_type":"video","codec_name":"vp9","pix_fmt":"yuv420p"},
+			{"codec_type":"audio","codec_name":"opus"}]}`},
+		{"av1 + flac", `{"streams":[
+			{"codec_type":"video","codec_name":"av1","pix_fmt":"yuv420p"},
+			{"codec_type":"audio","codec_name":"flac"}]}`},
+		{"video with no audio track", `{"streams":[
+			{"codec_type":"video","codec_name":"h264","pix_fmt":"yuv420p"}]}`},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			info, err := parseMediaCodecs([]byte(tc.probe))
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if !info.DirectPlay {
+				t.Errorf("expected direct play, got false (reason: %s)", info.Reason)
+			}
+		})
+	}
+}
+
+// TestParseMediaCodecs_RequiresTranscode covers what must not be handed to the
+// browser directly. The HEVC case is the one that produced the original bug
+// report: an .mp4 the player direct-played and Firefox could not decode.
+func TestParseMediaCodecs_RequiresTranscode(t *testing.T) {
+	cases := []struct {
+		name       string
+		probe      string
+		wantReason string
+	}{
+		{
+			name: "hevc 10-bit in mp4",
+			probe: `{"streams":[
+				{"codec_type":"video","codec_name":"hevc","profile":"Main 10","pix_fmt":"yuv420p10le"},
+				{"codec_type":"audio","codec_name":"aac"}]}`,
+			wantReason: "hevc",
+		},
+		{
+			name: "hevc 8-bit",
+			probe: `{"streams":[
+				{"codec_type":"video","codec_name":"hevc","profile":"Main","pix_fmt":"yuv420p"},
+				{"codec_type":"audio","codec_name":"aac"}]}`,
+			wantReason: "hevc",
+		},
+		{
+			name: "10-bit h264",
+			probe: `{"streams":[
+				{"codec_type":"video","codec_name":"h264","profile":"High 10","pix_fmt":"yuv420p10le"},
+				{"codec_type":"audio","codec_name":"aac"}]}`,
+			wantReason: "10-bit",
+		},
+		{
+			name: "unsupported audio codec",
+			probe: `{"streams":[
+				{"codec_type":"video","codec_name":"h264","pix_fmt":"yuv420p"},
+				{"codec_type":"audio","codec_name":"ac3"}]}`,
+			wantReason: "ac3",
+		},
+		{
+			name:       "no video stream at all",
+			probe:      `{"streams":[{"codec_type":"audio","codec_name":"aac"}]}`,
+			wantReason: "no video stream",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			info, err := parseMediaCodecs([]byte(tc.probe))
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if info.DirectPlay {
+				t.Error("expected a transcode to be required, got direct play")
+			}
+			if !strings.Contains(info.Reason, tc.wantReason) {
+				t.Errorf("reason %q does not mention %q", info.Reason, tc.wantReason)
+			}
+		})
+	}
+}
+
+// TestParseMediaCodecs_SkipsCoverArt verifies an attached cover image is not
+// mistaken for the video track, which would report the file as an mjpeg still.
+func TestParseMediaCodecs_SkipsCoverArt(t *testing.T) {
+	info, err := parseMediaCodecs([]byte(`{"streams":[
+		{"codec_type":"video","codec_name":"mjpeg","disposition":{"attached_pic":1}},
+		{"codec_type":"video","codec_name":"h264","pix_fmt":"yuv420p","width":1280,"height":720},
+		{"codec_type":"audio","codec_name":"aac"}]}`))
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if info.VideoCodec != "h264" {
+		t.Errorf("expected the real video track, got %q", info.VideoCodec)
+	}
+	if info.Width != 1280 || info.Height != 720 {
+		t.Errorf("expected 1280x720, got %dx%d", info.Width, info.Height)
+	}
+	if !info.DirectPlay {
+		t.Errorf("expected direct play, got false (reason: %s)", info.Reason)
+	}
+}
+
+// TestParseMediaCodecs_FirstStreamWins verifies only the primary tracks are
+// considered when a file carries several.
+func TestParseMediaCodecs_FirstStreamWins(t *testing.T) {
+	info, err := parseMediaCodecs([]byte(`{"streams":[
+		{"codec_type":"video","codec_name":"h264","pix_fmt":"yuv420p"},
+		{"codec_type":"video","codec_name":"hevc","pix_fmt":"yuv420p10le"},
+		{"codec_type":"audio","codec_name":"aac"},
+		{"codec_type":"audio","codec_name":"ac3"}]}`))
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if info.VideoCodec != "h264" || info.AudioCodec != "aac" {
+		t.Errorf("expected the first streams, got %s / %s", info.VideoCodec, info.AudioCodec)
+	}
+	if !info.DirectPlay {
+		t.Errorf("expected direct play, got false (reason: %s)", info.Reason)
+	}
+}
+
+// TestParseMediaCodecs_CaseInsensitive verifies codec names are normalised, as
+// ffprobe output casing is not guaranteed.
+func TestParseMediaCodecs_CaseInsensitive(t *testing.T) {
+	info, err := parseMediaCodecs([]byte(`{"streams":[
+		{"codec_type":"VIDEO","codec_name":"H264","pix_fmt":"yuv420p"},
+		{"codec_type":"Audio","codec_name":"AAC"}]}`))
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !info.DirectPlay {
+		t.Errorf("expected direct play, got false (reason: %s)", info.Reason)
+	}
+}
+
+// TestParseMediaCodecs_InvalidJSON verifies malformed probe output is an error
+// rather than a silent "not playable".
+func TestParseMediaCodecs_InvalidJSON(t *testing.T) {
+	if _, err := parseMediaCodecs([]byte("not json")); err == nil {
+		t.Error("expected an error for malformed ffprobe output, got nil")
+	}
+}
+
+// TestIsHighBitDepth covers the pixel formats that rule out direct H.264 play.
+func TestIsHighBitDepth(t *testing.T) {
+	cases := []struct {
+		pixFmt string
+		want   bool
+	}{
+		{"yuv420p", false},
+		{"yuvj420p", false},
+		{"nv12", false},
+		{"", false},
+		{"yuv420p10le", true},
+		{"yuv422p10le", true},
+		{"yuv420p12le", true},
+		{"p010le", true},
+		{"YUV420P10LE", true}, // casing is not guaranteed
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.pixFmt, func(t *testing.T) {
+			if got := isHighBitDepth(tc.pixFmt); got != tc.want {
+				t.Errorf("isHighBitDepth(%q) = %v, want %v", tc.pixFmt, got, tc.want)
+			}
+		})
+	}
+}

+ 6 - 1
src/mod/media/transcoder/transcoder.go

@@ -59,7 +59,12 @@ func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string
 		if height != "" {
 			vf = "scale=-1:" + height
 		}
-		videoCodecArgs = []string{"-vcodec", "libx264", "-preset", "superfast"}
+		// -pix_fmt yuv420p is not optional. libx264 otherwise matches the source
+		// bit depth, so a 10-bit input (routine in anime releases) produces a
+		// High 10 stream that Chrome and Firefox cannot decode — the transcode
+		// would appear to succeed and then fail to play, which is the whole
+		// problem transcoding exists to avoid.
+		videoCodecArgs = []string{"-vcodec", "libx264", "-preset", "superfast", "-pix_fmt", "yuv420p"}
 	}
 
 	middleArgs := []string{"-i", inputFile}

+ 107 - 16
src/web/Movie/backend/common.js

@@ -12,6 +12,7 @@ var BACKEND_PATH  = APP_NAME + "/backend/";
 var MEDIA_API     = "../media";               // ?file=<vpath>  streams a file
 var TRANSCODE_API  = "../media/transcode";            // ?file
 var HLS_API        = "../media/hls";                  // ?file  same transcode, as an HLS playlist
+var PROBE_API      = "../media/probe/";               // ?file  real codecs + playability
 var STORYBOARD_API = "../media/storyboard/";          // ?file[&image=1]  scrub previews
 var SUBTITLE_API   = "../media/subtitles/";           // ?file[&track=n|&font=n]  embedded tracks
 var AGI_INTERFACE = "../system/ajgi/interface?script=";
@@ -73,16 +74,85 @@ function usingHLS() {
     return isWebKitClient();
 }
 
-// WebKit plays HLS natively; everyone else needs hls.js, which is only present
-// if it has been vendored into web/script/.
+// WebKit plays HLS natively. Everywhere else it is played through Media Source
+// Extensions: hls.js if someone has vendored it into web/script/, otherwise the
+// built-in MSE player in web/script/hlsmse.js, which is enough because the
+// server emits single-variant fragmented-MP4 playlists.
 function nativeHLSSupported(videoEl) {
     if (!videoEl || !videoEl.canPlayType) { return false; }
-    return videoEl.canPlayType("application/vnd.apple.mpegurl") !== "";
+    if (videoEl.canPlayType("application/vnd.apple.mpegurl") === "") { return false; }
+    // canPlayType alone cannot be trusted here: Chrome answers "maybe" for the
+    // HLS MIME type and then fails to play the playlist. WebKit is the only
+    // engine with a real native HLS pipeline, so the answer only counts there —
+    // everyone else goes through Media Source instead.
+    return isWebKitClient();
 }
 
 function hlsPlaybackSupported(videoEl) {
     if (nativeHLSSupported(videoEl)) { return true; }
-    return !!(window.Hls && window.Hls.isSupported());
+    if (window.Hls && window.Hls.isSupported()) { return true; }
+    return !!(window.MovieHLS && window.MovieHLS.isSupported());
+}
+
+// ── Direct play vs transcode ─────────────────────────────────────────────────
+// The container extension is only a hint. An .mp4 may hold HEVC, AV1 or 10-bit
+// H.264, none of which most browsers decode — playing those directly fails with
+// a bare decode error (NS_ERROR_DOM_MEDIA_METADATA_ERR on Firefox) instead of
+// being transcoded. So the server is asked what the file really contains.
+var WEB_PLAYABLE_EXTENSIONS = ["mp4", "webm", "ogg", "m4v"];
+var _codecProbeCache = {};
+
+function isWebPlayableExtension(ext) {
+    return WEB_PLAYABLE_EXTENSIONS.indexOf(String(ext || "").toLowerCase()) !== -1;
+}
+
+// Decide whether a file can be handed to the browser as-is. Calls back with
+// true for direct play, false to transcode.
+//
+// Only web-native containers are probed: anything else is transcoded regardless,
+// so there is nothing to learn. Results are cached per file, making repeat plays
+// and episode changes free.
+function resolveDirectPlay(filepath, ext, callback) {
+    if (!isWebPlayableExtension(ext)) { callback(false); return; }
+    if (Object.prototype.hasOwnProperty.call(_codecProbeCache, filepath)) {
+        callback(_codecProbeCache[filepath]);
+        return;
+    }
+
+    fetch(PROBE_API + "?file=" + encodeURIComponent(filepath), { credentials: "same-origin" })
+        .then(function (r) { return r.json(); })
+        .then(function (info) {
+            // A probe error (no ffmpeg, remote file system) leaves the old
+            // extension-based behaviour in place rather than blocking playback.
+            var direct = (info && !info.error) ? !!info.directPlay : true;
+            _codecProbeCache[filepath] = direct;
+            callback(direct);
+        })
+        .catch(function () { callback(true); });
+}
+
+// Safety net for direct playback: the probe can still be wrong for a browser
+// missing a platform decoder, so a decode failure retries as a transcode rather
+// than leaving the viewer on a dead player.
+function onDirectPlaybackFailure(videoEl, onFallback) {
+    clearDirectPlaybackWatch(videoEl);
+    var handler = function () {
+        clearDirectPlaybackWatch(videoEl);
+        var err = videoEl.error;
+        // 3 = MEDIA_ERR_DECODE, 4 = MEDIA_ERR_SRC_NOT_SUPPORTED. A network
+        // abort is not a codec problem and must not trigger a transcode.
+        if (!err || (err.code !== 3 && err.code !== 4)) { return; }
+        if (typeof onFallback === "function") { onFallback(); }
+    };
+    videoEl._directFallback = handler;
+    videoEl.addEventListener("error", handler);
+}
+
+function clearDirectPlaybackWatch(videoEl) {
+    if (videoEl && videoEl._directFallback) {
+        videoEl.removeEventListener("error", videoEl._directFallback);
+        videoEl._directFallback = null;
+    }
 }
 
 // Build the streaming URL for a transcoded file, honouring the current mode.
@@ -100,21 +170,27 @@ function transcodeStreamURL(filepath, startSeconds) {
 // Point a <video> at a stream URL. HLS on a browser without native support is
 // routed through hls.js when it is available. Returns false when the stream
 // cannot be played at all, so the caller can say so rather than hang.
-function attachTranscodeStream(videoEl, url) {
-    if (videoEl._hlsInstance) {
-        // Tear down the previous hls.js attachment before rebinding
-        try { videoEl._hlsInstance.destroy(); } catch (e) {}
-        videoEl._hlsInstance = null;
-    }
+function attachTranscodeStream(videoEl, url, onError) {
+    detachTranscodeStream(videoEl);
 
     var isPlaylist = url.indexOf(HLS_API) === 0;
     if (isPlaylist && !nativeHLSSupported(videoEl)) {
-        if (!(window.Hls && window.Hls.isSupported())) { return false; }
-        var hls = new window.Hls({ enableWorker: true });
-        videoEl._hlsInstance = hls;
-        hls.loadSource(url);
-        hls.attachMedia(videoEl);
-        return true;
+        if (window.Hls && window.Hls.isSupported()) {
+            var hls = new window.Hls({ enableWorker: true });
+            videoEl._hlsInstance = hls;
+            hls.loadSource(url);
+            hls.attachMedia(videoEl);
+            return true;
+        }
+        if (window.MovieHLS && window.MovieHLS.isSupported()) {
+            videoEl._mseInstance = window.MovieHLS.attach(videoEl, url, {
+                onError: function (reason, err) {
+                    if (typeof onError === "function") { onError(reason, err); }
+                }
+            });
+            return true;
+        }
+        return false;
     }
 
     videoEl.src = url;
@@ -122,6 +198,21 @@ function attachTranscodeStream(videoEl, url) {
     return true;
 }
 
+// Release whichever player is currently bound to the element. Always call this
+// before pointing a <video> somewhere new, or an MSE attachment keeps feeding
+// segments into an element that has moved on.
+function detachTranscodeStream(videoEl) {
+    if (!videoEl) { return; }
+    if (videoEl._hlsInstance) {
+        try { videoEl._hlsInstance.destroy(); } catch (e) {}
+        videoEl._hlsInstance = null;
+    }
+    if (videoEl._mseInstance) {
+        try { videoEl._mseInstance.destroy(); } catch (e) {}
+        videoEl._mseInstance = null;
+    }
+}
+
 // ── Scanner settings ─────────────────────────────────────────────────────────
 var VALID_VIDEO_FORMATS = ["mp4", "webm", "ogg", "mkv", "avi", "mov", "m4v", "wmv", "flv", "rmvb", "ts"];
 var SKIP_ROOT_PREFIXES  = ["tmp:/", "trash:/"];    // roots to skip entirely

+ 61 - 28
src/web/Movie/embedded.html

@@ -7,6 +7,9 @@
     <meta name="theme-color" content="#000000">
     <script src="../script/jquery.min.js"></script>
     <script src="../script/ao_module.js"></script>
+    <!-- HLS via Media Source, so browsers without native HLS (Firefox,
+         Chrome) can play the transcoded playlist without hls.js -->
+    <script src="../script/hlsmse.js"></script>
     <script src="backend/common.js"></script>
 
     <!-- ASS/SSA subtitle parser + renderer (see script/ass.js) -->
@@ -775,21 +778,49 @@ if (files && files.length > 0) {
     };
     ao_module_setWindowTitle('Movie – ' + currentFile.name);
 
-    isTranscodedVideo = !isWebPlayable(currentFile.ext);
-
     vid.autoplay = true;
-    if (isTranscodedVideo) {
-        if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, 0))) {
-            showToast('HLS playback needs hls.js, which is not installed. Switch streaming mode to MP4 in settings.');
-        }
-    } else {
+
+    // The extension only says the container might be playable; the codec inside
+    // decides. An .mp4 holding HEVC or 10-bit H.264 has to be transcoded.
+    resolveDirectPlay(currentFile.filepath, currentFile.ext, function (directPlay) {
+        beginFileStream(directPlay);
+    });
+}
+
+// Bind the video element to the right stream and arm the resume prompt. Also
+// the retry target when direct playback turns out to fail anyway.
+function beginFileStream(directPlay) {
+    if (!currentFile) { return; }
+    clearDirectPlaybackWatch(vid);
+    detachTranscodeStream(vid);
+
+    isTranscodedVideo   = !directPlay;
+    transcodeSeekOffset = 0;
+    transcodeDuration   = 0;
+
+    if (directPlay) {
         vid.src = MEDIA_API + '?file=' + encodeURIComponent(currentFile.filepath);
         vid.load();
+        // Last resort: a browser without the platform decoder the probe assumed
+        onDirectPlaybackFailure(vid, function () {
+            showToast('This browser cannot decode that file — switching to a transcode');
+            beginFileStream(false);
+        });
+    } else if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, 0),
+                                      function (reason) { showToast(reason); })) {
+        showToast('This browser cannot play the transcoded stream. Switch streaming mode to MP4 in settings.');
+        return;
     }
 
-    // Resume position check (only for videos longer than 1 hour)
+    armResumePrompt();
+}
+
+// Offer to resume where the viewer left off. Direct playback learns its length
+// from the element; a transcode has to ask the server for it.
+function armResumePrompt() {
+    if (!currentFile) { return; }
     if (!isTranscodedVideo) {
-        $(vid).one('loadedmetadata.resume', function () {
+        $(vid).off('loadedmetadata.resume').one('loadedmetadata.resume', function () {
             if (vid.duration > 3600 && currentFile) {
                 ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: currentFile.filepath }, function (data) {
                     if (data && !data.error && data.position > 30 && data.position < vid.duration * 0.95) {
@@ -798,23 +829,25 @@ if (files && files.length > 0) {
                 });
             }
         });
-    } else {
-        // Fetch total duration separately (transcode stream doesn't expose it)
-        fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(currentFile.filepath))
-            .then(function (r) { return r.json(); })
-            .then(function (data) {
-                if (data.duration > 0) {
-                    transcodeDuration = data.duration;
-                    if (transcodeDuration > 3600 && currentFile) {
-                        ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: currentFile.filepath }, function (wdata) {
-                            if (wdata && !wdata.error && wdata.position > 30 && wdata.position < transcodeDuration * 0.95) {
-                                showResumePopup(wdata.position, transcodeDuration);
-                            }
-                        });
-                    }
-                }
-            }).catch(function () {});
+        return;
     }
+
+    // Fetch total duration separately (transcode stream doesn't expose it)
+    $(vid).off('loadedmetadata.resume');
+    fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(currentFile.filepath))
+        .then(function (r) { return r.json(); })
+        .then(function (data) {
+            if (data.duration > 0) {
+                transcodeDuration = data.duration;
+                if (transcodeDuration > 3600 && currentFile) {
+                    ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: currentFile.filepath }, function (wdata) {
+                        if (wdata && !wdata.error && wdata.position > 30 && wdata.position < transcodeDuration * 0.95) {
+                            showResumePopup(wdata.position, transcodeDuration);
+                        }
+                    });
+                }
+            }
+        }).catch(function () {});
 }
 
 // ── Scrub-bar hover preview (storyboard) ──────────────────────────────────────
@@ -969,7 +1002,7 @@ function transcodeSeekTo(seconds) {
     transcodeSeekOffset = pos;
     if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, pos))) {
         hideSeekFreeze();
-        showToast('HLS playback needs hls.js, which is not installed. Switch streaming mode to MP4 in settings.');
+        showToast('This browser cannot play the HLS stream. Switch streaming mode to MP4 in settings.');
         return;
     }
     playVideo(vid);
@@ -1274,7 +1307,7 @@ function applyStreamMode(mode, reloadCurrent) {
     var resumeAt = effectivePlaybackTime();
     transcodeSeekOffset = 0;
     if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, 0))) {
-        showToast('HLS playback needs hls.js, which is not installed.');
+        showToast('This browser cannot play the HLS stream.');
         return;
     }
     playVideo(vid);
@@ -1290,7 +1323,7 @@ function warnIfStreamModeUnplayable() {
     if (mode === 'mp4' && isWebKitClient()) {
         showToast('This browser cannot play the MP4 stream. Use Auto or HLS.');
     } else if (mode === 'hls' && !hlsPlaybackSupported(playerVideoElement())) {
-        showToast('HLS needs hls.js, which is not installed on this server.');
+        showToast('This browser has no HLS support (needs Media Source Extensions).');
     }
 }
 

+ 77 - 30
src/web/Movie/index.html

@@ -14,6 +14,9 @@
     <script src="../script/ao_module.js"></script>
 
     <!-- App path config (single source of truth for all API paths) -->
+    <!-- HLS via Media Source, so browsers without native HLS (Firefox,
+         Chrome) can play the transcoded playlist without hls.js -->
+    <script src="../script/hlsmse.js"></script>
     <script src="backend/common.js"></script>
 
     <!-- ASS/SSA subtitle parser + renderer (see script/ass.js) -->
@@ -2564,7 +2567,7 @@ function transcodeSeekTo(seconds) {
     transcodeSeekOffset = pos;
     if (!attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, pos))) {
         hideSeekFreeze();
-        showToast('HLS playback needs hls.js, which is not installed. Switch streaming mode to MP4 in settings.');
+        showToast('This browser cannot play the HLS stream. Switch streaming mode to MP4 in settings.');
         return;
     }
     playVideo(vid);
@@ -3408,38 +3411,41 @@ function startPlayback(index) {
     var ep = currentEpisodes[index];
 
     var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
-    var webPlayable = isWebPlayable(ext);
-    var directSrc = MEDIA_API + '?file=' + encodeURIComponent(ep.filepath);
 
     // Reset transcode seek state for the new episode
     transcodeSeekOffset = 0;
-    isTranscodedVideo = !webPlayable;
     transcodeDuration = 0;
 
     var vid = document.getElementById('main-video');
 
-    if (castMode && _castConnected()) {
-        // Don't load locally — avoids transcoding the same file twice.
-        // The receiver is a separate client, so it always gets the MP4 stream
-        // rather than this browser's streaming-mode preference.
-        vid.pause();
-        _castSend('media.load', {
-            filepath: ep.filepath, name: ep.name, type: 'video',
-            src: webPlayable ? directSrc : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath),
-            startTime: 0
-        });
-        // Volume after load, before play — guarantees device volume overrides
-        // whatever Arozcast was previously playing at.
-        _castSend('media.volume', { volume: vid.volume * 100, muted: vid.muted });
-        _castSend('media.play', {});
-    } else if (webPlayable) {
-        vid.src = directSrc;
-        playVideo(vid);
-    } else if (!attachTranscodeStream(vid, transcodeStreamURL(ep.filepath, 0))) {
-        showToast('HLS playback needs hls.js, which is not installed. Switch streaming mode to MP4 in settings.');
-    } else {
-        playVideo(vid);
-    }
+    // The extension only says the container might be playable; the codec inside
+    // decides. The cast receiver is a separate client but faces the same codecs,
+    // so it is routed through the same verdict.
+    resolveDirectPlay(ep.filepath, ext, function (directPlay) {
+        if (playingIndex !== index) { return; }   // episode changed while probing
+
+        if (castMode && _castConnected()) {
+            // Don't load locally — avoids transcoding the same file twice.
+            // The receiver always gets the MP4 stream rather than this browser's
+            // streaming-mode preference.
+            isTranscodedVideo = !directPlay;
+            vid.pause();
+            _castSend('media.load', {
+                filepath: ep.filepath, name: ep.name, type: 'video',
+                src: directPlay ? MEDIA_API + '?file=' + encodeURIComponent(ep.filepath)
+                                : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath),
+                startTime: 0
+            });
+            // Volume after load, before play — guarantees device volume overrides
+            // whatever Arozcast was previously playing at.
+            _castSend('media.volume', { volume: vid.volume * 100, muted: vid.muted });
+            _castSend('media.play', {});
+            armResumePrompt(ep, index);
+            return;
+        }
+
+        beginEpisodeStream(ep, index, directPlay);
+    });
 
     $('#now-playing-title, #topbar-title').text(ep.name);
     ao_module_setWindowTitle('Movie – ' + ep.name);
@@ -3463,9 +3469,46 @@ function startPlayback(index) {
 
     showView('player');
     showControls();
+}
 
-    // For non-transcoded video: offer to resume via the native loadedmetadata event.
-    // For transcoded video: wait for the duration fetch, then check for a saved position.
+// Bind the video element to the right stream and arm the resume prompt.
+//
+// Split out of startPlayback because the direct-play decision depends on a
+// codec probe and so arrives asynchronously; it is also the retry target when
+// direct playback turns out to fail anyway.
+function beginEpisodeStream(ep, index, directPlay) {
+    var vid = document.getElementById('main-video');
+    clearDirectPlaybackWatch(vid);
+    detachTranscodeStream(vid);
+
+    isTranscodedVideo   = !directPlay;
+    transcodeSeekOffset = 0;
+    transcodeDuration   = 0;
+
+    if (directPlay) {
+        vid.src = MEDIA_API + '?file=' + encodeURIComponent(ep.filepath);
+        // Last resort: a browser without the platform decoder the probe assumed
+        onDirectPlaybackFailure(vid, function () {
+            if (playingIndex !== index) { return; }
+            showToast('This browser cannot decode that file — switching to a transcode');
+            beginEpisodeStream(ep, index, false);
+        });
+        playVideo(vid);
+    } else if (!attachTranscodeStream(vid, transcodeStreamURL(ep.filepath, 0),
+                                      function (reason) { showToast(reason); })) {
+        showToast('This browser cannot play the transcoded stream. Switch streaming mode to MP4 in settings.');
+        return;
+    } else {
+        playVideo(vid);
+    }
+
+    armResumePrompt(ep, index);
+}
+
+// Offer to resume where the viewer left off. Direct playback learns its length
+// from the element; a transcode has to ask the server for it.
+function armResumePrompt(ep, index) {
+    var vid = document.getElementById('main-video');
     if (!isTranscodedVideo) {
         (function (epFilepath) {
             $(vid).off('loadedmetadata.resume').one('loadedmetadata.resume', function () {
@@ -3548,6 +3591,10 @@ function closePlayer() {
     if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
     var vid = document.getElementById('main-video');
     vid.pause();
+    // Release the HLS attachment before clearing the source, or it keeps
+    // fetching segments into an element nobody is watching.
+    clearDirectPlaybackWatch(vid);
+    detachTranscodeStream(vid);
     vid.removeAttribute('src');
     $('#resume-popup').removeClass('active');
     var returnTo = (currentAlbum && (currentAlbum.type === 'series' || currentAlbum.type === 'anime' || currentAlbum.type === 'collection'))
@@ -3951,7 +3998,7 @@ function applyStreamMode(mode, reloadCurrent) {
     transcodeSeekOffset = 0;
     var vid = document.getElementById('main-video');
     if (!attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, 0))) {
-        showToast('HLS playback needs hls.js, which is not installed.');
+        showToast('This browser cannot play the HLS stream.');
         return;
     }
     playVideo(vid);
@@ -3967,7 +4014,7 @@ function warnIfStreamModeUnplayable() {
     if (mode === 'mp4' && isWebKitClient()) {
         showToast('This browser cannot play the MP4 stream. Use Auto or HLS.');
     } else if (mode === 'hls' && !hlsPlaybackSupported(playerVideoElement())) {
-        showToast('HLS needs hls.js, which is not installed on this server.');
+        showToast('This browser has no HLS support (needs Media Source Extensions).');
     }
 }
 

+ 377 - 0
src/web/script/hlsmse.js

@@ -0,0 +1,377 @@
+/*
+    hlsmse.js — a small HLS player built on Media Source Extensions
+
+    Safari plays HLS natively; nothing else does. The usual answer is hls.js,
+    but that is a large third-party bundle to vendor and keep updated. This
+    module covers the one case ArozOS actually serves: a single-variant,
+    server-generated playlist of fragmented-MP4 segments.
+
+    fMP4 is what makes this short. Segments can be appended straight into a
+    SourceBuffer, so there is no transport-stream demuxer here — the browser's
+    own MP4 parser does that work. (An MPEG-TS playlist would need thousands of
+    lines of demuxing, which is exactly why the server emits fMP4.)
+
+    Supported
+      • EVENT and VOD media playlists, including ones still being written
+      • #EXT-X-MAP initialisation segments
+      • Seeking anywhere inside the playlist, and buffer trimming behind
+      • Codec detection read from the init segment, so the SourceBuffer is
+        created with the stream's real profile rather than a guess
+
+    Not supported (by design — the server never produces them)
+      • Master playlists / multiple variants / bitrate switching
+      • MPEG-TS segments, encryption, discontinuities, subtitle renditions
+*/
+(function (global) {
+'use strict';
+
+// How far ahead of the playhead to keep buffered before pausing downloads.
+var BUFFER_AHEAD_SECONDS = 30;
+// How much already-played media to keep before trimming it out of the buffer.
+var BUFFER_BEHIND_SECONDS = 30;
+// 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
+// profile digits differ.
+var FALLBACK_CODECS = 'avc1.640029,mp4a.40.2';
+
+function isSupported() {
+    if (!global.MediaSource || !global.MediaSource.isTypeSupported) { return false; }
+    return global.MediaSource.isTypeSupported('video/mp4; codecs="' + FALLBACK_CODECS + '"');
+}
+
+// ─── Playlist ─────────────────────────────────────────────────────────────────
+
+// Resolve a possibly-relative playlist URI against the playlist's own location.
+function resolveURI(uri, playlistURL) {
+    try { return new URL(uri, new URL(playlistURL, global.location.href)).href; }
+    catch (e) { return uri; }
+}
+
+function parsePlaylist(text, playlistURL) {
+    var out = { segments: [], initURL: null, ended: false, targetDuration: 4 };
+    var lines = String(text).replace(/\r/g, '').split('\n');
+    var pendingDuration = 0;
+
+    for (var i = 0; i < lines.length; i++) {
+        var line = lines[i].trim();
+        if (!line) { continue; }
+
+        if (line.charAt(0) === '#') {
+            if (line.indexOf('#EXTINF:') === 0) {
+                pendingDuration = parseFloat(line.slice(8)) || 0;
+            } else if (line.indexOf('#EXT-X-MAP:') === 0) {
+                var m = line.match(/URI="([^"]*)"/);
+                if (m) { out.initURL = resolveURI(m[1], playlistURL); }
+            } else if (line.indexOf('#EXT-X-TARGETDURATION:') === 0) {
+                out.targetDuration = parseFloat(line.slice(22)) || 4;
+            } else if (line.indexOf('#EXT-X-ENDLIST') === 0) {
+                out.ended = true;
+            }
+            continue;
+        }
+
+        out.segments.push({
+            url: resolveURI(line, playlistURL),
+            duration: pendingDuration,
+            start: 0   // filled in below
+        });
+        pendingDuration = 0;
+    }
+
+    var clock = 0;
+    for (var s = 0; s < out.segments.length; s++) {
+        out.segments[s].start = clock;
+        clock += out.segments[s].duration;
+    }
+    out.duration = clock;
+    return out;
+}
+
+// ─── Codec detection ──────────────────────────────────────────────────────────
+
+// Walk the init segment's box tree for the sample entries, so the SourceBuffer
+// is created with the codecs actually present rather than an assumption.
+function codecsFromInitSegment(buffer) {
+    var view = new DataView(buffer);
+    var bytes = new Uint8Array(buffer);
+    var video = null;
+    var audio = null;
+
+    function fourcc(offset) {
+        return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
+    }
+
+    // Containers whose payload is simply more boxes
+    var containers = { moov: 8, trak: 8, mdia: 8, minf: 8, stbl: 8, stsd: 16 };
+
+    function walk(start, end) {
+        var offset = start;
+        while (offset + 8 <= end) {
+            var size = view.getUint32(offset);
+            var type = fourcc(offset + 4);
+            if (size < 8 || offset + size > end) { return; }
+
+            if (containers[type] !== undefined) {
+                walk(offset + containers[type], offset + size);
+            } else if (type === 'avc1' || type === 'avc3') {
+                video = readAvcC(offset + 8 + 78, offset + size) || 'avc1.640029';
+            } else if (type === 'hvc1' || type === 'hev1') {
+                video = type + '.1.6.L93.B0';   // rare here; the server encodes H.264
+            } else if (type === 'mp4a') {
+                audio = 'mp4a.40.2';
+            }
+            offset += size;
+        }
+    }
+
+    // avcC carries profile / constraint flags / level, which form the codec string
+    function readAvcC(start, end) {
+        var offset = start;
+        while (offset + 8 <= end) {
+            var size = view.getUint32(offset);
+            var type = fourcc(offset + 4);
+            if (size < 8 || offset + size > end) { return null; }
+            if (type === 'avcC') {
+                var profile = bytes[offset + 9];
+                var compat = bytes[offset + 10];
+                var level = bytes[offset + 11];
+                return 'avc1.' + hex2(profile) + hex2(compat) + hex2(level);
+            }
+            offset += size;
+        }
+        return null;
+    }
+
+    function hex2(n) { return (n < 16 ? '0' : '') + n.toString(16); }
+
+    try { walk(0, bytes.length); } catch (e) { return null; }
+
+    var parts = [];
+    if (video) { parts.push(video); }
+    if (audio) { parts.push(audio); }
+    return parts.length ? parts.join(',') : null;
+}
+
+// ─── Player ───────────────────────────────────────────────────────────────────
+
+function Player(videoEl, playlistURL, options) {
+    this.video = videoEl;
+    this.playlistURL = playlistURL;
+    this.options = options || {};
+    this.destroyed = false;
+
+    this.mediaSource = null;
+    this.sourceBuffer = null;
+    this.playlist = null;
+    this.initBuffer = null;
+    this.nextIndex = 0;
+    this.appending = false;
+    this.refreshTimer = null;
+    this.objectURL = null;
+
+    this._onSourceOpen = this._onSourceOpen.bind(this);
+    this._pump = this._pump.bind(this);
+    this._onSeeking = this._onSeeking.bind(this);
+
+    this.mediaSource = new global.MediaSource();
+    this.objectURL = URL.createObjectURL(this.mediaSource);
+    this.mediaSource.addEventListener('sourceopen', this._onSourceOpen);
+    this.video.addEventListener('timeupdate', this._pump);
+    this.video.addEventListener('seeking', this._onSeeking);
+    this.video.src = this.objectURL;
+}
+
+Player.prototype._fail = function (reason, err) {
+    if (this.destroyed) { return; }
+    if (typeof this.options.onError === 'function') { this.options.onError(reason, err); }
+};
+
+Player.prototype._onSourceOpen = function () {
+    var self = this;
+    if (this.destroyed) { return; }
+
+    this._loadPlaylist().then(function () {
+        if (self.destroyed || !self.playlist) { return; }
+        if (!self.playlist.initURL) {
+            throw new Error('playlist has no initialisation segment');
+        }
+        return self._fetch(self.playlist.initURL).then(function (buffer) {
+            if (self.destroyed) { return; }
+            self.initBuffer = buffer;
+
+            var codecs = codecsFromInitSegment(buffer) || FALLBACK_CODECS;
+            var mime = 'video/mp4; codecs="' + codecs + '"';
+            if (!global.MediaSource.isTypeSupported(mime)) {
+                mime = 'video/mp4; codecs="' + FALLBACK_CODECS + '"';
+            }
+
+            self.sourceBuffer = self.mediaSource.addSourceBuffer(mime);
+            self.sourceBuffer.addEventListener('updateend', self._pump);
+            self.sourceBuffer.addEventListener('error', function () {
+                self._fail('The browser rejected a media segment');
+            });
+            self._append(buffer);
+        });
+    }).catch(function (err) {
+        self._fail('Could not start the HLS stream', err);
+    });
+};
+
+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._loadPlaylist = function () {
+    var self = this;
+    return fetch(this.playlistURL, { credentials: 'same-origin', cache: 'no-store' })
+        .then(function (response) {
+            if (!response.ok) { throw new Error('HTTP ' + response.status + ' for the playlist'); }
+            return response.text();
+        })
+        .then(function (text) {
+            if (self.destroyed) { return; }
+            self.playlist = parsePlaylist(text, self.playlistURL);
+            self._scheduleRefresh();
+        });
+};
+
+// A playlist still being written grows as the transcode advances, so keep
+// re-reading it until the server marks it complete.
+Player.prototype._scheduleRefresh = function () {
+    var self = this;
+    clearTimeout(this.refreshTimer);
+    if (this.destroyed || !this.playlist || this.playlist.ended) { return; }
+
+    var wait = Math.max(1000, (this.playlist.targetDuration || 4) * 500);
+    this.refreshTimer = setTimeout(function () {
+        if (self.destroyed) { return; }
+        self._loadPlaylist().then(function () { self._pump(); })
+            .catch(function () { self._scheduleRefresh(); });
+    }, wait);
+};
+
+Player.prototype._bufferedAhead = function () {
+    var buffered = this.video.buffered;
+    var time = this.video.currentTime;
+    for (var i = 0; i < buffered.length; i++) {
+        if (buffered.start(i) <= time + 0.25 && time < buffered.end(i)) {
+            return buffered.end(i) - time;
+        }
+    }
+    return 0;
+};
+
+Player.prototype._segmentIndexForTime = function (time) {
+    var segments = this.playlist ? this.playlist.segments : [];
+    for (var i = 0; i < segments.length; i++) {
+        if (time < segments[i].start + segments[i].duration) { return i; }
+    }
+    return segments.length;
+};
+
+Player.prototype._onSeeking = function () {
+    if (this.destroyed || !this.playlist || !this.sourceBuffer) { return; }
+    var target = this.video.currentTime;
+
+    // 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; }
+    }
+
+    // Otherwise restart the append cursor at the segment covering the target.
+    this.nextIndex = this._segmentIndexForTime(target);
+    this._pump();
+};
+
+// 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.
+Player.prototype._pump = function () {
+    var self = this;
+    if (this.destroyed || !this.sourceBuffer || this.sourceBuffer.updating || this.appending) { return; }
+    if (!this.playlist) { return; }
+
+    this._trimBehind();
+
+    if (this.nextIndex >= this.playlist.segments.length) {
+        if (this.playlist.ended && this.mediaSource.readyState === 'open') {
+            try { this.mediaSource.endOfStream(); } catch (e) {}
+        }
+        return;
+    }
+    if (this._bufferedAhead() > BUFFER_AHEAD_SECONDS) { return; }
+
+    var segment = this.playlist.segments[this.nextIndex];
+    this.appending = true;
+    this._fetch(segment.url).then(function (buffer) {
+        self.appending = false;
+        if (self.destroyed || !self.sourceBuffer) { return; }
+        self.nextIndex++;
+        self._append(buffer);
+    }).catch(function (err) {
+        self.appending = false;
+        self._fail('A media segment failed to load', err);
+    });
+};
+
+Player.prototype._append = function (buffer) {
+    if (this.destroyed || !this.sourceBuffer) { return; }
+    try {
+        this.sourceBuffer.appendBuffer(new Uint8Array(buffer));
+    } 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) {}
+        }
+        this._fail('The browser rejected a media segment', err);
+    }
+};
+
+Player.prototype._trimBehind = function (aggressive) {
+    if (!this.sourceBuffer || this.sourceBuffer.updating) { return; }
+    var keep = aggressive ? 5 : BUFFER_BEHIND_SECONDS;
+    var cutoff = this.video.currentTime - keep;
+    if (cutoff <= 0) { return; }
+
+    var buffered = this.sourceBuffer.buffered;
+    if (!buffered.length) { return; }
+    if (buffered.start(0) < cutoff) {
+        try { this.sourceBuffer.remove(buffered.start(0), cutoff); } catch (e) {}
+    }
+};
+
+Player.prototype.destroy = function () {
+    this.destroyed = true;
+    clearTimeout(this.refreshTimer);
+    this.video.removeEventListener('timeupdate', this._pump);
+    this.video.removeEventListener('seeking', this._onSeeking);
+
+    if (this.sourceBuffer) {
+        try { this.sourceBuffer.abort(); } catch (e) {}
+        this.sourceBuffer = null;
+    }
+    if (this.mediaSource && this.mediaSource.readyState === 'open') {
+        try { this.mediaSource.endOfStream(); } catch (e) {}
+    }
+    if (this.objectURL) {
+        try { URL.revokeObjectURL(this.objectURL); } catch (e) {}
+        this.objectURL = null;
+    }
+    this.mediaSource = null;
+};
+
+global.MovieHLS = {
+    isSupported: isSupported,
+    attach: function (videoEl, playlistURL, options) {
+        return new Player(videoEl, playlistURL, options);
+    },
+    // exposed for tests
+    _parsePlaylist: parsePlaylist,
+    _codecsFromInitSegment: codecsFromInitSegment
+};
+
+})(window);