fix: hls session ending and user can't watch video

This commit is contained in:
Rotimi Best
2026-06-28 13:15:37 +01:00
parent 9222c8947d
commit a70f8c5287
15 changed files with 81 additions and 21 deletions
@@ -75,7 +75,11 @@ export const publicCourseRouter = new Hono()
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
// `None` (not `Lax`) so the cookie is sent on cross-site segment
// fetches issued by hls.js — Lax is withheld on cross-site
// subresource requests, which would 403 every segment when the
// public site and api/stream live on different hosts.
sameSite: 'None',
maxAge: result.maxAgeSeconds
});
+6 -1
View File
@@ -261,7 +261,12 @@ export const assetsRouter = new Hono()
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
// `None` (not `Lax`) so the cookie is sent on cross-site segment
// fetches issued by hls.js — Lax is withheld on cross-site
// subresource requests, which would 403 every segment when the
// player and api/stream live on different hosts (BYOD custom
// domains, local cross-origin dev).
sameSite: 'None',
maxAge: maxAgeSeconds
});
@@ -6,7 +6,9 @@
import { jobsApi, JobPoller, type MediaJobEnvelope } from '$features/jobs';
import { t } from '$lib/utils/functions/translations';
import { sidePanel } from '$features/side-panel';
import { classroomio } from '$lib/utils/services/api';
import { classroomio, isAuthError } from '$lib/utils/services/api';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { lessonApi } from '$features/course/api';
import { snackbar } from '$features/ui/snackbar/store';
import type { AssetTranscriptPayload } from '$features/media/utils/types';
@@ -43,10 +45,18 @@
return built.toString().replace(/\/\*$/, '') + '/' + rest;
}
async function mintHlsCookie(assetId: string): Promise<void> {
async function mintHlsCookie(assetId: string): Promise<{ authExpired?: boolean } | void> {
try {
await classroomio.organization.assets[':assetId'].hls.cookie.$post({
param: { assetId }
});
} catch (error) {
// A 401 here means the viewer's session expired — the player should
// prompt a re-login rather than retry. Other failures (e.g. 503 when
// HLS_SIGNING_SECRET is unset locally) fall through so playback can
// still attempt the session-auth streaming route.
if (isAuthError(error)) return { authExpired: true };
}
}
/**
@@ -473,6 +483,9 @@
playbackErrorLabel: $t('course.navItem.lessons.materials.tabs.video.playback_error'),
playbackReloadLabel: $t('course.navItem.lessons.materials.tabs.video.playback_reload'),
onPlaybackReload: handlePlaybackReload,
playbackAuthErrorLabel: $t('course.navItem.lessons.materials.tabs.video.playback_auth_error'),
playbackAuthActionLabel: $t('course.navItem.lessons.materials.tabs.video.playback_auth_action'),
onPlaybackAuthRequired: () => goto(resolve('/login', {})),
seekPolicy
}}
/>
@@ -1998,7 +1998,9 @@
"generate_1080_in_progress": "Genererer 1080p...",
"generate_1080_ready": "1080p tilgængelig",
"generate_1080_unavailable": "1080p ikke tilgængelig for denne kildevideo",
"loading": "Indlæser video..."
"loading": "Indlæser video...",
"playback_auth_error": "Din session udløb. Log venligst ind igen for at se denne video.",
"playback_auth_action": "Log ind"
},
"slide": {
"title": "Slide",
@@ -1998,7 +1998,9 @@
"generate_1080_in_progress": "Generiert 1080p…",
"generate_1080_ready": "1080p verfügbar",
"generate_1080_unavailable": "1080p ist für dieses Quellvideo nicht verfügbar",
"loading": "Video wird geladen…"
"loading": "Video wird geladen…",
"playback_auth_error": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an, um dieses Video anzusehen.",
"playback_auth_action": "anmelden"
},
"slide": {
"title": "Rutsche",
@@ -2467,6 +2467,8 @@
"generate_1080_unavailable": "1080p unavailable for this source video",
"playback_error": "Loading this video failed.",
"playback_reload": "Reload video",
"playback_auth_error": "Your session expired. Please sign in again to watch this video.",
"playback_auth_action": "Sign in",
"loading": "Loading video…",
"simple_card": {
"kind_youtube": "YouTube",
@@ -1998,7 +1998,9 @@
"generate_1080_in_progress": "Generando 1080p…",
"generate_1080_ready": "1080p disponible",
"generate_1080_unavailable": "1080p no disponible para este vídeo fuente",
"loading": "Cargando vídeo…"
"loading": "Cargando vídeo…",
"playback_auth_error": "Tu sesión expiró. Inicie sesión nuevamente para ver este video.",
"playback_auth_action": "Iniciar sesión"
},
"slide": {
"title": "diapositiva",
@@ -1998,7 +1998,9 @@
"generate_1080_in_progress": "Génération de 1080p…",
"generate_1080_ready": "1080p disponible",
"generate_1080_unavailable": "1080p indisponible pour cette vidéo source",
"loading": "Chargement de la vidéo…"
"loading": "Chargement de la vidéo…",
"playback_auth_error": "Votre session a expiré. Veuillez vous reconnecter pour regarder cette vidéo.",
"playback_auth_action": "Se connecter"
},
"slide": {
"title": "Diapositive",
@@ -1998,7 +1998,9 @@
"generate_1080_in_progress": "1080p जेनरेट हो रहा है...",
"generate_1080_ready": "1080p उपलब्ध",
"generate_1080_unavailable": "इस स्रोत वीडियो के लिए 1080p अनुपलब्ध है",
"loading": "वीडियो लोड हो रहा है..."
"loading": "वीडियो लोड हो रहा है...",
"playback_auth_error": "आपका सत्र समाप्त हो गया. कृपया इस वीडियो को देखने के लिए फिर से साइन इन करें।",
"playback_auth_action": "दाखिल करना"
},
"slide": {
"title": "फिसल पट्टी",
@@ -1998,7 +1998,9 @@
"generate_1080_in_progress": "Generowanie 1080p…",
"generate_1080_ready": "Dostępna rozdzielczość 1080p",
"generate_1080_unavailable": "Rozdzielczość 1080p jest niedostępna dla tego wideo źródłowego",
"loading": "Ładowanie wideo…"
"loading": "Ładowanie wideo…",
"playback_auth_error": "Twoja sesja wygasła. Aby obejrzeć ten film, zaloguj się ponownie.",
"playback_auth_action": "Zalogować się"
},
"slide": {
"title": "Przesuń",
@@ -1998,7 +1998,9 @@
"generate_1080_in_progress": "Gerando 1080p…",
"generate_1080_ready": "1080p disponível",
"generate_1080_unavailable": "1080p indisponível para este vídeo de origem",
"loading": "Carregando vídeo…"
"loading": "Carregando vídeo…",
"playback_auth_error": "Sua sessão expirou. Faça login novamente para assistir a este vídeo.",
"playback_auth_action": "Entrar"
},
"slide": {
"title": "Deslizar",
@@ -1998,7 +1998,9 @@
"generate_1080_in_progress": "Генерация 1080p…",
"generate_1080_ready": "доступно 1080p",
"generate_1080_unavailable": "Разрешение 1080p недоступно для этого исходного видео.",
"loading": "Загрузка видео…"
"loading": "Загрузка видео…",
"playback_auth_error": "Срок действия вашей сессии истек. Пожалуйста, войдите еще раз, чтобы посмотреть это видео.",
"playback_auth_action": "Войти"
},
"slide": {
"title": "Слайд",
@@ -1998,7 +1998,9 @@
"generate_1080_in_progress": "Đang tạo 1080p…",
"generate_1080_ready": "1080p có sẵn",
"generate_1080_unavailable": "1080p không có sẵn cho video nguồn này",
"loading": "Đang tải video…"
"loading": "Đang tải video…",
"playback_auth_error": "Phiên của bạn đã hết hạn. Vui lòng đăng nhập lại để xem video này.",
"playback_auth_action": "Đăng nhập"
},
"slide": {
"title": "Trượt",
@@ -32,6 +32,7 @@
let playerInstance = $state<Plyr | null>(null);
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
let playbackFailed = $state(false);
let playbackAuthExpired = $state(false);
let isPlayerReady = $state(false);
let reloadInFlight = $state(false);
let timeupdateCleanup: (() => void) | undefined;
@@ -533,6 +534,7 @@
loadedSrc = nextSrc;
playbackFailed = false;
playbackAuthExpired = false;
const onLoadedMetadata = () => {
element.removeEventListener('loadedmetadata', onLoadedMetadata);
@@ -566,10 +568,17 @@
destroyHlsInstance();
teardownPlyrInstance();
playbackFailed = false;
playbackAuthExpired = false;
if (options.onBeforeHlsLoad) {
try {
await options.onBeforeHlsLoad();
const result = await options.onBeforeHlsLoad();
if (result?.authExpired) {
playbackAuthExpired = true;
handlePlaybackFailure();
return;
}
} catch (error) {
// The cookie endpoint may 503 in environments where HLS_SIGNING_SECRET
// isn't configured (local dev / self-hosted). Playback can still
@@ -750,13 +759,19 @@
</div>
{/if}
{#if playbackFailed && options.playbackErrorLabel}
{#if playbackFailed && (playbackAuthExpired ? options.playbackAuthErrorLabel : options.playbackErrorLabel)}
<div
class="ui:absolute ui:inset-0 ui:z-20 ui:flex ui:flex-col ui:items-center ui:justify-center ui:gap-4 ui:bg-muted/95 ui:px-6 ui:text-center"
role="alert"
>
<p class="ui:text-sm ui:text-muted-foreground">{options.playbackErrorLabel}</p>
{#if options.onPlaybackReload && options.playbackReloadLabel}
<p class="ui:text-sm ui:text-muted-foreground">
{playbackAuthExpired ? options.playbackAuthErrorLabel : options.playbackErrorLabel}
</p>
{#if playbackAuthExpired && options.onPlaybackAuthRequired && options.playbackAuthActionLabel}
<Button variant="outline" onclick={() => options.onPlaybackAuthRequired?.()}>
{options.playbackAuthActionLabel}
</Button>
{:else if !playbackAuthExpired && options.onPlaybackReload && options.playbackReloadLabel}
<Button variant="outline" disabled={reloadInFlight} onclick={() => void handlePlaybackReloadClick()}>
{options.playbackReloadLabel}
</Button>
+4 -1
View File
@@ -50,6 +50,9 @@ export interface MediaPlayerOptions {
playbackReloadLabel?: string;
/** Fetch a fresh playback URL (or otherwise prepare a retry). Return true to retry loading. */
onPlaybackReload?: () => boolean | Promise<boolean>;
playbackAuthErrorLabel?: string;
playbackAuthActionLabel?: string;
onPlaybackAuthRequired?: () => void;
/** Fires after a new `src` finishes loading metadata (used to restore seek position). */
onSourceLoaded?: (video: HTMLVideoElement) => void;
/**
@@ -67,7 +70,7 @@ export interface MediaPlayerOptions {
* production. Locally / self-hosted this is typically a no-op or a
* best-effort call that fails benignly.
*/
onBeforeHlsLoad?: () => Promise<void>;
onBeforeHlsLoad?: () => Promise<{ authExpired?: boolean } | void>;
seekPolicy?: {
mode: 'locked_until_complete';
watchThresholdPercent: number;