fix: enable adding video from library to another lesson

This commit is contained in:
Rotimi Best
2026-06-28 21:41:53 +01:00
parent 47b85a9697
commit 202fd30445
14 changed files with 87 additions and 38 deletions
@@ -12,7 +12,7 @@
import { classroomio } from '$lib/utils/services/api'; import { classroomio } from '$lib/utils/services/api';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { getBrowserTimezone, instantToZonedWallClock, zonedWallClockToInstant } from '$lib/utils/functions/date'; import { getBrowserTimezone, instantToZonedWallClock, zonedWallClockToInstant } from '$lib/utils/functions/date';
import { getVideoTitle, isHlsUploadVideo, type LessonVideo } from './video/video-card-utils'; import { getVideoTitle, type LessonVideo } from './video/video-card-utils';
type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none'; type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none';
@@ -328,9 +328,6 @@
/> />
<Field.Label for={`watch-enforced-${video.assetId}`} class="font-normal"> <Field.Label for={`watch-enforced-${video.assetId}`} class="font-normal">
{getVideoTitle(video, index)} {getVideoTitle(video, index)}
{#if isHlsUploadVideo(video)}
<span class="ui:text-muted-foreground ml-1 text-xs">(HLS)</span>
{/if}
</Field.Label> </Field.Label>
</Field.Field> </Field.Field>
{/each} {/each}
@@ -1,10 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import { Empty } from '@cio/ui/custom/empty';
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
import { lessonApi } from '$features/course/api'; import { lessonApi } from '$features/course/api';
import { mediaApi } from '$features/media/api'; import { mediaApi } from '$features/media/api';
import type { OrganizationAsset } from '$features/media/utils'; import type { OrganizationAsset } from '$features/media/utils';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
interface Props { interface Props {
@@ -15,10 +20,10 @@
let search = $state(''); let search = $state('');
let isLoading = $state(false); let isLoading = $state(false);
let addingAssetId = $state<string | null>(null);
const assets = $derived( // Local copy so adding a video swipes it out of this list without mutating the
mediaApi.assets.filter((asset) => asset.kind === 'video' && (asset.status ?? 'active') !== 'archived') // shared mediaApi.assets store (which also backs the media manager page).
); let assets = $state<OrganizationAsset[]>([]);
async function loadLibrary() { async function loadLibrary() {
isLoading = true; isLoading = true;
@@ -27,24 +32,32 @@
limit: 100, limit: 100,
search: search.trim() || undefined search: search.trim() || undefined
}); });
assets = mediaApi.assets.filter((asset) => asset.kind === 'video' && (asset.status ?? 'active') !== 'archived');
isLoading = false; isLoading = false;
} }
async function addFromLibrary(asset: OrganizationAsset) { async function addFromLibrary(asset: OrganizationAsset) {
if (!lessonApi.lesson) return; if (!lessonApi.lesson) return;
const position = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0; addingAssetId = asset.id;
const lessonVideo = await mediaApi.buildLessonVideoFromAsset(asset, { try {
lessonId: lessonId || undefined, const position = Array.isArray(lessonApi.lesson.videos) ? lessonApi.lesson.videos.length : 0;
position, const lessonVideo = await mediaApi.buildLessonVideoFromAsset(asset, {
slotType: 'lesson_video' lessonId: lessonId || undefined,
}); position,
slotType: 'lesson_video'
});
if (!lessonVideo) { if (!lessonVideo) {
return; return;
}
lessonApi.updateLessonState('videos', [lessonVideo], { append: true });
assets = assets.filter((item) => item.id !== asset.id);
snackbar.success('snackbar.media_manager.added_to_lesson');
} finally {
addingAssetId = null;
} }
lessonApi.updateLessonState('videos', [lessonVideo], { append: true });
} }
onMount(async () => { onMount(async () => {
@@ -66,22 +79,33 @@
</div> </div>
{#if isLoading} {#if isLoading}
<p class="ui:text-muted-foreground text-sm"> <Empty
{$t('course.navItem.lessons.materials.tabs.video.add_video.loading_library')} description={$t('course.navItem.lessons.materials.tabs.video.add_video.loading_library')}
</p> icon={Spinner}
class="ui:max-h-40 py-6"
/>
{:else if assets.length === 0} {:else if assets.length === 0}
<p class="ui:text-muted-foreground text-sm"> <p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.materials.tabs.video.add_video.no_library_videos')} {$t('course.navItem.lessons.materials.tabs.video.add_video.no_library_videos')}
</p> </p>
{:else} {:else}
<div class="max-h-[360px] space-y-2 overflow-auto pr-1"> <div class="max-h-[360px] space-y-2 overflow-x-hidden">
{#each assets as asset (asset.id)} {#each assets as asset (asset.id)}
<div class="flex items-center justify-between rounded-md border p-3"> <div
<div class="min-w-0"> class="flex items-center justify-between rounded-md border p-3"
<p class="truncate text-sm font-medium">{asset.title || asset.storageKey || asset.id}</p> animate:flip={{ duration: 250 }}
out:fly={{ x: -400, duration: 300 }}
>
<div class="max-w-md">
<p class="truncate text-sm font-medium wrap-break-word">{asset.title || asset.storageKey || asset.id}</p>
<p class="ui:text-muted-foreground text-xs">{asset.provider}</p> <p class="ui:text-muted-foreground text-xs">{asset.provider}</p>
</div> </div>
<Button size="sm" onclick={() => addFromLibrary(asset)}> <Button
size="sm"
onclick={() => addFromLibrary(asset)}
loading={addingAssetId === asset.id}
disabled={addingAssetId !== null}
>
{$t('course.navItem.lessons.materials.tabs.video.add_video.add_from_library')} {$t('course.navItem.lessons.materials.tabs.video.add_video.add_from_library')}
</Button> </Button>
</div> </div>
@@ -38,7 +38,7 @@ import {
ZAssetUpdate, ZAssetUpdate,
ZYouTubeMetadataQuery ZYouTubeMetadataQuery
} from '@cio/utils/validation/assets'; } from '@cio/utils/validation/assets';
import { mapAssetToLessonVideo } from '../utils/media-manager-utils'; import { getAssetHlsManifestLink, isHlsAsset, mapAssetToLessonVideo } from '../utils/media-manager-utils';
import type { AttachAssetRequest } from '../utils/types'; import type { AttachAssetRequest } from '../utils/types';
import type { DetachAssetRequest } from '../utils/types'; import type { DetachAssetRequest } from '../utils/types';
@@ -407,7 +407,9 @@ export class MediaApi extends BaseApiWithErrors {
asset: OrganizationAsset, asset: OrganizationAsset,
params: { lessonId?: string; position?: number; slotType?: string } = {} params: { lessonId?: string; position?: number; slotType?: string } = {}
) { ) {
const url = await this.getVideoPlaybackUrl(asset); // HLS assets have no presignable storageKey — the manifest is served via the
// /hls proxy + signed cookie, so the link is the relative manifest path.
const url = isHlsAsset(asset) ? getAssetHlsManifestLink(asset) : await this.getVideoPlaybackUrl(asset);
if (!url) { if (!url) {
snackbar.error('snackbar.media_manager.resolve_video_failed'); snackbar.error('snackbar.media_manager.resolve_video_failed');
return null; return null;
@@ -62,6 +62,20 @@ export function mapAssetProviderToLessonVideoType(provider: string): LessonVideo
return 'upload'; return 'upload';
} }
/**
* An HLS asset has no playable `storageKey` — its renditions live behind the
* `/hls/{assetId}/...` proxy and are streamed via a signed cookie, not a
* presigned URL. The lesson video stores the relative manifest path + `hls`
* flag so the player resolves and mints the cookie itself.
*/
export function isHlsAsset(asset: OrganizationAsset): boolean {
return Boolean(asset.hlsManifestKey);
}
export function getAssetHlsManifestLink(asset: OrganizationAsset): string {
return `/hls/${asset.id}/master.m3u8`;
}
export function mapAssetToLessonVideo( export function mapAssetToLessonVideo(
asset: OrganizationAsset, asset: OrganizationAsset,
options: { url: string; key?: string | null } options: { url: string; key?: string | null }
@@ -78,6 +92,7 @@ export function mapAssetToLessonVideo(
duration?: number; duration?: number;
aspectRatio?: string; aspectRatio?: string;
createdAt?: string; createdAt?: string;
hls?: boolean;
}; };
} { } {
return { return {
@@ -92,7 +107,8 @@ export function mapAssetToLessonVideo(
thumbnailUrl: asset.thumbnailUrl ?? undefined, thumbnailUrl: asset.thumbnailUrl ?? undefined,
duration: asset.durationSeconds ?? undefined, duration: asset.durationSeconds ?? undefined,
aspectRatio: asset.aspectRatio ?? undefined, aspectRatio: asset.aspectRatio ?? undefined,
createdAt: asset.createdAt ?? undefined createdAt: asset.createdAt ?? undefined,
...(isHlsAsset(asset) ? { hls: true } : {})
} }
}; };
} }
@@ -361,7 +361,8 @@
"video_added_to_lesson": "Video tilføjet til lektion", "video_added_to_lesson": "Video tilføjet til lektion",
"delete_success": "Video slettet", "delete_success": "Video slettet",
"delete_in_use": "Denne video er stadig knyttet til en lektion. Fjern det der først.", "delete_in_use": "Denne video er stadig knyttet til en lektion. Fjern det der først.",
"delete_failed": "Videoen kunne ikke slettes" "delete_failed": "Videoen kunne ikke slettes",
"added_to_lesson": "Video tilføjet til lektion"
}, },
"tags": { "tags": {
"group_created": "Taggruppe oprettet", "group_created": "Taggruppe oprettet",
@@ -361,7 +361,8 @@
"video_added_to_lesson": "Video zur Lektion hinzugefügt", "video_added_to_lesson": "Video zur Lektion hinzugefügt",
"delete_success": "Video gelöscht", "delete_success": "Video gelöscht",
"delete_in_use": "Dieses Video ist noch einer Lektion beigefügt. Entfernen Sie es zuerst dort.", "delete_in_use": "Dieses Video ist noch einer Lektion beigefügt. Entfernen Sie es zuerst dort.",
"delete_failed": "Video konnte nicht gelöscht werden" "delete_failed": "Video konnte nicht gelöscht werden",
"added_to_lesson": "Video zur Lektion hinzugefügt"
}, },
"tags": { "tags": {
"group_created": "Tag-Gruppe erstellt", "group_created": "Tag-Gruppe erstellt",
@@ -4144,6 +4144,7 @@
"delete_success": "Video deleted", "delete_success": "Video deleted",
"delete_in_use": "This video is still attached to a lesson. Remove it there first.", "delete_in_use": "This video is still attached to a lesson. Remove it there first.",
"delete_failed": "Failed to delete video", "delete_failed": "Failed to delete video",
"added_to_lesson": "Video added to lesson",
"download_failed": "Failed to get asset download URL", "download_failed": "Failed to get asset download URL",
"thumbnail_uploaded": "Thumbnail uploaded", "thumbnail_uploaded": "Thumbnail uploaded",
"thumbnail_upload_failed": "Failed to upload thumbnail", "thumbnail_upload_failed": "Failed to upload thumbnail",
@@ -361,7 +361,8 @@
"video_added_to_lesson": "Vídeo añadido a la lección.", "video_added_to_lesson": "Vídeo añadido a la lección.",
"delete_success": "Vídeo eliminado", "delete_success": "Vídeo eliminado",
"delete_in_use": "Este vídeo todavía está adjunto a una lección. Quítelo allí primero.", "delete_in_use": "Este vídeo todavía está adjunto a una lección. Quítelo allí primero.",
"delete_failed": "No se pudo eliminar el video" "delete_failed": "No se pudo eliminar el video",
"added_to_lesson": "Vídeo añadido a la lección."
}, },
"tags": { "tags": {
"group_created": "Grupo de etiquetas creado", "group_created": "Grupo de etiquetas creado",
@@ -361,7 +361,8 @@
"video_added_to_lesson": "Vidéo ajoutée à la leçon", "video_added_to_lesson": "Vidéo ajoutée à la leçon",
"delete_success": "Vidéo supprimée", "delete_success": "Vidéo supprimée",
"delete_in_use": "Cette vidéo est toujours jointe à une leçon. Retirez-le d'abord.", "delete_in_use": "Cette vidéo est toujours jointe à une leçon. Retirez-le d'abord.",
"delete_failed": "Échec de la suppression de la vidéo" "delete_failed": "Échec de la suppression de la vidéo",
"added_to_lesson": "Vidéo ajoutée à la leçon"
}, },
"tags": { "tags": {
"group_created": "Groupe de balises créé", "group_created": "Groupe de balises créé",
@@ -361,7 +361,8 @@
"video_added_to_lesson": "पाठ में वीडियो जोड़ा गया", "video_added_to_lesson": "पाठ में वीडियो जोड़ा गया",
"delete_success": "वीडियो हटा दिया गया", "delete_success": "वीडियो हटा दिया गया",
"delete_in_use": "यह वीडियो अभी भी एक पाठ से जुड़ा हुआ है। पहले इसे वहां से हटाएं.", "delete_in_use": "यह वीडियो अभी भी एक पाठ से जुड़ा हुआ है। पहले इसे वहां से हटाएं.",
"delete_failed": "वीडियो हटाने में विफल" "delete_failed": "वीडियो हटाने में विफल",
"added_to_lesson": "पाठ में वीडियो जोड़ा गया"
}, },
"tags": { "tags": {
"group_created": "टैग समूह बनाया गया", "group_created": "टैग समूह बनाया गया",
@@ -361,7 +361,8 @@
"video_added_to_lesson": "Film dodany do lekcji", "video_added_to_lesson": "Film dodany do lekcji",
"delete_success": "Film usunięty", "delete_success": "Film usunięty",
"delete_in_use": "Ten film jest nadal dołączony do lekcji. Najpierw usuń go tam.", "delete_in_use": "Ten film jest nadal dołączony do lekcji. Najpierw usuń go tam.",
"delete_failed": "Nie udało się usunąć filmu" "delete_failed": "Nie udało się usunąć filmu",
"added_to_lesson": "Film dodany do lekcji"
}, },
"tags": { "tags": {
"group_created": "Utworzono grupę tagów", "group_created": "Utworzono grupę tagów",
@@ -361,7 +361,8 @@
"video_added_to_lesson": "Vídeo adicionado à aula", "video_added_to_lesson": "Vídeo adicionado à aula",
"delete_success": "Vídeo excluído", "delete_success": "Vídeo excluído",
"delete_in_use": "Este vídeo ainda está anexado a uma aula. Remova-o primeiro.", "delete_in_use": "Este vídeo ainda está anexado a uma aula. Remova-o primeiro.",
"delete_failed": "Falha ao excluir vídeo" "delete_failed": "Falha ao excluir vídeo",
"added_to_lesson": "Vídeo adicionado à aula"
}, },
"tags": { "tags": {
"group_created": "Grupo de tags criado", "group_created": "Grupo de tags criado",
@@ -361,7 +361,8 @@
"video_added_to_lesson": "Видео добавлено в урок", "video_added_to_lesson": "Видео добавлено в урок",
"delete_success": "Видео удалено", "delete_success": "Видео удалено",
"delete_in_use": "Это видео все еще прикреплено к уроку. Сначала уберите его оттуда.", "delete_in_use": "Это видео все еще прикреплено к уроку. Сначала уберите его оттуда.",
"delete_failed": "Не удалось удалить видео." "delete_failed": "Не удалось удалить видео.",
"added_to_lesson": "Видео добавлено в урок"
}, },
"tags": { "tags": {
"group_created": "Группа тегов создана.", "group_created": "Группа тегов создана.",
@@ -361,7 +361,8 @@
"video_added_to_lesson": "Video đã được thêm vào bài học", "video_added_to_lesson": "Video đã được thêm vào bài học",
"delete_success": "Đã xóa video", "delete_success": "Đã xóa video",
"delete_in_use": "Video này vẫn được đính kèm với một bài học. Loại bỏ nó ở đó đầu tiên.", "delete_in_use": "Video này vẫn được đính kèm với một bài học. Loại bỏ nó ở đó đầu tiên.",
"delete_failed": "Không thể xóa video" "delete_failed": "Không thể xóa video",
"added_to_lesson": "Video đã được thêm vào bài học"
}, },
"tags": { "tags": {
"group_created": "Đã tạo nhóm thẻ", "group_created": "Đã tạo nhóm thẻ",