Przeglądaj źródła

Add hls support for Safari

Toby Chui 1 tydzień temu
rodzic
commit
f3b286b7f3

+ 6 - 0
src/main.go

@@ -68,6 +68,12 @@ func executeShutdownSequence() {
 		FTPManager.StopFtpServer()
 	}
 
+	//Shutdown media server, stopping any running HLS transcode
+	if mediaServer != nil {
+		systemWideLogger.PrintAndLog("System", "<!> Shutting down media server", nil)
+		mediaServer.Close()
+	}
+
 	//Cleaning up tmp files
 	systemWideLogger.PrintAndLog("System", "<!> Cleaning up tmp folder", nil)
 	os.RemoveAll(*tmp_directory)

+ 3 - 0
src/mediaServer.go

@@ -47,6 +47,9 @@ func mediaServer_init() {
 		//ffmpeg installed. allow transcode
 		http.HandleFunc("/media/transcode/", mediaServer.ServeVideoWithTranscode)
 		http.HandleFunc("/media/transcode/audio/", mediaServer.ServeAudioWithTranscode)
+		//HLS output, for clients that require byte-range-able media (Safari / iOS)
+		http.HandleFunc("/media/hls/", mediaServer.ServeHLSPlaylist)
+		http.HandleFunc(mediaserver.HLSSegmentEndpoint, mediaServer.ServeHLSSegment)
 		http.HandleFunc("/media/duration/", mediaServer.GetAudioDuration)
 		http.HandleFunc("/media/storyboard/", mediaServer.ServeStoryboard)
 		http.HandleFunc("/media/subtitles/", mediaServer.ServeEmbeddedSubtitles)

+ 210 - 0
src/mod/media/mediaserver/hls.go

@@ -0,0 +1,210 @@
+package mediaserver
+
+/*
+	hls.go
+
+	HLS delivery endpoints for transcoded video.
+
+	This module adds support for HLS based streaming to the media server.
+
+	Two endpoints make up the format:
+	  /media/hls/          ?file=<vpath>[&res=][&start=]  -> the .m3u8 playlist
+	  /media/hls/segment   ?sid=<session>&name=<segment>  -> one .ts segment
+
+	The playlist request creates (or joins) a transcode session; every segment
+	line inside it points back at the segment endpoint carrying that session id.
+*/
+
+import (
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+
+	"imuslab.com/arozos/mod/filesystem"
+	fs "imuslab.com/arozos/mod/filesystem"
+	"imuslab.com/arozos/mod/media/transcoder"
+	"imuslab.com/arozos/mod/utils"
+)
+
+// HLSSegmentEndpoint is the URL path that serves individual segments. It is
+// baked into every playlist ffmpeg writes, so it must match the route
+// registered in mediaServer.go.
+const HLSSegmentEndpoint = "/media/hls/segment"
+
+// transcodeResolutionFromRequest reads the optional "res" parameter.
+// An unrecognised value falls back to the source resolution, matching the
+// behaviour the MP4 endpoint has always had.
+func transcodeResolutionFromRequest(r *http.Request) transcoder.TranscodeOutputResolution {
+	resolution, err := utils.GetPara(r, "res")
+	if err != nil {
+		return transcoder.TranscodeResolution_original
+	}
+	switch resolution {
+	case "1080p":
+		return transcoder.TranscodeResolution_1080p
+	case "720p":
+		return transcoder.TranscodeResolution_720p
+	case "360p":
+		return transcoder.TranscodeResolution_360p
+	}
+	return transcoder.TranscodeResolution_original
+}
+
+// startTimeFromRequest reads the optional "start" seek offset in seconds.
+func startTimeFromRequest(r *http.Request) float64 {
+	startTimeStr, _ := utils.GetPara(r, "start")
+	if startTimeStr == "" {
+		return 0
+	}
+	startTime, err := strconv.ParseFloat(startTimeStr, 64)
+	if err != nil || startTime < 0 {
+		return 0
+	}
+	return startTime
+}
+
+// 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) {
+	if s.hlsManager == nil {
+		utils.SendErrorResponse(w, "HLS output is not available on this host")
+		return
+	}
+
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	//ValidateSourceFile authenticates the request and resolves the vpath
+	sourceFile, ok := s.resolveLocalTranscodeSource(w, r)
+	if !ok {
+		return
+	}
+
+	session, err := s.hlsManager.GetOrCreate(userinfo.Username, sourceFile,
+		transcodeResolutionFromRequest(r), startTimeFromRequest(r))
+	if err != nil {
+		s.options.Logger.PrintAndLog("Media Server", "Unable to start HLS session", err)
+		utils.SendErrorResponse(w, "Unable to start HLS transcode")
+		return
+	}
+
+	if err := session.WaitForPlaylist(transcoder.HLSPlaylistWaitTimeout); err != nil {
+		s.options.Logger.PrintAndLog("Media Server", "HLS session produced no playable segment", err)
+		utils.SendErrorResponse(w, "Transcode did not produce a playable stream")
+		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())
+}
+
+// ServeHLSSegment serves one segment of a running HLS session. Segments are
+// readable only by the user whose session produced them.
+func (s *Instance) ServeHLSSegment(w http.ResponseWriter, r *http.Request) {
+	if s.hlsManager == nil {
+		http.Error(w, "HLS output is not available on this host", http.StatusNotFound)
+		return
+	}
+
+	userinfo, err := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		http.Error(w, "User not logged in", http.StatusUnauthorized)
+		return
+	}
+
+	sessionID, err := utils.GetPara(r, "sid")
+	if err != nil {
+		http.Error(w, "Missing parameter 'sid'", http.StatusBadRequest)
+		return
+	}
+	segmentName, err := utils.GetPara(r, "name")
+	if err != nil {
+		http.Error(w, "Missing parameter 'name'", http.StatusBadRequest)
+		return
+	}
+
+	session := s.hlsManager.Session(sessionID)
+	if session == nil {
+		//Either a stale playlist from a reaped session, or a guessed id
+		http.Error(w, "No such HLS session", http.StatusNotFound)
+		return
+	}
+	if session.Owner != userinfo.Username {
+		http.Error(w, "Permission Denied", http.StatusForbidden)
+		return
+	}
+
+	segmentPath, err := session.SegmentPath(segmentName)
+	if err != nil {
+		http.Error(w, "Invalid segment name", http.StatusBadRequest)
+		return
+	}
+
+	//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")
+	w.Header().Set("Cache-Control", "private, max-age=3600")
+	http.ServeFile(w, r, segmentPath)
+}
+
+// resolveLocalTranscodeSource validates the request and returns an absolute
+// path to the source file on local disk. ffmpeg cannot read a remote file
+// system directly, so a file living on one is buffered locally first (reusing
+// an existing buffer when its hash still matches).
+//
+// It writes the error response itself and returns ok=false when the file cannot
+// be made available.
+func (s *Instance) resolveLocalTranscodeSource(w http.ResponseWriter, r *http.Request) (string, bool) {
+	userinfo, _ := s.options.UserHandler.GetUserInfoFromRequest(w, r)
+	targetFsh, vpath, realFilepath, err := s.ValidateSourceFile(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return "", false
+	}
+
+	if filesystem.FileExists(realFilepath) {
+		//Already on the local file system
+		absPath, err := filepath.Abs(realFilepath)
+		if err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return "", false
+		}
+		return absPath, true
+	}
+
+	//Remote file system: reuse the local buffer when it is still current
+	ps, _ := targetFsh.GetUniquePathHash(vpath, userinfo.Username)
+	buffpool := filepath.Join(s.options.TmpDirectory, "fsbuffpool")
+	buffFile := filepath.Join(buffpool, ps)
+	if fs.FileExists(buffFile) {
+		remoteFileHash, err := s.GetHashFromRemoteFile(targetFsh.FileSystemAbstraction, realFilepath)
+		if err == nil {
+			localFileHash, err := os.ReadFile(buffFile + ".hash")
+			if err == nil && string(localFileHash) == remoteFileHash {
+				buffFileAbs, _ := filepath.Abs(buffFile)
+				return buffFileAbs, true
+			}
+		}
+	}
+
+	if !s.options.EnableFileBuffering {
+		utils.SendErrorResponse(w, "unable to transcode remote file with file buffer disabled")
+		return "", false
+	}
+
+	os.MkdirAll(buffpool, 0775)
+	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 {
+		utils.SendErrorResponse(w, err.Error())
+		return "", false
+	}
+
+	buffFileAbs, _ := filepath.Abs(buffFile)
+	return buffFileAbs, true
+}

+ 26 - 78
src/mod/media/mediaserver/mediaserver.go

@@ -50,16 +50,36 @@ type Options struct {
 type Instance struct {
 	options             *Options
 	VirtualPathResolver func(string) (*fs.FileSystemHandler, string, error) //Virtual path to File system handler resolver, must be provided externally
+	hlsManager          *transcoder.HLSManager                              //Manages live HLS transcode sessions; nil if it could not be started
 }
 
 // Initialize a new media server instance
 func NewMediaServer(options *Options) *Instance {
-	return &Instance{
+	instance := &Instance{
 		options: options,
 		VirtualPathResolver: func(s string) (*fs.FileSystemHandler, string, error) {
 			return nil, "", errors.New("no virtual path resolver assigned")
 		},
 	}
+
+	//Prepare the HLS session store. A failure here only disables HLS output;
+	//the MP4 transcode path keeps working.
+	hlsManager, err := transcoder.NewHLSManager(options.TmpDirectory, HLSSegmentEndpoint)
+	if err != nil {
+		options.Logger.PrintAndLog("Media Server", "Unable to initiate HLS session store, HLS output disabled", err)
+	} else {
+		instance.hlsManager = hlsManager
+	}
+
+	return instance
+}
+
+// Close releases the resources held by this media server, stopping any running
+// HLS transcode and removing its working directory.
+func (s *Instance) Close() {
+	if s.hlsManager != nil {
+		s.hlsManager.Close()
+	}
 }
 
 // Set the virtual path resolver for this media instance
@@ -303,86 +323,14 @@ func (s *Instance) ServerMedia(w http.ResponseWriter, r *http.Request) {
 
 // Serve video file with real-time transcoder
 func (s *Instance) ServeVideoWithTranscode(w http.ResponseWriter, r *http.Request) {
-	userinfo, _ := s.options.UserHandler.GetUserInfoFromRequest(w, r)
-	//Serve normal media files
-	targetFsh, vpath, realFilepath, err := s.ValidateSourceFile(w, r)
-	if err != nil {
-		utils.SendErrorResponse(w, err.Error())
+	//Resolve to a local file first; ffmpeg cannot read a remote file system
+	sourceFile, ok := s.resolveLocalTranscodeSource(w, r)
+	if !ok {
 		return
 	}
 
-	resolution, err := utils.GetPara(r, "res")
-	if err != nil {
-		resolution = ""
-	}
-
-	transcodeOutputResolution := transcoder.TranscodeResolution_original
-	if resolution == "1080p" {
-		transcodeOutputResolution = transcoder.TranscodeResolution_1080p
-	} else if resolution == "720p" {
-		transcodeOutputResolution = transcoder.TranscodeResolution_720p
-	} else if resolution == "360p" {
-		transcodeOutputResolution = transcoder.TranscodeResolution_360p
-	}
-
-	var startTime float64
-	if startTimeStr, _ := utils.GetPara(r, "start"); startTimeStr != "" {
-		startTime, _ = strconv.ParseFloat(startTimeStr, 64)
-	}
-
-	//TODO: Cleanup unused code
-	//targetFshAbs := targetFsh.FileSystemAbstraction
-	transcodeSourceFile := realFilepath
-	if filesystem.FileExists(transcodeSourceFile) {
-		//This is a file from the local file system.
-		//Stream it out with transcoder
-		transcodeSrcFileAbsPath, err := filepath.Abs(realFilepath)
-		if err != nil {
-			utils.SendErrorResponse(w, err.Error())
-			return
-		}
-		transcoder.TranscodeAndStream(w, r, transcodeSrcFileAbsPath, transcodeOutputResolution, startTime)
-		return
-	} else {
-		//This file is from a remote file system. Check if it already has a local buffer
-		ps, _ := targetFsh.GetUniquePathHash(vpath, userinfo.Username)
-		buffpool := filepath.Join(s.options.TmpDirectory, "fsbuffpool")
-		buffFile := filepath.Join(buffpool, ps)
-		if fs.FileExists(buffFile) {
-			//Stream the buff file if hash matches
-			remoteFileHash, err := s.GetHashFromRemoteFile(targetFsh.FileSystemAbstraction, realFilepath)
-			if err == nil {
-				localFileHash, err := os.ReadFile(buffFile + ".hash")
-				if err == nil {
-					if string(localFileHash) == remoteFileHash {
-						//Hash matches. Serve local buffered file
-						buffFileAbs, _ := filepath.Abs(buffFile)
-						transcoder.TranscodeAndStream(w, r, buffFileAbs, transcodeOutputResolution, startTime)
-						return
-					}
-				}
-			}
-		}
-
-		//Buffer file not exists. Buffer it to local now
-		if s.options.EnableFileBuffering {
-			os.MkdirAll(buffpool, 0775)
-			s.options.Logger.PrintAndLog("Media Server", "Buffering video from remote file system handler (might take a while)", nil)
-			err = s.BufferRemoteFileToTmp(buffFile, targetFsh, realFilepath)
-			if err != nil {
-				utils.SendErrorResponse(w, err.Error())
-				return
-			}
-
-			//Buffer completed. Start transcode
-			buffFileAbs, _ := filepath.Abs(buffFile)
-			transcoder.TranscodeAndStream(w, r, buffFileAbs, transcodeOutputResolution, startTime)
-			return
-		} else {
-			utils.SendErrorResponse(w, "unable to transcode remote file with file buffer disabled")
-			return
-		}
-	}
+	transcoder.TranscodeAndStream(w, r, sourceFile,
+		transcodeResolutionFromRequest(r), startTimeFromRequest(r))
 
 	//Check if it is a remote file system. FFmpeg can only works with local files
 	//if the file is from a remote source, buffer it to local before transcoding.

+ 486 - 0
src/mod/media/transcoder/hls.go

@@ -0,0 +1,486 @@
+package transcoder
+
+/*
+	hls.go
+
+	HTTP Live Streaming output for the transcoder.
+
+	TranscodeAndStream pipes a single fragmented MP4 down one long-lived HTTP
+	response, which cannot answer byte-range requests and therefore cannot be
+	played by WebKit clients (Safari, and every browser on iOS). HLS solves that
+	by cutting the transcode into short segments listed in a playlist: each
+	segment is an ordinary, finite, seekable file.
+
+	A session owns one ffmpeg process writing segments into its own temp
+	directory. Sessions are keyed by owner + source file + resolution + start
+	offset so that a reload, or a second player on the same file, reuses the
+	transcode already running instead of starting another. Idle sessions are
+	reaped by a background janitor, which kills ffmpeg and removes the directory.
+
+	The playlist is written with -hls_playlist_type event, so it grows as the
+	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
+	does it: by starting a new session at a later -ss offset.
+*/
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"imuslab.com/arozos/mod/info/logger"
+)
+
+const (
+	// hlsSegmentSeconds is the target length of each segment. Four seconds is
+	// the usual HLS compromise: short enough that playback starts quickly and
+	// seeking lands close to the requested time, long enough that the segment
+	// count (and request rate) stays sane on a feature-length file.
+	hlsSegmentSeconds = 4
+
+	hlsPlaylistName    = "index.m3u8"
+	hlsSegmentPattern  = "seg%05d.ts"
+	hlsSegmentPrefix   = "seg"
+	hlsSegmentSuffix   = ".ts"
+	hlsWorkingDirName  = "hls"
+	hlsIdleTimeout     = 5 * time.Minute
+	hlsMaxSessions     = 8
+	hlsJanitorInterval = 30 * time.Second
+
+	// HLSPlaylistWaitTimeout bounds how long a playlist request should wait for
+	// ffmpeg to produce the first segment before giving up. Exported so the
+	// handler serving playlists uses the same budget the transcode was sized for.
+	HLSPlaylistWaitTimeout = 45 * time.Second
+)
+
+// HLSSession is one running transcode writing HLS segments to disk.
+type HLSSession struct {
+	ID        string  // opaque identifier, also the temp directory name
+	Owner     string  // username allowed to fetch this session's segments
+	Dir       string  // directory holding the playlist and its segments
+	StartTime float64 // -ss offset this session was started at, in seconds
+
+	cmd    *exec.Cmd
+	exited chan struct{} // closed once the transcode process has been reaped
+
+	mu         sync.Mutex
+	lastAccess time.Time
+	stopped    bool
+}
+
+// touch records activity so the janitor does not reap a session that is still
+// being played.
+func (s *HLSSession) touch() {
+	s.mu.Lock()
+	s.lastAccess = time.Now()
+	s.mu.Unlock()
+}
+
+func (s *HLSSession) idleFor() time.Duration {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return time.Since(s.lastAccess)
+}
+
+// PlaylistPath returns the on-disk location of this session's playlist.
+func (s *HLSSession) PlaylistPath() string {
+	return filepath.Join(s.Dir, hlsPlaylistName)
+}
+
+// SegmentPath resolves a segment name from a playlist entry to a path inside
+// this session's directory. The name is validated rather than merely cleaned:
+// it must look exactly like a name this session generated, so a request can
+// never address a file outside the session directory.
+func (s *HLSSession) SegmentPath(name string) (string, error) {
+	if !validHLSSegmentName(name) {
+		return "", errors.New("invalid segment name")
+	}
+	return filepath.Join(s.Dir, name), nil
+}
+
+// stop kills the transcode and removes the session's directory. Safe to call
+// more than once.
+//
+// Only the goroutine started in GetOrCreate ever calls cmd.Wait; this waits on
+// the channel that goroutine closes instead, since calling Wait twice on the
+// same command is an error.
+func (s *HLSSession) stop() {
+	s.mu.Lock()
+	if s.stopped {
+		s.mu.Unlock()
+		return
+	}
+	s.stopped = true
+	cmd := s.cmd
+	exited := s.exited
+	dir := s.Dir
+	s.mu.Unlock()
+
+	if cmd != nil && cmd.Process != nil {
+		cmd.Process.Kill()
+		if exited != nil {
+			<-exited
+		}
+	}
+	if dir != "" {
+		os.RemoveAll(dir)
+	}
+}
+
+// validHLSSegmentName reports whether name matches the segment naming this
+// package generates ("seg00000.ts"), rejecting anything containing a path
+// separator, "..", or unexpected characters.
+func validHLSSegmentName(name string) bool {
+	if !strings.HasPrefix(name, hlsSegmentPrefix) || !strings.HasSuffix(name, hlsSegmentSuffix) {
+		return false
+	}
+	digits := strings.TrimSuffix(strings.TrimPrefix(name, hlsSegmentPrefix), hlsSegmentSuffix)
+	if digits == "" {
+		return false
+	}
+	for _, c := range digits {
+		if c < '0' || c > '9' {
+			return false
+		}
+	}
+	return true
+}
+
+// hlsSessionKey identifies a reusable transcode. Two requests that would
+// produce byte-identical output share a session.
+func hlsSessionKey(owner string, inputFile string, resolution TranscodeOutputResolution, startTime float64) string {
+	raw := strings.Join([]string{
+		owner,
+		inputFile,
+		string(resolution),
+		strconv.FormatFloat(startTime, 'f', 3, 64),
+	}, "\x00")
+	sum := md5.Sum([]byte(raw))
+	return hex.EncodeToString(sum[:])
+}
+
+// buildHLSArgs assembles the ffmpeg command line for an HLS session. It mirrors
+// the encoder selection in TranscodeAndStream - the same hardware profile when
+// one is available, libx264 otherwise - and differs only in the muxer.
+//
+// segmentBaseURL is prepended to every segment name in the playlist, letting the
+// segments be fetched from an HTTP endpoint rather than sitting next to the
+// playlist on disk.
+func buildHLSArgs(inputFile string, dir string, resolution TranscodeOutputResolution, startTime float64, segmentBaseURL string, hw *hwEncoderProfile) ([]string, error) {
+	height, err := resolutionHeight(resolution)
+	if err != nil {
+		return nil, err
+	}
+
+	var args []string
+	var vf string
+	var videoCodecArgs []string
+	if hw != nil {
+		args = append(args, hw.PreInput...)
+		vf = hw.ScaleFilter(height)
+		videoCodecArgs = append([]string{"-vcodec", hw.Codec}, hw.EncodeArgs...)
+	} else {
+		if height != "" {
+			vf = "scale=-1:" + height
+		}
+		videoCodecArgs = []string{"-vcodec", "libx264", "-preset", "superfast"}
+	}
+
+	if startTime > 0.001 {
+		// Seeking before -i is the fast path: output timestamps then start at
+		// zero, which is what the growing playlist expects.
+		args = append(args, "-ss", fmt.Sprintf("%.3f", startTime))
+	}
+	args = append(args, "-i", inputFile)
+
+	// Take the first video and, if present, the first audio track. Without this
+	// a file carrying extra streams (subtitles, attachments, second audio) can
+	// fail to mux into MPEG-TS.
+	args = append(args, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
+
+	if vf != "" {
+		args = append(args, "-vf", vf)
+	}
+	args = append(args, videoCodecArgs...)
+
+	// Segments can only be cut on a keyframe, so force one exactly on every
+	// segment boundary; otherwise ffmpeg overshoots and segment lengths drift
+	// away from hlsSegmentSeconds.
+	args = append(args,
+		"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", hlsSegmentSeconds),
+		"-c:a", "aac", "-b:a", "128k", "-ac", "2",
+	)
+
+	args = append(args,
+		"-f", "hls",
+		"-hls_time", strconv.Itoa(hlsSegmentSeconds),
+		"-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",
+		"-hls_base_url", segmentBaseURL,
+		"-hls_segment_filename", filepath.Join(dir, hlsSegmentPattern),
+		filepath.Join(dir, hlsPlaylistName),
+	)
+	return args, nil
+}
+
+// resolutionHeight maps a requested output resolution to an ffmpeg scale
+// height, returning an error for anything unrecognised.
+func resolutionHeight(resolution TranscodeOutputResolution) (string, error) {
+	switch resolution {
+	case "360p":
+		return "360", nil
+	case "720p":
+		return "720", nil
+	case "1080p":
+		return "1080", nil
+	case "":
+		return "", nil
+	}
+	return "", errors.New("invalid resolution parameter")
+}
+
+// HLSManager owns every live HLS session and the temp directory they live in.
+type HLSManager struct {
+	root            string // parent directory for all session directories
+	segmentEndpoint string // URL path that serves segments back to the player
+
+	mu       sync.Mutex
+	sessions map[string]*HLSSession
+	closed   bool
+	stopChan chan struct{}
+}
+
+// NewHLSManager prepares the working directory and starts the reaper. Any
+// directory left behind by a previous run is discarded, since the ffmpeg
+// processes that were filling those directories died with that run.
+func NewHLSManager(tmpDirectory string, segmentEndpoint string) (*HLSManager, error) {
+	root := filepath.Join(tmpDirectory, hlsWorkingDirName)
+	os.RemoveAll(root)
+	if err := os.MkdirAll(root, 0755); err != nil {
+		return nil, err
+	}
+	m := &HLSManager{
+		root:            root,
+		segmentEndpoint: segmentEndpoint,
+		sessions:        map[string]*HLSSession{},
+		stopChan:        make(chan struct{}),
+	}
+	go m.janitor()
+	return m, nil
+}
+
+// janitor reaps sessions nobody has touched for hlsIdleTimeout.
+func (m *HLSManager) janitor() {
+	ticker := time.NewTicker(hlsJanitorInterval)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-m.stopChan:
+			return
+		case <-ticker.C:
+			m.reapIdle()
+		}
+	}
+}
+
+func (m *HLSManager) reapIdle() {
+	m.mu.Lock()
+	var expired []*HLSSession
+	for key, session := range m.sessions {
+		if session.idleFor() > hlsIdleTimeout {
+			expired = append(expired, session)
+			delete(m.sessions, key)
+		}
+	}
+	m.mu.Unlock()
+
+	for _, session := range expired {
+		session.stop()
+		logger.PrintAndLog("Transcoder", "Reaped idle HLS session "+session.ID, nil)
+	}
+}
+
+// Close stops the janitor and tears down every live session.
+func (m *HLSManager) Close() {
+	m.mu.Lock()
+	if m.closed {
+		m.mu.Unlock()
+		return
+	}
+	m.closed = true
+	close(m.stopChan)
+	sessions := make([]*HLSSession, 0, len(m.sessions))
+	for key, session := range m.sessions {
+		sessions = append(sessions, session)
+		delete(m.sessions, key)
+	}
+	m.mu.Unlock()
+
+	for _, session := range sessions {
+		session.stop()
+	}
+	os.RemoveAll(m.root)
+}
+
+// Session returns a live session by ID, or nil. It counts as activity, so
+// fetching segments keeps the session from being reaped mid-playback.
+func (m *HLSManager) Session(id string) *HLSSession {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	for _, session := range m.sessions {
+		if session.ID == id {
+			session.touch()
+			return session
+		}
+	}
+	return nil
+}
+
+// GetOrCreate returns the session for this exact transcode, starting one if it
+// is not already running.
+func (m *HLSManager) GetOrCreate(owner string, inputFile string, resolution TranscodeOutputResolution, startTime float64) (*HLSSession, error) {
+	key := hlsSessionKey(owner, inputFile, resolution, startTime)
+
+	m.mu.Lock()
+	if m.closed {
+		m.mu.Unlock()
+		return nil, errors.New("HLS manager closed")
+	}
+	if existing, ok := m.sessions[key]; ok {
+		existing.touch()
+		m.mu.Unlock()
+		return existing, nil
+	}
+	m.mu.Unlock()
+
+	// Make room before starting another transcode; each one costs a process.
+	m.evictToCapacity()
+
+	session := &HLSSession{
+		ID:         key,
+		Owner:      owner,
+		Dir:        filepath.Join(m.root, key),
+		StartTime:  startTime,
+		lastAccess: time.Now(),
+	}
+	if err := os.MkdirAll(session.Dir, 0755); err != nil {
+		return nil, err
+	}
+
+	args, err := buildHLSArgs(inputFile, session.Dir, resolution, startTime,
+		m.segmentBaseURL(key), getHWEncoderProfile())
+	if err != nil {
+		os.RemoveAll(session.Dir)
+		return nil, err
+	}
+
+	cmd := exec.Command("ffmpeg", args...)
+	if err := cmd.Start(); err != nil {
+		os.RemoveAll(session.Dir)
+		return nil, err
+	}
+	session.cmd = cmd
+	session.exited = make(chan struct{})
+	//Sole owner of cmd.Wait: reaps the process whether it finishes the file or
+	//is killed, and signals both facts through the same channel.
+	go func(exited chan struct{}) {
+		cmd.Wait()
+		close(exited)
+	}(session.exited)
+
+	m.mu.Lock()
+	// Another request may have created the same session while ffmpeg was
+	// starting; keep the winner and discard this duplicate.
+	if existing, ok := m.sessions[key]; ok {
+		m.mu.Unlock()
+		session.stop()
+		existing.touch()
+		return existing, nil
+	}
+	m.sessions[key] = session
+	m.mu.Unlock()
+	return session, nil
+}
+
+// evictToCapacity stops the least recently used sessions until there is room
+// for one more.
+func (m *HLSManager) evictToCapacity() {
+	for {
+		m.mu.Lock()
+		if len(m.sessions) < hlsMaxSessions {
+			m.mu.Unlock()
+			return
+		}
+		var oldestKey string
+		var oldest *HLSSession
+		for key, session := range m.sessions {
+			if oldest == nil || session.idleFor() > oldest.idleFor() {
+				oldestKey, oldest = key, session
+			}
+		}
+		if oldest == nil {
+			m.mu.Unlock()
+			return
+		}
+		delete(m.sessions, oldestKey)
+		m.mu.Unlock()
+
+		oldest.stop()
+		logger.PrintAndLog("Transcoder", "Evicted HLS session "+oldest.ID+" to stay within the session limit", nil)
+	}
+}
+
+func (m *HLSManager) segmentBaseURL(sessionID string) string {
+	return m.segmentEndpoint + "?sid=" + sessionID + "&name="
+}
+
+// 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 {
+	deadline := time.Now().Add(timeout)
+	s.mu.Lock()
+	exited := s.exited
+	s.mu.Unlock()
+
+	for {
+		if playlistHasSegment(s.PlaylistPath()) {
+			return nil
+		}
+		// Check for a dead transcode only after re-checking the playlist above,
+		// so a process that exited right after writing its last segment still
+		// counts as a success.
+		select {
+		case <-exited:
+			if playlistHasSegment(s.PlaylistPath()) {
+				return nil
+			}
+			return errors.New("transcode ended before producing any segment")
+		default:
+		}
+		if time.Now().After(deadline) {
+			return errors.New("timed out waiting for the first segment")
+		}
+		time.Sleep(200 * time.Millisecond)
+	}
+}
+
+// playlistHasSegment reports whether the playlist on disk already references a
+// segment. ffmpeg writes the header before the first segment is complete, so
+// the file existing is not on its own enough.
+func playlistHasSegment(path string) bool {
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return false
+	}
+	return strings.Contains(string(content), hlsSegmentSuffix)
+}

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

@@ -0,0 +1,311 @@
+package transcoder
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// Source-file fixtures. These are never opened - the functions under test only
+// ever treat them as opaque strings - but they are built with filepath.Join so
+// no absolute, OS-specific path literal appears in the tree.
+var (
+	srcA = filepath.Join("movies", "a.avi")
+	srcB = filepath.Join("movies", "b.avi")
+)
+
+// TestValidHLSSegmentName verifies that only names this package generates are
+// accepted, since the name is used to build a path inside the session folder.
+func TestValidHLSSegmentName(t *testing.T) {
+	cases := []struct {
+		name string
+		want bool
+	}{
+		{"seg00000.ts", true},
+		{"seg00123.ts", true},
+		{"seg1.ts", true},
+		{"", false},
+		{"seg.ts", 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},
+		{"..", false},
+		{"../passwd", false},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := validHLSSegmentName(tc.name); got != tc.want {
+				t.Errorf("validHLSSegmentName(%q) = %v, want %v", tc.name, got, tc.want)
+			}
+		})
+	}
+}
+
+// TestSegmentPathRejectsTraversal confirms a rejected name yields an error
+// rather than a path escaping the session directory.
+func TestSegmentPathRejectsTraversal(t *testing.T) {
+	session := &HLSSession{Dir: t.TempDir()}
+
+	if _, err := session.SegmentPath("../../passwd"); err == nil {
+		t.Error("SegmentPath accepted a traversal name, want error")
+	}
+
+	got, err := session.SegmentPath("seg00007.ts")
+	if err != nil {
+		t.Fatalf("SegmentPath(valid) returned error: %v", err)
+	}
+	if want := filepath.Join(session.Dir, "seg00007.ts"); got != want {
+		t.Errorf("SegmentPath = %q, want %q", got, want)
+	}
+}
+
+// TestHLSSessionKey verifies that only identical transcodes share a key.
+func TestHLSSessionKey(t *testing.T) {
+	base := hlsSessionKey("alice", srcA, TranscodeResolution_original, 0)
+
+	if again := hlsSessionKey("alice", srcA, TranscodeResolution_original, 0); again != base {
+		t.Errorf("same inputs produced different keys: %q vs %q", base, again)
+	}
+
+	differing := map[string]string{
+		"other user":       hlsSessionKey("bob", srcA, TranscodeResolution_original, 0),
+		"other file":       hlsSessionKey("alice", srcB, TranscodeResolution_original, 0),
+		"other resolution": hlsSessionKey("alice", srcA, TranscodeResolution_720p, 0),
+		"other start":      hlsSessionKey("alice", srcA, TranscodeResolution_original, 90),
+	}
+	for name, key := range differing {
+		t.Run(name, func(t *testing.T) {
+			if key == base {
+				t.Errorf("%s produced the same session key as the base case", name)
+			}
+		})
+	}
+}
+
+// TestResolutionHeight covers the shared mapping used by both the MP4 and the
+// HLS path, including the rejection that guards the endpoint.
+func TestResolutionHeight(t *testing.T) {
+	cases := []struct {
+		resolution TranscodeOutputResolution
+		want       string
+		wantErr    bool
+	}{
+		{TranscodeResolution_original, "", false},
+		{"360p", "360", false},
+		{"720p", "720", false},
+		{"1080p", "1080", false},
+		{"banana", "", true},
+		{"480p", "", true},
+	}
+
+	for _, tc := range cases {
+		t.Run(string(tc.resolution), func(t *testing.T) {
+			got, err := resolutionHeight(tc.resolution)
+			if tc.wantErr {
+				if err == nil {
+					t.Fatalf("resolutionHeight(%q) returned no error, want one", tc.resolution)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("resolutionHeight(%q) returned error: %v", tc.resolution, err)
+			}
+			if got != tc.want {
+				t.Errorf("resolutionHeight(%q) = %q, want %q", tc.resolution, got, tc.want)
+			}
+		})
+	}
+}
+
+// TestBuildHLSArgs checks the generated command line for the properties that
+// make the output playable: correct muxer settings, the whole playlist kept,
+// the segment URL prefix, and the encoder chosen the same way the MP4 path
+// chooses it.
+func TestBuildHLSArgs(t *testing.T) {
+	dir := t.TempDir()
+	const baseURL = "/media/hls/segment?sid=abc&name="
+
+	t.Run("software encoder", func(t *testing.T) {
+		args, err := buildHLSArgs(srcA, dir, TranscodeResolution_360p, 0, baseURL, nil)
+		if err != nil {
+			t.Fatalf("buildHLSArgs returned error: %v", err)
+		}
+		joined := strings.Join(args, " ")
+
+		for _, want := range []string{
+			"-f hls",
+			"-hls_list_size 0",
+			"-hls_playlist_type event",
+			"-hls_base_url " + baseURL,
+			"-vcodec libx264",
+			"-vf scale=-1:360",
+			"-c:a aac",
+			"-map 0:v:0",
+		} {
+			if !strings.Contains(joined, want) {
+				t.Errorf("args missing %q\ngot: %v", want, args)
+			}
+		}
+		if strings.Contains(joined, "-ss ") {
+			t.Errorf("start time 0 should not add -ss\ngot: %v", args)
+		}
+		if last := args[len(args)-1]; last != filepath.Join(dir, hlsPlaylistName) {
+			t.Errorf("playlist path = %q, want it last and inside the session dir", last)
+		}
+	})
+
+	t.Run("hardware encoder", func(t *testing.T) {
+		hw := &hwEncoderProfile{
+			Name:        "test",
+			Codec:       "h264_videotoolbox",
+			ScaleFilter: nv12ScaleFilter,
+			EncodeArgs:  []string{"-realtime", "1"},
+		}
+		args, err := buildHLSArgs(srcA, dir, TranscodeResolution_original, 0, baseURL, hw)
+		if err != nil {
+			t.Fatalf("buildHLSArgs returned error: %v", err)
+		}
+		joined := strings.Join(args, " ")
+		if !strings.Contains(joined, "-vcodec h264_videotoolbox -realtime 1") {
+			t.Errorf("hardware encoder args not applied\ngot: %v", args)
+		}
+		if strings.Contains(joined, "libx264") {
+			t.Errorf("hardware path should not fall back to libx264\ngot: %v", args)
+		}
+	})
+
+	t.Run("pre-input args precede the input", func(t *testing.T) {
+		hw := &hwEncoderProfile{
+			Name:        "vaapi-like",
+			Codec:       "h264_vaapi",
+			PreInput:    []string{"-vaapi_device", "renderD128"},
+			ScaleFilter: func(string) string { return "format=nv12,hwupload" },
+		}
+		args, err := buildHLSArgs(srcA, dir, TranscodeResolution_original, 30, baseURL, hw)
+		if err != nil {
+			t.Fatalf("buildHLSArgs returned error: %v", err)
+		}
+		device := indexOf(args, "-vaapi_device")
+		seek := indexOf(args, "-ss")
+		input := indexOf(args, "-i")
+		if device == -1 || seek == -1 || input == -1 {
+			t.Fatalf("expected -vaapi_device, -ss and -i in args: %v", args)
+		}
+		if !(device < seek && seek < input) {
+			t.Errorf("expected -vaapi_device before -ss before -i, got positions %d, %d, %d\nargs: %v",
+				device, seek, input, args)
+		}
+	})
+
+	t.Run("invalid resolution", func(t *testing.T) {
+		if _, err := buildHLSArgs(srcA, dir, "banana", 0, baseURL, nil); err == nil {
+			t.Error("buildHLSArgs accepted an invalid resolution, want error")
+		}
+	})
+}
+
+func indexOf(list []string, want string) int {
+	for i, v := range list {
+		if v == want {
+			return i
+		}
+	}
+	return -1
+}
+
+// TestPlaylistHasSegment verifies the readiness check distinguishes a header
+// only playlist from one that actually lists a segment.
+func TestPlaylistHasSegment(t *testing.T) {
+	dir := t.TempDir()
+
+	missing := filepath.Join(dir, "missing.m3u8")
+	if playlistHasSegment(missing) {
+		t.Error("playlistHasSegment reported true for a missing file")
+	}
+
+	headerOnly := filepath.Join(dir, "header.m3u8")
+	if err := os.WriteFile(headerOnly, []byte("#EXTM3U\n#EXT-X-VERSION:3\n"), 0644); err != nil {
+		t.Fatalf("writing header playlist: %v", err)
+	}
+	if playlistHasSegment(headerOnly) {
+		t.Error("playlistHasSegment reported true for a playlist with no segments")
+	}
+
+	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"
+	if err := os.WriteFile(withSegment, []byte(body), 0644); err != nil {
+		t.Fatalf("writing ready playlist: %v", err)
+	}
+	if !playlistHasSegment(withSegment) {
+		t.Error("playlistHasSegment reported false for a playlist listing a segment")
+	}
+}
+
+// TestHLSManagerLifecycle verifies the working directory is created up front
+// and removed on Close, and that unknown session IDs are not resolvable.
+func TestHLSManagerLifecycle(t *testing.T) {
+	tmp := t.TempDir()
+	m, err := NewHLSManager(tmp, "/media/hls/segment")
+	if err != nil {
+		t.Fatalf("NewHLSManager returned error: %v", err)
+	}
+
+	root := filepath.Join(tmp, hlsWorkingDirName)
+	if _, err := os.Stat(root); err != nil {
+		t.Errorf("working directory was not created: %v", err)
+	}
+	if got := m.Session("does-not-exist"); got != nil {
+		t.Errorf("Session(unknown) = %v, want nil", got)
+	}
+
+	m.Close()
+	if _, err := os.Stat(root); !os.IsNotExist(err) {
+		t.Errorf("working directory still present after Close (err=%v)", err)
+	}
+	m.Close() // must be safe to call twice
+}
+
+// TestHLSManagerDiscardsStaleSessions verifies that session directories left
+// behind by a previous run are removed at startup. The ffmpeg processes that
+// were filling them died with that run, so the segments can never be completed
+// and the directories would otherwise accumulate after every crash.
+func TestHLSManagerDiscardsStaleSessions(t *testing.T) {
+	tmp := t.TempDir()
+	stale := filepath.Join(tmp, hlsWorkingDirName, "stale-session")
+	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 {
+		t.Fatalf("seeding a stale segment: %v", err)
+	}
+
+	m, err := NewHLSManager(tmp, "/media/hls/segment")
+	if err != nil {
+		t.Fatalf("NewHLSManager returned error: %v", err)
+	}
+	defer m.Close()
+
+	if _, err := os.Stat(stale); !os.IsNotExist(err) {
+		t.Errorf("stale session directory survived startup (err=%v)", err)
+	}
+	if _, err := os.Stat(filepath.Join(tmp, hlsWorkingDirName)); err != nil {
+		t.Errorf("working directory should have been recreated: %v", err)
+	}
+}
+
+// TestSegmentBaseURL verifies the prefix ffmpeg writes in front of every
+// playlist entry addresses the serving endpoint for this session.
+func TestSegmentBaseURL(t *testing.T) {
+	m := &HLSManager{segmentEndpoint: "/media/hls/segment"}
+	got := m.segmentBaseURL("deadbeef")
+	want := "/media/hls/segment?sid=deadbeef&name="
+	if got != want {
+		t.Errorf("segmentBaseURL = %q, want %q", got, want)
+	}
+}

+ 2 - 11
src/mod/media/transcoder/transcoder.go

@@ -38,17 +38,8 @@ func TranscodeAndStream(w http.ResponseWriter, r *http.Request, inputFile string
 	// Build the FFmpeg command based on the resolution parameter
 	var cmd *exec.Cmd
 
-	var height string
-	switch resolution {
-	case "360p":
-		height = "360"
-	case "720p":
-		height = "720"
-	case "1080p":
-		height = "1080"
-	case "":
-		height = ""
-	default:
+	height, err := resolutionHeight(resolution)
+	if err != nil {
 		http.Error(w, "Invalid resolution parameter", http.StatusBadRequest)
 		return
 	}

+ 94 - 0
src/web/Movie/backend/common.js

@@ -11,6 +11,7 @@ var BACKEND_PATH  = APP_NAME + "/backend/";
 // ── Server API endpoints (relative from any page in this app) ────────────────
 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 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=";
@@ -28,6 +29,99 @@ var SCRIPT_SET_WATCHTIME      = BACKEND_PATH + "setWatchTime.js";
 var SCRIPT_GET_INDEX_STATS    = BACKEND_PATH + "getIndexStats.js";
 var SCRIPT_CLEAR_INDEX        = BACKEND_PATH + "clearIndex.js";
 
+// ── Streaming mode ───────────────────────────────────────────────────────────
+// Formats the browser cannot decode are transcoded on the fly, and there are
+// two ways to deliver that transcode:
+//
+//   mp4  a single fragmented MP4 streamed down one response. The long-standing
+//        default: it starts fastest and costs the server nothing but the ffmpeg
+//        process. It cannot answer byte-range requests, which WebKit clients
+//        (Safari, and every browser on iOS) require before they will play
+//        anything, so on those it fails outright.
+//   hls  the same transcode cut into segments behind a playlist. Every segment
+//        is a finite, seekable file, so WebKit plays it and seeking inside the
+//        transcoded window no longer restarts the stream.
+//
+// "auto" picks hls on WebKit and mp4 everywhere else, so nothing changes for
+// browsers that were already working.
+var STREAM_MODE_KEY = "movie_stream_mode";
+
+function isWebKitClient() {
+    var ua = navigator.userAgent;
+    // On iOS every browser is WebKit underneath, whatever it calls itself.
+    if (/CriOS|FxiOS/.test(ua)) { return true; }
+    // Chrome, Edge and Opera all carry "Safari" in their desktop UA.
+    if (/Chrome\/|Chromium|Edg\/|OPR\/|Firefox\//.test(ua)) { return false; }
+    return /Safari/.test(ua);
+}
+
+function getStreamMode() {
+    var mode = localStorage.getItem(STREAM_MODE_KEY);
+    return (mode === "mp4" || mode === "hls") ? mode : "auto";
+}
+
+function setStreamMode(mode) {
+    if (mode !== "mp4" && mode !== "hls") { mode = "auto"; }
+    localStorage.setItem(STREAM_MODE_KEY, mode);
+}
+
+// Whether the current preference resolves to HLS for this browser.
+function usingHLS() {
+    var mode = getStreamMode();
+    if (mode === "hls") { return true; }
+    if (mode === "mp4") { return false; }
+    return isWebKitClient();
+}
+
+// WebKit plays HLS natively; everyone else needs hls.js, which is only present
+// if it has been vendored into web/script/.
+function nativeHLSSupported(videoEl) {
+    if (!videoEl || !videoEl.canPlayType) { return false; }
+    return videoEl.canPlayType("application/vnd.apple.mpegurl") !== "";
+}
+
+function hlsPlaybackSupported(videoEl) {
+    if (nativeHLSSupported(videoEl)) { return true; }
+    return !!(window.Hls && window.Hls.isSupported());
+}
+
+// Build the streaming URL for a transcoded file, honouring the current mode.
+// startSeconds restarts the transcode at an offset; the resulting stream always
+// begins at zero, so callers track the offset separately.
+function transcodeStreamURL(filepath, startSeconds) {
+    var base = usingHLS() ? HLS_API : TRANSCODE_API;
+    var url  = base + "?file=" + encodeURIComponent(filepath);
+    if (startSeconds && startSeconds > 0.001) {
+        url += "&start=" + startSeconds.toFixed(3);
+    }
+    return url;
+}
+
+// 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;
+    }
+
+    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;
+    }
+
+    videoEl.src = url;
+    videoEl.load();
+    return true;
+}
+
 // ── 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

+ 115 - 20
src/web/Movie/embedded.html

@@ -445,7 +445,7 @@
         .set-seg-btn:hover  { color: var(--text); }
         .set-seg-btn.active { background: var(--surface); color: var(--text); }
 
-        .speed-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
+        .speed-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
         .speed-btn {
             padding: 5px 0; font-size: 12px; font-family: inherit;
             border: none; border-radius: var(--radius); cursor: pointer;
@@ -471,7 +471,8 @@
             color: var(--text); padding: 10px 20px; border-radius: 20px;
             font-size: 13px; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
             opacity: 0; transition: opacity 0.3s, transform 0.3s;
-            pointer-events: none; z-index: 1000; white-space: nowrap;
+            pointer-events: none; z-index: 1000; max-width: min(90vw, 30em);
+            text-align: center; line-height: 1.45;
         }
         #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
     </style>
@@ -598,14 +599,11 @@
         <div class="set-section">
             <div class="set-head"><img src="img/icons/play_white.svg" alt="">Playback speed</div>
             <div class="speed-grid" id="set-speed-grid">
-                <button class="speed-btn" data-speed="0.25">0.25×</button>
-                <button class="speed-btn" data-speed="0.5">0.5×</button>
-                <button class="speed-btn" data-speed="0.75">0.75×</button>
-                <button class="speed-btn active" data-speed="1">Normal</button>
-                <button class="speed-btn" data-speed="1.25">1.25×</button>
-                <button class="speed-btn" data-speed="1.5">1.5×</button>
-                <button class="speed-btn" data-speed="1.75">1.75×</button>
-                <button class="speed-btn" data-speed="2">2×</button>
+                <button class="speed-btn" data-speed="0.5">0.5&times;</button>
+                <button class="speed-btn active" data-speed="1">1&times;</button>
+                <button class="speed-btn" data-speed="1.2">1.2&times;</button>
+                <button class="speed-btn" data-speed="1.5">1.5&times;</button>
+                <button class="speed-btn" data-speed="2">2&times;</button>
             </div>
         </div>
         <div class="set-section">
@@ -615,6 +613,14 @@
                 <button class="set-seg-btn"        data-volbar="always">Always show</button>
             </div>
         </div>
+        <div class="set-section">
+            <div class="set-head"><img src="img/icons/movie_white.svg" alt="">Streaming mode</div>
+            <div class="set-seg" id="set-stream-seg">
+                <button class="set-seg-btn active" data-stream="auto">Auto</button>
+                <button class="set-seg-btn"        data-stream="mp4">MP4</button>
+                <button class="set-seg-btn"        data-stream="hls">HLS</button>
+            </div>
+        </div>
         <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…
@@ -675,7 +681,13 @@ var pendingResumePos    = 0;
 var watchSaveInterval   = null;
 var controlsTimer       = null;
 var repeatSingle        = (localStorage.getItem('movie_repeat_one') === '1');
-var playbackSpeed       = parseFloat(localStorage.getItem('movie_playback_speed') || '1') || 1;
+// Snapped to the nearest preset on load: an earlier build let this be dragged
+// to any value, and the popup highlights presets only.
+// The preset rates offered in the settings popup. Declared here rather than
+// beside setPlaybackSpeed because the initialiser below runs on load, and a
+// `var` further down the file would still be undefined at that point.
+var PLAYBACK_SPEEDS = [0.5, 1, 1.2, 1.5, 2];
+var playbackSpeed       = nearestPlaybackSpeed(localStorage.getItem('movie_playback_speed') || '1');
 var alwaysShowVolumeBar = (localStorage.getItem('movie_always_volume_bar') === '1');
 var infoRefreshTimer    = null;
 var currentInfoTab      = 'props';
@@ -765,11 +777,15 @@ if (files && files.length > 0) {
 
     isTranscodedVideo = !isWebPlayable(currentFile.ext);
 
-    vid.src = isTranscodedVideo
-        ? TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
-        : MEDIA_API     + '?file=' + encodeURIComponent(currentFile.filepath);
     vid.autoplay = true;
-    vid.load();
+    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 {
+        vid.src = MEDIA_API + '?file=' + encodeURIComponent(currentFile.filepath);
+        vid.load();
+    }
 
     // Resume position check (only for videos longer than 1 hour)
     if (!isTranscodedVideo) {
@@ -929,22 +945,48 @@ function hideSeekFreeze() {
     $('#seek-spinner').removeClass('active');
 }
 
-// Restart the transcode stream at `seconds`, holding the last frame until the
-// new segment plays. Clamped to the known duration.
+// Seek to `seconds` (absolute, whole-file time), clamped to the known duration.
+//
+// An HLS stream keeps every segment produced so far in its playlist, so a seek
+// landing inside the transcoded window is an ordinary <video> seek — no reload,
+// 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
+// the new offset.
 function transcodeSeekTo(seconds) {
     if (!currentFile) { return; }
     var pos = Math.max(0, seconds);
     if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
 
+    if (usingHLS() && seekWithinTranscodedWindow(vid, pos)) {
+        setSubtitleSeekFloor(pos);
+        vid.currentTime = pos - transcodeSeekOffset;
+        playVideo(vid);
+        return;
+    }
+
     showSeekFreeze();
     setSubtitleSeekFloor(pos);
     transcodeSeekOffset = pos;
-    vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(currentFile.filepath)
-            + '&start=' + pos.toFixed(3);
-    vid.load();
+    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.');
+        return;
+    }
     playVideo(vid);
 }
 
+// Whether `pos` (absolute time) falls inside what the current stream has
+// already produced and can therefore be reached without reloading.
+function seekWithinTranscodedWindow(videoEl, pos) {
+    var relative = pos - transcodeSeekOffset;
+    if (relative < 0) { return false; }
+    if (!videoEl.seekable || videoEl.seekable.length === 0) { return false; }
+    var available = videoEl.seekable.end(videoEl.seekable.length - 1);
+    // Stay a segment clear of the live edge; seeking right onto it stalls until
+    // the next segment lands.
+    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;
@@ -1205,10 +1247,58 @@ function setPlaybackSpeed(rate) {
         .filter('[data-speed="' + playbackSpeed + '"]').addClass('active');
 }
 
+// Snap an arbitrary stored rate onto the nearest preset, so the popup always
+// has exactly one button highlighted. Needed because an earlier build persisted
+// free-form rates from a drag slider.
+function nearestPlaybackSpeed(rate) {
+    var value = parseFloat(rate);
+    if (!value || value <= 0) { return 1; }
+    return PLAYBACK_SPEEDS.reduce(function (best, preset) {
+        return Math.abs(preset - value) < Math.abs(best - value) ? preset : best;
+    }, PLAYBACK_SPEEDS[0]);
+}
+
+// Apply a streaming mode. Changing it mid-playback re-opens the current file on
+// the other transport, resuming where the viewer was.
+function applyStreamMode(mode, reloadCurrent) {
+    var previous = getStreamMode();
+    setStreamMode(mode);
+    $('#set-stream-seg .set-seg-btn').removeClass('active')
+        .filter('[data-stream="' + getStreamMode() + '"]').addClass('active');
+
+    if (!reloadCurrent || previous === getStreamMode()) { return; }
+    // Warn only when the viewer actively picks a mode this browser cannot play
+    warnIfStreamModeUnplayable();
+    if (!isTranscodedVideo || !currentFile) { return; }
+
+    var resumeAt = effectivePlaybackTime();
+    transcodeSeekOffset = 0;
+    if (!attachTranscodeStream(vid, transcodeStreamURL(currentFile.filepath, 0))) {
+        showToast('HLS playback needs hls.js, which is not installed.');
+        return;
+    }
+    playVideo(vid);
+    if (resumeAt > 1) { transcodeSeekTo(resumeAt); }
+}
+
+function playerVideoElement() { return vid; }
+
+// Surface the two streaming modes that cannot work on a given browser. Anything
+// that is simply working says nothing at all.
+function warnIfStreamModeUnplayable() {
+    var mode = getStreamMode();
+    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.');
+    }
+}
+
 function openSettingsPopup() {
     setRepeatSingle(repeatSingle);
     setPlaybackSpeed(playbackSpeed);
     setAlwaysShowVolumeBar(alwaysShowVolumeBar);
+    applyStreamMode(getStreamMode(), false);
     $('#player-ctx').hide();
     $('#settings-popup').addClass('active');
     showControls();
@@ -1245,6 +1335,10 @@ function initSettingsPopup() {
         setAlwaysShowVolumeBar($(this).data('volbar') === 'always');
     });
 
+    $('#set-stream-seg').on('click', '.set-seg-btn', function () {
+        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'); });
@@ -1253,6 +1347,7 @@ function initSettingsPopup() {
     setRepeatSingle(repeatSingle);
     setPlaybackSpeed(playbackSpeed);
     setAlwaysShowVolumeBar(alwaysShowVolumeBar);
+    applyStreamMode(getStreamMode(), false);
 
     // A transcode seek replaces the media source, which resets playbackRate —
     // reapply the chosen speed every time new media is loaded.

+ 134 - 29
src/web/Movie/index.html

@@ -734,7 +734,8 @@ body.always-show-volume #volume-slider { display: block; }
     transition: opacity 0.3s, transform 0.3s;
     pointer-events: none;
     z-index: 1000;
-    white-space: nowrap;
+    max-width: min(90vw, 30em);
+    text-align: center; line-height: 1.45;
 }
 #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
 
@@ -1157,7 +1158,7 @@ body.always-show-volume #volume-slider { display: block; }
 .set-seg-btn:hover  { color: var(--text); }
 .set-seg-btn.active { background: var(--surface); color: var(--text); }
 
-.speed-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
+.speed-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
 .speed-btn {
     padding: 5px 0; font-size: 12px; font-family: inherit;
     border: none; border-radius: 5px; cursor: pointer;
@@ -1761,14 +1762,11 @@ img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
                 <div class="set-section">
                     <div class="set-head"><img src="img/icons/play_white.svg" alt="">Playback speed</div>
                     <div class="speed-grid" id="set-speed-grid">
-                        <button class="speed-btn" data-speed="0.25">0.25×</button>
-                        <button class="speed-btn" data-speed="0.5">0.5×</button>
-                        <button class="speed-btn" data-speed="0.75">0.75×</button>
-                        <button class="speed-btn active" data-speed="1">Normal</button>
-                        <button class="speed-btn" data-speed="1.25">1.25×</button>
-                        <button class="speed-btn" data-speed="1.5">1.5×</button>
-                        <button class="speed-btn" data-speed="1.75">1.75×</button>
-                        <button class="speed-btn" data-speed="2">2×</button>
+                        <button class="speed-btn" data-speed="0.5">0.5&times;</button>
+                        <button class="speed-btn active" data-speed="1">1&times;</button>
+                        <button class="speed-btn" data-speed="1.2">1.2&times;</button>
+                        <button class="speed-btn" data-speed="1.5">1.5&times;</button>
+                        <button class="speed-btn" data-speed="2">2&times;</button>
                     </div>
                 </div>
                 <div class="set-section">
@@ -1778,6 +1776,14 @@ img.ctx-icon { height: 16px; opacity: 0.85; display: block; }
                         <button class="set-seg-btn"        data-volbar="always">Always show</button>
                     </div>
                 </div>
+                <div class="set-section">
+                    <div class="set-head"><img src="img/icons/movie_white.svg" alt="">Streaming mode</div>
+                    <div class="set-seg" id="set-stream-seg">
+                        <button class="set-seg-btn active" data-stream="auto">Auto</button>
+                        <button class="set-seg-btn"        data-stream="mp4">MP4</button>
+                        <button class="set-seg-btn"        data-stream="hls">HLS</button>
+                    </div>
+                </div>
                 <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…
@@ -1901,7 +1907,13 @@ var countdownTimer  = null;
 var repeatSingle = (localStorage.getItem('movie_repeat_one') === '1');
 
 // Playback speed (persisted, reapplied whenever a new source loads)
-var playbackSpeed = parseFloat(localStorage.getItem('movie_playback_speed') || '1') || 1;
+// Snapped to the nearest preset on load: an earlier build let this be dragged
+// to any value, and the popup highlights presets only.
+// The preset rates offered in the settings popup. Declared here rather than
+// beside setPlaybackSpeed because the initialiser below runs on load, and a
+// `var` further down the file would still be undefined at that point.
+var PLAYBACK_SPEEDS = [0.5, 1, 1.2, 1.5, 2];
+var playbackSpeed = nearestPlaybackSpeed(localStorage.getItem('movie_playback_speed') || '1');
 
 // Force the volume slider visible even on portrait phones (see the media query)
 var alwaysShowVolumeBar = (localStorage.getItem('movie_always_volume_bar') === '1');
@@ -1995,6 +2007,10 @@ function connectCast() {
                 if (playingIndex >= 0 && currentEpisodes[playingIndex]) {
                     var ep  = currentEpisodes[playingIndex];
                     var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
+                    // Always hand the receiver the MP4 stream: it is a separate
+                    // client whose HLS support we cannot probe from here, and
+                    // this browser's streaming-mode preference says nothing
+                    // about it.
                     var src = isWebPlayable(ext)
                         ? MEDIA_API + '?file=' + encodeURIComponent(ep.filepath)
                         : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
@@ -2064,14 +2080,17 @@ function _castResumeLocally() {
     var pos = castCurrentTime;
     var vid = document.getElementById('main-video');
     if (isTranscodedVideo && pos > 0) {
-        // Seek-by-reload: restart the transcode stream at the cast position
+        // Restart the transcode at the cast position, on whichever transport
+        // this browser is using
         transcodeSeekOffset = pos;
-        vid.src = TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath) + '&start=' + pos.toFixed(3);
+        attachTranscodeStream(vid, transcodeStreamURL(ep.filepath, pos));
     } else {
         var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
-        vid.src = isWebPlayable(ext)
-            ? MEDIA_API     + '?file=' + encodeURIComponent(ep.filepath)
-            : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
+        if (isWebPlayable(ext)) {
+            vid.src = MEDIA_API + '?file=' + encodeURIComponent(ep.filepath);
+        } else {
+            attachTranscodeStream(vid, transcodeStreamURL(ep.filepath, 0));
+        }
         if (pos > 0) {
             $(vid).one('loadedmetadata.castresume', function() { vid.currentTime = pos; });
         }
@@ -2520,24 +2539,49 @@ function hideSeekFreeze() {
     $('#seek-spinner').removeClass('active');
 }
 
-// Restart the transcode stream at `seconds`, holding the last frame until the
-// new segment plays. Clamped to the known duration.
+// Seek to `seconds` (absolute, whole-file time), clamped to the known duration.
+//
+// An HLS stream keeps every segment produced so far in its playlist, so a seek
+// landing inside the transcoded window is an ordinary <video> seek — no reload,
+// 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
+// the new offset.
 function transcodeSeekTo(seconds) {
     if (playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
     var vid = document.getElementById('main-video');
     var pos = Math.max(0, seconds);
     if (transcodeDuration > 0) { pos = Math.min(transcodeDuration, pos); }
 
+    if (usingHLS() && seekWithinTranscodedWindow(vid, pos)) {
+        setSubtitleSeekFloor(pos);
+        vid.currentTime = pos - transcodeSeekOffset;
+        playVideo(vid);
+        return;
+    }
+
     showSeekFreeze();
     setSubtitleSeekFloor(pos);
     transcodeSeekOffset = pos;
-    vid.src = TRANSCODE_API + '?file='
-            + encodeURIComponent(currentEpisodes[playingIndex].filepath)
-            + '&start=' + pos.toFixed(3);
-    vid.load();
+    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.');
+        return;
+    }
     playVideo(vid);
 }
 
+// Whether `pos` (absolute time) falls inside what the current stream has
+// already produced and can therefore be reached without reloading.
+function seekWithinTranscodedWindow(vid, pos) {
+    var relative = pos - transcodeSeekOffset;
+    if (relative < 0) { return false; }
+    if (!vid.seekable || vid.seekable.length === 0) { return false; }
+    var available = vid.seekable.end(vid.seekable.length - 1);
+    // Stay a segment clear of the live edge; seeking right onto it stalls until
+    // the next segment lands.
+    return relative <= available - 4;
+}
+
 // Current playback position in whole-file terms (transcode streams restart at 0)
 function effectivePlaybackTime() {
     var vid = document.getElementById('main-video');
@@ -3364,30 +3408,36 @@ function startPlayback(index) {
     var ep = currentEpisodes[index];
 
     var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
-    var src = isWebPlayable(ext)
-        ? MEDIA_API   + '?file=' + encodeURIComponent(ep.filepath)
-        : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
+    var webPlayable = isWebPlayable(ext);
+    var directSrc = MEDIA_API + '?file=' + encodeURIComponent(ep.filepath);
 
     // Reset transcode seek state for the new episode
     transcodeSeekOffset = 0;
-    isTranscodedVideo = !isWebPlayable(ext);
+    isTranscodedVideo = !webPlayable;
     transcodeDuration = 0;
 
     var vid = document.getElementById('main-video');
 
     if (castMode && _castConnected()) {
-        // Don't load locally — avoids transcoding the same file twice
+        // 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: src, startTime: 0
+            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 {
-        vid.src = src;
         playVideo(vid);
     }
 
@@ -3883,6 +3933,44 @@ function setRepeatSingle(on) {
         .filter('[data-repeat="' + (repeatSingle ? 'one' : 'off') + '"]').addClass('active');
 }
 
+// Apply a streaming mode. Changing it mid-playback re-opens the current file on
+// the other transport, resuming where the viewer was.
+function applyStreamMode(mode, reloadCurrent) {
+    var previous = getStreamMode();
+    setStreamMode(mode);
+    $('#set-stream-seg .set-seg-btn').removeClass('active')
+        .filter('[data-stream="' + getStreamMode() + '"]').addClass('active');
+
+    if (!reloadCurrent || previous === getStreamMode()) { return; }
+    // Warn only when the viewer actively picks a mode this browser cannot play
+    warnIfStreamModeUnplayable();
+    if (!isTranscodedVideo || playingIndex < 0 || !currentEpisodes[playingIndex]) { return; }
+
+    // Restart the current file on the newly chosen transport, at the same spot
+    var resumeAt = effectivePlaybackTime();
+    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.');
+        return;
+    }
+    playVideo(vid);
+    if (resumeAt > 1) { transcodeSeekTo(resumeAt); }
+}
+
+function playerVideoElement() { return document.getElementById('main-video'); }
+
+// Surface the two streaming modes that cannot work on a given browser. Anything
+// that is simply working says nothing at all.
+function warnIfStreamModeUnplayable() {
+    var mode = getStreamMode();
+    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.');
+    }
+}
+
 function setPlaybackSpeed(rate) {
     playbackSpeed = parseFloat(rate) || 1;
     document.getElementById('main-video').playbackRate = playbackSpeed;
@@ -3891,10 +3979,22 @@ function setPlaybackSpeed(rate) {
         .filter('[data-speed="' + playbackSpeed + '"]').addClass('active');
 }
 
+// Snap an arbitrary stored rate onto the nearest preset, so the popup always
+// has exactly one button highlighted. Needed because an earlier build persisted
+// free-form rates from a drag slider.
+function nearestPlaybackSpeed(rate) {
+    var value = parseFloat(rate);
+    if (!value || value <= 0) { return 1; }
+    return PLAYBACK_SPEEDS.reduce(function (best, preset) {
+        return Math.abs(preset - value) < Math.abs(best - value) ? preset : best;
+    }, PLAYBACK_SPEEDS[0]);
+}
+
 function openSettingsPopup() {
     setRepeatSingle(repeatSingle);
     setPlaybackSpeed(playbackSpeed);
     setAlwaysShowVolumeBar(alwaysShowVolumeBar);
+    applyStreamMode(getStreamMode(), false);
     $('#player-ctx').hide();
     $('#settings-popup').addClass('active');
     showControls();
@@ -3930,6 +4030,10 @@ function initSettingsPopup() {
         setAlwaysShowVolumeBar($(this).data('volbar') === 'always');
     });
 
+    $('#set-stream-seg').on('click', '.set-seg-btn', function () {
+        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'); });
@@ -3938,6 +4042,7 @@ function initSettingsPopup() {
     setRepeatSingle(repeatSingle);
     setPlaybackSpeed(playbackSpeed);
     setAlwaysShowVolumeBar(alwaysShowVolumeBar);
+    applyStreamMode(getStreamMode(), false);
 
     // Changing episode — or a transcode seek — replaces the media source, which
     // resets playbackRate. Reapply the chosen speed every time media loads.