feat(dashboard): restyle lesson transcript and summarize actions (#758)
* feat(dashboard): restyle lesson transcript and summarize actions Add separator lines around lesson material actions below the video, switch transcript and summarize controls to plain ghost buttons with hover-animated icons, and separate the two actions with a pipe when both are visible. Display rules are unchanged. Co-authored-by: Rotimi Best <[email protected]> * refactor(dashboard): inline summarize action into material actions Fold lesson-summarize-button into lesson-material-actions and remove the standalone component. Lessons without videos use the same actions bar with separators disabled. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): align lesson actions with note width on mobile Constrain material actions to max-w-2xl when there is no video so they match the note column. Improve small-screen layout with tighter padding, flex-wrap, and hide the pipe separator below the sm breakpoint. Co-authored-by: Rotimi Best <[email protected]> * add script update * fix(dashboard): tighten spacing between lesson video and note Remove gap-2 from the lesson content stack and drop bottom margin on the last video wrapper so material actions separators control the gap before the note. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): show bottom separator on note-only lesson actions Lessons without video were passing showSeparators=false, hiding both lines. Split into top/bottom controls so summarize still gets a line before the note without one above the action row. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): always show top separator on lesson material actions Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): reduce lesson material action separator margin to my-2 Co-authored-by: Rotimi Best <[email protected]> * feat(dashboard): consolidate multi-video transcript actions and navigation Register transcript sources per upload video, show a single material actions row after all videos, and add prev/next video navigation in the transcript side panel when multiple transcripts are available. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): address greptile review on lesson material actions Require lessonId on material actions and stop overwriting the transcript seek wrapper with a stale localSeekFn snapshot in the sync effect. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): stop transcript registry effect infinite loop Replace the reactive sync effect with explicit registry updates from loadTranscript and skip no-op transcript source writes to avoid reading and writing transcriptSources in the same effect pass. Co-authored-by: Rotimi Best <[email protected]> * fix(ui): raise tooltip z-index above app bar side panel Use z-menu-elevated for tooltip content so header icon tooltips are not hidden behind the fixed transcript side panel (z-app-bar). Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
656fa4fa7e
commit
3980aa0a9b
@@ -10,4 +10,4 @@ export { default as Document } from './document/document.svelte';
|
||||
export { default as AddVideoModal } from './video/add-video-modal.svelte';
|
||||
export { default as AddDocumentModal } from './document/add-document-modal.svelte';
|
||||
export { default as LessonSettingsTab } from './lesson-settings-tab.svelte';
|
||||
export { default as LessonSummarizeButton } from './lesson-summarize-button.svelte';
|
||||
export { default as LessonMaterialActions } from './lesson-material-actions.svelte';
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { ContentIcon, HoverableItem, PremiumIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { sendPromptToAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { buildLessonSummarizePrompt } from './utils/lesson-summarize-prompt';
|
||||
|
||||
interface Props {
|
||||
showTranscript?: boolean;
|
||||
showSummarize?: boolean;
|
||||
showTopSeparator?: boolean;
|
||||
showBottomSeparator?: boolean;
|
||||
/** Match the note column (`max-w-2xl`) instead of the full video column. */
|
||||
alignWithNote?: boolean;
|
||||
lessonId: string;
|
||||
onTranscript?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
showTranscript = false,
|
||||
showSummarize = false,
|
||||
showTopSeparator = true,
|
||||
showBottomSeparator = true,
|
||||
alignWithNote = false,
|
||||
lessonId,
|
||||
onTranscript = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
function handleSummarizeLesson() {
|
||||
sendPromptToAssistant(buildLessonSummarizePrompt(lessonId));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showTranscript || showSummarize}
|
||||
<div class={alignWithNote ? 'mx-auto w-full max-w-2xl' : 'w-full'}>
|
||||
{#if showTopSeparator}
|
||||
<Separator class="my-2" />
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 py-1 sm:gap-x-3">
|
||||
{#if showTranscript}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<Button variant="ghost" size="sm" class="ui:h-auto ui:px-1 ui:py-1 sm:ui:px-2" onclick={onTranscript}>
|
||||
<ContentIcon {isHovered} size={16} />
|
||||
{$t('course.navItem.lessons.materials.show_transcript')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/if}
|
||||
|
||||
{#if showTranscript && showSummarize}
|
||||
<span class="ui:text-muted-foreground hidden text-sm sm:inline" aria-hidden="true">|</span>
|
||||
{/if}
|
||||
|
||||
{#if showSummarize}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="ui:h-auto ui:px-1 ui:py-1 sm:ui:px-2"
|
||||
onclick={handleSummarizeLesson}
|
||||
>
|
||||
<PremiumIcon {isHovered} size={16} />
|
||||
{$t('course.navItem.lessons.materials.summarize_lesson')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showBottomSeparator}
|
||||
<Separator class="my-2" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { sendPromptToAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { buildLessonSummarizePrompt } from './utils/lesson-summarize-prompt';
|
||||
|
||||
interface Props {
|
||||
lessonId: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { lessonId, class: className = '' }: Props = $props();
|
||||
|
||||
function handleSummarizeLesson() {
|
||||
sendPromptToAssistant(buildLessonSummarizePrompt(lessonId));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button variant="outline" size="sm" class={className} onclick={handleSummarizeLesson}>
|
||||
{$t('course.navItem.lessons.materials.summarize_lesson')}
|
||||
</Button>
|
||||
+141
-3
@@ -1,18 +1,32 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { AssetTranscriptPayload } from '$features/media/utils/types';
|
||||
|
||||
type SeekFn = (seconds: number) => void;
|
||||
|
||||
export type LessonTranscriptSource = {
|
||||
assetId: string;
|
||||
videoIndex: number;
|
||||
transcript: AssetTranscriptPayload | null;
|
||||
transcriptLoading: boolean;
|
||||
currentTimeSeconds: number;
|
||||
seek: SeekFn;
|
||||
};
|
||||
|
||||
class LessonVideoBus {
|
||||
/** Asset id of the currently mounted upload-video player (if any). */
|
||||
assetId = $state<string | null>(null);
|
||||
/** Last reported playback time in seconds. */
|
||||
/** Last reported playback time in seconds for the active player asset. */
|
||||
currentTimeSeconds = $state(0);
|
||||
/** True after the user (or autoplay) has started playback at least once for the current asset. */
|
||||
hasPlayed = $state(false);
|
||||
/** Latest transcript payload for the active asset; null if none / still loading. */
|
||||
/** Latest transcript payload for the active transcript source. */
|
||||
transcript = $state<AssetTranscriptPayload | null>(null);
|
||||
/** True while the transcript fetch is in flight. */
|
||||
/** True while the active transcript fetch is in flight. */
|
||||
transcriptLoading = $state(false);
|
||||
/** Transcript sources keyed by upload asset id. */
|
||||
transcriptSources = new SvelteMap<string, LessonTranscriptSource>();
|
||||
/** Which upload asset transcript is shown in the side panel. */
|
||||
activeTranscriptAssetId = $state<string | null>(null);
|
||||
|
||||
private seekFn: SeekFn = () => {};
|
||||
|
||||
@@ -21,14 +35,136 @@ class LessonVideoBus {
|
||||
}
|
||||
|
||||
seek(seconds: number) {
|
||||
const activeSource = this.getActiveTranscriptSource();
|
||||
|
||||
if (activeSource) {
|
||||
activeSource.seek(seconds);
|
||||
return;
|
||||
}
|
||||
|
||||
this.seekFn(seconds);
|
||||
}
|
||||
|
||||
registerTranscriptSource(source: LessonTranscriptSource) {
|
||||
this.transcriptSources.set(source.assetId, source);
|
||||
}
|
||||
|
||||
unregisterTranscriptSource(assetId: string) {
|
||||
this.transcriptSources.delete(assetId);
|
||||
|
||||
if (this.activeTranscriptAssetId === assetId) {
|
||||
const nextSource = this.getNavigableTranscriptSources()[0] ?? null;
|
||||
this.activeTranscriptAssetId = nextSource?.assetId ?? null;
|
||||
this.syncActiveTranscriptToLegacyFields();
|
||||
}
|
||||
}
|
||||
|
||||
updateTranscriptSource(assetId: string, patch: Partial<LessonTranscriptSource>) {
|
||||
const existing = this.transcriptSources.get(assetId);
|
||||
|
||||
if (!existing) return;
|
||||
|
||||
let next = existing;
|
||||
let changed = false;
|
||||
|
||||
for (const key of Object.keys(patch) as (keyof LessonTranscriptSource)[]) {
|
||||
const value = patch[key];
|
||||
|
||||
if (value !== undefined && existing[key] !== value) {
|
||||
if (!changed) {
|
||||
next = { ...existing };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
next[key] = value as LessonTranscriptSource[typeof key];
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
this.transcriptSources.set(assetId, next);
|
||||
|
||||
if (this.activeTranscriptAssetId === assetId) {
|
||||
this.syncActiveTranscriptToLegacyFields();
|
||||
}
|
||||
}
|
||||
|
||||
getActiveTranscriptSource(): LessonTranscriptSource | null {
|
||||
const assetId = this.activeTranscriptAssetId;
|
||||
|
||||
if (!assetId) return null;
|
||||
|
||||
return this.transcriptSources.get(assetId) ?? null;
|
||||
}
|
||||
|
||||
getNavigableTranscriptSources(): LessonTranscriptSource[] {
|
||||
return [...this.transcriptSources.values()]
|
||||
.filter((source) => (source.transcript?.segments?.length ?? 0) > 0)
|
||||
.sort((left, right) => left.videoIndex - right.videoIndex);
|
||||
}
|
||||
|
||||
hasTranscriptAvailable(): boolean {
|
||||
return [...this.transcriptSources.values()].some((source) => source.transcript != null);
|
||||
}
|
||||
|
||||
selectTranscriptSource(assetId: string) {
|
||||
if (!this.transcriptSources.has(assetId)) return;
|
||||
|
||||
this.activeTranscriptAssetId = assetId;
|
||||
this.syncActiveTranscriptToLegacyFields();
|
||||
}
|
||||
|
||||
prepareTranscriptPanelSelection() {
|
||||
const navigableSources = this.getNavigableTranscriptSources();
|
||||
|
||||
if (navigableSources.length === 0) {
|
||||
const fallbackSource = [...this.transcriptSources.values()]
|
||||
.filter((source) => source.transcript || source.transcriptLoading)
|
||||
.sort((left, right) => left.videoIndex - right.videoIndex)[0];
|
||||
|
||||
if (!fallbackSource) return false;
|
||||
|
||||
this.selectTranscriptSource(fallbackSource.assetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentAssetId = this.activeTranscriptAssetId;
|
||||
const hasCurrent = currentAssetId != null && navigableSources.some((source) => source.assetId === currentAssetId);
|
||||
|
||||
if (!hasCurrent) {
|
||||
this.selectTranscriptSource(navigableSources[0].assetId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replace the active transcript (e.g. after an edit is saved). */
|
||||
setTranscript(transcript: AssetTranscriptPayload | null) {
|
||||
const assetId = this.activeTranscriptAssetId;
|
||||
|
||||
if (assetId) {
|
||||
this.updateTranscriptSource(assetId, { transcript });
|
||||
return;
|
||||
}
|
||||
|
||||
this.transcript = transcript;
|
||||
}
|
||||
|
||||
private syncActiveTranscriptToLegacyFields() {
|
||||
const source = this.getActiveTranscriptSource();
|
||||
|
||||
if (!source) {
|
||||
this.transcript = null;
|
||||
this.transcriptLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.assetId = source.assetId;
|
||||
this.transcript = source.transcript;
|
||||
this.transcriptLoading = source.transcriptLoading;
|
||||
this.seekFn = source.seek;
|
||||
}
|
||||
|
||||
/** Reset all per-lesson state (called when the upload asset changes or the player unmounts). */
|
||||
reset(opts?: { keepAssetId?: boolean }) {
|
||||
if (!opts?.keepAssetId) {
|
||||
@@ -39,6 +175,8 @@ class LessonVideoBus {
|
||||
this.hasPlayed = false;
|
||||
this.transcript = null;
|
||||
this.transcriptLoading = false;
|
||||
this.activeTranscriptAssetId = null;
|
||||
this.transcriptSources.clear();
|
||||
this.seekFn = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
+54
-40
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MediaPlayer } from '@cio/ui/custom/media-player';
|
||||
import { presignApi } from '$features/course/api/presign.svelte';
|
||||
import { mediaApi } from '$features/media/api';
|
||||
@@ -17,7 +16,6 @@
|
||||
import { resolveWatchEnforcedAssetIds, type LessonVideo } from './video-card-utils';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
|
||||
import LessonSummarizeButton from '../lesson-summarize-button.svelte';
|
||||
|
||||
/**
|
||||
* HLS playback flag — set by the upload flow when the asset was encoded
|
||||
@@ -100,10 +98,10 @@
|
||||
video: LessonVideo;
|
||||
courseId: string;
|
||||
lessonId: string;
|
||||
showSummarizeButton?: boolean;
|
||||
videoIndex: number;
|
||||
}
|
||||
|
||||
let { video, courseId, lessonId, showSummarizeButton = false }: Props = $props();
|
||||
let { video, courseId, lessonId, videoIndex }: Props = $props();
|
||||
|
||||
let localTranscript = $state<AssetTranscriptPayload | null>(null);
|
||||
let localTranscriptLoading = $state(false);
|
||||
@@ -128,19 +126,26 @@
|
||||
|
||||
let localSeekFn: (seconds: number) => void = () => {};
|
||||
|
||||
function ownsVideoBus(): boolean {
|
||||
function ownsPlaybackBus(): boolean {
|
||||
return uploadAssetId !== null && lessonVideoBus.assetId === uploadAssetId;
|
||||
}
|
||||
|
||||
function syncVideoBus(): void {
|
||||
function syncPlaybackBus(): void {
|
||||
if (!uploadAssetId) return;
|
||||
|
||||
lessonVideoBus.assetId = uploadAssetId;
|
||||
lessonVideoBus.transcript = localTranscript;
|
||||
lessonVideoBus.transcriptLoading = localTranscriptLoading;
|
||||
lessonVideoBus.setSeekFn(localSeekFn);
|
||||
}
|
||||
|
||||
function syncTranscriptToRegistry(): void {
|
||||
if (!uploadAssetId) return;
|
||||
|
||||
lessonVideoBus.updateTranscriptSource(uploadAssetId, {
|
||||
transcript: localTranscript,
|
||||
transcriptLoading: localTranscriptLoading
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const rawUrl = localTranscript?.vttUrl;
|
||||
if (!rawUrl) {
|
||||
@@ -254,9 +259,7 @@
|
||||
if (!uploadAssetId) {
|
||||
if (isMounted) {
|
||||
localTranscript = null;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcript = null;
|
||||
}
|
||||
syncTranscriptToRegistry();
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -265,9 +268,7 @@
|
||||
if (!isMounted) return;
|
||||
|
||||
localTranscriptLoading = true;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcriptLoading = true;
|
||||
}
|
||||
syncTranscriptToRegistry();
|
||||
|
||||
try {
|
||||
const data = await mediaApi.getAssetTranscript(uploadAssetId);
|
||||
@@ -276,15 +277,11 @@
|
||||
|
||||
localTranscript = data;
|
||||
scheduleVttRefetch(data?.vttUrlExpiresAt);
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcript = data;
|
||||
}
|
||||
syncTranscriptToRegistry();
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
localTranscriptLoading = false;
|
||||
if (ownsVideoBus()) {
|
||||
lessonVideoBus.transcriptLoading = false;
|
||||
}
|
||||
syncTranscriptToRegistry();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,8 +389,19 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (uploadAssetId) {
|
||||
lessonVideoBus.registerTranscriptSource({
|
||||
assetId: uploadAssetId,
|
||||
videoIndex,
|
||||
transcript: null,
|
||||
transcriptLoading: false,
|
||||
currentTimeSeconds: 0,
|
||||
seek: (seconds) => localSeekFn(seconds)
|
||||
});
|
||||
}
|
||||
|
||||
if (lessonVideoBus.assetId === null || lessonVideoBus.assetId === uploadAssetId) {
|
||||
syncVideoBus();
|
||||
syncPlaybackBus();
|
||||
}
|
||||
|
||||
void restoreWatchProgress();
|
||||
@@ -413,9 +421,22 @@
|
||||
stopJobPoller();
|
||||
revokeBlobTrackUrl?.();
|
||||
revokeBlobTrackUrl = null;
|
||||
if (ownsVideoBus()) {
|
||||
|
||||
if (uploadAssetId) {
|
||||
lessonVideoBus.unregisterTranscriptSource(uploadAssetId);
|
||||
}
|
||||
|
||||
if (lessonVideoBus.transcriptSources.size === 0) {
|
||||
lessonVideoBus.reset();
|
||||
sidePanel.closeIfScope('lesson');
|
||||
return;
|
||||
}
|
||||
|
||||
if (uploadAssetId && ownsPlaybackBus()) {
|
||||
lessonVideoBus.assetId = null;
|
||||
lessonVideoBus.currentTimeSeconds = 0;
|
||||
lessonVideoBus.hasPlayed = false;
|
||||
lessonVideoBus.setSeekFn(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -434,12 +455,14 @@
|
||||
);
|
||||
|
||||
function handleFirstPlay() {
|
||||
syncVideoBus();
|
||||
syncPlaybackBus();
|
||||
lessonVideoBus.hasPlayed = true;
|
||||
}
|
||||
|
||||
function openTranscriptPanel() {
|
||||
syncVideoBus();
|
||||
if (!uploadAssetId) return;
|
||||
|
||||
lessonVideoBus.selectTranscriptSource(uploadAssetId);
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
|
||||
@@ -468,7 +491,11 @@
|
||||
controls: true,
|
||||
playsinline: true,
|
||||
onTimeUpdate: (seconds) => {
|
||||
if (ownsVideoBus()) {
|
||||
if (uploadAssetId) {
|
||||
lessonVideoBus.updateTranscriptSource(uploadAssetId, { currentTimeSeconds: seconds });
|
||||
}
|
||||
|
||||
if (ownsPlaybackBus()) {
|
||||
lessonVideoBus.currentTimeSeconds = seconds;
|
||||
}
|
||||
},
|
||||
@@ -476,7 +503,8 @@
|
||||
localSeekFn = (seconds) => {
|
||||
player.currentTime = seconds;
|
||||
};
|
||||
if (ownsVideoBus()) {
|
||||
|
||||
if (ownsPlaybackBus()) {
|
||||
lessonVideoBus.setSeekFn(localSeekFn);
|
||||
}
|
||||
},
|
||||
@@ -494,18 +522,4 @@
|
||||
seekPolicy
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if localTranscript || showSummarizeButton}
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
{#if localTranscript}
|
||||
<Button variant="outline" size="sm" onclick={openTranscriptPanel}>
|
||||
{$t('course.navItem.lessons.materials.show_transcript')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if showSummarizeButton}
|
||||
<LessonSummarizeButton {lessonId} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+72
-7
@@ -1,10 +1,13 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import CaptionsIcon from '@lucide/svelte/icons/captions';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
@@ -12,12 +15,20 @@
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import type { TranscriptSegment } from '$features/media/utils/types';
|
||||
|
||||
const transcript = $derived(lessonVideoBus.transcript);
|
||||
const transcriptLoading = $derived(lessonVideoBus.transcriptLoading);
|
||||
const activeSource = $derived.by(() => lessonVideoBus.getActiveTranscriptSource());
|
||||
const transcript = $derived(activeSource?.transcript ?? null);
|
||||
const transcriptLoading = $derived(activeSource?.transcriptLoading ?? false);
|
||||
const segments = $derived<TranscriptSegment[]>(transcript?.segments ?? []);
|
||||
const language = $derived(transcript?.language ?? '');
|
||||
const assetId = $derived(lessonVideoBus.assetId);
|
||||
const currentTimeSeconds = $derived(lessonVideoBus.currentTimeSeconds);
|
||||
const assetId = $derived(activeSource?.assetId ?? null);
|
||||
const currentTimeSeconds = $derived(activeSource?.currentTimeSeconds ?? 0);
|
||||
|
||||
const navigableSources = $derived.by(() => lessonVideoBus.getNavigableTranscriptSources());
|
||||
const activeNavIndex = $derived(navigableSources.findIndex((source) => source.assetId === assetId));
|
||||
const activeVideoNumber = $derived(activeNavIndex >= 0 ? activeNavIndex + 1 : 1);
|
||||
const canGoToPreviousVideo = $derived(activeNavIndex > 0);
|
||||
const canGoToNextVideo = $derived(activeNavIndex >= 0 && activeNavIndex < navigableSources.length - 1);
|
||||
const showVideoNavigation = $derived(navigableSources.length > 1);
|
||||
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
@@ -43,12 +54,34 @@
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60)
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
|
||||
return `${m}:${s}`;
|
||||
return `${minutes}:${remainingSeconds}`;
|
||||
}
|
||||
|
||||
function goToPreviousVideo() {
|
||||
if (!canGoToPreviousVideo) return;
|
||||
|
||||
if (editing) {
|
||||
cancelEditing();
|
||||
}
|
||||
|
||||
const previousSource = navigableSources[activeNavIndex - 1];
|
||||
lessonVideoBus.selectTranscriptSource(previousSource.assetId);
|
||||
}
|
||||
|
||||
function goToNextVideo() {
|
||||
if (!canGoToNextVideo) return;
|
||||
|
||||
if (editing) {
|
||||
cancelEditing();
|
||||
}
|
||||
|
||||
const nextSource = navigableSources[activeNavIndex + 1];
|
||||
lessonVideoBus.selectTranscriptSource(nextSource.assetId);
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
@@ -122,6 +155,38 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showVideoNavigation && !editing}
|
||||
<div class="border-border flex items-center justify-between gap-2 border-b px-4 py-2">
|
||||
<IconButton
|
||||
onclick={goToPreviousVideo}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
disabled={!canGoToPreviousVideo}
|
||||
tooltip={$t('course.navItem.lessons.materials.tabs.video.transcript.previous_video')}
|
||||
>
|
||||
<ChevronLeftIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<p class="min-w-0 truncate text-center text-sm font-medium">
|
||||
{$t('course.navItem.lessons.materials.tabs.video.transcript.video_nav_label', {
|
||||
number: activeVideoNumber
|
||||
})}
|
||||
</p>
|
||||
|
||||
<IconButton
|
||||
onclick={goToNextVideo}
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
disabled={!canGoToNextVideo}
|
||||
tooltip={$t('course.navItem.lessons.materials.tabs.video.transcript.next_video')}
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{:else if showVideoNavigation && editing}
|
||||
<Separator />
|
||||
{/if}
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{#if editing}
|
||||
{#each draft as segment, index (index)}
|
||||
|
||||
+16
-5
@@ -187,16 +187,27 @@
|
||||
async function handleViewTranscript() {
|
||||
if (!assetId) return;
|
||||
|
||||
lessonVideoBus.assetId = assetId;
|
||||
lessonVideoBus.transcriptLoading = true;
|
||||
if (!lessonVideoBus.transcriptSources.has(assetId)) {
|
||||
lessonVideoBus.registerTranscriptSource({
|
||||
assetId,
|
||||
videoIndex: 0,
|
||||
transcript: null,
|
||||
transcriptLoading: true,
|
||||
currentTimeSeconds: 0,
|
||||
seek: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
lessonVideoBus.selectTranscriptSource(assetId);
|
||||
lessonVideoBus.updateTranscriptSource(assetId, { transcriptLoading: true });
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
|
||||
try {
|
||||
const data = await mediaApi.getAssetTranscript(assetId);
|
||||
lessonVideoBus.transcript = data;
|
||||
lessonVideoBus.updateTranscriptSource(assetId, { transcript: data, transcriptLoading: false });
|
||||
hasTranscript = !!data?.segments?.length;
|
||||
} finally {
|
||||
lessonVideoBus.transcriptLoading = false;
|
||||
} catch {
|
||||
lessonVideoBus.updateTranscriptSource(assetId, { transcriptLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { lessonVideoUpload } from '$features/course/components/lesson/store';
|
||||
import LessonMaterialActions from '$features/course/components/lesson/lesson-material-actions.svelte';
|
||||
import { sidePanel } from '$features/side-panel';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import LessonVideoSimpleCard from './lesson-video-simple-card.svelte';
|
||||
import LessonVideoPlayer from './lesson-video-player.svelte';
|
||||
import { lessonVideoBus } from './lesson-video-bus.svelte';
|
||||
import { TRANSCRIPT_PANEL_ID } from './transcript-panel-definition';
|
||||
|
||||
interface Props {
|
||||
mode?: (typeof MODES)[keyof typeof MODES];
|
||||
@@ -21,6 +25,11 @@
|
||||
|
||||
const videos = $derived(lessonApi.lesson?.videos || []);
|
||||
|
||||
const hasTranscript = $derived.by(() => {
|
||||
lessonVideoBus.transcriptSources;
|
||||
return lessonVideoBus.hasTranscriptAvailable();
|
||||
});
|
||||
|
||||
let openDeleteVideoModal = $state(false);
|
||||
let videoIndexToDelete = $state<number | null>(null);
|
||||
|
||||
@@ -40,11 +49,17 @@
|
||||
}
|
||||
openDeleteVideoModal = false;
|
||||
}
|
||||
|
||||
function openTranscriptPanel() {
|
||||
if (!lessonVideoBus.prepareTranscriptPanelSelection()) return;
|
||||
|
||||
sidePanel.open(TRANSCRIPT_PANEL_ID);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet content(video, index)}
|
||||
{#key video.type === 'upload' ? ((video as typeof video & { assetId?: string }).assetId ?? video.link) : video.link}
|
||||
<LessonVideoPlayer {video} {courseId} {lessonId} showSummarizeButton={index === videos.length - 1} />
|
||||
<LessonVideoPlayer {video} {courseId} {lessonId} videoIndex={index} />
|
||||
{/key}
|
||||
{/snippet}
|
||||
|
||||
@@ -82,10 +97,17 @@
|
||||
{#if videos.length}
|
||||
<div class="w-full">
|
||||
{#each videos as video, index}
|
||||
<div class="mb-5 w-full overflow-hidden">
|
||||
<div class="{index < videos.length - 1 ? 'mb-5' : ''} w-full overflow-hidden">
|
||||
{@render content(video, index)}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<LessonMaterialActions
|
||||
showTranscript={hasTranscript}
|
||||
showSummarize={videos.length > 0}
|
||||
{lessonId}
|
||||
onTranscript={openTranscriptPanel}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
AddVideoModal,
|
||||
AddDocumentModal,
|
||||
LessonSettingsTab,
|
||||
LessonSummarizeButton
|
||||
LessonMaterialActions
|
||||
} from '$features/course/components/lesson';
|
||||
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
@@ -525,11 +525,9 @@
|
||||
</UnderlineTabs.Root>
|
||||
{:else if lessonApi.lesson && !isMaterialsEmpty}
|
||||
{#key lessonId}
|
||||
<div class="mb-20 flex w-full flex-col gap-2" in:fade={{ delay: 500 }} out:fade>
|
||||
<div class="mb-20 flex w-full flex-col" in:fade={{ delay: 500 }} out:fade>
|
||||
{#if !hasLessonVideos}
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
<LessonSummarizeButton {lessonId} />
|
||||
</div>
|
||||
<LessonMaterialActions showSummarize {lessonId} alignWithNote />
|
||||
{/if}
|
||||
|
||||
{#each viewModeComponents as Component, index (index)}
|
||||
|
||||
Reference in New Issue
Block a user