Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
Warnings:

- Added the required column `content_duration` to the `learning_units` table without a default value. This is not possible if the table is not empty.

*/
-- AlterTable
ALTER TABLE "public"."learning_units" ADD COLUMN "content_duration" DECIMAL(65,30) NOT NULL;
11 changes: 6 additions & 5 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ model LearningUnit {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

// Domain-Specific Fields.
title String @map("title")
contentType ContentType @map("content_type")
contentURL String @map("content_url")
summary String @map("summary")
createdBy String @map("created_by")
title String @map("title")
contentType ContentType @map("content_type")
contentURL String @map("content_url")
contentDuration Decimal @map("content_duration")
summary String @map("summary")
createdBy String @map("created_by")

// Relations.
collectionId BigInt @map("collection_id")
Expand Down
4 changes: 4 additions & 0 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const learningUnits: Prisma.LearningUnitCreateInput[] = [
questionAnswers: {
create: questionAnswers,
},
contentDuration: 300,
},
{
id: 2,
Expand Down Expand Up @@ -157,6 +158,7 @@ const learningUnits: Prisma.LearningUnitCreateInput[] = [
questionAnswers: {
create: questionAnswers,
},
contentDuration: 300,
},
{
id: 3,
Expand Down Expand Up @@ -184,6 +186,7 @@ const learningUnits: Prisma.LearningUnitCreateInput[] = [
questionAnswers: {
create: questionAnswers,
},
contentDuration: 300,
},
{
id: 4,
Expand Down Expand Up @@ -211,6 +214,7 @@ const learningUnits: Prisma.LearningUnitCreateInput[] = [
questionAnswers: {
create: questionAnswers,
},
contentDuration: 300,
},
];

Expand Down
41 changes: 24 additions & 17 deletions src/lib/components/LearningUnit/LearningUnit.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { Badge, type BadgeProps } from '$lib/components/Badge/index.js';
import { Button } from '$lib/components/Button/index.js';
import { ProgressSpinner } from '$lib/components/ProgressSpinner/index.js';

export interface Props {
/**
Expand Down Expand Up @@ -52,6 +53,14 @@
* A callback to resume playback.
*/
onresume: () => void;
/**
* The time remaining in the playback.
*/
timeremaining: number;
/**
* The duration of the playback.
*/
duration: number;
} | null;
}

Expand All @@ -77,6 +86,14 @@

player?.onresume();
};

const formatTimeRemaining = (timeInSeconds: number): string => {
if (timeInSeconds <= 0) return '0 mins left';
if (timeInSeconds < 60) return '< 1 min left';
const minutes = Math.ceil(timeInSeconds / 60);
const unit = minutes === 1 ? 'min' : 'mins';
return `${minutes} ${unit} left`;
};
</script>

<a
Expand Down Expand Up @@ -125,23 +142,13 @@
{/if}

<div class="flex items-center gap-x-2">
<svg viewBox="0 0 24 24" class="h-6 w-6">
<circle cx="12" cy="12" r="9" stroke="#E2E8F0" fill="none" stroke-width="3px" />
<circle
cx="12"
cy="12"
r="9"
stroke="#020617"
fill="none"
stroke-width="3px"
stroke-dasharray="56.55"
stroke-dashoffset="12"
transform-origin="center"
transform="rotate(-90 0 0)"
/>
</svg>

<span class="text-sm text-slate-600">23m left</span>
<ProgressSpinner
duration={player?.duration ?? 0}
currentTime={player ? player.duration - player.timeremaining : 0}
/>
<span class="text-sm text-slate-600">
{formatTimeRemaining(player.timeremaining ?? 0)}
</span>
</div>
</div>
{/if}
Expand Down
51 changes: 51 additions & 0 deletions src/lib/components/ProgressSpinner/ProgressSpinner.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';

export interface Props extends HTMLAttributes<HTMLDivElement> {
/**
* The current progress value, ranging from 0 to 100.
*
* @default 0
*/
value?: number;
/**
* The total duration of the content in seconds.
*/
duration?: number;
/**
* The current time elapsed in seconds.
*/
currentTime?: number;
}

const { value = 0, duration = 0, currentTime = 0 }: Props = $props();

const radius = 9;
const circumference = 2 * Math.PI * radius;

const progress = $derived(
duration > 0 ? Math.min((currentTime / duration) * 100, 100) : Math.min(value, 100),
);

const dashArray = $derived(circumference);
const dashOffset = $derived(circumference - (progress / 100) * circumference);
</script>

<svg viewBox="0 0 24 24" class="h-6 w-6">
<!-- Background track -->
<circle cx="12" cy="12" r={radius} stroke="#020617" fill="none" stroke-width="3px" />
<!-- Progress arc -->
<circle
cx="12"
cy="12"
r={radius}
stroke="#E2E8F0"
fill="none"
stroke-width="3.5px"
stroke-linecap="butt"
stroke-dasharray={dashArray}
stroke-dashoffset={dashOffset}
transform="rotate(-90 12 12)"
class="transition-all duration-300 ease-out"
/>
</svg>
4 changes: 4 additions & 0 deletions src/lib/components/ProgressSpinner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
default as ProgressSpinner,
type Props as ProgressSpinnerProps,
} from './ProgressSpinner.svelte';
7 changes: 7 additions & 0 deletions src/lib/states/player.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ export class Player extends EventTarget {
};
this.#audio.ontimeupdate = () => {
this.#progress = this.#audio?.currentTime || 0;
this.dispatchEvent(
new CustomEvent('progress', {
detail: {
currentTime: this.#audio?.currentTime,
},
}),
);
};
this.#audio.onended = () => {
this.#isPlaying = false;
Expand Down
11 changes: 10 additions & 1 deletion src/routes/(protected)/(core)/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const load: PageServerLoad = async (event) => {
select: {
id: true,
title: true,
contentDuration: true,
contentURL: true,
createdAt: true,
createdBy: true,
Expand Down Expand Up @@ -58,8 +59,16 @@ export const load: PageServerLoad = async (event) => {
throw error(500);
}

const journeys = learningJourneys.map((journey) => ({
...journey,
learningUnit: {
...journey.learningUnit,
contentDuration: journey.learningUnit.contentDuration.toNumber(),
},
}));

return {
learningJourneys,
learningJourneys: journeys,
username: user.name,
};
};
75 changes: 64 additions & 11 deletions src/routes/(protected)/(core)/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,23 +1,46 @@
<script lang="ts">
import { onMount } from 'svelte';

import { LinkButton } from '$lib/components/Button/index.js';
import { LearningUnit } from '$lib/components/LearningUnit/index.js';
import { tagCodeToBadgeVariant } from '$lib/helpers/index.js';
import { Player } from '$lib/states/index.js';

const { data } = $props();

interface ProgressEventDetail {
currentTime: number;
duration: number;
}

let progressState = $state(
data.learningJourneys.map((lj) => {
return {
id: lj.learningUnit.id,
contentDuration: lj.learningUnit.contentDuration,
progress: 0,
};
}),
);

const player = Player.get();

const handleLearningUnitPlay = (learningJourney: (typeof data.learningJourneys)[0]) => {
player.play({
id: learningJourney.learningUnit.id,
tags: learningJourney.learningUnit.tags.map((t) => ({
code: t.tag.code,
label: t.tag.label,
})),
title: learningJourney.learningUnit.title,
url: learningJourney.learningUnit.contentURL,
});
const handleLearningUnitPlay = (
learningJourney: (typeof data.learningJourneys)[0],
progress?: number,
) => {
player.play(
{
id: learningJourney.learningUnit.id,
tags: learningJourney.learningUnit.tags.map((t) => ({
code: t.tag.code,
label: t.tag.label,
})),
title: learningJourney.learningUnit.title,
url: learningJourney.learningUnit.contentURL,
},
progress,
);
};

const handleLearningUnitPause = () => {
Expand All @@ -27,6 +50,25 @@
const handleLearningUnitResume = () => {
player.toggle();
};

onMount(() => {
const onProgress = (event: CustomEvent<ProgressEventDetail>) => {
const { currentTime } = event.detail;

const currentState = progressState.find((ps) => ps.id === player.currentTrack?.id);
if (!currentState) {
return;
}

currentState.progress = currentTime;
};

player.addEventListener('progress', onProgress as EventListener);

return () => {
player.removeEventListener('progress', onProgress as EventListener);
};
});
</script>

<main class="relative mx-auto flex min-h-svh max-w-5xl flex-col gap-y-3 px-4 pt-43 pb-28">
Expand All @@ -49,9 +91,20 @@
player={{
isactive: player.currentTrack?.id === learningJourney.learningUnit.id,
isplaying: player.isPlaying,
onplay: () => handleLearningUnitPlay(learningJourney),
onplay: () =>
handleLearningUnitPlay(
learningJourney,
progressState.find((ps) => ps.id === learningJourney.learningUnit.id)?.progress,
),
onpause: handleLearningUnitPause,
onresume: handleLearningUnitResume,
duration:
progressState.find((ps) => ps.id === learningJourney.learningUnit.id)
?.contentDuration ?? 0,
timeremaining: (() => {
const ps = progressState.find((ps) => ps.id === learningJourney.learningUnit.id);
return Math.max((ps?.contentDuration ?? 0) - (ps?.progress ?? 0), 0);
})(),
}}
/>
{/each}
Expand Down