feat: Introduce isGraded prop to MediaPlayer to conditionally hide interactive elements and refine its layout.
This commit is contained in:
@@ -546,6 +546,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
config={block.config || {}}
|
||||
editMode={editMode}
|
||||
transcription={lesson.transcription}
|
||||
isGraded={isGraded}
|
||||
onChange={(updates) => updateBlock(block.id, updates)}
|
||||
/>
|
||||
)}
|
||||
@@ -610,6 +611,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
||||
videoUrl={block.url || ""}
|
||||
markers={block.markers || []}
|
||||
editMode={editMode}
|
||||
isGraded={isGraded}
|
||||
onChange={(updates) => updateBlock(block.id, updates)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -12,22 +12,31 @@ interface MediaPlayerProps {
|
||||
} | null;
|
||||
locked?: boolean;
|
||||
onEnded?: () => void;
|
||||
isGraded?: boolean;
|
||||
showInteractive?: boolean;
|
||||
}
|
||||
|
||||
export default function MediaPlayer({ src, type, transcription, locked, onEnded }: MediaPlayerProps) {
|
||||
export default function MediaPlayer({ src, type, transcription, locked, onEnded, isGraded, showInteractive = true }: MediaPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [currentCaption, setCurrentCaption] = useState("");
|
||||
const [language, setLanguage] = useState<"en" | "es">("en");
|
||||
|
||||
// Hide everything if graded activity or explicitely disabled
|
||||
const shouldShowTranscription = transcription && !locked && !isGraded && showInteractive;
|
||||
|
||||
useEffect(() => {
|
||||
const media = type === "video" ? videoRef.current : audioRef.current;
|
||||
if (!media) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const time = media.currentTime;
|
||||
setCurrentTime(time);
|
||||
|
||||
if (transcription?.cues) {
|
||||
const activeCue = transcription.cues.find(cue =>
|
||||
media.currentTime >= cue.start && media.currentTime <= cue.end
|
||||
time >= cue.start && time <= cue.end
|
||||
);
|
||||
setCurrentCaption(activeCue?.text || "");
|
||||
}
|
||||
@@ -45,6 +54,14 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
||||
};
|
||||
}, [type, transcription, onEnded]);
|
||||
|
||||
const handleSeek = (time: number) => {
|
||||
const media = type === "video" ? videoRef.current : audioRef.current;
|
||||
if (media) {
|
||||
media.currentTime = time;
|
||||
media.play();
|
||||
}
|
||||
};
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="glass aspect-video flex flex-col items-center justify-center border-dashed border-2 border-white/10 text-gray-500">
|
||||
@@ -68,18 +85,18 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
if (isYouTube || isVimeo) {
|
||||
let embedUrl = "";
|
||||
if (isYouTube) {
|
||||
const id = getYouTubeId(src);
|
||||
embedUrl = `https://www.youtube.com/embed/${id}`;
|
||||
} else {
|
||||
const id = getVimeoId(src);
|
||||
embedUrl = `https://player.vimeo.com/video/${id}`;
|
||||
}
|
||||
const renderMedia = () => {
|
||||
if (isYouTube || isVimeo) {
|
||||
let embedUrl = "";
|
||||
if (isYouTube) {
|
||||
const id = getYouTubeId(src);
|
||||
embedUrl = `https://www.youtube.com/embed/${id}`;
|
||||
} else {
|
||||
const id = getVimeoId(src);
|
||||
embedUrl = `https://player.vimeo.com/video/${id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 relative group">
|
||||
return (
|
||||
<div className={`glass overflow-hidden border border-white/10 aspect-video ${locked ? 'blur-xl grayscale' : ''}`}>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
@@ -88,45 +105,10 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{locked && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm rounded-xl z-10 text-center p-6 border border-white/10">
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mb-4 border border-white/20">
|
||||
<span className="text-3xl">🔒</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Playback Limited</h3>
|
||||
<p className="text-sm text-gray-300 max-w-xs">This content can only be played once according to the activity rules.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{transcription && !locked && (
|
||||
<div className="glass p-6 space-y-4 border border-blue-500/20">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="text-lg font-semibold flex items-center gap-2">
|
||||
Transcription <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded">AI Enhanced</span>
|
||||
</h4>
|
||||
<div className="flex bg-white/5 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setLanguage("en")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "en" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
>EN</button>
|
||||
<button
|
||||
onClick={() => setLanguage("es")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "es" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
>ES</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 leading-relaxed max-h-40 overflow-y-auto italic bg-white/5 p-4 rounded-lg">
|
||||
"{transcription[language] || "Transcription not available."}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 relative group">
|
||||
return (
|
||||
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
|
||||
{type === "video" ? (
|
||||
<video
|
||||
@@ -151,36 +133,89 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{locked && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm rounded-xl z-10 text-center p-6 border border-white/10">
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mb-4 border border-white/20">
|
||||
<span className="text-3xl">🔒</span>
|
||||
return (
|
||||
<div className={`grid grid-cols-1 ${shouldShowTranscription && transcription?.cues ? 'xl:grid-cols-12' : ''} gap-8 h-full`}>
|
||||
{/* Media Content */}
|
||||
<div className={`${shouldShowTranscription && transcription?.cues ? 'xl:col-span-8' : ''} space-y-4 relative group`}>
|
||||
{renderMedia()}
|
||||
|
||||
{locked && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 backdrop-blur-sm rounded-xl z-10 text-center p-6 border border-white/10">
|
||||
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mb-4 border border-white/20">
|
||||
<span className="text-3xl">🔒</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Playback Limited</h3>
|
||||
<p className="text-sm text-gray-300 max-w-xs">This content can only be played once according to the activity rules.</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Playback Limited</h3>
|
||||
<p className="text-sm text-gray-300 max-w-xs">This content can only be played once according to the activity rules.</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{transcription && !locked && (
|
||||
<div className="glass p-6 space-y-4 border border-blue-500/20">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="text-lg font-semibold flex items-center gap-2">
|
||||
Transcription <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded">AI Enhanced</span>
|
||||
</h4>
|
||||
{/* Simple Transcript (Fallback or Mobile) */}
|
||||
{shouldShowTranscription && !transcription?.cues && (
|
||||
<div className="glass p-6 space-y-4 border border-blue-500/20">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="text-lg font-semibold flex items-center gap-2">
|
||||
Transcription <span className="text-xs bg-blue-500/20 text-blue-400 px-2 py-1 rounded">AI Enhanced</span>
|
||||
</h4>
|
||||
<div className="flex bg-white/5 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setLanguage("en")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "en" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
>EN</button>
|
||||
<button
|
||||
onClick={() => setLanguage("es")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "es" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
>ES</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 leading-relaxed max-h-40 overflow-y-auto italic bg-white/5 p-4 rounded-lg">
|
||||
"{transcription[language] || "Transcription not available."}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Sidebar */}
|
||||
{shouldShowTranscription && transcription?.cues && (
|
||||
<div className="xl:col-span-4 glass border-white/5 bg-white/5 rounded-2xl overflow-hidden flex flex-col h-[500px] xl:h-auto border border-blue-500/10">
|
||||
<div className="p-4 border-b border-white/5 flex items-center justify-between bg-white/5">
|
||||
<h4 className="text-xs font-black uppercase tracking-widest text-gray-400">Interactive Content</h4>
|
||||
<div className="flex bg-white/5 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setLanguage("en")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "en" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
className={`px-2 py-1 text-[10px] font-bold rounded ${language === "en" ? "bg-blue-500 text-white" : "text-gray-500 hover:text-white"}`}
|
||||
>EN</button>
|
||||
<button
|
||||
onClick={() => setLanguage("es")}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-all ${language === "es" ? "bg-blue-500 text-white shadow-lg" : "text-gray-400 hover:text-white"}`}
|
||||
className={`px-2 py-1 text-[10px] font-bold rounded ${language === "es" ? "bg-blue-500 text-white" : "text-gray-500 hover:text-white"}`}
|
||||
>ES</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 leading-relaxed max-h-40 overflow-y-auto italic bg-white/5 p-4 rounded-lg">
|
||||
"{transcription[language] || "Transcription not available."}"
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
|
||||
{language === "en" && transcription.en && !transcription.cues.some(c => c.text === transcription.en) && (
|
||||
<p className="text-sm text-gray-500 italic mb-4 p-4 bg-blue-500/5 rounded-xl border border-blue-500/10">
|
||||
{transcription.en}
|
||||
</p>
|
||||
)}
|
||||
{transcription.cues.map((cue, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSeek(cue.start)}
|
||||
className={`text-left p-3 rounded-xl transition-all border group relative ${currentTime >= cue.start && currentTime <= cue.end
|
||||
? "bg-blue-500/20 border-blue-500/40 text-white shadow-lg shadow-blue-500/10"
|
||||
: "bg-white/5 border-transparent text-gray-400 hover:bg-white/10 hover:border-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-[9px] font-mono mb-1 block ${currentTime >= cue.start && currentTime <= cue.end ? 'text-blue-300' : 'text-gray-600'}`}>
|
||||
{Math.floor(cue.start / 60)}:{String(Math.floor(cue.start % 60)).padStart(2, '0')}
|
||||
</span>
|
||||
<p className="text-sm leading-relaxed font-medium">
|
||||
{cue.text}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -39,9 +39,10 @@ interface MediaBlockProps {
|
||||
es?: string;
|
||||
cues?: { start: number; end: number; text: string }[];
|
||||
} | null;
|
||||
isGraded?: boolean;
|
||||
}
|
||||
|
||||
export default function MediaBlock({ title, url, type, config, editMode, onChange, transcription }: MediaBlockProps) {
|
||||
export default function MediaBlock({ title, url, type, config, editMode, onChange, transcription, isGraded }: MediaBlockProps) {
|
||||
const [localPlays, setLocalPlays] = useState(config.currentPlays || 0);
|
||||
const [sourceType, setSourceType] = useState<"url" | "upload">(url.startsWith("/assets/") ? "upload" : "url");
|
||||
const maxPlays = config.maxPlays || 0;
|
||||
@@ -298,6 +299,8 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang
|
||||
type={type}
|
||||
transcription={transcription}
|
||||
locked={isLocked}
|
||||
isGraded={isGraded}
|
||||
showInteractive={config.show_transcript !== false}
|
||||
onEnded={handleEnded}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Clock, Plus, Trash2, Play, AlertCircle } from "lucide-react";
|
||||
import MediaPlayer from "../MediaPlayer";
|
||||
|
||||
interface VideoMarker {
|
||||
timestamp: number;
|
||||
@@ -16,6 +17,7 @@ interface VideoMarkerBlockProps {
|
||||
markers: VideoMarker[];
|
||||
onChange: (updates: { title?: string; markers?: VideoMarker[] }) => void;
|
||||
editMode: boolean;
|
||||
isGraded?: boolean;
|
||||
}
|
||||
|
||||
export default function VideoMarkerBlock({
|
||||
@@ -23,7 +25,8 @@ export default function VideoMarkerBlock({
|
||||
videoUrl,
|
||||
markers,
|
||||
onChange,
|
||||
editMode
|
||||
editMode,
|
||||
isGraded
|
||||
}: VideoMarkerBlockProps) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
@@ -124,12 +127,14 @@ export default function VideoMarkerBlock({
|
||||
<span className="text-xs font-mono text-gray-500">{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
className="w-full rounded-lg"
|
||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||
/>
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<MediaPlayer
|
||||
src={videoUrl}
|
||||
type="video"
|
||||
isGraded={isGraded}
|
||||
showInteractive={false} // Interactive markers are separate here
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addMarker}
|
||||
|
||||
Reference in New Issue
Block a user