-
Notifications
You must be signed in to change notification settings - Fork 73
feat(course-progress): add resume playback, completion tracking, and progress bars #147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,16 +2,18 @@ | |
| import VidPlayerBox from "@/components/atoms/dashboard/vid-player-box"; | ||
| import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; | ||
| import Link from "next/link"; | ||
| import React, { useState, useMemo } from "react"; | ||
| import React, { useState, useMemo, useCallback } from "react"; | ||
| import Button from "@/components/atoms/form/Button"; | ||
| import StarRate from "@/components/atoms/form/StarRate"; | ||
| import ReviewsSection from "@/components/organisms/dashboard/ReviewsSection"; | ||
| import { useAuth } from "@/hooks/useAuth"; | ||
| import { Textarea } from "@/components/ui/textarea"; | ||
| import { addCourseReview } from "@/lib/actions/courses/addReview"; | ||
| import { useHasCourse, usePurchaseCourse } from "@/hooks/usePurchase"; | ||
| import { useCourseProgress, formatTime } from "@/hooks/useCourseProgress"; | ||
| import { Progress } from "@/components/ui/progress"; | ||
| import { toast } from "sonner"; | ||
| import { Wallet } from "lucide-react"; | ||
| import { Wallet, RotateCcw, Play } from "lucide-react"; | ||
| import PaymentModal from "@/components/stellar/PaymentModal"; | ||
| import { useStellar } from "@/components/stellar/StellarProvider"; | ||
|
|
||
|
|
@@ -26,23 +28,54 @@ export default function CourseDetailClient({ course }) { | |
| const [loading, setLoading] = useState(false); | ||
| const [showPaymentModal, setShowPaymentModal] = useState(false); | ||
|
|
||
| // Check if user already owns the course | ||
| const hasCourse = useHasCourse(course?._id); | ||
| // Local state to hide button after purchase | ||
| const [purchased, setPurchased] = useState(false); | ||
|
|
||
| // Check if the current user has already reviewed | ||
| const { | ||
| progress, | ||
| reportProgress, | ||
| resetProgress, | ||
| resumeTime, | ||
| resumeLabel, | ||
| } = useCourseProgress(course?._id); | ||
|
|
||
| const [useResume, setUseResume] = useState(false); | ||
| const [playerKey, setPlayerKey] = useState(0); | ||
|
|
||
| const effectiveStartTime = useResume && resumeTime ? resumeTime : 0; | ||
|
|
||
| const handleTimeUpdate = useCallback( | ||
| (currentTime, duration) => { | ||
| reportProgress(currentTime, duration); | ||
| }, | ||
| [reportProgress] | ||
| ); | ||
|
|
||
| const handleEnded = useCallback(() => { | ||
| if (course?._id) { | ||
| reportProgress(progress.durationSeconds || 0, progress.durationSeconds || 0); | ||
| } | ||
| }, [reportProgress, progress.durationSeconds, course?._id]); | ||
|
|
||
| const handleResume = () => { | ||
| setUseResume(true); | ||
| }; | ||
|
|
||
| const handleStartOver = () => { | ||
| resetProgress(); | ||
| setUseResume(false); | ||
| setPlayerKey((k) => k + 1); | ||
| }; | ||
|
Comment on lines
+60
to
+68
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: In the Vidstack React MediaPlayer, the clipStartTime prop is intended to define a playback range boundary [1]. While you can change the clipStartTime prop after the component has mounted by updating the state passed to the MediaPlayer, this does not automatically trigger a seek operation to that new start time [2]. To achieve a reactive update where the player seeks to the new start time when the clipStartTime prop changes, you must manually coordinate the seek operation using the player instance [2][3]. Implementation Strategy: 1. Access the Player Instance: Use the useMediaPlayer hook within a child component of the MediaPlayer to retrieve the player instance [3][4]. 2. Detect Changes: Use a useEffect hook to watch for changes to the clipStartTime variable [5]. 3. Trigger Seek: Inside the effect, use the player instance or a MediaRemoteControl to set the currentTime to the new clipStartTime [3]. Example approach: import { useMediaPlayer, useEffect } from ' Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
file='app/dashboard/courses/[courseId]/CourseDetailPageClient.jsx'
# Show the relevant region with line numbers.
sed -n '1,220p' "$file" | nl -ba | sed -n '1,220p'
# Find player-related state and props in the file.
echo
rg -n "playerKey|useResume|resumeTime|clipStartTime|currentTime|MediaPlayer|handleResume|handleStartOver|resetProgress" "$file"Repository: Deen-Bridge/dnb-frontend Length of output: 202 🏁 Script executed: #!/bin/bash
set -euo pipefail
file='app/dashboard/courses/[courseId]/CourseDetailPageClient.jsx'
echo '--- relevant file section ---'
sed -n '1,240p' "$file" | cat -n
echo
echo '--- player/start-time references ---'
rg -n "playerKey|useResume|resumeTime|clipStartTime|currentTime|MediaPlayer|handleResume|handleStartOver|resetProgress|startTime" "$file"Repository: Deen-Bridge/dnb-frontend Length of output: 11269 🏁 Script executed: #!/bin/bash
set -euo pipefail
file='components/atoms/dashboard/vid-player-box.jsx'
echo '--- file outline ---'
wc -l "$file"
echo
sed -n '1,260p' "$file" | cat -n
echo
echo '--- startTime / MediaPlayer references ---'
rg -n "startTime|clipStartTime|currentTime|MediaPlayer|useMediaPlayer|seek|playerKey|key=" "$file"Repository: Deen-Bridge/dnb-frontend Length of output: 3119 Resume needs a remount or explicit seek
🤖 Prompt for AI Agents |
||
|
|
||
| const userReview = useMemo(() => { | ||
| if (!user?._id || !course?.reviews) return null; | ||
| return course.reviews.find( | ||
| (r) => r.user?._id === user._id || r.user?.id === user._id | ||
| ); | ||
| }, [user, course?.reviews]); | ||
|
|
||
| // Check if creator has wallet connected | ||
| const creatorHasWallet = course?.createdBy?.stellarWallet?.publicKey; | ||
|
|
||
| // Handle opening the payment modal | ||
| const handlePurchaseCourse = () => { | ||
| if (!user?._id) { | ||
| toast.error("Please sign in to purchase this course."); | ||
|
|
@@ -51,7 +84,6 @@ export default function CourseDetailClient({ course }) { | |
| setShowPaymentModal(true); | ||
| }; | ||
|
|
||
| // Handle successful payment | ||
| const handlePaymentSuccess = async (result) => { | ||
| setPurchased(true); | ||
| if (user?._id) { | ||
|
|
@@ -86,7 +118,6 @@ export default function CourseDetailClient({ course }) { | |
| return null; | ||
| } | ||
|
|
||
| // Check if user can access the course | ||
| const canAccess = | ||
| hasCourse || purchased || user?._id === course.createdBy?._id; | ||
|
|
||
|
|
@@ -97,12 +128,77 @@ export default function CourseDetailClient({ course }) { | |
| </h2> | ||
|
|
||
| {canAccess ? ( | ||
| <div className="w-full aspect-video mb-8 rounded-xl"> | ||
| <VidPlayerBox data={course} /> | ||
| <div className="w-full mb-8 rounded-xl"> | ||
| {progress.percent > 0 && !progress.completed && resumeLabel && !useResume && ( | ||
| <div className="mb-4 p-4 bg-accent/10 border border-accent/30 rounded-xl flex flex-col sm:flex-row items-center justify-between gap-3"> | ||
| <div className="flex items-center gap-3"> | ||
| <Play className="h-5 w-5 text-accent" /> | ||
| <div> | ||
| <p className="font-semibold text-sm">{resumeLabel}</p> | ||
| <Progress value={progress.percent} className="w-48 h-1.5 mt-1" /> | ||
| </div> | ||
| </div> | ||
| <div className="flex gap-2"> | ||
| <Button | ||
| round | ||
| className="bg-accent hover:bg-accent/90 text-white text-sm font-semibold px-4" | ||
| onClick={handleResume} | ||
| > | ||
| <Play className="h-4 w-4 mr-1" /> | ||
| Resume | ||
| </Button> | ||
| <Button | ||
| round | ||
| outlined | ||
| className="text-sm px-4" | ||
| onClick={handleStartOver} | ||
| > | ||
| <RotateCcw className="h-4 w-4 mr-1" /> | ||
| Start Over | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| )} | ||
|
|
||
| {progress.completed && ( | ||
| <div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-xl flex items-center gap-2"> | ||
| <span className="text-green-600 font-semibold text-sm"> | ||
| ✅ Course Completed | ||
| </span> | ||
| <Button | ||
| round | ||
| outlined | ||
| className="text-xs ml-auto px-3 py-1" | ||
| onClick={handleStartOver} | ||
| > | ||
| <RotateCcw className="h-3 w-3 mr-1" /> | ||
| Watch Again | ||
| </Button> | ||
| </div> | ||
| )} | ||
|
|
||
| <div className="w-full aspect-video rounded-xl overflow-hidden"> | ||
| <VidPlayerBox | ||
| key={playerKey} | ||
| data={course} | ||
| startTime={effectiveStartTime} | ||
| onTimeUpdate={handleTimeUpdate} | ||
| onEnded={handleEnded} | ||
| /> | ||
| </div> | ||
|
|
||
| {progress.percent > 0 && ( | ||
| <div className="mt-3"> | ||
| <div className="flex justify-between text-xs text-muted-foreground mb-1"> | ||
| <span>{progress.percent}% complete</span> | ||
| <span>{formatTime(progress.positionSeconds)} / {formatTime(progress.durationSeconds)}</span> | ||
| </div> | ||
| <Progress value={progress.percent} className="h-1.5" /> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ) : ( | ||
| <div className="w-full aspect-video mb-8 relative rounded-xl overflow-hidden"> | ||
| {/* Blurred thumbnail preview */} | ||
| <div className="absolute inset-0 bg-gradient-to-br from-accent/20 to-highlight/20 backdrop-blur-sm z-10 flex items-center justify-center"> | ||
| <div className="text-center space-y-4 p-8 bg-background/90 rounded-xl shadow-2xl"> | ||
| <h3 className="text-2xl font-bold">🔒 Course Locked</h3> | ||
|
|
@@ -214,18 +310,15 @@ export default function CourseDetailClient({ course }) { | |
| </div> | ||
| )} | ||
| </div> | ||
| {/* Right Column: Pricing & Actions */} | ||
| <aside className="space-y-4"> | ||
| <div className="border rounded-xl p-6 shadow-lg bg-card space-y-6 sticky top-4"> | ||
| {/* Price Section */} | ||
| <div className="text-center space-y-2"> | ||
| <p className="text-sm text-muted-foreground">Course Price</p> | ||
| <div className="text-5xl font-bold text-accent"> | ||
| {course.price === 0 ? "Free" : `$${course.price}`} | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Purchase/Access Button */} | ||
| {!hasCourse && !purchased && user?._id !== course.createdBy?._id ? ( | ||
| <div className="space-y-2"> | ||
| <Button | ||
|
|
@@ -254,7 +347,6 @@ export default function CourseDetailClient({ course }) { | |
| </div> | ||
| ) : null} | ||
|
|
||
| {/* Course Stats */} | ||
| <div className="space-y-3 pt-4 border-t"> | ||
| <div className="flex items-center justify-between"> | ||
| <span className="text-sm text-muted-foreground"> | ||
|
|
@@ -281,7 +373,6 @@ export default function CourseDetailClient({ course }) { | |
| </aside> | ||
| </div> | ||
|
|
||
| {/* Reviews Section */} | ||
| <div className="px-2 sm:px-10 mt-12"> | ||
| <h2 className="text-3xl font-semibold mb-6">Reviews</h2> | ||
| <ReviewsSection | ||
|
|
@@ -292,7 +383,6 @@ export default function CourseDetailClient({ course }) { | |
| /> | ||
| </div> | ||
|
|
||
| {/* Stellar Payment Modal */} | ||
| <PaymentModal | ||
| isOpen={showPaymentModal} | ||
| onClose={() => setShowPaymentModal(false)} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,13 +8,43 @@ import { | |
| MediaProvider, | ||
| Poster, | ||
| Track, | ||
| useMediaPlayer, | ||
| } from '@vidstack/react'; | ||
| import { | ||
| DefaultVideoLayout, | ||
| defaultLayoutIcons, | ||
| } from '@vidstack/react/player/layouts/default'; | ||
| import { useEffect, useRef } from 'react'; | ||
|
|
||
| const VidPlayerBox = ({ data }) => { | ||
| function PlayerProgressTracker({ onTimeUpdate, onEnded }) { | ||
| const player = useMediaPlayer(); | ||
| const lastReportRef = useRef(0); | ||
|
|
||
| useEffect(() => { | ||
| if (!player) return; | ||
|
|
||
| const timeSub = player.on('time-update', (e) => { | ||
| if (onTimeUpdate) { | ||
| onTimeUpdate(e.currentTime, e.duration); | ||
| } | ||
| }); | ||
|
|
||
| const endedSub = player.on('ended', () => { | ||
| if (onEnded) { | ||
| onEnded(); | ||
| } | ||
| }); | ||
|
Comment on lines
+32
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win The
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| return () => { | ||
| timeSub(); | ||
| endedSub(); | ||
| }; | ||
| }, [player, onTimeUpdate, onEnded]); | ||
|
|
||
| return null; | ||
| } | ||
|
Comment on lines
+19
to
+45
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win Progress reporting is unthrottled end-to-end. The PR aims to "throttle progress writes", but the only throttle is the 15s gate on the backend PUT. Vidstack's
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| const VidPlayerBox = ({ data, startTime, onTimeUpdate, onEnded }) => { | ||
| const textTracks = data?.subtitles?.length | ||
| ? data.subtitles | ||
| : []; | ||
|
|
@@ -29,6 +59,7 @@ const VidPlayerBox = ({ data }) => { | |
| playsInline | ||
| title={data?.title} | ||
| poster={data?.thumbnail} | ||
| clipStartTime={startTime || undefined} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: In Vidstack, clipStartTime and clipEndTime are properties used to shorten media by defining the boundaries of playback [1][2]. Their behavior regarding the timeline, duration, and state management is defined as follows: Duration Behavior: When clipStartTime and clipEndTime are set, the player updates the media duration to match the new clipped length [1][2]. Seeking, progress tracking, and other time-based UI components (such as chapters) are recalculated to reflect this clipped duration [1]. For example, if a 60-second video is clipped to start at 30 seconds and end at 60 seconds, the player's reported duration becomes 30 seconds, and seeking to 30s moves the playhead to the end of the media [1]. Timeline and State Behavior: - Internal URI Fragments: Vidstack internally uses Media URI Fragments (e.g., /video.mp4#t=30,60) to efficiently load and constrain the media between the specified start and end times [1][2]. - Seeking: Seeking operations are relative to the clipped duration [1]. - Reporting: The duration property (available on the player component) reflects the total length of the clipped segment [1][3]. - Implementation Notes: While Vidstack handles the clipping logic, there have been reported edge cases in specific versions where the UI timeline or progress thumb might behave unexpectedly (e.g., getting "stuck" or resetting) depending on how and when the clipping properties are updated during playback [4]. Usage: These properties can be passed directly as attributes or props to the MediaPlayer component [2]: - If you require looping within a custom range rather than permanent clipping, Vidstack does not provide a built-in range-loop feature; this is typically handled by subscribing to time updates and programmatically resetting currentTime when the end time is reached [5]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
file="components/atoms/dashboard/vid-player-box.jsx"
echo "== outline =="
ast-grep outline "$file" --view expanded || true
echo
echo "== relevant lines =="
nl -ba "$file" | sed -n '1,180p'Repository: Deen-Bridge/dnb-frontend Length of output: 418 🏁 Script executed: #!/bin/bash
set -euo pipefail
file="components/atoms/dashboard/vid-player-box.jsx"
echo "== lines 1-140 =="
awk 'NR>=1 && NR<=140 { printf("%4d %s\n", NR, $0) }' "$file"Repository: Deen-Bridge/dnb-frontend Length of output: 2587 🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n "onTimeUpdate|calcPercent|reportProgress|VidPlayerBox" components . --glob '!**/node_modules/**'Repository: Deen-Bridge/dnb-frontend Length of output: 3238 🏁 Script executed: #!/bin/bash
set -euo pipefail
files=(
"app/dashboard/courses/[courseId]/CourseDetailPageClient.jsx"
"hooks/useCourseProgress.js"
)
for file in "${files[@]}"; do
echo "== $file =="
awk 'NR>=1 && NR<=260 { printf("%4d %s\n", NR, $0) }' "$file"
echo
doneRepository: Deen-Bridge/dnb-frontend Length of output: 20434 🌐 Web query:
💡 Result: In Vidstack Player, the relationship between playback time and clipping is managed by distinguishing between the "real" playback time (raw provider time) and the "clipped" playback time [1][2]. When using clipStartTime and clipEndTime, Vidstack automatically adjusts the player state to reflect the clipped duration [3][4]. Here is how the relevant time properties behave: currentTime: The currentTime property represents the playback time relative to the clipped duration [1][5]. If you have a 60-second video and set a clipStartTime of 30 seconds, the player will treat the 30-second mark of the source as 0 seconds in the clipped timeline [1][3]. Setting this value will seek the media to the corresponding position within the clipped window [1][5]. time-update: The time-update event is dispatched when the playback time changes [2]. The detail provided in this event contains the currentTime value, which is relative to the clipped duration [2][6]. This is the recommended event to listen to if you want to track the playback position within the clipped range [2]. time-change: If you require the "real" or "raw" time (the actual position in the original source media without accounting for clipping), you should listen to the time-change event instead [2]. The time-change event provides the absolute provider time [2]. Underlying Logic: The internal state management calculates currentTime using the following logic [1]: If clipStartTime > 0, currentTime is derived by subtracting the clipStartTime from the realCurrentTime (clamped to the clipped duration) [1]. The duration property is automatically updated to match the clipped length (e.g., if you clip a 1-minute video to 30 seconds, the duration will be 30 seconds) [3][4]. This approach allows you to work with clipped media as if it were a standalone file, while the underlying MediaRemoteControl or state hooks provide the necessary tools to manipulate playback within those bounds [7][8][3]. Citations:
components/atoms/dashboard/vid-player-box.jsx:62 — 🤖 Prompt for AI Agents |
||
| > | ||
| <MediaProvider> | ||
| <Poster className="vds-poster" /> | ||
|
|
@@ -41,6 +72,11 @@ const VidPlayerBox = ({ data }) => { | |
| thumbnails={data?.thumbnails || undefined} | ||
| icons={defaultLayoutIcons} | ||
| /> | ||
|
|
||
| <PlayerProgressTracker | ||
| onTimeUpdate={onTimeUpdate} | ||
| onEnded={onEnded} | ||
| /> | ||
| </MediaPlayer> | ||
| </div> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Surface the hook's
loadingstate.useCourseProgressreturnsloading, but it isn't destructured here. Until theGET /api/progress/coursesround-trip resolves,progress.percentis0, so the page renders as "no progress" and then the resume banner + progress bar pop in and shift the player down. Gating those blocks on!loading(or rendering a small skeleton) avoids the layout jump.🛠️ Suggested wiring
const { progress, + loading: progressLoading, reportProgress, resetProgress, resumeTime, resumeLabel, } = useCourseProgress(course?._id);Then guard the resume banner and the elapsed-time block with
!progressLoading.As per path instructions, this is a Next.js 15 App Router file and I should flag "missing loading/error states for data fetching".
🤖 Prompt for AI Agents
Source: Path instructions