From 31b5104dd5bd447eddddbf7784376d9194920afd Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 22 Apr 2025 17:25:41 +0200 Subject: [PATCH] feat: add details to video activities wip: uploadable video activities --- .../versions/a5afa69dd917_activity_details.py | 31 +++++++ apps/api/src/db/courses/activities.py | 4 + .../routers/courses/activities/activities.py | 4 +- .../src/services/courses/activities/video.py | 6 +- .../Objects/Activities/Video/Video.tsx | 37 +++++++- .../NewActivityModal/VideoActivityModal.tsx | 85 +++++++++++++++++++ apps/web/services/courses/activities.ts | 17 ++++ 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 apps/api/migrations/versions/a5afa69dd917_activity_details.py diff --git a/apps/api/migrations/versions/a5afa69dd917_activity_details.py b/apps/api/migrations/versions/a5afa69dd917_activity_details.py new file mode 100644 index 00000000..78531b4b --- /dev/null +++ b/apps/api/migrations/versions/a5afa69dd917_activity_details.py @@ -0,0 +1,31 @@ +"""Activity Details + +Revision ID: a5afa69dd917 +Revises: adb944cc8bec +Create Date: 2025-04-22 16:04:58.028488 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa # noqa: F401 +import sqlmodel # noqa: F401 + + +# revision identifiers, used by Alembic. +revision: str = 'a5afa69dd917' +down_revision: Union[str, None] = 'adb944cc8bec' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('activity', sa.Column('details', sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('activity', 'details') + # ### end Alembic commands ### diff --git a/apps/api/src/db/courses/activities.py b/apps/api/src/db/courses/activities.py index 50ec31c8..1cb327ea 100644 --- a/apps/api/src/db/courses/activities.py +++ b/apps/api/src/db/courses/activities.py @@ -32,6 +32,7 @@ class ActivityBase(SQLModel): activity_type: ActivityTypeEnum activity_sub_type: ActivitySubTypeEnum content: dict = Field(default={}, sa_column=Column(JSON)) + details: Optional[dict] = Field(default=None, sa_column=Column(JSON)) published: bool = False @@ -53,6 +54,7 @@ class ActivityCreate(ActivityBase): chapter_id: int activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM + details: dict = Field(default={}, sa_column=Column(JSON)) pass @@ -61,6 +63,7 @@ class ActivityUpdate(ActivityBase): content: dict = Field(default={}, sa_column=Column(JSON)) activity_type: Optional[ActivityTypeEnum] activity_sub_type: Optional[ActivitySubTypeEnum] + details: Optional[dict] = Field(default=None, sa_column=Column(JSON)) published_version: Optional[int] version: Optional[int] @@ -72,4 +75,5 @@ class ActivityRead(ActivityBase): activity_uuid: str creation_date: str update_date: str + details: Optional[dict] = Field(default=None, sa_column=Column(JSON)) pass diff --git a/apps/api/src/routers/courses/activities/activities.py b/apps/api/src/routers/courses/activities/activities.py index 3b5bc84a..c8e58c08 100644 --- a/apps/api/src/routers/courses/activities/activities.py +++ b/apps/api/src/routers/courses/activities/activities.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from fastapi import APIRouter, Depends, UploadFile, Form, Request from src.db.courses.activities import ActivityCreate, ActivityRead, ActivityUpdate from src.db.users import PublicUser @@ -113,6 +113,7 @@ async def api_create_video_activity( request: Request, name: str = Form(), chapter_id: str = Form(), + details: Optional[dict] = Form(default=None), current_user: PublicUser = Depends(get_current_user), video_file: UploadFile | None = None, db_session=Depends(get_db_session), @@ -127,6 +128,7 @@ async def api_create_video_activity( current_user, db_session, video_file, + details, ) diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index da428865..1ef9c13f 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Literal, Optional from src.db.courses.courses import Course from src.db.organizations import Organization @@ -31,6 +31,7 @@ async def create_video_activity( current_user: PublicUser, db_session: Session, video_file: UploadFile | None = None, + details: Optional[dict] = None, ): # RBAC check await rbac_check(request, "activity_x", current_user, "create", db_session) @@ -99,6 +100,7 @@ async def create_video_activity( "filename": "video." + video_format, "activity_uuid": activity_uuid, }, + details=details, version=1, creation_date=str(datetime.now()), update_date=str(datetime.now()), @@ -144,6 +146,7 @@ class ExternalVideo(BaseModel): uri: str type: Literal["youtube", "vimeo"] chapter_id: str + details: Optional[dict] = None class ExternalVideoInDB(BaseModel): @@ -194,6 +197,7 @@ async def create_external_video_activity( "type": data.type, "activity_uuid": activity_uuid, }, + details=data.details, version=1, creation_date=str(datetime.now()), update_date=str(datetime.now()), diff --git a/apps/web/components/Objects/Activities/Video/Video.tsx b/apps/web/components/Objects/Activities/Video/Video.tsx index cf09ca56..4c1037a0 100644 --- a/apps/web/components/Objects/Activities/Video/Video.tsx +++ b/apps/web/components/Objects/Activities/Video/Video.tsx @@ -3,6 +3,13 @@ import YouTube from 'react-youtube' import { getActivityMediaDirectory } from '@services/media/media' import { useOrg } from '@components/Contexts/OrgContext' +interface VideoDetails { + startTime?: number + endTime?: number | null + autoplay?: boolean + muted?: boolean +} + interface VideoActivityProps { activity: { activity_sub_type: string @@ -11,6 +18,7 @@ interface VideoActivityProps { filename?: string uri?: string } + details?: VideoDetails } course: { course_uuid: string @@ -20,6 +28,7 @@ interface VideoActivityProps { function VideoActivity({ activity, course }: VideoActivityProps) { const org = useOrg() as any const [videoId, setVideoId] = React.useState('') + const videoRef = React.useRef(null) React.useEffect(() => { if (activity?.content?.uri) { @@ -39,6 +48,26 @@ function VideoActivity({ activity, course }: VideoActivityProps) { ) } + // Handle native video time update + const handleTimeUpdate = () => { + const video = videoRef.current + if (video && activity.details?.endTime) { + if (video.currentTime >= activity.details.endTime) { + video.pause() + } + } + } + + // Handle native video load + const handleVideoLoad = () => { + const video = videoRef.current + if (video && activity.details) { + video.currentTime = activity.details.startTime || 0 + video.autoplay = activity.details.autoplay || false + video.muted = activity.details.muted || false + } + } + return (
{activity && ( @@ -47,9 +76,12 @@ function VideoActivity({ activity, course }: VideoActivityProps) {
@@ -63,7 +95,10 @@ function VideoActivity({ activity, course }: VideoActivityProps) { width: '100%', height: '100%', playerVars: { - autoplay: 0 + autoplay: activity.details?.autoplay ? 1 : 0, + mute: activity.details?.muted ? 1 : 0, + start: activity.details?.startTime || 0, + end: activity.details?.endTime || undefined }, }} videoId={videoId} diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx index 38e7e7f7..56e51f98 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx @@ -14,11 +14,19 @@ import { constructAcceptValue } from '@/lib/constants' const SUPPORTED_FILES = constructAcceptValue(['mp4', 'webm']) +interface VideoDetails { + startTime: number + endTime: number | null + autoplay: boolean + muted: boolean +} + interface ExternalVideoObject { name: string type: string uri: string chapter_id: string + details: VideoDetails } function VideoModal({ @@ -32,6 +40,12 @@ function VideoModal({ const [name, setName] = React.useState('') const [youtubeUrl, setYoutubeUrl] = React.useState('') const [selectedView, setSelectedView] = React.useState<'file' | 'youtube'>('file') + const [videoDetails, setVideoDetails] = React.useState({ + startTime: 0, + endTime: null, + autoplay: false, + muted: false + }) const handleVideoChange = (event: React.ChangeEvent) => { if (event.target.files?.[0]) { @@ -56,6 +70,7 @@ function VideoModal({ published_version: 1, version: 1, course_id: course.id, + details: videoDetails }, chapterId ) @@ -67,6 +82,7 @@ function VideoModal({ type: 'youtube', uri: youtubeUrl, chapter_id: chapterId, + details: videoDetails } await submitExternalVideo( @@ -80,6 +96,73 @@ function VideoModal({ } } + const VideoSettingsForm = () => ( +
+

Video Settings

+
+ + Start Time (seconds) + + setVideoDetails({ + ...videoDetails, + startTime: Math.max(0, parseInt(e.target.value) || 0) + })} + placeholder="0" + /> + + + + + End Time (seconds, optional) + + setVideoDetails({ + ...videoDetails, + endTime: e.target.value ? parseInt(e.target.value) : null + })} + placeholder="Leave empty for full duration" + /> + + +
+ +
+ + + +
+
+ ) + return ( @@ -143,6 +226,7 @@ function VideoModal({ />
+ )} @@ -160,6 +244,7 @@ function VideoModal({ /> + )} diff --git a/apps/web/services/courses/activities.ts b/apps/web/services/courses/activities.ts index f49324e4..e6209ff3 100644 --- a/apps/web/services/courses/activities.ts +++ b/apps/web/services/courses/activities.ts @@ -39,6 +39,15 @@ export async function createFileActivity( if (type === 'video') { formData.append('name', data.name) formData.append('video_file', file) + // Add video details + if (data.details) { + formData.append('details', JSON.stringify({ + startTime: data.details.startTime || 0, + endTime: data.details.endTime || null, + autoplay: data.details.autoplay || false, + muted: data.details.muted || false + })) + } endpoint = `${getAPIUrl()}activities/video` } else if (type === 'documentpdf') { formData.append('pdf_file', file) @@ -65,6 +74,14 @@ export async function createExternalVideoActivity( // add coursechapter_id to data data.chapter_id = chapter_id data.activity_id = activity.id + + // Add video details if provided + data.details = { + startTime: data.startTime || 0, + endTime: data.endTime || null, + autoplay: data.autoplay || false, + muted: data.muted || false + } const result = await fetch( `${getAPIUrl()}activities/external_video`,