feat: add details to video activities

wip: uploadable video activities
This commit is contained in:
swve 2025-04-22 17:25:41 +02:00
parent 3173e6b417
commit 31b5104dd5
7 changed files with 181 additions and 3 deletions

View file

@ -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 ###

View file

@ -32,6 +32,7 @@ class ActivityBase(SQLModel):
activity_type: ActivityTypeEnum activity_type: ActivityTypeEnum
activity_sub_type: ActivitySubTypeEnum activity_sub_type: ActivitySubTypeEnum
content: dict = Field(default={}, sa_column=Column(JSON)) content: dict = Field(default={}, sa_column=Column(JSON))
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
published: bool = False published: bool = False
@ -53,6 +54,7 @@ class ActivityCreate(ActivityBase):
chapter_id: int chapter_id: int
activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM
activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM
details: dict = Field(default={}, sa_column=Column(JSON))
pass pass
@ -61,6 +63,7 @@ class ActivityUpdate(ActivityBase):
content: dict = Field(default={}, sa_column=Column(JSON)) content: dict = Field(default={}, sa_column=Column(JSON))
activity_type: Optional[ActivityTypeEnum] activity_type: Optional[ActivityTypeEnum]
activity_sub_type: Optional[ActivitySubTypeEnum] activity_sub_type: Optional[ActivitySubTypeEnum]
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
published_version: Optional[int] published_version: Optional[int]
version: Optional[int] version: Optional[int]
@ -72,4 +75,5 @@ class ActivityRead(ActivityBase):
activity_uuid: str activity_uuid: str
creation_date: str creation_date: str
update_date: str update_date: str
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
pass pass

View file

@ -1,4 +1,4 @@
from typing import List from typing import List, Optional
from fastapi import APIRouter, Depends, UploadFile, Form, Request from fastapi import APIRouter, Depends, UploadFile, Form, Request
from src.db.courses.activities import ActivityCreate, ActivityRead, ActivityUpdate from src.db.courses.activities import ActivityCreate, ActivityRead, ActivityUpdate
from src.db.users import PublicUser from src.db.users import PublicUser
@ -113,6 +113,7 @@ async def api_create_video_activity(
request: Request, request: Request,
name: str = Form(), name: str = Form(),
chapter_id: str = Form(), chapter_id: str = Form(),
details: Optional[dict] = Form(default=None),
current_user: PublicUser = Depends(get_current_user), current_user: PublicUser = Depends(get_current_user),
video_file: UploadFile | None = None, video_file: UploadFile | None = None,
db_session=Depends(get_db_session), db_session=Depends(get_db_session),
@ -127,6 +128,7 @@ async def api_create_video_activity(
current_user, current_user,
db_session, db_session,
video_file, video_file,
details,
) )

View file

@ -1,4 +1,4 @@
from typing import Literal from typing import Literal, Optional
from src.db.courses.courses import Course from src.db.courses.courses import Course
from src.db.organizations import Organization from src.db.organizations import Organization
@ -31,6 +31,7 @@ async def create_video_activity(
current_user: PublicUser, current_user: PublicUser,
db_session: Session, db_session: Session,
video_file: UploadFile | None = None, video_file: UploadFile | None = None,
details: Optional[dict] = None,
): ):
# RBAC check # RBAC check
await rbac_check(request, "activity_x", current_user, "create", db_session) await rbac_check(request, "activity_x", current_user, "create", db_session)
@ -99,6 +100,7 @@ async def create_video_activity(
"filename": "video." + video_format, "filename": "video." + video_format,
"activity_uuid": activity_uuid, "activity_uuid": activity_uuid,
}, },
details=details,
version=1, version=1,
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),
@ -144,6 +146,7 @@ class ExternalVideo(BaseModel):
uri: str uri: str
type: Literal["youtube", "vimeo"] type: Literal["youtube", "vimeo"]
chapter_id: str chapter_id: str
details: Optional[dict] = None
class ExternalVideoInDB(BaseModel): class ExternalVideoInDB(BaseModel):
@ -194,6 +197,7 @@ async def create_external_video_activity(
"type": data.type, "type": data.type,
"activity_uuid": activity_uuid, "activity_uuid": activity_uuid,
}, },
details=data.details,
version=1, version=1,
creation_date=str(datetime.now()), creation_date=str(datetime.now()),
update_date=str(datetime.now()), update_date=str(datetime.now()),

View file

@ -3,6 +3,13 @@ import YouTube from 'react-youtube'
import { getActivityMediaDirectory } from '@services/media/media' import { getActivityMediaDirectory } from '@services/media/media'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
interface VideoDetails {
startTime?: number
endTime?: number | null
autoplay?: boolean
muted?: boolean
}
interface VideoActivityProps { interface VideoActivityProps {
activity: { activity: {
activity_sub_type: string activity_sub_type: string
@ -11,6 +18,7 @@ interface VideoActivityProps {
filename?: string filename?: string
uri?: string uri?: string
} }
details?: VideoDetails
} }
course: { course: {
course_uuid: string course_uuid: string
@ -20,6 +28,7 @@ interface VideoActivityProps {
function VideoActivity({ activity, course }: VideoActivityProps) { function VideoActivity({ activity, course }: VideoActivityProps) {
const org = useOrg() as any const org = useOrg() as any
const [videoId, setVideoId] = React.useState('') const [videoId, setVideoId] = React.useState('')
const videoRef = React.useRef<HTMLVideoElement>(null)
React.useEffect(() => { React.useEffect(() => {
if (activity?.content?.uri) { 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 ( return (
<div className="w-full max-w-full px-2 sm:px-4"> <div className="w-full max-w-full px-2 sm:px-4">
{activity && ( {activity && (
@ -47,9 +76,12 @@ function VideoActivity({ activity, course }: VideoActivityProps) {
<div className="my-3 md:my-5 w-full"> <div className="my-3 md:my-5 w-full">
<div className="relative w-full aspect-video rounded-lg overflow-hidden ring-1 ring-gray-300/30 dark:ring-gray-600/30 sm:ring-gray-200/10 sm:dark:ring-gray-700/20 shadow-xs sm:shadow-none"> <div className="relative w-full aspect-video rounded-lg overflow-hidden ring-1 ring-gray-300/30 dark:ring-gray-600/30 sm:ring-gray-200/10 sm:dark:ring-gray-700/20 shadow-xs sm:shadow-none">
<video <video
ref={videoRef}
className="w-full h-full object-cover" className="w-full h-full object-cover"
controls controls
src={getVideoSrc()} src={getVideoSrc()}
onLoadedMetadata={handleVideoLoad}
onTimeUpdate={handleTimeUpdate}
></video> ></video>
</div> </div>
</div> </div>
@ -63,7 +95,10 @@ function VideoActivity({ activity, course }: VideoActivityProps) {
width: '100%', width: '100%',
height: '100%', height: '100%',
playerVars: { 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} videoId={videoId}

View file

@ -14,11 +14,19 @@ import { constructAcceptValue } from '@/lib/constants'
const SUPPORTED_FILES = constructAcceptValue(['mp4', 'webm']) const SUPPORTED_FILES = constructAcceptValue(['mp4', 'webm'])
interface VideoDetails {
startTime: number
endTime: number | null
autoplay: boolean
muted: boolean
}
interface ExternalVideoObject { interface ExternalVideoObject {
name: string name: string
type: string type: string
uri: string uri: string
chapter_id: string chapter_id: string
details: VideoDetails
} }
function VideoModal({ function VideoModal({
@ -32,6 +40,12 @@ function VideoModal({
const [name, setName] = React.useState('') const [name, setName] = React.useState('')
const [youtubeUrl, setYoutubeUrl] = React.useState('') const [youtubeUrl, setYoutubeUrl] = React.useState('')
const [selectedView, setSelectedView] = React.useState<'file' | 'youtube'>('file') const [selectedView, setSelectedView] = React.useState<'file' | 'youtube'>('file')
const [videoDetails, setVideoDetails] = React.useState<VideoDetails>({
startTime: 0,
endTime: null,
autoplay: false,
muted: false
})
const handleVideoChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleVideoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files?.[0]) { if (event.target.files?.[0]) {
@ -56,6 +70,7 @@ function VideoModal({
published_version: 1, published_version: 1,
version: 1, version: 1,
course_id: course.id, course_id: course.id,
details: videoDetails
}, },
chapterId chapterId
) )
@ -67,6 +82,7 @@ function VideoModal({
type: 'youtube', type: 'youtube',
uri: youtubeUrl, uri: youtubeUrl,
chapter_id: chapterId, chapter_id: chapterId,
details: videoDetails
} }
await submitExternalVideo( await submitExternalVideo(
@ -80,6 +96,73 @@ function VideoModal({
} }
} }
const VideoSettingsForm = () => (
<div className="space-y-4 mt-4 p-4 bg-gray-50 rounded-lg">
<h3 className="font-medium text-gray-900 mb-3">Video Settings</h3>
<div className="grid grid-cols-2 gap-4">
<FormField name="start-time">
<FormLabel>Start Time (seconds)</FormLabel>
<Form.Control asChild>
<Input
type="number"
min="0"
value={videoDetails.startTime}
onChange={(e) => setVideoDetails({
...videoDetails,
startTime: Math.max(0, parseInt(e.target.value) || 0)
})}
placeholder="0"
/>
</Form.Control>
</FormField>
<FormField name="end-time">
<FormLabel>End Time (seconds, optional)</FormLabel>
<Form.Control asChild>
<Input
type="number"
min={videoDetails.startTime + 1}
value={videoDetails.endTime || ''}
onChange={(e) => setVideoDetails({
...videoDetails,
endTime: e.target.value ? parseInt(e.target.value) : null
})}
placeholder="Leave empty for full duration"
/>
</Form.Control>
</FormField>
</div>
<div className="flex items-center space-x-6 mt-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={videoDetails.autoplay}
onChange={(e) => setVideoDetails({
...videoDetails,
autoplay: e.target.checked
})}
className="rounded border-gray-300 text-black focus:ring-black"
/>
<span className="text-sm text-gray-700">Autoplay video</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={videoDetails.muted}
onChange={(e) => setVideoDetails({
...videoDetails,
muted: e.target.checked
})}
className="rounded border-gray-300 text-black focus:ring-black"
/>
<span className="text-sm text-gray-700">Start muted</span>
</label>
</div>
</div>
)
return ( return (
<FormLayout onSubmit={handleSubmit}> <FormLayout onSubmit={handleSubmit}>
<FormField name="video-activity-name"> <FormField name="video-activity-name">
@ -143,6 +226,7 @@ function VideoModal({
/> />
</div> </div>
</FormField> </FormField>
<VideoSettingsForm />
</div> </div>
)} )}
@ -160,6 +244,7 @@ function VideoModal({
/> />
</Form.Control> </Form.Control>
</FormField> </FormField>
<VideoSettingsForm />
</div> </div>
)} )}
</div> </div>

View file

@ -39,6 +39,15 @@ export async function createFileActivity(
if (type === 'video') { if (type === 'video') {
formData.append('name', data.name) formData.append('name', data.name)
formData.append('video_file', file) 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` endpoint = `${getAPIUrl()}activities/video`
} else if (type === 'documentpdf') { } else if (type === 'documentpdf') {
formData.append('pdf_file', file) formData.append('pdf_file', file)
@ -65,6 +74,14 @@ export async function createExternalVideoActivity(
// add coursechapter_id to data // add coursechapter_id to data
data.chapter_id = chapter_id data.chapter_id = chapter_id
data.activity_id = activity.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( const result = await fetch(
`${getAPIUrl()}activities/external_video`, `${getAPIUrl()}activities/external_video`,