Browse Source

Fixed hls trancode on macos

Toby Chui 1 day ago
parent
commit
39fe0d355f

+ 43 - 11
src/mod/media/mediaserver/hls.go

@@ -52,6 +52,28 @@ func transcodeResolutionFromRequest(r *http.Request) transcoder.TranscodeOutputR
 	return transcoder.TranscodeResolution_original
 	return transcoder.TranscodeResolution_original
 }
 }
 
 
+// clientIDFromRequest reads the optional "client" parameter identifying the
+// player. It lets a seek retire the transcode it replaces instead of leaving it
+// running (see transcoder.HLSManager.GetOrCreate); an absent id is not an
+// error, only a less precise answer to "which transcode is this replacing".
+func clientIDFromRequest(r *http.Request) string {
+	clientID, err := utils.GetPara(r, "client")
+	if err != nil {
+		return ""
+	}
+	//Only ever compared for equality, so anything unexpected can simply be
+	//dropped rather than sanitised.
+	if len(clientID) > 64 {
+		return ""
+	}
+	for _, c := range clientID {
+		if !(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') && c != '-' && c != '_' {
+			return ""
+		}
+	}
+	return clientID
+}
+
 // startTimeFromRequest reads the optional "start" seek offset in seconds.
 // startTimeFromRequest reads the optional "start" seek offset in seconds.
 func startTimeFromRequest(r *http.Request) float64 {
 func startTimeFromRequest(r *http.Request) float64 {
 	startTimeStr, _ := utils.GetPara(r, "start")
 	startTimeStr, _ := utils.GetPara(r, "start")
@@ -102,14 +124,18 @@ func (s *Instance) ServeMediaProbe(w http.ResponseWriter, r *http.Request) {
 // ServeHLSPlaylist starts (or joins) an HLS transcode of the requested file and
 // ServeHLSPlaylist starts (or joins) an HLS transcode of the requested file and
 // returns its playlist once the first segment is ready.
 // returns its playlist once the first segment is ready.
 func (s *Instance) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request) {
 func (s *Instance) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request) {
+	//Errors here are reported with an HTTP status rather than a JSON body: the
+	//caller is a <video> element or an HLS player, and a 200 carrying JSON is
+	//indistinguishable to it from a playlist it cannot parse - which surfaces to
+	//the viewer as a bare "cannot play this format" with the real reason lost.
 	if s.hlsManager == nil {
 	if s.hlsManager == nil {
-		utils.SendErrorResponse(w, "HLS output is not available on this host")
+		http.Error(w, "HLS output is not available on this host", http.StatusNotImplemented)
 		return
 		return
 	}
 	}
 
 
 	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
 	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
 	if err != nil {
 	if err != nil {
-		utils.SendErrorResponse(w, "User not logged in")
+		http.Error(w, "User not logged in", http.StatusUnauthorized)
 		return
 		return
 	}
 	}
 
 
@@ -119,17 +145,22 @@ func (s *Instance) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	session, err := s.hlsManager.GetOrCreate(userinfo.Username, sourceFile,
+	session, err := s.hlsManager.GetOrCreate(userinfo.Username, clientIDFromRequest(r), sourceFile,
 		transcodeResolutionFromRequest(r), startTimeFromRequest(r))
 		transcodeResolutionFromRequest(r), startTimeFromRequest(r))
 	if err != nil {
 	if err != nil {
 		s.options.Logger.PrintAndLog("Media Server", "Unable to start HLS session", err)
 		s.options.Logger.PrintAndLog("Media Server", "Unable to start HLS session", err)
-		utils.SendErrorResponse(w, "Unable to start HLS transcode")
+		http.Error(w, "Unable to start HLS transcode", http.StatusInternalServerError)
 		return
 		return
 	}
 	}
 
 
 	if err := session.WaitForPlaylist(transcoder.HLSPlaylistWaitTimeout); err != nil {
 	if err := session.WaitForPlaylist(transcoder.HLSPlaylistWaitTimeout); err != nil {
+		//ffmpeg's own last words explain a failed transcode - a seek past the
+		//end of the file, an unreadable stream - and nothing else does.
+		if tail := session.StderrTail(); tail != "" {
+			s.options.Logger.PrintAndLog("Media Server", "HLS transcode output: "+tail, nil)
+		}
 		s.options.Logger.PrintAndLog("Media Server", "HLS session produced no playable segment", err)
 		s.options.Logger.PrintAndLog("Media Server", "HLS session produced no playable segment", err)
-		utils.SendErrorResponse(w, "Transcode did not produce a playable stream")
+		http.Error(w, "Transcode did not produce a playable stream", http.StatusServiceUnavailable)
 		return
 		return
 	}
 	}
 
 
@@ -138,7 +169,7 @@ func (s *Instance) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request) {
 	playlist, err := s.hlsManager.ReadPlaylist(session)
 	playlist, err := s.hlsManager.ReadPlaylist(session)
 	if err != nil {
 	if err != nil {
 		s.options.Logger.PrintAndLog("Media Server", "Unable to read HLS playlist", err)
 		s.options.Logger.PrintAndLog("Media Server", "Unable to read HLS playlist", err)
-		utils.SendErrorResponse(w, "Unable to read the transcode playlist")
+		http.Error(w, "Unable to read the transcode playlist", http.StatusInternalServerError)
 		return
 		return
 	}
 	}
 
 
@@ -204,12 +235,13 @@ func (s *Instance) ServeHLSSegment(w http.ResponseWriter, r *http.Request) {
 // an existing buffer when its hash still matches).
 // an existing buffer when its hash still matches).
 //
 //
 // It writes the error response itself and returns ok=false when the file cannot
 // It writes the error response itself and returns ok=false when the file cannot
-// be made available.
+// be made available. Like the endpoints it serves, it answers with an HTTP
+// status rather than a JSON body, since the caller is always a media element.
 func (s *Instance) resolveLocalTranscodeSource(w http.ResponseWriter, r *http.Request) (string, bool) {
 func (s *Instance) resolveLocalTranscodeSource(w http.ResponseWriter, r *http.Request) (string, bool) {
 	userinfo, _ := s.options.UserHandler.GetUserInfoFromRequest(w, r)
 	userinfo, _ := s.options.UserHandler.GetUserInfoFromRequest(w, r)
 	targetFsh, vpath, realFilepath, err := s.ValidateSourceFile(w, r)
 	targetFsh, vpath, realFilepath, err := s.ValidateSourceFile(w, r)
 	if err != nil {
 	if err != nil {
-		utils.SendErrorResponse(w, err.Error())
+		http.Error(w, err.Error(), http.StatusBadRequest)
 		return "", false
 		return "", false
 	}
 	}
 
 
@@ -217,7 +249,7 @@ func (s *Instance) resolveLocalTranscodeSource(w http.ResponseWriter, r *http.Re
 		//Already on the local file system
 		//Already on the local file system
 		absPath, err := filepath.Abs(realFilepath)
 		absPath, err := filepath.Abs(realFilepath)
 		if err != nil {
 		if err != nil {
-			utils.SendErrorResponse(w, err.Error())
+			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return "", false
 			return "", false
 		}
 		}
 		return absPath, true
 		return absPath, true
@@ -239,14 +271,14 @@ func (s *Instance) resolveLocalTranscodeSource(w http.ResponseWriter, r *http.Re
 	}
 	}
 
 
 	if !s.options.EnableFileBuffering {
 	if !s.options.EnableFileBuffering {
-		utils.SendErrorResponse(w, "unable to transcode remote file with file buffer disabled")
+		http.Error(w, "unable to transcode remote file with file buffer disabled", http.StatusNotImplemented)
 		return "", false
 		return "", false
 	}
 	}
 
 
 	os.MkdirAll(buffpool, 0775)
 	os.MkdirAll(buffpool, 0775)
 	s.options.Logger.PrintAndLog("Media Server", "Buffering video from remote file system handler (might take a while)", nil)
 	s.options.Logger.PrintAndLog("Media Server", "Buffering video from remote file system handler (might take a while)", nil)
 	if err := s.BufferRemoteFileToTmp(buffFile, targetFsh, realFilepath); err != nil {
 	if err := s.BufferRemoteFileToTmp(buffFile, targetFsh, realFilepath); err != nil {
-		utils.SendErrorResponse(w, err.Error())
+		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return "", false
 		return "", false
 	}
 	}
 
 

+ 106 - 3
src/mod/media/transcoder/hls.go

@@ -21,6 +21,13 @@ package transcoder
 	transcode proceeds and the player can seek freely within whatever has been
 	transcode proceeds and the player can seek freely within whatever has been
 	produced so far. Seeking past that point is done the same way the MP4 path
 	produced so far. Seeking past that point is done the same way the MP4 path
 	does it: by starting a new session at a later -ss offset.
 	does it: by starting a new session at a later -ss offset.
+
+	That last part is why a session carries the id of the player that asked for
+	it. The MP4 path kills its ffmpeg as soon as the client drops the response,
+	so a seek never leaves a transcode behind; an HLS session has no such
+	signal, and without one every seek would add another ffmpeg racing through
+	the rest of the film. A new session therefore supersedes the ones the same
+	player left behind (see GetOrCreate).
 */
 */
 
 
 import (
 import (
@@ -56,8 +63,11 @@ const (
 	// serving playlists has to rewrite that URI onto the segment endpoint.
 	// serving playlists has to rewrite that URI onto the segment endpoint.
 	HLSInitSegmentName = "init.mp4"
 	HLSInitSegmentName = "init.mp4"
 
 
-	hlsWorkingDirName  = "hls"
-	hlsIdleTimeout     = 5 * time.Minute
+	hlsWorkingDirName = "hls"
+	// hlsIdleTimeout has to outlast a pause: a paused player stops fetching
+	// segments entirely, and reaping its session under it means the next
+	// segment request 404s and playback dies with an unexplained decode error.
+	hlsIdleTimeout     = 15 * time.Minute
 	hlsMaxSessions     = 8
 	hlsMaxSessions     = 8
 	hlsJanitorInterval = 30 * time.Second
 	hlsJanitorInterval = 30 * time.Second
 
 
@@ -71,10 +81,13 @@ const (
 type HLSSession struct {
 type HLSSession struct {
 	ID        string  // opaque identifier, also the temp directory name
 	ID        string  // opaque identifier, also the temp directory name
 	Owner     string  // username allowed to fetch this session's segments
 	Owner     string  // username allowed to fetch this session's segments
+	Client    string  // opaque id of the player that asked for this transcode
+	Source    string  // source file being transcoded
 	Dir       string  // directory holding the playlist and its segments
 	Dir       string  // directory holding the playlist and its segments
 	StartTime float64 // -ss offset this session was started at, in seconds
 	StartTime float64 // -ss offset this session was started at, in seconds
 
 
 	cmd    *exec.Cmd
 	cmd    *exec.Cmd
+	stderr *tailBuffer   // last few KB of ffmpeg's diagnostics
 	exited chan struct{} // closed once the transcode process has been reaped
 	exited chan struct{} // closed once the transcode process has been reaped
 
 
 	mu         sync.Mutex
 	mu         sync.Mutex
@@ -82,6 +95,45 @@ type HLSSession struct {
 	stopped    bool
 	stopped    bool
 }
 }
 
 
+// tailBuffer keeps the last hlsStderrTailBytes written to it. ffmpeg reports
+// why a transcode produced nothing on stderr, and that is the only explanation
+// available when a session fails; the whole stream is not worth keeping, but
+// the end of it names the reason.
+type tailBuffer struct {
+	mu   sync.Mutex
+	data []byte
+}
+
+// hlsStderrTailBytes bounds how much of ffmpeg's stderr is retained per
+// session. Enough for the final error plus the surrounding context, small
+// enough that eight idle sessions cost nothing worth measuring.
+const hlsStderrTailBytes = 4096
+
+func (t *tailBuffer) Write(p []byte) (int, error) {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	t.data = append(t.data, p...)
+	if len(t.data) > hlsStderrTailBytes {
+		t.data = t.data[len(t.data)-hlsStderrTailBytes:]
+	}
+	return len(p), nil
+}
+
+func (t *tailBuffer) String() string {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+	return string(t.data)
+}
+
+// StderrTail returns the end of the transcode's diagnostic output, for logging
+// when the session fails to produce anything playable.
+func (s *HLSSession) StderrTail() string {
+	if s.stderr == nil {
+		return ""
+	}
+	return strings.TrimSpace(s.stderr.String())
+}
+
 // touch records activity so the janitor does not reap a session that is still
 // touch records activity so the janitor does not reap a session that is still
 // being played.
 // being played.
 func (s *HLSSession) touch() {
 func (s *HLSSession) touch() {
@@ -367,7 +419,13 @@ func (m *HLSManager) Session(id string) *HLSSession {
 
 
 // GetOrCreate returns the session for this exact transcode, starting one if it
 // GetOrCreate returns the session for this exact transcode, starting one if it
 // is not already running.
 // is not already running.
-func (m *HLSManager) GetOrCreate(owner string, inputFile string, resolution TranscodeOutputResolution, startTime float64) (*HLSSession, error) {
+//
+// client identifies the player asking, so that a seek - which arrives as a
+// request for the same file at a different offset - retires the transcode it
+// replaces instead of leaving it running. Pass an empty string when there is no
+// such id; the previous transcode of the same file for the same user is then
+// treated as the one being replaced.
+func (m *HLSManager) GetOrCreate(owner string, client string, inputFile string, resolution TranscodeOutputResolution, startTime float64) (*HLSSession, error) {
 	key := hlsSessionKey(owner, inputFile, resolution, startTime)
 	key := hlsSessionKey(owner, inputFile, resolution, startTime)
 
 
 	m.mu.Lock()
 	m.mu.Lock()
@@ -388,8 +446,11 @@ func (m *HLSManager) GetOrCreate(owner string, inputFile string, resolution Tran
 	session := &HLSSession{
 	session := &HLSSession{
 		ID:         key,
 		ID:         key,
 		Owner:      owner,
 		Owner:      owner,
+		Client:     client,
+		Source:     inputFile,
 		Dir:        filepath.Join(m.root, key),
 		Dir:        filepath.Join(m.root, key),
 		StartTime:  startTime,
 		StartTime:  startTime,
+		stderr:     &tailBuffer{},
 		lastAccess: time.Now(),
 		lastAccess: time.Now(),
 	}
 	}
 	if err := os.MkdirAll(session.Dir, 0755); err != nil {
 	if err := os.MkdirAll(session.Dir, 0755); err != nil {
@@ -404,6 +465,9 @@ func (m *HLSManager) GetOrCreate(owner string, inputFile string, resolution Tran
 	}
 	}
 
 
 	cmd := exec.Command("ffmpeg", args...)
 	cmd := exec.Command("ffmpeg", args...)
+	//Kept rather than discarded: when a transcode produces no playable segment,
+	//ffmpeg's own message is the only thing that says why.
+	cmd.Stderr = session.stderr
 	if err := cmd.Start(); err != nil {
 	if err := cmd.Start(); err != nil {
 		os.RemoveAll(session.Dir)
 		os.RemoveAll(session.Dir)
 		return nil, err
 		return nil, err
@@ -427,10 +491,49 @@ func (m *HLSManager) GetOrCreate(owner string, inputFile string, resolution Tran
 		return existing, nil
 		return existing, nil
 	}
 	}
 	m.sessions[key] = session
 	m.sessions[key] = session
+	superseded := m.collectSuperseded(key, session)
 	m.mu.Unlock()
 	m.mu.Unlock()
+
+	for _, old := range superseded {
+		old.stop()
+		logger.PrintAndLog("Transcoder", "Retired superseded HLS session "+old.ID, nil)
+	}
 	return session, nil
 	return session, nil
 }
 }
 
 
+// collectSuperseded removes and returns the sessions the newly created one
+// replaces: the same player's earlier transcodes, or - when the player did not
+// identify itself - the same user's earlier transcodes of the same file.
+//
+// Must be called with m.mu held; the returned sessions are stopped by the
+// caller once the lock is released, since stopping waits on a process.
+func (m *HLSManager) collectSuperseded(newKey string, session *HLSSession) []*HLSSession {
+	var superseded []*HLSSession
+	for key, candidate := range m.sessions {
+		if key == newKey {
+			continue
+		}
+		if candidate.Owner != session.Owner {
+			continue
+		}
+		if session.Client != "" {
+			//A player only ever plays one thing at a time, so anything else it
+			//started - another offset, or the previous episode - is finished with.
+			if candidate.Client != session.Client {
+				continue
+			}
+		} else if candidate.Client != "" || candidate.Source != session.Source {
+			//Without an id the most that can be assumed is that the same user
+			//re-opened the same file. Never retire a session that does belong to
+			//an identified player.
+			continue
+		}
+		superseded = append(superseded, candidate)
+		delete(m.sessions, key)
+	}
+	return superseded
+}
+
 // evictToCapacity stops the least recently used sessions until there is room
 // evictToCapacity stops the least recently used sessions until there is room
 // for one more.
 // for one more.
 func (m *HLSManager) evictToCapacity() {
 func (m *HLSManager) evictToCapacity() {

+ 234 - 0
src/mod/media/transcoder/hls_test.go

@@ -2,9 +2,11 @@ package transcoder
 
 
 import (
 import (
 	"os"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
+	"time"
 )
 )
 
 
 // Source-file fixtures. These are never opened - the functions under test only
 // Source-file fixtures. These are never opened - the functions under test only
@@ -375,3 +377,235 @@ func TestSegmentBaseURL(t *testing.T) {
 		t.Errorf("segmentBaseURL = %q, want %q", got, want)
 		t.Errorf("segmentBaseURL = %q, want %q", got, want)
 	}
 	}
 }
 }
+
+// TestTailBufferKeepsTail verifies the stderr capture keeps the end of the
+// stream and stays bounded, since ffmpeg's final message is the only
+// explanation available when a transcode produces nothing.
+func TestTailBufferKeepsTail(t *testing.T) {
+	tests := []struct {
+		name   string
+		writes []string
+		want   string
+	}{
+		{name: "empty", writes: nil, want: ""},
+		{name: "single write", writes: []string{"boom"}, want: "boom"},
+		{name: "appends in order", writes: []string{"a", "b", "c"}, want: "abc"},
+		{
+			name:   "keeps only the tail",
+			writes: []string{strings.Repeat("x", hlsStderrTailBytes), "tail"},
+			want:   strings.Repeat("x", hlsStderrTailBytes-4) + "tail",
+		},
+		{
+			name:   "single oversized write is trimmed",
+			writes: []string{strings.Repeat("y", hlsStderrTailBytes+10)},
+			want:   strings.Repeat("y", hlsStderrTailBytes),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			buf := &tailBuffer{}
+			for _, chunk := range tt.writes {
+				n, err := buf.Write([]byte(chunk))
+				if err != nil {
+					t.Fatalf("Write returned error: %v", err)
+				}
+				if n != len(chunk) {
+					t.Errorf("Write returned n = %d, want %d", n, len(chunk))
+				}
+			}
+			if got := buf.String(); got != tt.want {
+				t.Errorf("String() length %d, want %d (content mismatch)", len(got), len(tt.want))
+			}
+		})
+	}
+}
+
+// TestStderrTailWithoutBuffer verifies a session carrying no capture buffer
+// reports no diagnostics rather than panicking.
+func TestStderrTailWithoutBuffer(t *testing.T) {
+	session := &HLSSession{}
+	if got := session.StderrTail(); got != "" {
+		t.Errorf("StderrTail() = %q, want empty", got)
+	}
+
+	session.stderr = &tailBuffer{}
+	session.stderr.Write([]byte("  ffmpeg said this  \n"))
+	if got := session.StderrTail(); got != "ffmpeg said this" {
+		t.Errorf("StderrTail() = %q, want trimmed message", got)
+	}
+}
+
+// TestCollectSuperseded verifies which running transcodes a newly created
+// session retires. A seek arrives as a request for the same file at another
+// offset, and without this the transcode it replaces would keep running.
+func TestCollectSuperseded(t *testing.T) {
+	newSession := func(id, owner, client, source string) *HLSSession {
+		return &HLSSession{ID: id, Owner: owner, Client: client, Source: source}
+	}
+
+	tests := []struct {
+		name     string
+		existing map[string]*HLSSession
+		created  *HLSSession
+		want     []string
+	}{
+		{
+			name:     "same player seeking retires its earlier offset",
+			existing: map[string]*HLSSession{"old": newSession("old", "alice", "tab1", srcA)},
+			created:  newSession("new", "alice", "tab1", srcA),
+			want:     []string{"old"},
+		},
+		{
+			name:     "same player switching file retires the previous one",
+			existing: map[string]*HLSSession{"old": newSession("old", "alice", "tab1", srcA)},
+			created:  newSession("new", "alice", "tab1", srcB),
+			want:     []string{"old"},
+		},
+		{
+			name:     "another tab of the same user is left alone",
+			existing: map[string]*HLSSession{"old": newSession("old", "alice", "tab2", srcA)},
+			created:  newSession("new", "alice", "tab1", srcA),
+			want:     nil,
+		},
+		{
+			name:     "another user is left alone",
+			existing: map[string]*HLSSession{"old": newSession("old", "bob", "tab1", srcA)},
+			created:  newSession("new", "alice", "tab1", srcA),
+			want:     nil,
+		},
+		{
+			name:     "unidentified player retires its own file only",
+			existing: map[string]*HLSSession{"old": newSession("old", "alice", "", srcA)},
+			created:  newSession("new", "alice", "", srcA),
+			want:     []string{"old"},
+		},
+		{
+			name:     "unidentified player leaves another file alone",
+			existing: map[string]*HLSSession{"old": newSession("old", "alice", "", srcB)},
+			created:  newSession("new", "alice", "", srcA),
+			want:     nil,
+		},
+		{
+			name:     "unidentified player never retires an identified one",
+			existing: map[string]*HLSSession{"old": newSession("old", "alice", "tab1", srcA)},
+			created:  newSession("new", "alice", "", srcA),
+			want:     nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			m := &HLSManager{sessions: map[string]*HLSSession{}}
+			for key, session := range tt.existing {
+				m.sessions[key] = session
+			}
+			m.sessions["new"] = tt.created
+
+			got := m.collectSuperseded("new", tt.created)
+			var gotIDs []string
+			for _, session := range got {
+				gotIDs = append(gotIDs, session.ID)
+			}
+			if strings.Join(gotIDs, ",") != strings.Join(tt.want, ",") {
+				t.Errorf("collectSuperseded returned %v, want %v", gotIDs, tt.want)
+			}
+			if _, stillThere := m.sessions["new"]; !stillThere {
+				t.Error("collectSuperseded removed the session that was just created")
+			}
+			for _, id := range gotIDs {
+				if _, stillThere := m.sessions[id]; stillThere {
+					t.Errorf("superseded session %q was returned but not removed from the map", id)
+				}
+			}
+			if len(m.sessions) != len(tt.existing)+1-len(tt.want) {
+				t.Errorf("session map holds %d entries after superseding, want %d",
+					len(m.sessions), len(tt.existing)+1-len(tt.want))
+			}
+		})
+	}
+}
+
+// TestGetOrCreateSupersedesOnSeek runs the real thing: a session is started,
+// then the same player asks for a later offset the way a seek does. The first
+// transcode must be gone by the time the second playlist is ready, since two
+// ffmpeg processes racing through the same film is what starves the host and
+// leaves the player waiting on a segment that never arrives.
+//
+// Skipped where ffmpeg is not installed, which is also where the HLS endpoints
+// are not registered at all.
+func TestGetOrCreateSupersedesOnSeek(t *testing.T) {
+	if _, err := exec.LookPath("ffmpeg"); err != nil {
+		t.Skip("ffmpeg not installed on this host")
+	}
+
+	tmp := t.TempDir()
+	source := filepath.Join(tmp, "source.mp4")
+	generateTestVideo(t, source)
+
+	m, err := NewHLSManager(tmp, "/media/hls/segment")
+	if err != nil {
+		t.Fatalf("NewHLSManager returned error: %v", err)
+	}
+	defer m.Close()
+
+	first, err := m.GetOrCreate("alice", "tab1", source, TranscodeResolution_original, 0)
+	if err != nil {
+		t.Fatalf("starting the first session: %v", err)
+	}
+	if err := first.WaitForPlaylist(HLSPlaylistWaitTimeout); err != nil {
+		t.Fatalf("first session produced no segment: %v (ffmpeg: %s)", err, first.StderrTail())
+	}
+
+	//The seek: same player, same file, later offset
+	second, err := m.GetOrCreate("alice", "tab1", source, TranscodeResolution_original, 6)
+	if err != nil {
+		t.Fatalf("starting the session for the seek: %v", err)
+	}
+	if second.ID == first.ID {
+		t.Fatal("a seek to another offset reused the session it should have replaced")
+	}
+	if err := second.WaitForPlaylist(HLSPlaylistWaitTimeout); err != nil {
+		t.Fatalf("seek session produced no segment: %v (ffmpeg: %s)", err, second.StderrTail())
+	}
+
+	if got := m.Session(first.ID); got != nil {
+		t.Error("the superseded session is still being served")
+	}
+	select {
+	case <-first.exited:
+	case <-time.After(10 * time.Second):
+		t.Error("the superseded transcode is still running")
+	}
+	if _, err := os.Stat(first.Dir); !os.IsNotExist(err) {
+		t.Errorf("the superseded session's directory survived (err=%v)", err)
+	}
+
+	//The replacement has to be intact and still serving
+	if m.Session(second.ID) == nil {
+		t.Fatal("the session started by the seek is not being served")
+	}
+	playlist, err := m.ReadPlaylist(second)
+	if err != nil {
+		t.Fatalf("reading the playlist of the seek session: %v", err)
+	}
+	if !strings.Contains(string(playlist), m.segmentBaseURL(second.ID)+HLSInitSegmentName) {
+		t.Error("the playlist does not point its init segment at the segment endpoint")
+	}
+	if !strings.Contains(string(playlist), hlsSegmentSuffix) {
+		t.Error("the playlist lists no media segment")
+	}
+}
+
+// generateTestVideo writes a short, deterministic clip with ffmpeg for the
+// tests that need a real file to transcode.
+func generateTestVideo(t *testing.T, path string) {
+	t.Helper()
+	cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
+		"-f", "lavfi", "-i", "testsrc=size=192x108:rate=15:duration=12",
+		"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
+		"-an", path)
+	if output, err := cmd.CombinedOutput(); err != nil {
+		t.Skipf("could not generate a test clip with ffmpeg: %v (%s)", err, string(output))
+	}
+}

+ 98 - 15
src/web/Movie/backend/common.js

@@ -47,6 +47,17 @@ var SCRIPT_CLEAR_INDEX        = BACKEND_PATH + "clearIndex.js";
 // browsers that were already working.
 // browsers that were already working.
 var STREAM_MODE_KEY = "movie_stream_mode";
 var STREAM_MODE_KEY = "movie_stream_mode";
 
 
+// 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
+// way to tell that the previous one is finished with: the MP4 stream dies with
+// its HTTP response, an HLS session does not. Without this every seek leaves
+// another ffmpeg racing through the rest of the film, and after a couple of
+// jumps the host is too busy to produce the segment the player is waiting for.
+//
+// One id per page load, so two tabs never retire each other's transcode.
+// Restricted to characters the server accepts for this parameter.
+var MOVIE_CLIENT_ID = "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
+
 function isWebKitClient() {
 function isWebKitClient() {
     var ua = navigator.userAgent;
     var ua = navigator.userAgent;
     // On iOS every browser is WebKit underneath, whatever it calls itself.
     // On iOS every browser is WebKit underneath, whatever it calls itself.
@@ -159,42 +170,112 @@ function clearDirectPlaybackWatch(videoEl) {
 // startSeconds restarts the transcode at an offset; the resulting stream always
 // startSeconds restarts the transcode at an offset; the resulting stream always
 // begins at zero, so callers track the offset separately.
 // begins at zero, so callers track the offset separately.
 function transcodeStreamURL(filepath, startSeconds) {
 function transcodeStreamURL(filepath, startSeconds) {
-    var base = usingHLS() ? HLS_API : TRANSCODE_API;
+    var hls  = usingHLS();
+    var base = hls ? HLS_API : TRANSCODE_API;
     var url  = base + "?file=" + encodeURIComponent(filepath);
     var url  = base + "?file=" + encodeURIComponent(filepath);
     if (startSeconds && startSeconds > 0.001) {
     if (startSeconds && startSeconds > 0.001) {
         url += "&start=" + startSeconds.toFixed(3);
         url += "&start=" + startSeconds.toFixed(3);
     }
     }
+    // Only HLS needs it, and the MP4 response is cacheable — a per-load
+    // parameter would defeat that cache for nothing.
+    if (hls) { url += "&client=" + MOVIE_CLIENT_ID; }
     return url;
     return url;
 }
 }
 
 
+// Fetch a playlist before handing it to the player.
+//
+// Answering a playlist request means starting (or joining) a transcode and
+// waiting for its first segment, so it can fail long after the player has
+// committed to the URL: a seek past the end of the file, a host too busy to
+// produce a segment in time, a session that has already been reaped. A <video>
+// element cannot tell any of that from a stream it simply cannot decode — it
+// reports NotSupportedError and the reason is lost. Asking first keeps the real
+// message, and hands the player a playlist that is already ready.
+function preflightPlaylist(url, callback) {
+    fetch(url, { credentials: "same-origin", cache: "no-store" })
+        .then(function (response) {
+            return response.text().then(function (body) {
+                if (response.ok && String(body).trim().indexOf("#EXTM3U") === 0) {
+                    callback(true);
+                    return;
+                }
+                callback(false, playlistErrorMessage(body, response.status));
+            });
+        })
+        .catch(function () {
+            callback(false, "Transcode failed: could not reach the server");
+        });
+}
+
+// Turn whatever arrived instead of a playlist into a line worth showing.
+function playlistErrorMessage(body, status) {
+    var text = String(body || "").trim();
+    // Some endpoints still report failure as a 200 carrying a JSON error object
+    if (text.charAt(0) === "{") {
+        try {
+            var parsed = JSON.parse(text);
+            if (parsed && parsed.error) { text = String(parsed.error); }
+        } catch (e) { /* not JSON after all — show it as it came */ }
+    }
+    if (!text) { text = "the transcode did not start (HTTP " + status + ")"; }
+    if (text.length > 120) { text = text.substring(0, 119) + "…"; }
+    return "Transcode failed: " + text;
+}
+
 // Point a <video> at a stream URL. HLS on a browser without native support is
 // 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
 // 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.
 // cannot be played at all, so the caller can say so rather than hang.
-function attachTranscodeStream(videoEl, url, onError) {
+//
+// 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
+// is where the caller should call play(). onError reports a stream that never
+// became playable, with the server's own explanation.
+function attachTranscodeStream(videoEl, url, onError, onReady) {
     detachTranscodeStream(videoEl);
     detachTranscodeStream(videoEl);
 
 
-    var isPlaylist = url.indexOf(HLS_API) === 0;
-    if (isPlaylist && !nativeHLSSupported(videoEl)) {
-        if (window.Hls && window.Hls.isSupported()) {
+    var ready = function () { if (typeof onReady === "function") { onReady(); } };
+    var fail  = function (reason, err) { if (typeof onError === "function") { onError(reason, err); } };
+
+    if (url.indexOf(HLS_API) !== 0) {
+        videoEl.src = url;
+        videoEl.load();
+        ready();
+        return true;
+    }
+
+    // Choose the player before making a request, so a browser that cannot play
+    // HLS at all is reported without waiting on a transcode it will not use.
+    var bind;
+    if (nativeHLSSupported(videoEl)) {
+        bind = function () { videoEl.src = url; videoEl.load(); ready(); };
+    } else if (window.Hls && window.Hls.isSupported()) {
+        bind = function () {
             var hls = new window.Hls({ enableWorker: true });
             var hls = new window.Hls({ enableWorker: true });
             videoEl._hlsInstance = hls;
             videoEl._hlsInstance = hls;
             hls.loadSource(url);
             hls.loadSource(url);
             hls.attachMedia(videoEl);
             hls.attachMedia(videoEl);
-            return true;
-        }
-        if (window.MovieHLS && window.MovieHLS.isSupported()) {
+            ready();
+        };
+    } else if (window.MovieHLS && window.MovieHLS.isSupported()) {
+        bind = function () {
             videoEl._mseInstance = window.MovieHLS.attach(videoEl, url, {
             videoEl._mseInstance = window.MovieHLS.attach(videoEl, url, {
-                onError: function (reason, err) {
-                    if (typeof onError === "function") { onError(reason, err); }
-                }
+                onError: function (reason, err) { fail(reason, err); }
             });
             });
-            return true;
-        }
+            ready();
+        };
+    } else {
         return false;
         return false;
     }
     }
 
 
-    videoEl.src = url;
-    videoEl.load();
+    // A seek made while the previous playlist is still being fetched must not
+    // be overtaken by that older answer.
+    var generation = (videoEl._streamGeneration || 0) + 1;
+    videoEl._streamGeneration = generation;
+    preflightPlaylist(url, function (ok, reason) {
+        if (videoEl._streamGeneration !== generation) { return; }
+        if (!ok) { fail(reason); return; }
+        bind();
+    });
     return true;
     return true;
 }
 }
 
 
@@ -203,6 +284,8 @@ function attachTranscodeStream(videoEl, url, onError) {
 // segments into an element that has moved on.
 // segments into an element that has moved on.
 function detachTranscodeStream(videoEl) {
 function detachTranscodeStream(videoEl) {
     if (!videoEl) { return; }
     if (!videoEl) { return; }
+    // Retires any playlist request still in flight for the old stream
+    videoEl._streamGeneration = (videoEl._streamGeneration || 0) + 1;
     if (videoEl._hlsInstance) {
     if (videoEl._hlsInstance) {
         try { videoEl._hlsInstance.destroy(); } catch (e) {}
         try { videoEl._hlsInstance.destroy(); } catch (e) {}
         videoEl._hlsInstance = null;
         videoEl._hlsInstance = null;

+ 198 - 174
src/web/Movie/embedded.html

@@ -81,6 +81,12 @@
         }
         }
         .ctrl-btn:hover { opacity: 0.7; }
         .ctrl-btn:hover { opacity: 0.7; }
         .ctrl-btn img { width: 24px; height: 24px; display: block; }
         .ctrl-btn img { width: 24px; height: 24px; display: block; }
+        .ctrl-cc-icon {
+            width: 24px; height: 24px;
+            display: flex; align-items: center; justify-content: center;
+            font-size: 10px; font-weight: 700; letter-spacing: 0.4px;
+            border: 1.5px solid #fff; border-radius: 4px; line-height: 1;
+        }
 
 
         #volume-wrap { display: flex; align-items: center; gap: 8px; }
         #volume-wrap { display: flex; align-items: center; gap: 8px; }
         #volume-slider {
         #volume-slider {
@@ -296,7 +302,13 @@
             border-radius: calc(var(--radius) * 1.5); padding: 4px 0; min-width: 200px;
             border-radius: calc(var(--radius) * 1.5); padding: 4px 0; min-width: 200px;
             box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
             box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
             z-index: 32;
             z-index: 32;
+            overflow-y: auto;
+            max-height: 320px;
         }
         }
+        #ctx-subtitle-sub::-webkit-scrollbar { width: 8px; }
+        #ctx-subtitle-sub::-webkit-scrollbar-track { background: transparent; }
+        #ctx-subtitle-sub::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
+        #ctx-subtitle-sub::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
 
 
         /* ── Subtitle settings modal ───────────────────────────────────────── */
         /* ── Subtitle settings modal ───────────────────────────────────────── */
         #subtitle-settings-modal {
         #subtitle-settings-modal {
@@ -424,8 +436,37 @@
             color: var(--text);
             color: var(--text);
             font-family: -apple-system, BlinkMacSystemFont, sans-serif;
             font-family: -apple-system, BlinkMacSystemFont, sans-serif;
             user-select: none;
             user-select: none;
+            overflow-y: auto;
+            max-height: 320px;
         }
         }
         #settings-popup.active { display: block; }
         #settings-popup.active { display: block; }
+        #settings-popup::-webkit-scrollbar,
+        #subtitle-popup::-webkit-scrollbar { width: 8px; }
+        #settings-popup::-webkit-scrollbar-track,
+        #subtitle-popup::-webkit-scrollbar-track { background: transparent; }
+        #settings-popup::-webkit-scrollbar-thumb,
+        #subtitle-popup::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
+        #settings-popup::-webkit-scrollbar-thumb:hover,
+        #subtitle-popup::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
+
+        /* ── Subtitle popup (dedicated CC button) ────────────────────────────── */
+        #subtitle-popup {
+            display: none;
+            position: absolute;
+            right: 14px; bottom: 62px;
+            z-index: 35;
+            background: rgba(28,28,30,0.97);
+            backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
+            border-radius: calc(var(--radius) * 1.5);
+            padding: 4px 0; min-width: 220px;
+            box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
+            color: var(--text);
+            font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+            user-select: none;
+            overflow-y: auto;
+            max-height: 320px;
+        }
+        #subtitle-popup.active { display: block; }
         .set-section { padding: 6px 14px 8px; }
         .set-section { padding: 6px 14px 8px; }
         .set-section + .set-section { border-top: 1px solid rgba(255,255,255,0.08); }
         .set-section + .set-section { border-top: 1px solid rgba(255,255,255,0.08); }
         .set-head {
         .set-head {
@@ -625,18 +666,23 @@
             </div>
             </div>
         </div>
         </div>
         <div class="set-section" style="padding-left:0;padding-right:0;padding-bottom:2px;">
         <div class="set-section" style="padding-left:0;padding-right:0;padding-bottom:2px;">
-            <div class="set-link" id="set-load-subtitle">
-                <img src="img/icons/movie_white.svg" alt="">Load SRT subtitle…
-            </div>
-            <div class="set-link" id="set-subtitle-settings">
-                <img src="img/icons/settings_white.svg" alt="">Subtitle settings
-            </div>
             <div class="set-link" id="set-video-props">
             <div class="set-link" id="set-video-props">
                 <img src="img/icons/menu_white.svg" alt="">Video properties
                 <img src="img/icons/menu_white.svg" alt="">Video properties
             </div>
             </div>
         </div>
         </div>
     </div>
     </div>
 
 
+    <!-- Subtitle popup (dedicated CC button) -->
+    <div id="subtitle-popup">
+        <div class="ctx-item ctx-active" id="sub-pop-disable"><i class="ctx-icon">✓</i>Disable</div>
+        <div class="ctx-divider"></div>
+        <div class="ctx-item" id="sub-pop-load"><i class="ctx-icon">+</i>Load SRT file…</div>
+        <div class="ctx-divider"></div>
+        <div class="set-link" id="sub-pop-settings">
+            <img src="img/icons/settings_white.svg" alt="">Subtitle settings
+        </div>
+    </div>
+
     <!-- Player controls -->
     <!-- Player controls -->
     <div id="video-controls">
     <div id="video-controls">
         <div id="progress-wrap">
         <div id="progress-wrap">
@@ -659,6 +705,9 @@
             </div>
             </div>
             <span id="time-display">0:00 / 0:00</span>
             <span id="time-display">0:00 / 0:00</span>
             <span id="spacer"></span>
             <span id="spacer"></span>
+            <button class="ctrl-btn" id="ctrl-subtitle" title="Subtitles">
+                <span class="ctrl-cc-icon">CC</span>
+            </button>
             <button class="ctrl-btn" id="ctrl-settings" title="Playback settings">
             <button class="ctrl-btn" id="ctrl-settings" title="Playback settings">
                 <img src="img/icons/settings_white.svg" alt="">
                 <img src="img/icons/settings_white.svg" alt="">
             </button>
             </button>
@@ -985,10 +1034,17 @@ function hideSeekFreeze() {
 // no re-transcode, no black frame. Only a seek past that window (or an MP4
 // no re-transcode, no black frame. Only a seek past that window (or an MP4
 // stream, which has no seekable history at all) has to restart the transcode at
 // stream, which has no seekable history at all) has to restart the transcode at
 // the new offset.
 // the new offset.
+// Restarting the transcode this close to the end of the file would leave ffmpeg
+// with nothing to encode, and the request comes back with no playable stream at
+// all — so a scrub to the very end lands just short of it instead.
+var TRANSCODE_SEEK_TAIL = 5;
+
 function transcodeSeekTo(seconds) {
 function transcodeSeekTo(seconds) {
     if (!currentFile) { return; }
     if (!currentFile) { return; }
     var pos = Math.max(0, seconds);
     var pos = Math.max(0, seconds);
-    if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
+    if (transcodeDuration > 0) {
+        pos = Math.min(Math.max(0, transcodeDuration - TRANSCODE_SEEK_TAIL), pos);
+    }
 
 
     if (usingHLS() && seekWithinTranscodedWindow(vid, pos)) {
     if (usingHLS() && seekWithinTranscodedWindow(vid, pos)) {
         setSubtitleSeekFloor(pos);
         setSubtitleSeekFloor(pos);
@@ -1000,12 +1056,15 @@ function transcodeSeekTo(seconds) {
     showSeekFreeze();
     showSeekFreeze();
     setSubtitleSeekFloor(pos);
     setSubtitleSeekFloor(pos);
     transcodeSeekOffset = pos;
     transcodeSeekOffset = pos;
-    if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, pos))) {
+    // The freeze frame stays up until the new stream is bound: attaching a
+    // playlist waits on the server to start the transcode, so playback resumes
+    // from the onReady callback rather than immediately.
+    if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, pos),
+            function (reason) { hideSeekFreeze(); showToast(reason); },
+            function () { playVideo(vid); })) {
         hideSeekFreeze();
         hideSeekFreeze();
         showToast('This browser cannot play the HLS stream. 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);
 }
 }
 
 
 // Whether `pos` (absolute time) falls inside what the current stream has
 // Whether `pos` (absolute time) falls inside what the current stream has
@@ -1020,153 +1079,28 @@ function seekWithinTranscodedWindow(videoEl, pos) {
     return relative <= available - 4;
     return relative <= available - 4;
 }
 }
 
 
-// Current playback position in whole-file terms (transcode streams restart at 0)
-function effectivePlaybackTime() {
-    return isTranscodedVideo ? (vid.currentTime + transcodeSeekOffset) : vid.currentTime;
-}
-
-// ── Scrub-bar hover preview (storyboard) ──────────────────────────────────────
-// The server renders one sprite sheet per file holding a downscaled frame every
-// few seconds; we fetch it once and keep it in memory, so hovering the timeline
-// costs nothing. Asking ffmpeg for a frame per hover would be hopeless on a
-// software-decoded H.265 source, which is exactly where previews matter most.
-var storyboard      = null;   // layout from the server + the loaded sheet URL
-var storyboardToken = 0;      // invalidates in-flight loads when the file changes
-var storyboardTimer = null;
-
-function resetStoryboard() {
-    storyboardToken++;
-    storyboard = null;
-    clearTimeout(storyboardTimer);
-    storyboardTimer = null;
-    $('#scrub-preview-thumb').removeClass('ready');
-    hideScrubPreview();
-}
-
-// Building the sheet runs ffmpeg, so hold off briefly and let playback (and any
-// transcode) get established before adding more load.
-function scheduleStoryboardLoad(filepath) {
-    resetStoryboard();
-    if (!filepath) { return; }
-    var token = storyboardToken;
-    storyboardTimer = setTimeout(function () { loadStoryboard(filepath, token); }, 3000);
-}
-
-function loadStoryboard(filepath, token) {
-    var base = STORYBOARD_API + '?file=' + encodeURIComponent(filepath);
-    fetch(base)
-        .then(function (r) { return r.json(); })
-        .then(function (meta) {
-            if (token !== storyboardToken) { return; }   // switched file while loading
-            if (!meta || meta.error || !meta.interval || !meta.tileWidth) { return; }
-            var img = new Image();
-            img.onload = function () {
-                if (token !== storyboardToken) { return; }
-                meta.url   = img.src;
-                storyboard = meta;
-            };
-            img.src = base + '&image=1';
-        })
-        .catch(function () {});   // previews are optional — stay silent on failure
-}
-
-// Whole-file duration, whichever playback mode is active
-function scrubTotalDuration() {
-    if (isTranscodedVideo) { return transcodeDuration; }
-    return vid.duration || 0;
-}
-
-function initScrubPreview() {
-    var $wrap = $('#progress-wrap');
-    $wrap.on('mousemove', function (e) {
-        var total = scrubTotalDuration();
-        var rect  = this.getBoundingClientRect();
-        if (!total || !isFinite(total) || rect.width <= 0) { hideScrubPreview(); return; }
-        var ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-        showScrubPreview(ratio * total, ratio, rect.width);
-    });
-    $wrap.on('mouseleave', hideScrubPreview);
-}
-
-function showScrubPreview(time, ratio, barWidth) {
-    $('#scrub-preview-time').text(formatTime(time));
-
-    var $thumb = $('#scrub-preview-thumb');
-    var halfWidth;
-    if (storyboard && storyboard.url) {
-        var idx = Math.max(0, Math.min(storyboard.count - 1,
-                                       Math.floor(time / storyboard.interval)));
-        var col = idx % storyboard.cols;
-        var row = Math.floor(idx / storyboard.cols);
-        $thumb.addClass('ready').css({
-            'width':               storyboard.tileWidth + 'px',
-            'height':              storyboard.tileHeight + 'px',
-            'background-image':    'url("' + storyboard.url + '")',
-            'background-position': (-col * storyboard.tileWidth) + 'px '
-                                 + (-row * storyboard.tileHeight) + 'px'
-        });
-        halfWidth = storyboard.tileWidth / 2;
-    } else {
-        // Sheet not ready (or unavailable) — the timestamp alone is still useful
-        $thumb.removeClass('ready');
-        halfWidth = 28;
-    }
-
-    // Keep the popup within the bar rather than letting it hang off an edge
-    var x = Math.max(halfWidth, Math.min(barWidth - halfWidth, ratio * barWidth));
-    $('#scrub-preview').css('left', x + 'px').addClass('active');
-}
-
-function hideScrubPreview() { $('#scrub-preview').removeClass('active'); }
-
-// ── Transcode seek (seek-by-reload) ───────────────────────────────────────────
-// Formats the browser can't decode are streamed through ffmpeg, so "seeking"
-// means restarting the transcode at a new offset. Replacing .src blanks the
-// <video> to black until the first frame of the new segment arrives, which can
-// take seconds. Freeze the last painted frame under a spinner instead, and only
-// reveal the live element once it is genuinely playing again.
-var seekFreezeTimer = null;
-
-function showSeekFreeze() {
-    var canvas = document.getElementById('seek-freeze');
-    if (canvas && vid.videoWidth > 0 && vid.videoHeight > 0) {
-        canvas.width  = vid.videoWidth;
-        canvas.height = vid.videoHeight;
-        try {
-            canvas.getContext('2d').drawImage(vid, 0, 0, canvas.width, canvas.height);
-            $(canvas).show();
-        } catch (e) {
-            // No decodable frame right now (e.g. seeking again mid-buffer) —
-            // leave whatever is already frozen on screen rather than flashing black.
-        }
-    }
-    $('#seek-spinner').addClass('active');
-    clearTimeout(seekFreezeTimer);
-    // Safety net: a stalled transcode must never leave the overlay stuck on
-    seekFreezeTimer = setTimeout(hideSeekFreeze, 30000);
-}
-
-function hideSeekFreeze() {
-    clearTimeout(seekFreezeTimer);
-    seekFreezeTimer = null;
-    $('#seek-freeze').hide();
-    $('#seek-spinner').removeClass('active');
-}
+// ─── HLS stream recovery ──────────────────────────────────────────────────────
+// An HLS transcode lives on the server, and it can disappear from under the
+// 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
+// element reports a decode error, which leaves a black frame and no way back.
+// Re-open the stream where it stopped instead.
+var streamRecoveryAttempts = 0;
+var STREAM_RECOVERY_LIMIT  = 3;   // consecutive tries before giving up
 
 
-// Restart the transcode stream at `seconds`, holding the last frame until the
-// new segment plays. Clamped to the known duration.
-function transcodeSeekTo(seconds) {
-    if (!currentFile) { return; }
-    var pos = Math.max(0, seconds);
-    if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
+function recoverTranscodeStream() {
+    if (!isTranscodedVideo || !usingHLS() || !currentFile) { return; }
+    // A file that genuinely cannot be transcoded fails every time; retrying it
+    // forever would replace one dead player with a flickering one.
+    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return; }
+    streamRecoveryAttempts++;
 
 
+    var resumeAt = effectivePlaybackTime();
     showSeekFreeze();
     showSeekFreeze();
-    setSubtitleSeekFloor(pos);
-    transcodeSeekOffset = pos;
-    vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
-            + '&start=' + pos.toFixed(3);
-    vid.load();
-    vid.play();
+    transcodeSeekOffset = resumeAt;
+    attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, resumeAt),
+        function (reason) { hideSeekFreeze(); showToast(reason); },
+        function () { playVideo(vid); });
 }
 }
 
 
 // Current playback position in whole-file terms (transcode streams restart at 0)
 // Current playback position in whole-file terms (transcode streams restart at 0)
@@ -1276,9 +1210,15 @@ function initVideoControls() {
     // Any seek — native or transcode reload — re-bases the subtitle floor
     // Any seek — native or transcode reload — re-bases the subtitle floor
     $(vid).on('seeking', function () { setSubtitleSeekFloor(effectivePlaybackTime()); });
     $(vid).on('seeking', function () { setSubtitleSeekFloor(effectivePlaybackTime()); });
 
 
-    $(vid).on('playing', hideSeekFreeze);
+    $(vid).on('playing', function () {
+        hideSeekFreeze();
+        streamRecoveryAttempts = 0;   // the stream is healthy again
+    });
     $(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
     $(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
-    $(vid).on('error', hideSeekFreeze);
+    $(vid).on('error', function () {
+        hideSeekFreeze();
+        recoverTranscodeStream();
+    });
 
 
     // Auto-hide controls on mouse movement
     // Auto-hide controls on mouse movement
     $('#player-wrap').on('mousemove touchstart', showControls);
     $('#player-wrap').on('mousemove touchstart', showControls);
@@ -1305,8 +1245,8 @@ function showControls() {
     $('#video-controls').removeClass('hidden');
     $('#video-controls').removeClass('hidden');
     clearTimeout(controlsTimer);
     clearTimeout(controlsTimer);
     controlsTimer = setTimeout(function () {
     controlsTimer = setTimeout(function () {
-        // Keep the bar up while the settings popup is open — it is anchored to it
-        if (!vid.paused && !$('#settings-popup').hasClass('active')) {
+        // Keep the bar up while a popup is open — both are anchored to it
+        if (!vid.paused && !$('#settings-popup').hasClass('active') && !$('#subtitle-popup').hasClass('active')) {
             $('#video-controls').addClass('hidden');
             $('#video-controls').addClass('hidden');
         }
         }
     }, 3000);
     }, 3000);
@@ -1382,6 +1322,7 @@ function initContextMenu() {
                 + '" alt="">Repeat: ' + (repeatSingle ? 'On' : 'Off'));
                 + '" alt="">Repeat: ' + (repeatSingle ? 'On' : 'Off'));
         $('#ctx-subtitle-sub').hide();
         $('#ctx-subtitle-sub').hide();
         closeSettingsPopup();
         closeSettingsPopup();
+        closeSubtitlePopup();
 
 
         var rect = this.getBoundingClientRect();
         var rect = this.getBoundingClientRect();
         var x = e.clientX - rect.left;
         var x = e.clientX - rect.left;
@@ -1453,14 +1394,16 @@ function applyStreamMode(mode, reloadCurrent) {
     warnIfStreamModeUnplayable();
     warnIfStreamModeUnplayable();
     if (!isTranscodedVideo || !currentFile) { return; }
     if (!isTranscodedVideo || !currentFile) { return; }
 
 
+    // Opening the file directly at the current position rather than at zero
+    // avoids starting a transcode from the beginning only to abandon it.
     var resumeAt = effectivePlaybackTime();
     var resumeAt = effectivePlaybackTime();
-    transcodeSeekOffset = 0;
-    if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, 0))) {
+    var startAt  = resumeAt > 1 ? resumeAt : 0;
+    transcodeSeekOffset = startAt;
+    if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, startAt),
+            function (reason) { showToast(reason); },
+            function () { playVideo(vid); })) {
         showToast('This browser cannot play the HLS stream.');
         showToast('This browser cannot play the HLS stream.');
-        return;
     }
     }
-    playVideo(vid);
-    if (resumeAt > 1) { transcodeSeekTo(resumeAt); }
 }
 }
 
 
 function playerVideoElement() { return vid; }
 function playerVideoElement() { return vid; }
@@ -1476,13 +1419,28 @@ function warnIfStreamModeUnplayable() {
     }
     }
 }
 }
 
 
+// Cap a bottom-anchored popup's height so it never runs past the top edge of
+// the player (it only ever opens upward) — the popup's own scrollbar takes
+// over for whatever doesn't fit.
+function clampBottomPopupHeight($popup) {
+    var container = document.getElementById('player-wrap');
+    if (!container) { return; }
+    var margin        = 10;
+    var containerRect = container.getBoundingClientRect();
+    var popupRect     = $popup[0].getBoundingClientRect();
+    var available     = popupRect.bottom - containerRect.top - margin;
+    $popup.css('max-height', Math.max(120, available) + 'px');
+}
+
 function openSettingsPopup() {
 function openSettingsPopup() {
     setRepeatSingle(repeatSingle);
     setRepeatSingle(repeatSingle);
     setPlaybackSpeed(playbackSpeed);
     setPlaybackSpeed(playbackSpeed);
     setAlwaysShowVolumeBar(alwaysShowVolumeBar);
     setAlwaysShowVolumeBar(alwaysShowVolumeBar);
     applyStreamMode(getStreamMode(), false);
     applyStreamMode(getStreamMode(), false);
     $('#player-ctx').hide();
     $('#player-ctx').hide();
+    closeSubtitlePopup();
     $('#settings-popup').addClass('active');
     $('#settings-popup').addClass('active');
+    clampBottomPopupHeight($('#settings-popup'));
     showControls();
     showControls();
 }
 }
 
 
@@ -1493,6 +1451,46 @@ function toggleSettingsPopup() {
     else { openSettingsPopup(); }
     else { openSettingsPopup(); }
 }
 }
 
 
+// ── Subtitle popup (dedicated CC button) ────────────────────────────────────
+function openSubtitlePopup() {
+    updateSubtitleSubmenu();
+    $('#player-ctx').hide();
+    closeSettingsPopup();
+    $('#subtitle-popup').addClass('active');
+    clampBottomPopupHeight($('#subtitle-popup'));
+    showControls();
+}
+
+function closeSubtitlePopup() { $('#subtitle-popup').removeClass('active'); }
+
+function toggleSubtitlePopup() {
+    if ($('#subtitle-popup').hasClass('active')) { closeSubtitlePopup(); }
+    else { openSubtitlePopup(); }
+}
+
+function initSubtitlePopup() {
+    $('#ctrl-subtitle').on('click', function (e) {
+        e.stopPropagation();
+        toggleSubtitlePopup();
+    });
+
+    $(document).on('mousedown.subpop', function (e) {
+        if (!$(e.target).closest('#subtitle-popup, #ctrl-subtitle').length) {
+            closeSubtitlePopup();
+        }
+    });
+
+    $('#sub-pop-disable').on('click', function () {
+        activeSubtitleIndex = -1;
+        stopAssTrack();
+        $('#subtitle-display').hide();
+        updateSubtitleSubmenu();
+        closeSubtitlePopup();
+    });
+    $('#sub-pop-load').on('click',     function () { closeSubtitlePopup(); pickSubtitleFile(); });
+    $('#sub-pop-settings').on('click', function () { closeSubtitlePopup(); openSubtitleSettings(); });
+}
+
 function initSettingsPopup() {
 function initSettingsPopup() {
     $('#ctrl-settings').on('click', function (e) {
     $('#ctrl-settings').on('click', function (e) {
         e.stopPropagation();
         e.stopPropagation();
@@ -1521,9 +1519,7 @@ function initSettingsPopup() {
         applyStreamMode(String($(this).data('stream')), true);
         applyStreamMode(String($(this).data('stream')), true);
     });
     });
 
 
-    $('#set-load-subtitle').on('click',    function () { closeSettingsPopup(); pickSubtitleFile(); });
-    $('#set-subtitle-settings').on('click', function () { closeSettingsPopup(); openSubtitleSettings(); });
-    $('#set-video-props').on('click',       function () { closeSettingsPopup(); openVideoInfo('props'); });
+    $('#set-video-props').on('click', function () { closeSettingsPopup(); openVideoInfo('props'); });
 
 
     // Apply persisted values to the video element
     // Apply persisted values to the video element
     setRepeatSingle(repeatSingle);
     setRepeatSingle(repeatSingle);
@@ -2087,15 +2083,16 @@ function ensureEmbeddedFontLoaded(family) {
     });
     });
 }
 }
 
 
-function updateSubtitleSubmenu() {
-    $('#ctx-subtitle-sub .ctx-sub-dynamic').remove();
+// Builds the track list (Disable / embedded tracks / loaded sidecar files) into
+// one menu, given its "Disable" and "Load" anchor items — shared by the
+// right-click context submenu and the dedicated CC button popup so both stay
+// in sync from a single call to updateSubtitleSubmenu().
+function populateSubtitleTrackList($disable, $load, onSelect) {
+    $disable.siblings('.ctx-sub-dynamic').remove();
     var disabled = (activeSubtitleIndex < 0);
     var disabled = (activeSubtitleIndex < 0);
-    $('#ctx-sub-disable')
-        .toggleClass('ctx-active', disabled)
+    $disable.toggleClass('ctx-active', disabled)
         .find('.ctx-icon').text(disabled ? '✓' : '');
         .find('.ctx-icon').text(disabled ? '✓' : '');
 
 
-    var $load = $('#ctx-sub-load');
-
     // Tracks muxed into the video, listed whether or not they are loaded yet
     // Tracks muxed into the video, listed whether or not they are loaded yet
     if (embeddedSubtitles.length > 0) {
     if (embeddedSubtitles.length > 0) {
         $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
         $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
@@ -2112,7 +2109,7 @@ function updateSubtitleSubmenu() {
                 + '</div>')
                 + '</div>')
             .on('click', function () {
             .on('click', function () {
                 selectEmbeddedSubtitle(track, position);
                 selectEmbeddedSubtitle(track, position);
-                $('#player-ctx').hide();
+                onSelect();
             })
             })
             .insertBefore($load);
             .insertBefore($load);
         });
         });
@@ -2135,13 +2132,23 @@ function updateSubtitleSubmenu() {
                 activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
                 activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
                 applyActiveSubtitle();
                 applyActiveSubtitle();
                 updateSubtitleSubmenu();
                 updateSubtitleSubmenu();
-                $('#player-ctx').hide();
+                onSelect();
             })
             })
             .insertBefore($load);
             .insertBefore($load);
         });
         });
     }
     }
 }
 }
 
 
+function updateSubtitleSubmenu() {
+    populateSubtitleTrackList($('#ctx-sub-disable'), $('#ctx-sub-load'), function () {
+        $('#player-ctx').hide();
+    });
+    populateSubtitleTrackList($('#sub-pop-disable'), $('#sub-pop-load'), function () {
+        closeSubtitlePopup();
+    });
+    if ($('#subtitle-popup').hasClass('active')) { clampBottomPopupHeight($('#subtitle-popup')); }
+}
+
 function initSubtitleMenu() {
 function initSubtitleMenu() {
     var $ctx    = $('#player-ctx');
     var $ctx    = $('#player-ctx');
     var $parent = $('#ctx-subtitle-parent');
     var $parent = $('#ctx-subtitle-parent');
@@ -2162,6 +2169,21 @@ function initSubtitleMenu() {
         } else {
         } else {
             $sub.css({ left: '100%', right: 'auto' });
             $sub.css({ left: '100%', right: 'auto' });
         }
         }
+
+        // Clamp vertically so the submenu never runs past the player edge —
+        // flip to open upward when there's more room above than below, and
+        // cap its height to whichever side it opens on (scrollbar handles the rest).
+        var margin        = 10;
+        var containerRect = document.getElementById('player-wrap').getBoundingClientRect();
+        var parentRect    = this.getBoundingClientRect();
+        var spaceBelow    = containerRect.bottom - parentRect.top - margin;
+        var spaceAbove    = parentRect.bottom - containerRect.top - margin;
+        if (spaceBelow < 160 && spaceAbove > spaceBelow) {
+            $sub.css({ top: 'auto', bottom: 0, maxHeight: Math.max(120, spaceAbove) + 'px' });
+        } else {
+            $sub.css({ top: 0, bottom: 'auto', maxHeight: Math.max(120, spaceBelow) + 'px' });
+        }
+
         $sub.show();
         $sub.show();
     });
     });
     $parent.on('mouseleave', scheduleSubHide);
     $parent.on('mouseleave', scheduleSubHide);
@@ -2257,6 +2279,7 @@ function initKeyboard() {
                 e.preventDefault();
                 e.preventDefault();
                 $('#player-ctx').hide();
                 $('#player-ctx').hide();
                 closeSettingsPopup();
                 closeSettingsPopup();
+                closeSubtitlePopup();
                 closeVideoInfo();
                 closeVideoInfo();
                 closeSubtitleSettings(); break;
                 closeSubtitleSettings(); break;
 
 
@@ -2271,6 +2294,7 @@ function initMain(){
     initVideoControls();
     initVideoControls();
     initContextMenu();
     initContextMenu();
     initSettingsPopup();
     initSettingsPopup();
+    initSubtitlePopup();
     initScrubPreview();
     initScrubPreview();
     initAssRenderer();
     initAssRenderer();
     resetSubtitleSeekFloor();   // a freshly opened file starts unsuppressed
     resetSubtitleSeekFloor();   // a freshly opened file starts unsuppressed

+ 208 - 36
src/web/Movie/index.html

@@ -481,6 +481,12 @@ html, body {
     display: flex; align-items: center; justify-content: center;
     display: flex; align-items: center; justify-content: center;
 }
 }
 .ctrl-btn img { width: 24px; height: 24px; display: block; }
 .ctrl-btn img { width: 24px; height: 24px; display: block; }
+.ctrl-cc-icon {
+    width: 24px; height: 24px;
+    display: flex; align-items: center; justify-content: center;
+    font-size: 10px; font-weight: 700; letter-spacing: 0.4px;
+    border: 1.5px solid #fff; border-radius: 4px; line-height: 1;
+}
 .ctrl-btn:hover { opacity: 0.7; }
 .ctrl-btn:hover { opacity: 0.7; }
 .ctrl-btn.focused { background: rgba(10,132,255,0.18); border-radius: 6px; }
 .ctrl-btn.focused { background: rgba(10,132,255,0.18); border-radius: 6px; }
 
 
@@ -1137,8 +1143,38 @@ body.always-show-volume #volume-slider { display: block; }
     box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
     box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
     color: var(--text);
     color: var(--text);
     user-select: none;
     user-select: none;
+    overflow-y: auto;
+    max-height: 320px;
 }
 }
 #settings-popup.active { display: block; }
 #settings-popup.active { display: block; }
+#settings-popup::-webkit-scrollbar,
+#subtitle-popup::-webkit-scrollbar { width: 8px; }
+#settings-popup::-webkit-scrollbar-track,
+#subtitle-popup::-webkit-scrollbar-track { background: transparent; }
+#settings-popup::-webkit-scrollbar-thumb,
+#subtitle-popup::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
+#settings-popup::-webkit-scrollbar-thumb:hover,
+#subtitle-popup::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
+
+/* ─── Subtitle popup (dedicated CC button) ───────────────────────────────────── */
+#subtitle-popup {
+    display: none;
+    position: absolute;
+    right: 20px; bottom: 62px;
+    z-index: 35;
+    background: rgba(28,28,30,0.97);
+    backdrop-filter: blur(16px);
+    -webkit-backdrop-filter: blur(16px);
+    border-radius: 10px;
+    padding: 4px 0;
+    min-width: 220px;
+    box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
+    color: var(--text);
+    user-select: none;
+    overflow-y: auto;
+    max-height: 320px;
+}
+#subtitle-popup.active { display: block; }
 .set-section { padding: 6px 14px 8px; }
 .set-section { padding: 6px 14px 8px; }
 .set-section + .set-section { border-top: 1px solid rgba(255,255,255,0.08); }
 .set-section + .set-section { border-top: 1px solid rgba(255,255,255,0.08); }
 .set-head {
 .set-head {
@@ -1227,7 +1263,13 @@ img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
     min-width: 200px;
     min-width: 200px;
     box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
     box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
     z-index: 32;
     z-index: 32;
+    overflow-y: auto;
+    max-height: 320px;
 }
 }
+#ctx-subtitle-sub::-webkit-scrollbar { width: 8px; }
+#ctx-subtitle-sub::-webkit-scrollbar-track { background: transparent; }
+#ctx-subtitle-sub::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
+#ctx-subtitle-sub::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
 
 
 /* Subtitle overlay */
 /* Subtitle overlay */
 #subtitle-display {
 #subtitle-display {
@@ -1788,18 +1830,23 @@ img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
                     </div>
                     </div>
                 </div>
                 </div>
                 <div class="set-section" style="padding-left:0;padding-right:0;padding-bottom:2px;">
                 <div class="set-section" style="padding-left:0;padding-right:0;padding-bottom:2px;">
-                    <div class="set-link" id="set-load-subtitle">
-                        <img src="img/icons/movie_white.svg" alt="">Load SRT subtitle…
-                    </div>
-                    <div class="set-link" id="set-subtitle-settings">
-                        <img src="img/icons/settings_white.svg" alt="">Subtitle settings
-                    </div>
                     <div class="set-link" id="set-video-props">
                     <div class="set-link" id="set-video-props">
                         <img src="img/icons/menu_white.svg" alt="">Video properties
                         <img src="img/icons/menu_white.svg" alt="">Video properties
                     </div>
                     </div>
                 </div>
                 </div>
             </div>
             </div>
 
 
+            <!-- Subtitle popup (dedicated CC button) -->
+            <div id="subtitle-popup">
+                <div class="ctx-item ctx-active" id="sub-pop-disable"><i class="ctx-icon">✓</i>Disable</div>
+                <div class="ctx-divider"></div>
+                <div class="ctx-item" id="sub-pop-load"><i class="ctx-icon">+</i>Load SRT file…</div>
+                <div class="ctx-divider"></div>
+                <div class="set-link" id="sub-pop-settings">
+                    <img src="img/icons/settings_white.svg" alt="">Subtitle settings
+                </div>
+            </div>
+
             <!-- Custom controls -->
             <!-- Custom controls -->
             <div id="video-controls">
             <div id="video-controls">
                 <div id="progress-wrap">
                 <div id="progress-wrap">
@@ -1822,6 +1869,7 @@ img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
                     <span id="now-playing-title"></span>
                     <span id="now-playing-title"></span>
                     <button class="ctrl-btn" id="ctrl-list"   title="Episode list (L)"><img src="img/icons/menu_white.svg" alt=""></button>
                     <button class="ctrl-btn" id="ctrl-list"   title="Episode list (L)"><img src="img/icons/menu_white.svg" alt=""></button>
                     <button class="ctrl-btn" id="ctrl-cast"   title="Cast to Arozcast" onclick="openCastDialog()"><img src="img/icons/cast_white.svg" alt="" width="18" height="18"></button>
                     <button class="ctrl-btn" id="ctrl-cast"   title="Cast to Arozcast" onclick="openCastDialog()"><img src="img/icons/cast_white.svg" alt="" width="18" height="18"></button>
+                    <button class="ctrl-btn" id="ctrl-subtitle" title="Subtitles"><span class="ctrl-cc-icon">CC</span></button>
                     <button class="ctrl-btn" id="ctrl-settings" title="Playback settings (S)"><img src="img/icons/settings_white.svg" alt=""></button>
                     <button class="ctrl-btn" id="ctrl-settings" title="Playback settings (S)"><img src="img/icons/settings_white.svg" alt=""></button>
                     <button class="ctrl-btn" id="ctrl-fs"     title="Fullscreen (F)"><img src="img/icons/fullscreen_white.svg" alt=""></button>
                     <button class="ctrl-btn" id="ctrl-fs"     title="Fullscreen (F)"><img src="img/icons/fullscreen_white.svg" alt=""></button>
                 </div>
                 </div>
@@ -2549,11 +2597,18 @@ function hideSeekFreeze() {
 // no re-transcode, no black frame. Only a seek past that window (or an MP4
 // no re-transcode, no black frame. Only a seek past that window (or an MP4
 // stream, which has no seekable history at all) has to restart the transcode at
 // stream, which has no seekable history at all) has to restart the transcode at
 // the new offset.
 // the new offset.
+// Restarting the transcode this close to the end of the file would leave ffmpeg
+// with nothing to encode, and the request comes back with no playable stream at
+// all — so a scrub to the very end lands just short of it instead.
+var TRANSCODE_SEEK_TAIL = 5;
+
 function transcodeSeekTo(seconds) {
 function transcodeSeekTo(seconds) {
     if (playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
     if (playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
     var vid = document.getElementById('main-video');
     var vid = document.getElementById('main-video');
     var pos = Math.max(0, seconds);
     var pos = Math.max(0, seconds);
-    if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
+    if (transcodeDuration > 0) {
+        pos = Math.min(Math.max(0, transcodeDuration - TRANSCODE_SEEK_TAIL), pos);
+    }
 
 
     if (usingHLS() && seekWithinTranscodedWindow(vid, pos)) {
     if (usingHLS() && seekWithinTranscodedWindow(vid, pos)) {
         setSubtitleSeekFloor(pos);
         setSubtitleSeekFloor(pos);
@@ -2565,12 +2620,15 @@ function transcodeSeekTo(seconds) {
     showSeekFreeze();
     showSeekFreeze();
     setSubtitleSeekFloor(pos);
     setSubtitleSeekFloor(pos);
     transcodeSeekOffset = pos;
     transcodeSeekOffset = pos;
-    if (!attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, pos))) {
+    // The freeze frame stays up until the new stream is bound: attaching a
+    // playlist waits on the server to start the transcode, so playback resumes
+    // from the onReady callback rather than immediately.
+    if (!attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, pos),
+            function (reason) { hideSeekFreeze(); showToast(reason); },
+            function () { playVideo(vid); })) {
         hideSeekFreeze();
         hideSeekFreeze();
         showToast('This browser cannot play the HLS stream. 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);
 }
 }
 
 
 // Whether `pos` (absolute time) falls inside what the current stream has
 // Whether `pos` (absolute time) falls inside what the current stream has
@@ -2585,6 +2643,32 @@ function seekWithinTranscodedWindow(vid, pos) {
     return relative <= available - 4;
     return relative <= available - 4;
 }
 }
 
 
+// ─── HLS stream recovery ──────────────────────────────────────────────────────
+// An HLS transcode lives on the server, and it can disappear from under the
+// 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
+// element reports a decode error, which leaves a black frame and no way back.
+// Re-open the stream where it stopped instead.
+var streamRecoveryAttempts = 0;
+var STREAM_RECOVERY_LIMIT  = 3;   // consecutive tries before giving up
+
+function recoverTranscodeStream() {
+    if (!isTranscodedVideo || !usingHLS()) { return; }
+    if (playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
+    // A file that genuinely cannot be transcoded fails every time; retrying it
+    // forever would replace one dead player with a flickering one.
+    if (streamRecoveryAttempts >= STREAM_RECOVERY_LIMIT) { return; }
+    streamRecoveryAttempts++;
+
+    var vid      = document.getElementById('main-video');
+    var resumeAt = effectivePlaybackTime();
+    showSeekFreeze();
+    transcodeSeekOffset = resumeAt;
+    attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, resumeAt),
+        function (reason) { hideSeekFreeze(); showToast(reason); },
+        function () { playVideo(vid); });
+}
+
 // Current playback position in whole-file terms (transcode streams restart at 0)
 // Current playback position in whole-file terms (transcode streams restart at 0)
 function effectivePlaybackTime() {
 function effectivePlaybackTime() {
     var vid = document.getElementById('main-video');
     var vid = document.getElementById('main-video');
@@ -2811,6 +2895,7 @@ $(document).ready(function () {
     initSearch();
     initSearch();
     initContextMenu();
     initContextMenu();
     initSettingsPopup();
     initSettingsPopup();
+    initSubtitlePopup();
     initScrubPreview();
     initScrubPreview();
     initAssRenderer();
     initAssRenderer();
     initSubtitleMenu();
     initSubtitleMenu();
@@ -3495,11 +3580,10 @@ function beginEpisodeStream(ep, index, directPlay) {
         });
         });
         playVideo(vid);
         playVideo(vid);
     } else if (!attachTranscodeStream(vid, transcodeStreamURL(ep.filepath, 0),
     } else if (!attachTranscodeStream(vid, transcodeStreamURL(ep.filepath, 0),
-                                      function (reason) { showToast(reason); })) {
+                                      function (reason) { showToast(reason); },
+                                      function () { playVideo(vid); })) {
         showToast('This browser cannot play the transcoded stream. Switch streaming mode to MP4 in settings.');
         showToast('This browser cannot play the transcoded stream. Switch streaming mode to MP4 in settings.');
         return;
         return;
-    } else {
-        playVideo(vid);
     }
     }
 
 
     armResumePrompt(ep, index);
     armResumePrompt(ep, index);
@@ -3756,9 +3840,15 @@ function initVideoControls() {
     // Any seek — native or transcode reload — re-bases the subtitle floor
     // Any seek — native or transcode reload — re-bases the subtitle floor
     $(vid).on('seeking', function () { setSubtitleSeekFloor(effectivePlaybackTime()); });
     $(vid).on('seeking', function () { setSubtitleSeekFloor(effectivePlaybackTime()); });
 
 
-    $(vid).on('playing', hideSeekFreeze);
+    $(vid).on('playing', function () {
+        hideSeekFreeze();
+        streamRecoveryAttempts = 0;   // the stream is healthy again
+    });
     $(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
     $(vid).on('loadeddata', function () { if (vid.paused) { hideSeekFreeze(); } });
-    $(vid).on('error', hideSeekFreeze);
+    $(vid).on('error', function () {
+        hideSeekFreeze();
+        recoverTranscodeStream();
+    });
 
 
     // Auto-hide controls
     // Auto-hide controls
     $('#video-container').on('mousemove touchstart', function () { showControls(); });
     $('#video-container').on('mousemove touchstart', function () { showControls(); });
@@ -3824,8 +3914,8 @@ function showControls() {
     clearTimeout(controlsTimer);
     clearTimeout(controlsTimer);
     controlsTimer = setTimeout(function () {
     controlsTimer = setTimeout(function () {
         var isPlaying = castMode ? castIsPlaying : !document.getElementById('main-video').paused;
         var isPlaying = castMode ? castIsPlaying : !document.getElementById('main-video').paused;
-        // Keep the bar up while the settings popup is open — it is anchored to it
-        if (isPlaying && !$('#settings-popup').hasClass('active')) {
+        // Keep the bar up while a popup is open — both are anchored to it
+        if (isPlaying && !$('#settings-popup').hasClass('active') && !$('#subtitle-popup').hasClass('active')) {
             $('#video-controls').addClass('hidden');
             $('#video-controls').addClass('hidden');
         }
         }
     }, 3000);
     }, 3000);
@@ -3907,6 +3997,7 @@ function initContextMenu() {
         // Always collapse subtitle submenu on fresh open
         // Always collapse subtitle submenu on fresh open
         $('#ctx-subtitle-sub').hide();
         $('#ctx-subtitle-sub').hide();
         closeSettingsPopup();
         closeSettingsPopup();
+        closeSubtitlePopup();
 
 
         // Position menu, clamp inside container
         // Position menu, clamp inside container
         var rect = this.getBoundingClientRect();
         var rect = this.getBoundingClientRect();
@@ -3993,16 +4084,18 @@ function applyStreamMode(mode, reloadCurrent) {
     warnIfStreamModeUnplayable();
     warnIfStreamModeUnplayable();
     if (!isTranscodedVideo || playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
     if (!isTranscodedVideo || playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
 
 
-    // Restart the current file on the newly chosen transport, at the same spot
+    // Restart the current file on the newly chosen transport, at the same spot.
+    // Opening it directly at that offset rather than at zero avoids starting a
+    // transcode from the beginning only to abandon it a moment later.
     var resumeAt = effectivePlaybackTime();
     var resumeAt = effectivePlaybackTime();
-    transcodeSeekOffset = 0;
+    var startAt  = resumeAt > 1 ? resumeAt : 0;
     var vid = document.getElementById('main-video');
     var vid = document.getElementById('main-video');
-    if (!attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, 0))) {
+    transcodeSeekOffset = startAt;
+    if (!attachTranscodeStream(vid, transcodeStreamURL(currentEpisodes[playingIndex].filepath, startAt),
+            function (reason) { showToast(reason); },
+            function () { playVideo(vid); })) {
         showToast('This browser cannot play the HLS stream.');
         showToast('This browser cannot play the HLS stream.');
-        return;
     }
     }
-    playVideo(vid);
-    if (resumeAt > 1) { transcodeSeekTo(resumeAt); }
 }
 }
 
 
 function playerVideoElement() { return document.getElementById('main-video'); }
 function playerVideoElement() { return document.getElementById('main-video'); }
@@ -4037,13 +4130,28 @@ function nearestPlaybackSpeed(rate) {
     }, PLAYBACK_SPEEDS[0]);
     }, PLAYBACK_SPEEDS[0]);
 }
 }
 
 
+// Cap a bottom-anchored popup's height so it never runs past the top edge of
+// the player (it only ever opens upward) — the popup's own scrollbar takes
+// over for whatever doesn't fit.
+function clampBottomPopupHeight($popup) {
+    var container = document.getElementById('video-container');
+    if (!container) { return; }
+    var margin        = 10;
+    var containerRect = container.getBoundingClientRect();
+    var popupRect     = $popup[0].getBoundingClientRect();
+    var available     = popupRect.bottom - containerRect.top - margin;
+    $popup.css('max-height', Math.max(120, available) + 'px');
+}
+
 function openSettingsPopup() {
 function openSettingsPopup() {
     setRepeatSingle(repeatSingle);
     setRepeatSingle(repeatSingle);
     setPlaybackSpeed(playbackSpeed);
     setPlaybackSpeed(playbackSpeed);
     setAlwaysShowVolumeBar(alwaysShowVolumeBar);
     setAlwaysShowVolumeBar(alwaysShowVolumeBar);
     applyStreamMode(getStreamMode(), false);
     applyStreamMode(getStreamMode(), false);
     $('#player-ctx').hide();
     $('#player-ctx').hide();
+    closeSubtitlePopup();
     $('#settings-popup').addClass('active');
     $('#settings-popup').addClass('active');
+    clampBottomPopupHeight($('#settings-popup'));
     showControls();
     showControls();
 }
 }
 
 
@@ -4054,6 +4162,46 @@ function toggleSettingsPopup() {
     else { openSettingsPopup(); }
     else { openSettingsPopup(); }
 }
 }
 
 
+// ── Subtitle popup (dedicated CC button) ────────────────────────────────────
+function openSubtitlePopup() {
+    updateSubtitleSubmenu();
+    $('#player-ctx').hide();
+    closeSettingsPopup();
+    $('#subtitle-popup').addClass('active');
+    clampBottomPopupHeight($('#subtitle-popup'));
+    showControls();
+}
+
+function closeSubtitlePopup() { $('#subtitle-popup').removeClass('active'); }
+
+function toggleSubtitlePopup() {
+    if ($('#subtitle-popup').hasClass('active')) { closeSubtitlePopup(); }
+    else { openSubtitlePopup(); }
+}
+
+function initSubtitlePopup() {
+    $('#ctrl-subtitle').on('click', function (e) {
+        e.stopPropagation();
+        toggleSubtitlePopup();
+    });
+
+    $(document).on('mousedown.subpop', function (e) {
+        if (!$(e.target).closest('#subtitle-popup, #ctrl-subtitle').length) {
+            closeSubtitlePopup();
+        }
+    });
+
+    $('#sub-pop-disable').on('click', function () {
+        activeSubtitleIndex = -1;
+        stopAssTrack();
+        $('#subtitle-display').hide();
+        updateSubtitleSubmenu();
+        closeSubtitlePopup();
+    });
+    $('#sub-pop-load').on('click',     function () { closeSubtitlePopup(); pickSubtitleFile(); });
+    $('#sub-pop-settings').on('click', function () { closeSubtitlePopup(); openSubtitleSettings(); });
+}
+
 function initSettingsPopup() {
 function initSettingsPopup() {
     $('#ctrl-settings').on('click', function (e) {
     $('#ctrl-settings').on('click', function (e) {
         e.stopPropagation();
         e.stopPropagation();
@@ -4081,9 +4229,7 @@ function initSettingsPopup() {
         applyStreamMode(String($(this).data('stream')), true);
         applyStreamMode(String($(this).data('stream')), true);
     });
     });
 
 
-    $('#set-load-subtitle').on('click',     function () { closeSettingsPopup(); pickSubtitleFile(); });
-    $('#set-subtitle-settings').on('click', function () { closeSettingsPopup(); openSubtitleSettings(); });
-    $('#set-video-props').on('click',       function () { closeSettingsPopup(); openVideoInfo('props'); });
+    $('#set-video-props').on('click', function () { closeSettingsPopup(); openVideoInfo('props'); });
 
 
     // Apply persisted values to the video element
     // Apply persisted values to the video element
     setRepeatSingle(repeatSingle);
     setRepeatSingle(repeatSingle);
@@ -4254,10 +4400,12 @@ function initKeyboard() {
                     // Escape dismisses an open overlay first; only closes the
                     // Escape dismisses an open overlay first; only closes the
                     // player once nothing is layered on top of it.
                     // player once nothing is layered on top of it.
                     if ($('#settings-popup').hasClass('active') ||
                     if ($('#settings-popup').hasClass('active') ||
+                        $('#subtitle-popup').hasClass('active') ||
                         $('#player-ctx').is(':visible') ||
                         $('#player-ctx').is(':visible') ||
                         $('#video-info-modal').is(':visible') ||
                         $('#video-info-modal').is(':visible') ||
                         $('#subtitle-settings-modal').is(':visible')) {
                         $('#subtitle-settings-modal').is(':visible')) {
                         closeSettingsPopup();
                         closeSettingsPopup();
+                        closeSubtitlePopup();
                         $('#player-ctx').hide();
                         $('#player-ctx').hide();
                         closeVideoInfo();
                         closeVideoInfo();
                         closeSubtitleSettings();
                         closeSubtitleSettings();
@@ -4877,17 +5025,16 @@ function ensureEmbeddedFontLoaded(family) {
     });
     });
 }
 }
 
 
-function updateSubtitleSubmenu() {
-    // Remove previously injected dynamic entries
-    $('#ctx-subtitle-sub .ctx-sub-dynamic').remove();
-
+// Builds the track list (Disable / embedded tracks / loaded sidecar files) into
+// one menu, given its "Disable" and "Load" anchor items — shared by the
+// right-click context submenu and the dedicated CC button popup so both stay
+// in sync from a single call to updateSubtitleSubmenu().
+function populateSubtitleTrackList($disable, $load, onSelect) {
+    $disable.siblings('.ctx-sub-dynamic').remove();
     var disabled = (activeSubtitleIndex < 0);
     var disabled = (activeSubtitleIndex < 0);
-    $('#ctx-sub-disable')
-        .toggleClass('ctx-active', disabled)
+    $disable.toggleClass('ctx-active', disabled)
         .find('.ctx-icon').text(disabled ? '✓' : '');
         .find('.ctx-icon').text(disabled ? '✓' : '');
 
 
-    var $load = $('#ctx-sub-load');
-
     // Tracks muxed into the video, listed whether or not they are loaded yet
     // Tracks muxed into the video, listed whether or not they are loaded yet
     if (embeddedSubtitles.length > 0) {
     if (embeddedSubtitles.length > 0) {
         $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
         $('<div class="ctx-divider ctx-sub-dynamic"></div>').insertBefore($load);
@@ -4904,7 +5051,7 @@ function updateSubtitleSubmenu() {
                 + '</div>')
                 + '</div>')
             .on('click', function () {
             .on('click', function () {
                 selectEmbeddedSubtitle(track, position);
                 selectEmbeddedSubtitle(track, position);
-                $('#player-ctx').hide();
+                onSelect();
             })
             })
             .insertBefore($load);
             .insertBefore($load);
         });
         });
@@ -4927,13 +5074,23 @@ function updateSubtitleSubmenu() {
                 activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
                 activeSubtitleIndex = parseInt($(this).data('sub-idx'), 10);
                 applyActiveSubtitle();
                 applyActiveSubtitle();
                 updateSubtitleSubmenu();
                 updateSubtitleSubmenu();
-                $('#player-ctx').hide();
+                onSelect();
             })
             })
             .insertBefore($load);
             .insertBefore($load);
         });
         });
     }
     }
 }
 }
 
 
+function updateSubtitleSubmenu() {
+    populateSubtitleTrackList($('#ctx-sub-disable'), $('#ctx-sub-load'), function () {
+        $('#player-ctx').hide();
+    });
+    populateSubtitleTrackList($('#sub-pop-disable'), $('#sub-pop-load'), function () {
+        closeSubtitlePopup();
+    });
+    if ($('#subtitle-popup').hasClass('active')) { clampBottomPopupHeight($('#subtitle-popup')); }
+}
+
 function initSubtitleMenu() {
 function initSubtitleMenu() {
     var $ctx    = $('#player-ctx');
     var $ctx    = $('#player-ctx');
     var $parent = $('#ctx-subtitle-parent');
     var $parent = $('#ctx-subtitle-parent');
@@ -4956,6 +5113,21 @@ function initSubtitleMenu() {
         } else {
         } else {
             $sub.css({ left: '100%', right: 'auto' });
             $sub.css({ left: '100%', right: 'auto' });
         }
         }
+
+        // Clamp vertically so the submenu never runs past the container edge —
+        // flip to open upward when there's more room above than below, and
+        // cap its height to whichever side it opens on (scrollbar handles the rest).
+        var margin        = 10;
+        var containerRect = document.getElementById('video-container').getBoundingClientRect();
+        var parentRect    = this.getBoundingClientRect();
+        var spaceBelow    = containerRect.bottom - parentRect.top - margin;
+        var spaceAbove    = parentRect.bottom - containerRect.top - margin;
+        if (spaceBelow < 160 && spaceAbove > spaceBelow) {
+            $sub.css({ top: 'auto', bottom: 0, maxHeight: Math.max(120, spaceAbove) + 'px' });
+        } else {
+            $sub.css({ top: 0, bottom: 'auto', maxHeight: Math.max(120, spaceBelow) + 'px' });
+        }
+
         $sub.show();
         $sub.show();
     });
     });
     $parent.on('mouseleave', scheduleSubHide);
     $parent.on('mouseleave', scheduleSubHide);