fix: reduce hls playback churn
This commit is contained in:
@@ -44,6 +44,9 @@ func RequestLogger(metrics *observability.Metrics) gin.HandlerFunc {
|
|||||||
if len(privateErrors) > 0 {
|
if len(privateErrors) > 0 {
|
||||||
logErr = privateErrors.Last().Err
|
logErr = privateErrors.Last().Err
|
||||||
}
|
}
|
||||||
|
if route == "/watch/proxy/stream" && status < 400 && len(privateErrors) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
if route != path {
|
if route != path {
|
||||||
fields["route"] = route
|
fields["route"] = route
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,60 @@ func TestRequestLoggerUsesMatchedRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestLoggerSkipsSuccessfulStreamProxy(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
previousOutput := log.Writer()
|
||||||
|
log.SetOutput(&logs)
|
||||||
|
defer log.SetOutput(previousOutput)
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(RequestContextMiddleware())
|
||||||
|
router.Use(RequestLogger(observability.NewMetrics()))
|
||||||
|
router.GET("/watch/proxy/stream", func(c *gin.Context) {
|
||||||
|
c.String(http.StatusOK, "segment")
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/stream?token=abc", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := logs.String(); got != "" {
|
||||||
|
t.Fatalf("expected no successful stream proxy log, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestLoggerLogsFailedStreamProxy(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
previousOutput := log.Writer()
|
||||||
|
log.SetOutput(&logs)
|
||||||
|
defer log.SetOutput(previousOutput)
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(RequestContextMiddleware())
|
||||||
|
router.Use(RequestLogger(observability.NewMetrics()))
|
||||||
|
router.GET("/watch/proxy/stream", func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusBadGateway)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/stream?token=abc", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
if got := logs.String(); !strings.Contains(got, " ERROR http 502 GET /watch/proxy/stream") {
|
||||||
|
t.Fatalf("expected failed stream proxy log, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRespondErrorIncludesRequestContext(t *testing.T) {
|
func TestRespondErrorIncludesRequestContext(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|||||||
@@ -156,13 +156,27 @@ const initPlayer = async (): Promise<void> => {
|
|||||||
|
|
||||||
await ensurePreferredModeSource(signal);
|
await ensurePreferredModeSource(signal);
|
||||||
|
|
||||||
|
const resumeAfterModeSwitch = (() => {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem("mal:resume-after-mode-switch");
|
||||||
|
if (raw === null) return null;
|
||||||
|
sessionStorage.removeItem("mal:resume-after-mode-switch");
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("failed to parse resume state:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const initialStartTime = resumeAfterModeSwitch ?? state.playback.startTimeSeconds;
|
||||||
|
|
||||||
// build video src from mode, token, and saved quality preference
|
// build video src from mode, token, and saved quality preference
|
||||||
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
|
const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best";
|
||||||
const streamToken = state.playback.modeSources[state.playback.currentMode]?.token;
|
const streamToken = state.playback.modeSources[state.playback.currentMode]?.token;
|
||||||
if (streamToken) {
|
if (streamToken) {
|
||||||
const source = state.playback.modeSources[state.playback.currentMode];
|
const source = state.playback.modeSources[state.playback.currentMode];
|
||||||
const url = `${state.playback.streamURL}?mode=${encodeURIComponent(state.playback.currentMode)}&token=${encodeURIComponent(streamToken)}${source?.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
|
const url = `${state.playback.streamURL}?mode=${encodeURIComponent(state.playback.currentMode)}&token=${encodeURIComponent(streamToken)}${source?.type === "m3u8" ? "&hls=1" : ""}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`;
|
||||||
loadVideoSource(url, source?.type);
|
loadVideoSource(url, source?.type, initialStartTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSubtitleOptions();
|
updateSubtitleOptions();
|
||||||
@@ -190,20 +204,6 @@ const initPlayer = async (): Promise<void> => {
|
|||||||
const resumeTime = bounds.duration > 0 ? Math.min(startTime, bounds.duration) : 0;
|
const resumeTime = bounds.duration > 0 ? Math.min(startTime, bounds.duration) : 0;
|
||||||
const isAtEnd = startTime > 0 && bounds.duration > 0 && startTime >= bounds.duration - 2;
|
const isAtEnd = startTime > 0 && bounds.duration > 0 && startTime >= bounds.duration - 2;
|
||||||
|
|
||||||
// Resume after a mode-switch page reload (best effort, session-scoped).
|
|
||||||
const resumeAfterModeSwitch = (() => {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem("mal:resume-after-mode-switch");
|
|
||||||
if (raw === null) return null;
|
|
||||||
sessionStorage.removeItem("mal:resume-after-mode-switch");
|
|
||||||
const parsed = Number(raw);
|
|
||||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("failed to parse resume state:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (resumeAfterModeSwitch !== null) {
|
if (resumeAfterModeSwitch !== null) {
|
||||||
const clamped = bounds.duration > 0 ? Math.min(resumeAfterModeSwitch, bounds.duration) : 0;
|
const clamped = bounds.duration > 0 ? Math.min(resumeAfterModeSwitch, bounds.duration) : 0;
|
||||||
if (clamped > 0) {
|
if (clamped > 0) {
|
||||||
@@ -425,7 +425,9 @@ const initPlayer = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupThumbnails();
|
setupThumbnails();
|
||||||
void hydrateAlternateMode(signal);
|
window.setTimeout(() => {
|
||||||
|
if (!signal.aborted) void hydrateAlternateMode(signal);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
document.body.addEventListener(
|
document.body.addEventListener(
|
||||||
"htmx:beforeSwap",
|
"htmx:beforeSwap",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const shouldUseHLS = (type: string | undefined, url: string): boolean => {
|
|||||||
* Some browsers can be flaky when switching between HLS URLs while playing.
|
* Some browsers can be flaky when switching between HLS URLs while playing.
|
||||||
* Clearing `src` first ensures the media element fully resets before the new URL is set.
|
* Clearing `src` first ensures the media element fully resets before the new URL is set.
|
||||||
*/
|
*/
|
||||||
export const loadVideoSource = (url: string, type?: string): void => {
|
export const loadVideoSource = (url: string, type?: string, startTimeSeconds?: number): void => {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
const wasPlaying = !state.elements.video.paused;
|
const wasPlaying = !state.elements.video.paused;
|
||||||
@@ -49,10 +49,24 @@ export const loadVideoSource = (url: string, type?: string): void => {
|
|||||||
destroyVideoSource();
|
destroyVideoSource();
|
||||||
|
|
||||||
if (shouldUseHLS(type, url) && Hls.isSupported()) {
|
if (shouldUseHLS(type, url) && Hls.isSupported()) {
|
||||||
hls = new Hls();
|
hls = new Hls({
|
||||||
|
autoStartLoad: false,
|
||||||
|
backBufferLength: 30,
|
||||||
|
maxBufferLength: 18,
|
||||||
|
maxMaxBufferLength: 30,
|
||||||
|
maxBufferSize: 20 * 1000 * 1000,
|
||||||
|
startFragPrefetch: false,
|
||||||
|
});
|
||||||
stopHLSProfile = attachHLSProfile(hls, state.elements.video);
|
stopHLSProfile = attachHLSProfile(hls, state.elements.video);
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(state.elements.video);
|
hls.attachMedia(state.elements.video);
|
||||||
|
hls.once(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
|
const startPosition =
|
||||||
|
startTimeSeconds !== undefined && Number.isFinite(startTimeSeconds) && startTimeSeconds > 0
|
||||||
|
? startTimeSeconds
|
||||||
|
: -1;
|
||||||
|
hls?.startLoad(startPosition);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
state.elements.video.src = url;
|
state.elements.video.src = url;
|
||||||
state.elements.video.load();
|
state.elements.video.load();
|
||||||
|
|||||||
Reference in New Issue
Block a user