fix: hls session ending and user can't watch video
This commit is contained in:
@@ -75,7 +75,11 @@ export const publicCourseRouter = new Hono()
|
|||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: 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
|
maxAge: result.maxAgeSeconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -261,7 +261,12 @@ export const assetsRouter = new Hono()
|
|||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: 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
|
maxAge: maxAgeSeconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+18
-5
@@ -6,7 +6,9 @@
|
|||||||
import { jobsApi, JobPoller, type MediaJobEnvelope } from '$features/jobs';
|
import { jobsApi, JobPoller, type MediaJobEnvelope } from '$features/jobs';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { sidePanel } from '$features/side-panel';
|
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 { lessonApi } from '$features/course/api';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||||
@@ -43,10 +45,18 @@
|
|||||||
return built.toString().replace(/\/\*$/, '') + '/' + rest;
|
return built.toString().replace(/\/\*$/, '') + '/' + rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mintHlsCookie(assetId: string): Promise<void> {
|
async function mintHlsCookie(assetId: string): Promise<{ authExpired?: boolean } | void> {
|
||||||
await classroomio.organization.assets[':assetId'].hls.cookie.$post({
|
try {
|
||||||
param: { assetId }
|
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'),
|
playbackErrorLabel: $t('course.navItem.lessons.materials.tabs.video.playback_error'),
|
||||||
playbackReloadLabel: $t('course.navItem.lessons.materials.tabs.video.playback_reload'),
|
playbackReloadLabel: $t('course.navItem.lessons.materials.tabs.video.playback_reload'),
|
||||||
onPlaybackReload: handlePlaybackReload,
|
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
|
seekPolicy
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1998,7 +1998,9 @@
|
|||||||
"generate_1080_in_progress": "Genererer 1080p...",
|
"generate_1080_in_progress": "Genererer 1080p...",
|
||||||
"generate_1080_ready": "1080p tilgængelig",
|
"generate_1080_ready": "1080p tilgængelig",
|
||||||
"generate_1080_unavailable": "1080p ikke tilgængelig for denne kildevideo",
|
"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": {
|
"slide": {
|
||||||
"title": "Slide",
|
"title": "Slide",
|
||||||
|
|||||||
@@ -1998,7 +1998,9 @@
|
|||||||
"generate_1080_in_progress": "Generiert 1080p…",
|
"generate_1080_in_progress": "Generiert 1080p…",
|
||||||
"generate_1080_ready": "1080p verfügbar",
|
"generate_1080_ready": "1080p verfügbar",
|
||||||
"generate_1080_unavailable": "1080p ist für dieses Quellvideo nicht 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": {
|
"slide": {
|
||||||
"title": "Rutsche",
|
"title": "Rutsche",
|
||||||
|
|||||||
@@ -2467,6 +2467,8 @@
|
|||||||
"generate_1080_unavailable": "1080p unavailable for this source video",
|
"generate_1080_unavailable": "1080p unavailable for this source video",
|
||||||
"playback_error": "Loading this video failed.",
|
"playback_error": "Loading this video failed.",
|
||||||
"playback_reload": "Reload video",
|
"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…",
|
"loading": "Loading video…",
|
||||||
"simple_card": {
|
"simple_card": {
|
||||||
"kind_youtube": "YouTube",
|
"kind_youtube": "YouTube",
|
||||||
|
|||||||
@@ -1998,7 +1998,9 @@
|
|||||||
"generate_1080_in_progress": "Generando 1080p…",
|
"generate_1080_in_progress": "Generando 1080p…",
|
||||||
"generate_1080_ready": "1080p disponible",
|
"generate_1080_ready": "1080p disponible",
|
||||||
"generate_1080_unavailable": "1080p no disponible para este vídeo fuente",
|
"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": {
|
"slide": {
|
||||||
"title": "diapositiva",
|
"title": "diapositiva",
|
||||||
|
|||||||
@@ -1998,7 +1998,9 @@
|
|||||||
"generate_1080_in_progress": "Génération de 1080p…",
|
"generate_1080_in_progress": "Génération de 1080p…",
|
||||||
"generate_1080_ready": "1080p disponible",
|
"generate_1080_ready": "1080p disponible",
|
||||||
"generate_1080_unavailable": "1080p indisponible pour cette vidéo source",
|
"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": {
|
"slide": {
|
||||||
"title": "Diapositive",
|
"title": "Diapositive",
|
||||||
|
|||||||
@@ -1998,7 +1998,9 @@
|
|||||||
"generate_1080_in_progress": "1080p जेनरेट हो रहा है...",
|
"generate_1080_in_progress": "1080p जेनरेट हो रहा है...",
|
||||||
"generate_1080_ready": "1080p उपलब्ध",
|
"generate_1080_ready": "1080p उपलब्ध",
|
||||||
"generate_1080_unavailable": "इस स्रोत वीडियो के लिए 1080p अनुपलब्ध है",
|
"generate_1080_unavailable": "इस स्रोत वीडियो के लिए 1080p अनुपलब्ध है",
|
||||||
"loading": "वीडियो लोड हो रहा है..."
|
"loading": "वीडियो लोड हो रहा है...",
|
||||||
|
"playback_auth_error": "आपका सत्र समाप्त हो गया. कृपया इस वीडियो को देखने के लिए फिर से साइन इन करें।",
|
||||||
|
"playback_auth_action": "दाखिल करना"
|
||||||
},
|
},
|
||||||
"slide": {
|
"slide": {
|
||||||
"title": "फिसल पट्टी",
|
"title": "फिसल पट्टी",
|
||||||
|
|||||||
@@ -1998,7 +1998,9 @@
|
|||||||
"generate_1080_in_progress": "Generowanie 1080p…",
|
"generate_1080_in_progress": "Generowanie 1080p…",
|
||||||
"generate_1080_ready": "Dostępna rozdzielczość 1080p",
|
"generate_1080_ready": "Dostępna rozdzielczość 1080p",
|
||||||
"generate_1080_unavailable": "Rozdzielczość 1080p jest niedostępna dla tego wideo źródłowego",
|
"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": {
|
"slide": {
|
||||||
"title": "Przesuń",
|
"title": "Przesuń",
|
||||||
|
|||||||
@@ -1998,7 +1998,9 @@
|
|||||||
"generate_1080_in_progress": "Gerando 1080p…",
|
"generate_1080_in_progress": "Gerando 1080p…",
|
||||||
"generate_1080_ready": "1080p disponível",
|
"generate_1080_ready": "1080p disponível",
|
||||||
"generate_1080_unavailable": "1080p indisponível para este vídeo de origem",
|
"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": {
|
"slide": {
|
||||||
"title": "Deslizar",
|
"title": "Deslizar",
|
||||||
|
|||||||
@@ -1998,7 +1998,9 @@
|
|||||||
"generate_1080_in_progress": "Генерация 1080p…",
|
"generate_1080_in_progress": "Генерация 1080p…",
|
||||||
"generate_1080_ready": "доступно 1080p",
|
"generate_1080_ready": "доступно 1080p",
|
||||||
"generate_1080_unavailable": "Разрешение 1080p недоступно для этого исходного видео.",
|
"generate_1080_unavailable": "Разрешение 1080p недоступно для этого исходного видео.",
|
||||||
"loading": "Загрузка видео…"
|
"loading": "Загрузка видео…",
|
||||||
|
"playback_auth_error": "Срок действия вашей сессии истек. Пожалуйста, войдите еще раз, чтобы посмотреть это видео.",
|
||||||
|
"playback_auth_action": "Войти"
|
||||||
},
|
},
|
||||||
"slide": {
|
"slide": {
|
||||||
"title": "Слайд",
|
"title": "Слайд",
|
||||||
|
|||||||
@@ -1998,7 +1998,9 @@
|
|||||||
"generate_1080_in_progress": "Đang tạo 1080p…",
|
"generate_1080_in_progress": "Đang tạo 1080p…",
|
||||||
"generate_1080_ready": "1080p có sẵn",
|
"generate_1080_ready": "1080p có sẵn",
|
||||||
"generate_1080_unavailable": "1080p không có sẵn cho video nguồn này",
|
"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": {
|
"slide": {
|
||||||
"title": "Trượt",
|
"title": "Trượt",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
let playerInstance = $state<Plyr | null>(null);
|
let playerInstance = $state<Plyr | null>(null);
|
||||||
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
|
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
|
||||||
let playbackFailed = $state(false);
|
let playbackFailed = $state(false);
|
||||||
|
let playbackAuthExpired = $state(false);
|
||||||
let isPlayerReady = $state(false);
|
let isPlayerReady = $state(false);
|
||||||
let reloadInFlight = $state(false);
|
let reloadInFlight = $state(false);
|
||||||
let timeupdateCleanup: (() => void) | undefined;
|
let timeupdateCleanup: (() => void) | undefined;
|
||||||
@@ -533,6 +534,7 @@
|
|||||||
|
|
||||||
loadedSrc = nextSrc;
|
loadedSrc = nextSrc;
|
||||||
playbackFailed = false;
|
playbackFailed = false;
|
||||||
|
playbackAuthExpired = false;
|
||||||
|
|
||||||
const onLoadedMetadata = () => {
|
const onLoadedMetadata = () => {
|
||||||
element.removeEventListener('loadedmetadata', onLoadedMetadata);
|
element.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
@@ -566,10 +568,17 @@
|
|||||||
destroyHlsInstance();
|
destroyHlsInstance();
|
||||||
teardownPlyrInstance();
|
teardownPlyrInstance();
|
||||||
playbackFailed = false;
|
playbackFailed = false;
|
||||||
|
playbackAuthExpired = false;
|
||||||
|
|
||||||
if (options.onBeforeHlsLoad) {
|
if (options.onBeforeHlsLoad) {
|
||||||
try {
|
try {
|
||||||
await options.onBeforeHlsLoad();
|
const result = await options.onBeforeHlsLoad();
|
||||||
|
if (result?.authExpired) {
|
||||||
|
playbackAuthExpired = true;
|
||||||
|
handlePlaybackFailure();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The cookie endpoint may 503 in environments where HLS_SIGNING_SECRET
|
// The cookie endpoint may 503 in environments where HLS_SIGNING_SECRET
|
||||||
// isn't configured (local dev / self-hosted). Playback can still
|
// isn't configured (local dev / self-hosted). Playback can still
|
||||||
@@ -750,13 +759,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if playbackFailed && options.playbackErrorLabel}
|
{#if playbackFailed && (playbackAuthExpired ? options.playbackAuthErrorLabel : options.playbackErrorLabel)}
|
||||||
<div
|
<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"
|
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"
|
role="alert"
|
||||||
>
|
>
|
||||||
<p class="ui:text-sm ui:text-muted-foreground">{options.playbackErrorLabel}</p>
|
<p class="ui:text-sm ui:text-muted-foreground">
|
||||||
{#if options.onPlaybackReload && options.playbackReloadLabel}
|
{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()}>
|
<Button variant="outline" disabled={reloadInFlight} onclick={() => void handlePlaybackReloadClick()}>
|
||||||
{options.playbackReloadLabel}
|
{options.playbackReloadLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export interface MediaPlayerOptions {
|
|||||||
playbackReloadLabel?: string;
|
playbackReloadLabel?: string;
|
||||||
/** Fetch a fresh playback URL (or otherwise prepare a retry). Return true to retry loading. */
|
/** Fetch a fresh playback URL (or otherwise prepare a retry). Return true to retry loading. */
|
||||||
onPlaybackReload?: () => boolean | Promise<boolean>;
|
onPlaybackReload?: () => boolean | Promise<boolean>;
|
||||||
|
playbackAuthErrorLabel?: string;
|
||||||
|
playbackAuthActionLabel?: string;
|
||||||
|
onPlaybackAuthRequired?: () => void;
|
||||||
/** Fires after a new `src` finishes loading metadata (used to restore seek position). */
|
/** Fires after a new `src` finishes loading metadata (used to restore seek position). */
|
||||||
onSourceLoaded?: (video: HTMLVideoElement) => void;
|
onSourceLoaded?: (video: HTMLVideoElement) => void;
|
||||||
/**
|
/**
|
||||||
@@ -67,7 +70,7 @@ export interface MediaPlayerOptions {
|
|||||||
* production. Locally / self-hosted this is typically a no-op or a
|
* production. Locally / self-hosted this is typically a no-op or a
|
||||||
* best-effort call that fails benignly.
|
* best-effort call that fails benignly.
|
||||||
*/
|
*/
|
||||||
onBeforeHlsLoad?: () => Promise<void>;
|
onBeforeHlsLoad?: () => Promise<{ authExpired?: boolean } | void>;
|
||||||
seekPolicy?: {
|
seekPolicy?: {
|
||||||
mode: 'locked_until_complete';
|
mode: 'locked_until_complete';
|
||||||
watchThresholdPercent: number;
|
watchThresholdPercent: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user