From de24857376caee7b2ecfe267dd43af4fa04ed15c Mon Sep 17 00:00:00 2001 From: agent-pushpinderbal Date: Thu, 22 Oct 2026 19:33:01 +0000 Subject: [PATCH] feat: add playback speed controls and scale buffering --- README.md | 1 + .../app/auth_playback_integration_test.go | 1 + internal/app/playback_integration_test.go | 22 +- internal/app/preferences_integration_test.go | 3 + internal/app/server.go | 29 +++ internal/stream/buffering_integration_test.go | 129 +++++++++++ internal/stream/stream.go | 36 ++- web/src/App.tsx | 50 ++++- web/src/api.ts | 1 + web/src/components/player-controls.tsx | 212 +++++++++++------- web/src/styles.css | 36 ++- web/tests/speed-playback.mjs | 141 ++++++++++++ web/tests/workflow.spec.ts | 80 ++++++- 13 files changed, 619 insertions(+), 122 deletions(-) create mode 100644 internal/stream/buffering_integration_test.go create mode 100644 web/tests/speed-playback.mjs diff --git a/README.md b/README.md index 63f9473..4ba526e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Restream brings your IPTV subscription to the browser. Host it on your own compu - ⚡ **Shared library cache**: cached listings stay available while the server refreshes them in the background, reducing repeated requests to your provider. - 🔐 **Optional OIDC sign-in**: use your identity provider with viewer and administrator roles, or run without a login on your private network. - 📱 **Desktop and mobile**: responsive browsing, touch controls, and fullscreen playback. +- ⏩ **Playback speed**: watch movies and episodes at 0.5× to 2× speed from the player controls. The buffer adjusts to your speed; changing speed briefly reloads at your current position. - 💬 **Subtitles**: choose provider subtitles or embedded captions from the player's CC menu. Preview your preferred font, size, color, and background in Settings, saved for this browser. Availability varies by movie and episode. ## Get started diff --git a/internal/app/auth_playback_integration_test.go b/internal/app/auth_playback_integration_test.go index 7f02170..da99d11 100644 --- a/internal/app/auth_playback_integration_test.go +++ b/internal/app/auth_playback_integration_test.go @@ -64,6 +64,7 @@ func TestOIDCPlaybackBelongsToItsUser(t *testing.T) { {"GET", "/api/sessions/test-1", ""}, {"POST", "/api/sessions/test-1/heartbeat", ""}, {"POST", "/api/sessions/test-1/seek", `{"position":5}`}, + {"POST", "/api/sessions/test-1/speed", `{"position":5,"rate":2}`}, {"DELETE", "/api/sessions/test-1", ""}, {"GET", "/api/streams/test-1/1/index.m3u8", ""}, {"GET", "/api/streams/test-1/1/seg-000001.ts", ""}, diff --git a/internal/app/playback_integration_test.go b/internal/app/playback_integration_test.go index e90a78d..29d482c 100644 --- a/internal/app/playback_integration_test.go +++ b/internal/app/playback_integration_test.go @@ -172,16 +172,32 @@ func TestPortalToBrowserPlayback(t *testing.T) { t.Fatalf("duration not probed: %+v", session) } assertLocalMedia(t, server.URL, session.URL, portal.URL) + previous := session.URL + for _, invalid := range []string{`{"position":5,"rate":0}`, `{"position":-1,"rate":2}`, `{"position":5,"rate":3}`, `{"position":5}`, `{"position":5,"rate":null}`} { + status, body = request(t, server.URL, "POST", "/api/sessions/"+session.ID+"/speed", invalid) + if status != 400 { + t.Fatalf("invalid playback speed accepted: %d %s", status, body) + } + } + status, body = request(t, server.URL, "POST", "/api/sessions/"+session.ID+"/speed", `{"position":5,"rate":2}`) + if status != 200 { + t.Fatal(status, body) + } + session = awaitPlayable(t, server.URL, session.ID) + if session.URL == previous || session.Offset != 5 || session.PlaybackRate != 2 || manager.Active() != 1 { + t.Fatalf("playback speed lost slot or position: %+v", session) + } + assertLocalMedia(t, server.URL, session.URL, portal.URL) if mediaRequests.Load() == 0 { t.Fatal("media bypassed central server") } - previous := session.URL + previous = session.URL status, body = request(t, server.URL, "POST", "/api/sessions/"+session.ID+"/seek", `{"position":5}`) if status != 200 { t.Fatal(status, body) } session = awaitPlayable(t, server.URL, session.ID) - if session.URL == previous || session.Offset != 5 || manager.Active() != 1 { + if session.URL == previous || session.Offset != 5 || session.PlaybackRate != 2 || manager.Active() != 1 { t.Fatalf("seek lost slot or timeline: %+v", session) } assertLocalMedia(t, server.URL, session.URL, portal.URL) @@ -194,7 +210,7 @@ func TestPortalToBrowserPlayback(t *testing.T) { t.Fatal("stopped media still accessible") } if os.Getenv("RESTREAM_BROWSER_TESTS") == "1" { - for _, check := range []struct{ script, mode string }{{"subtitles-playback.mjs", "copy"}, {"subtitles-playback.mjs", "transcode"}, {"embedded-captions-playback.mjs", "copy"}, {"embedded-captions-playback.mjs", "transcode"}, {"real-playback.mjs", "transcode"}} { + for _, check := range []struct{ script, mode string }{{"speed-playback.mjs", "copy"}, {"speed-playback.mjs", "transcode"}, {"subtitles-playback.mjs", "copy"}, {"subtitles-playback.mjs", "transcode"}, {"embedded-captions-playback.mjs", "copy"}, {"embedded-captions-playback.mjs", "transcode"}, {"real-playback.mjs", "transcode"}} { if err := manager.Configure(1, check.mode); err != nil { t.Fatal(err) } diff --git a/internal/app/preferences_integration_test.go b/internal/app/preferences_integration_test.go index dd7b058..3feb3e5 100644 --- a/internal/app/preferences_integration_test.go +++ b/internal/app/preferences_integration_test.go @@ -80,6 +80,9 @@ func (p *policyStreams) Heartbeat(id string) error { _, err := p.Get(id); return func (p *policyStreams) Seek(_ context.Context, id string, _ float64) (stream.Session, error) { return p.Get(id) } +func (p *policyStreams) SetPlaybackRate(_ context.Context, id string, _, _ float64) (stream.Session, error) { + return p.Get(id) +} func (p *policyStreams) Stop(id string) error { p.mu.Lock() defer p.mu.Unlock() diff --git a/internal/app/server.go b/internal/app/server.go index 840f1ef..0964828 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -30,6 +30,7 @@ type Streams interface { Get(string) (stream.Session, error) Heartbeat(string) error Seek(context.Context, string, float64) (stream.Session, error) + SetPlaybackRate(context.Context, string, float64, float64) (stream.Session, error) Stop(string) error Active() int MediaHandler() http.Handler @@ -157,6 +158,7 @@ func New(cfg Config, provider model.Provider, streams Streams) (*Server, error) mux.HandleFunc("GET /api/sessions/{id}", s.session) mux.HandleFunc("POST /api/sessions/{id}/heartbeat", s.heartbeat) mux.HandleFunc("POST /api/sessions/{id}/seek", s.seek) + mux.HandleFunc("POST /api/sessions/{id}/speed", s.playbackSpeed) mux.HandleFunc("DELETE /api/sessions/{id}", s.stop) if streams != nil { mux.Handle("/api/streams/", s.policyMedia(streams.MediaHandler())) @@ -833,6 +835,33 @@ func (s *Server) seek(w http.ResponseWriter, r *http.Request) { } respond(w, 200, session) } +func (s *Server) playbackSpeed(w http.ResponseWriter, r *http.Request) { + if !s.sessionAllowed(w, r, r.PathValue("id")) { + return + } + if s.streams == nil { + streamError(w, stream.ErrNotFound) + return + } + var body struct { + Position float64 `json:"position"` + Rate float64 `json:"rate"` + } + if !decode(w, r, &body) { + return + } + if !validPosition(body.Position) || math.IsNaN(body.Rate) || body.Rate < 0.5 || body.Rate > 2 { + fail(w, 400, "invalid", "Choose a playback speed between 0.5× and 2× and a valid position.") + return + } + session, err := s.streams.SetPlaybackRate(r.Context(), r.PathValue("id"), body.Position, body.Rate) + if err != nil { + streamError(w, err) + return + } + respond(w, 200, session) +} + func (s *Server) stop(w http.ResponseWriter, r *http.Request) { if !s.sessionOwned(w, r, r.PathValue("id")) { return diff --git a/internal/stream/buffering_integration_test.go b/internal/stream/buffering_integration_test.go new file mode 100644 index 0000000..1fd334b --- /dev/null +++ b/internal/stream/buffering_integration_test.go @@ -0,0 +1,129 @@ +package stream + +import ( + "context" + "errors" + "io" + "math" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/pushpinderbal/restream/internal/model" +) + +func TestPlaybackBufferFollowsSpeed(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg required") + } + video := filepath.Join(t.TempDir(), "source.mp4") + cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=160x90:rate=10", "-t", "150", "-c:v", "libx264", "-preset", "ultrafast", "-g", "10", "-pix_fmt", "yuv420p", "-movflags", "+faststart", video) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generate media: %v: %s", err, out) + } + upstream := httptest.NewServer(http.FileServer(http.Dir(filepath.Dir(video)))) + defer upstream.Close() + m, err := New(Config{MaxStreams: 1, TranscodeMode: "copy", SessionTTL: time.Minute}, func(context.Context, model.Item) (model.Source, error) { + return model.Source{URL: upstream.URL + "/source.mp4"}, nil + }) + if err != nil { + t.Fatal(err) + } + defer m.Close() + media := httptest.NewServer(m.MediaHandler()) + defer media.Close() + s, err := m.Start(context.Background(), model.Item{ID: "episode", Kind: "episode"}, 0) + if err != nil { + t.Fatal(err) + } + s = waitState(t, m, s.ID, "ready") + if s.PlaybackRate != 1 { + t.Fatalf("default speed: %+v", s) + } + edge := func() float64 { + t.Helper() + response, err := http.Get(media.URL + s.URL) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil || response.StatusCode != 200 { + t.Fatalf("read playlist: %d %v", response.StatusCode, err) + } + duration := 0.0 + for _, line := range strings.Split(string(body), "\n") { + if value, ok := strings.CutPrefix(line, "#EXTINF:"); ok { + seconds, err := strconv.ParseFloat(strings.TrimSuffix(value, ","), 64) + if err != nil { + t.Fatal(err) + } + duration += seconds + } + } + return duration + } + waitEdge := func(want float64) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if edge() >= want { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("initial buffer did not reach %v seconds: %v", want, edge()) + } + assertPacing := func(rate float64) { + t.Helper() + before, started := edge(), time.Now() + // Ten seconds separates the 20% headroom from real-time pacing even + // when the playlist advances in whole one-second segments. + time.Sleep(10 * time.Second) + got := (edge() - before) / time.Since(started).Seconds() + if math.Abs(got-rate) > 0.15 { + t.Fatalf("relay advanced at %.2fx; want %.2fx", got, rate) + } + } + waitEdge(30) + assertPacing(1.2) + for _, rate := range []float64{0, 0.49, 2.01, math.NaN(), math.Inf(1)} { + if _, err := m.SetPlaybackRate(context.Background(), s.ID, 10, rate); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid speed %v accepted: %v", rate, err) + } + } + previous := s.URL + s, err = m.SetPlaybackRate(context.Background(), s.ID, 10, 2) + if err != nil || s.Offset != 10 || s.PlaybackRate != 2 || s.URL == previous || m.Active() != 1 { + t.Fatalf("speed change lost position or slot: %+v %v", s, err) + } + s = waitState(t, m, s.ID, "ready") + waitEdge(60) + assertPacing(2.4) + previous = s.URL + s, err = m.Seek(context.Background(), s.ID, 20) + if err != nil || s.PlaybackRate != 2 || s.Offset != 20 || s.URL == previous { + t.Fatalf("seek lost selected speed: %+v %v", s, err) + } + s = waitState(t, m, s.ID, "ready") + previous = s.URL + s, err = m.SetPlaybackRate(context.Background(), s.ID, 20, 1) + if err != nil || s.PlaybackRate != 1 || s.Offset != 20 || s.URL == previous { + t.Fatalf("return to normal speed: %+v %v", s, err) + } + if err := m.Stop(s.ID); err != nil || m.Active() != 0 { + t.Fatalf("stop sped-up playback: %v", err) + } + s, err = m.Start(context.Background(), model.Item{ID: "live", Kind: "live"}, 0) + if err != nil { + t.Fatal(err) + } + if _, err := m.SetPlaybackRate(context.Background(), s.ID, 0, 2); !errors.Is(err, ErrInvalid) { + t.Fatalf("live speed change accepted: %v", err) + } +} diff --git a/internal/stream/stream.go b/internal/stream/stream.go index 9f3041a..7cd8b86 100644 --- a/internal/stream/stream.go +++ b/internal/stream/stream.go @@ -42,6 +42,7 @@ type Session struct { State string `json:"state"` Duration float64 `json:"duration"` Offset float64 `json:"offset"` + PlaybackRate float64 `json:"playbackRate"` Error string `json:"error,omitempty"` Subtitles []SubtitleTrack `json:"subtitles,omitempty"` SubtitleOffset *float64 `json:"subtitleOffset,omitempty"` @@ -53,6 +54,7 @@ type generation struct { dir string done chan struct{} subtitles *subtitleState + rate float64 } type probeResult struct { @@ -198,7 +200,7 @@ func (m *Manager) Start(ctx context.Context, item model.Item, start float64) (Se logger = slog.Default() } e := &entry{item: item, transcodeMode: m.cfg.TranscodeMode, heartbeat: time.Now(), logger: logger.With("session_id", id)} - e.session = Session{ID: id, Duration: validDuration(item.Duration)} + e.session = Session{ID: id, Duration: validDuration(item.Duration), PlaybackRate: 1} m.sessions[id] = e m.launchLocked(e, start, nil) return e.session, nil @@ -208,7 +210,7 @@ func (m *Manager) launchLocked(e *entry, pos float64, prior <-chan struct{}) { m.next++ number := m.next ctx, cancel := context.WithCancel(m.ctx) - g := &generation{number: number, cancel: cancel, dir: filepath.Join(m.dir, e.session.ID, strconv.FormatUint(number, 10)), done: make(chan struct{})} + g := &generation{number: number, cancel: cancel, dir: filepath.Join(m.dir, e.session.ID, strconv.FormatUint(number, 10)), done: make(chan struct{}), rate: e.session.PlaybackRate} e.gen = g e.session.State = "starting" e.session.Offset = pos @@ -252,6 +254,19 @@ func (m *Manager) Heartbeat(id string) error { } func (m *Manager) Seek(ctx context.Context, id string, position float64) (Session, error) { + return m.restart(ctx, id, position, nil) +} + +// SetPlaybackRate restarts the relay at the viewer's position so FFmpeg's input +// pacing and initial buffer match the selected speed. Seeking retains this rate. +func (m *Manager) SetPlaybackRate(ctx context.Context, id string, position, rate float64) (Session, error) { + if math.IsNaN(rate) || rate < 0.5 || rate > 2 { + return Session{}, ErrInvalid + } + return m.restart(ctx, id, position, &rate) +} + +func (m *Manager) restart(ctx context.Context, id string, position float64, rate *float64) (Session, error) { if err := ctx.Err(); err != nil { return Session{}, err } @@ -273,8 +288,14 @@ func (m *Manager) Seek(ctx context.Context, id string, position float64) (Sessio if e.session.Duration > 0 && position >= e.session.Duration { return Session{}, ErrInvalid } + if rate != nil { + if *rate == e.session.PlaybackRate { + return e.session, nil + } + e.session.PlaybackRate = *rate + } old := e.gen - e.logger.Debug("Stream seeking", "offset", position) + e.logger.Debug("Stream seeking", "offset", position, "playback_rate", e.session.PlaybackRate) old.cancel() go m.discard(old) m.launchLocked(e, position, old.done) @@ -474,10 +495,13 @@ func (m *Manager) run(ctx context.Context, e *entry, g *generation, pos float64, } m.update(e, g, func(s *Session) { s.Duration = duration }) live := source.Live || e.item.Kind == "live" - args := []string{"-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-readrate", "1"} + args := []string{"-hide_banner", "-loglevel", "error", "-nostdin", "-y"} if !live { - // Fill initial segments immediately, then pace the relay. - args = append(args, "-readrate_initial_burst", "8") + // Preserve 30 seconds of viewing time at every speed, then build the + // server buffer with 20% headroom over the viewer's consumption rate. + args = append(args, "-readrate", strconv.FormatFloat(g.rate*1.2, 'f', -1, 64), "-readrate_initial_burst", strconv.FormatFloat(30*g.rate, 'f', -1, 64)) + } else { + args = append(args, "-readrate", "1") } args = append(args, "-rw_timeout", "10000000", "-protocol_whitelist", "http,https,tcp,tls,crypto") if pos > 0 { diff --git a/web/src/App.tsx b/web/src/App.tsx index 85b615f..95c4328 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2602,6 +2602,7 @@ function PlaybackPlayer({ current.state === next.state && current.url === next.url && current.offset === next.offset && + current.playbackRate === next.playbackRate && current.subtitleOffset === next.subtitleOffset && current.duration === next.duration && JSON.stringify(current.subtitles) === @@ -2710,9 +2711,12 @@ function PlaybackPlayer({ setPlaying(false); setBuffering(true); attachedUrlRef.current = playbackUrl; + const rate = latestSession.current?.playbackRate ?? 1; element.pause(); element.removeAttribute("src"); element.load(); + element.defaultPlaybackRate = rate; + element.playbackRate = rate; const startNative = () => { if (element.canPlayType("application/vnd.apple.mpegurl")) element.src = playbackUrl; @@ -2739,10 +2743,11 @@ function PlaybackPlayer({ // The ESM distribution requires an explicit worker URL. workerPath: hlsWorkerUrl, startPosition: activeItem.kind === "live" ? -1 : 0, - maxBufferLength: 30, - maxMaxBufferLength: 30, - backBufferLength: 30, - frontBufferFlushThreshold: 30, + // Keep 30 seconds of viewing time, including at faster speeds. + maxBufferLength: 30 * rate, + maxMaxBufferLength: 30 * rate, + backBufferLength: 30 * rate, + frontBufferFlushThreshold: 30 * rate, // VOD is an append-only event playlist; never jump to its live edge. liveSyncOnStallIncrease: activeItem.kind === "live" ? 1 : 0, liveSyncDuration: activeItem.kind === "live" ? undefined : 86400, @@ -2843,15 +2848,20 @@ function PlaybackPlayer({ } }; - const seek = async (target: number, resume = false) => { + const seek = async (target: number, resume = false, rate?: number) => { saveProgress(); onFlushProgress(); const id = sessionId.current; if (!id || !session || seekingRef.current) return; - if (!Number.isFinite(session.duration) || session.duration <= 0) return; + const hasDuration = + Number.isFinite(session.duration) && session.duration > 0; + if (!Number.isFinite(target) || (!hasDuration && rate === undefined)) + return; const desired = Math.max( 0, - Math.min(Math.max(0, session.duration - 0.5), target), + hasDuration + ? Math.min(Math.max(0, session.duration - 0.5), target) + : target, ); const element = video.current; const local = desired - session.offset; @@ -2867,7 +2877,7 @@ function PlaybackPlayer({ (i) => local >= ranges.start(i) && local < ranges.end(i) - 0.25, ), )); - if (element && available) { + if (element && available && rate === undefined) { element.currentTime = local; setPosition(desired); if (resume) @@ -2885,14 +2895,15 @@ function PlaybackPlayer({ setError(""); try { const next = await jsonRequest( - `/api/sessions/${encodeURIComponent(id)}/seek`, + `/api/sessions/${encodeURIComponent(id)}/${rate === undefined ? "seek" : "speed"}`, "POST", - { position: desired }, + { position: desired, ...(rate === undefined ? {} : { rate }) }, ); if (sessionId.current !== id) return; pendingPlayUrlRef.current = resumeAfterSeek ? next.url : null; setSession(next); setPosition(next.offset); + setBuffered([]); if (next.state === "failed") setError(next.error || "Could not seek in this video."); } catch (cause) { @@ -2997,6 +3008,11 @@ function PlaybackPlayer({ playsInline controls={false} preload="auto" + onLoadedMetadata={(event) => { + const rate = latestSession.current?.playbackRate ?? 1; + event.currentTarget.defaultPlaybackRate = rate; + event.currentTarget.playbackRate = rate; + }} onPointerDown={(event) => { videoPointer.current = event.pointerType; }} @@ -3137,6 +3153,20 @@ function PlaybackPlayer({ buffered={buffered} live={!vod} liveDelay={liveDelay} + playbackRate={session?.playbackRate ?? 1} + onPlaybackRate={(rate) => { + const current = latestSession.current; + if ( + current && + video.current && + rate !== (current.playbackRate ?? 1) + ) + void seek( + current.offset + video.current.currentTime, + false, + rate, + ); + }} onGoLive={() => void goLive()} onPlay={() => void togglePlay()} onSeek={(target) => void seek(target)} diff --git a/web/src/api.ts b/web/src/api.ts index bc2a97f..4221cdf 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -148,6 +148,7 @@ export type Session = { state: "starting" | "ready" | "ended" | "failed"; duration: number; offset: number; + playbackRate?: number; error?: string; subtitles?: SubtitleTrack[]; subtitleOffset?: number; diff --git a/web/src/components/player-controls.tsx b/web/src/components/player-controls.tsx index 4c220d6..0397c1c 100644 --- a/web/src/components/player-controls.tsx +++ b/web/src/components/player-controls.tsx @@ -50,6 +50,8 @@ type Props = { buffered: { start: number; end: number }[]; live: boolean; liveDelay: number | null; + playbackRate: number; + onPlaybackRate: (rate: number) => void; onGoLive: () => void; onPlay: () => void; onSeek: (position: number) => void; @@ -84,7 +86,7 @@ export function PlayerControls(props: Props) { actions.current = props; const timer = useRef | undefined>(undefined); const scrubbing = useRef(false); - const subtitleMenuOpen = useRef(false); + const menuOpen = useRef(false); const canSeek = !live && duration > 0 && !busy && !finished; const reveal = useCallback(() => { setVisible(true); @@ -93,7 +95,7 @@ export function PlayerControls(props: Props) { timer.current = setTimeout(() => { if ( scrubbing.current || - subtitleMenuOpen.current || + menuOpen.current || area.current?.querySelector(":focus-visible") ) return; @@ -112,7 +114,7 @@ export function PlayerControls(props: Props) { playing && !busy && !scrubbing.current && - !subtitleMenuOpen.current && + !menuOpen.current && !element.querySelector(":focus-visible") ) { clearTimeout(timer.current); @@ -125,7 +127,7 @@ export function PlayerControls(props: Props) { timer.current = setTimeout(() => { if ( !scrubbing.current && - !subtitleMenuOpen.current && + !menuOpen.current && !element.querySelector(":focus-visible") ) setVisible(false); @@ -355,88 +357,136 @@ export function PlayerControls(props: Props) { {time(value)} / {time(duration)} )} - {!!props.subtitles?.tracks.length && ( - { - subtitleMenuOpen.current = open; - reveal(); - }} - > - - + + - - - - Playback speed + + props.onPlaybackRate(Number(value))} + > + {[0.5, 0.75, 1, 1.25, 1.5, 1.75, 2].map((rate) => ( + + {rate === 1 ? "1× (Normal)" : `${rate}×`} + + ))} + + + + )} + {!!props.subtitles?.tracks.length && ( + { + menuOpen.current = open; + reveal(); + }} > - Subtitles - - - props.subtitles?.select(value === "off" ? null : value) - } + + + + - Off - {props.subtitles.tracks.map((track) => ( - - {subtitleLabel(track)} - - ))} - - {props.subtitles.selected && - props.subtitles.status !== "ready" && ( - <> - -
- {props.subtitles.status === "loading" - ? "Loading subtitles…" - : "Subtitles temporarily unavailable"} -
- {props.subtitles.status === "error" && ( - - props.subtitles?.retry - ? props.subtitles.retry() - : props.subtitles?.select(props.subtitles.selected) - } + Subtitles + + + props.subtitles?.select(value === "off" ? null : value) + } + > + Off + {props.subtitles.tracks.map((track) => ( + + {subtitleLabel(track)} + + ))} + + {props.subtitles.selected && + props.subtitles.status !== "ready" && ( + <> + +
- Try again - - )} - - )} - - - )} -
+ {props.subtitles.status === "error" && ( + + props.subtitles?.retry + ? props.subtitles.retry() + : props.subtitles?.select( + props.subtitles.selected, + ) + } + > + Try again + + )} + + )} + +
)} - + + ); diff --git a/web/src/styles.css b/web/src/styles.css index ede9b3c..6d9b424 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -967,12 +967,6 @@ p { .control-button[data-active="true"] { color: var(--primary); } -.subtitles-control { - margin-left: auto; -} -.subtitles-control + .fullscreen { - margin-left: 0; -} .control-button:disabled { opacity: 0.35; } @@ -992,9 +986,6 @@ p { .time-readout span { color: #a3b3d0; } -.fullscreen { - margin-left: auto; -} .timeline-shell { position: relative; width: 100%; @@ -1304,18 +1295,6 @@ p { } } -@media (max-width: 380px) { - .controls-row { - gap: 3px; - } - .time-readout { - line-height: 1.2; - } - .time-readout span { - display: block; - } -} - @media (max-width: 560px) { .catalog-grid:not(.live-grid) { grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1383,6 +1362,21 @@ p { } } +@media (max-width: 380px) { + .video-controls { + padding-inline: 6px; + } + .controls-row { + gap: 2px; + } + .time-readout { + line-height: 1.2; + } + .time-readout span { + display: block; + } +} + @media (max-width: 560px) { .inline-expansion { scroll-margin-top: 24px; diff --git a/web/tests/speed-playback.mjs b/web/tests/speed-playback.mjs new file mode 100644 index 0000000..daeedd4 --- /dev/null +++ b/web/tests/speed-playback.mjs @@ -0,0 +1,141 @@ +import { chromium, expect } from "@playwright/test"; + +const baseURL = process.argv[2]; +const mode = process.argv[3]; +const mobile = mode === "transcode"; +const browser = await chromium.launch({ + headless: true, + ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } + : {}), + args: ["--autoplay-policy=no-user-gesture-required"], +}); +const page = await browser.newPage({ + viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + hasTouch: mobile, +}); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +const video = page.locator("video"); +const area = page.getByLabel("Video player", { exact: true }); +const reveal = () => area.press("ArrowUp"); +const progressing = () => { + const element = document.querySelector("video"); + return ( + element && + !element.paused && + element.currentTime > 0.5 && + element.videoWidth > 0 + ); +}; +let session; +try { + await page.goto(`${baseURL}/?section=movie`); + await page + .getByRole("button", { name: "Open Test movie", exact: true }) + .click(); + const created = page.waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/sessions" && + response.status() === 201, + ); + await page.getByRole("button", { name: "Play", exact: true }).click(); + session = await (await created).json(); + await page.waitForFunction(progressing, undefined, { timeout: 15000 }); + const originalSource = await video.evaluate((element) => element.currentSrc); + await reveal(); + await page + .getByRole("button", { name: "Playback speed: 1×", exact: true }) + .click(); + const changed = page.waitForResponse((response) => + response.url().endsWith("/speed"), + ); + await page.getByRole("menuitemradio", { name: "2×", exact: true }).click(); + const response = await changed; + expect(response.status()).toBe(200); + const accelerated = await response.json(); + expect(accelerated.id).toBe(session.id); + expect(accelerated.playbackRate).toBe(2); + expect(accelerated.offset).toBeCloseTo( + response.request().postDataJSON().position, + 2, + ); + await expect(video).toHaveJSProperty("playbackRate", 2); + await page.waitForFunction(progressing, undefined, { timeout: 15000 }); + const acceleratedSource = await video.evaluate( + (element) => element.currentSrc, + ); + expect(acceleratedSource).not.toBe(originalSource); + const before = await video.evaluate((element) => ({ + time: element.currentTime, + clock: performance.now(), + })); + await expect + .poll(async () => video.evaluate((element) => element.currentTime), { + timeout: 8000, + }) + .toBeGreaterThan(before.time + 4); + const after = await video.evaluate((element) => ({ + time: element.currentTime, + clock: performance.now(), + })); + expect( + (after.time - before.time) / ((after.clock - before.clock) / 1000), + ).toBeGreaterThan(1.6); + await expect + .poll( + () => + video.evaluate((element) => + element.buffered.length + ? element.buffered.end(element.buffered.length - 1) - + element.currentTime + : 0, + ), + { timeout: 5000 }, + ) + .toBeGreaterThan(35); + expect( + (await (await page.request.get(`${baseURL}/api/status`)).json()) + .activeStreams, + ).toBe(1); + + await reveal(); + await page.getByRole("button", { name: "Pause", exact: true }).click(); + await page + .getByRole("button", { name: "Playback speed: 2×", exact: true }) + .click(); + await page.screenshot({ path: `/tmp/restream-speed-${mode}.png` }); + const reset = page.waitForResponse((response) => + response.url().endsWith("/speed"), + ); + await page + .getByRole("menuitemradio", { name: "1× (Normal)", exact: true }) + .click(); + expect((await (await reset).json()).playbackRate).toBe(1); + await expect(video).toHaveJSProperty("playbackRate", 1); + await expect + .poll(() => video.evaluate((element) => element.currentSrc)) + .not.toBe(acceleratedSource); + await expect(video).toHaveJSProperty("paused", true); + await reveal(); + await page.getByRole("button", { name: "Play", exact: true }).click(); + await page.waitForFunction(progressing, undefined, { timeout: 15000 }); + const bounds = await page + .getByRole("button", { name: "Playback speed: 1×", exact: true }) + .boundingBox(); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual( + page.viewportSize().width, + ); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(page.viewportSize().width); + expect(errors).toEqual([]); + console.log( + `${mode}: ${mobile ? "mobile" : "desktop"} playback advances at 2×, returns to 1× while paused, and retains one stream slot`, + ); +} finally { + if (session) + await page.request.delete(`${baseURL}/api/sessions/${session.id}`); + await browser.close(); +} diff --git a/web/tests/workflow.spec.ts b/web/tests/workflow.spec.ts index ae9989f..4110f55 100644 --- a/web/tests/workflow.spec.ts +++ b/web/tests/workflow.spec.ts @@ -324,6 +324,8 @@ async function mockApi( }; const sessionStarts: number[] = []; const sessionOffsets = new Map(); + const sessionRates = new Map(); + const sessionGenerations = new Map(); let preferences: BrowserPreferences = structuredClone( options.preferences ?? { refreshIntervalSeconds: 21600, @@ -748,7 +750,7 @@ async function mockApi( const id = path.split("/").at(-1); return json({ id, - url: `/api/streams/${id}/0/index.m3u8`, + url: `/api/streams/${id}/${sessionGenerations.get(id!) ?? 0}/index.m3u8`, state: starting ? "starting" : options.producerEnded && polls > 1 @@ -759,6 +761,7 @@ async function mockApi( options.sessionDuration ?? 1800, offset: sessionOffsets.get(id!) ?? 0, + playbackRate: sessionRates.get(id!) ?? 1, }); } if (/^\/api\/sessions\/session-\d+$/.test(path) && method === "DELETE") { @@ -771,6 +774,22 @@ async function mockApi( return route.fulfill({ status: 204 }); } if (path.endsWith("/heartbeat")) return route.fulfill({ status: 204 }); + if (path.endsWith("/speed")) { + const id = path.split("/")[3]; + const { position, rate } = route.request().postDataJSON(); + sessionOffsets.set(id, position); + sessionRates.set(id, rate); + const generation = (sessionGenerations.get(id) ?? 0) + 1; + sessionGenerations.set(id, generation); + return json({ + id, + url: `/api/streams/${id}/${generation}/index.m3u8`, + state: "ready", + duration: options.sessionDuration ?? 1800, + offset: position, + playbackRate: rate, + }); + } if (path.endsWith("/seek")) return json({ id: path.split("/")[3], @@ -875,6 +894,9 @@ test("LIVE greys behind the stream and jumps to the latest seek point while resu .or(page.locator(".primary-control")), ).toBeEnabled(); await playableVideo(page); + await expect( + page.getByRole("button", { name: /Playback speed:/ }), + ).toHaveCount(0); const liveButton = page.getByRole("button", { name: "Go to live" }); const revealControls = async () => { await page.locator(".playback-area").press("ArrowUp"); @@ -1345,6 +1367,62 @@ test("player supports pause, keyboard resume, and fullscreen", async ({ expect(api.events).not.toContain("DELETE"); }); +test("native playback without a known duration recovers from a rejected speed change and preserves pause", async ({ + page, +}) => { + const api = await mockApi(page, { sessionDuration: 0 }); + await page.goto("/?section=movie"); + await page.getByRole("button", { name: "Open The Quiet Coast" }).click(); + await page.getByRole("button", { name: "Play", exact: true }).click(); + await expect(page.locator(".primary-control")).toBeEnabled(); + await playableVideo(page); + const video = page.locator("video"); + const originalSource = await video.getAttribute("src"); + await page.route( + "**/api/sessions/session-1/speed", + (route) => + route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: "Speed change temporarily unavailable" }), + }), + { times: 1 }, + ); + await page + .getByRole("button", { name: "Playback speed: 1×", exact: true }) + .click(); + await page.getByRole("menuitemradio", { name: "2×", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText( + "Speed change temporarily unavailable", + ); + await expect(video).toHaveAttribute("src", originalSource!); + await expect(video).toHaveJSProperty("playbackRate", 1); + await expect(video).toHaveJSProperty("paused", false); + + await page.getByRole("button", { name: "Pause", exact: true }).click(); + await page + .getByRole("button", { name: "Playback speed: 1×", exact: true }) + .click(); + const changed = page.waitForRequest((request) => + request.url().endsWith("/speed"), + ); + await page.getByRole("menuitemradio", { name: "1.5×", exact: true }).click(); + expect((await changed).postDataJSON()).toEqual({ position: 30, rate: 1.5 }); + await expect(video).toHaveAttribute("src", /\/session-1\/1\/index.m3u8$/); + await expect(video).toHaveJSProperty("playbackRate", 1.5); + await expect(video).toHaveJSProperty("paused", true); + await expect(page.getByRole("alert")).toHaveCount(0); + await page + .getByRole("button", { name: "Playback speed: 1.5×", exact: true }) + .click(); + await expect( + page.getByRole("menuitemradio", { name: "1.5×", exact: true }), + ).toHaveAttribute("aria-checked", "true"); + await page.keyboard.press("Escape"); + expect(api.sessionItemIds).toEqual([movie.id]); + expect(api.events).not.toContain("DELETE"); +}); + test("watch workspace fits responsive screens without replacing its decoder", async ({ page, }) => {