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 || {}}
|
config={block.config || {}}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
transcription={lesson.transcription}
|
transcription={lesson.transcription}
|
||||||
|
isGraded={isGraded}
|
||||||
onChange={(updates) => updateBlock(block.id, updates)}
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -610,6 +611,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
videoUrl={block.url || ""}
|
videoUrl={block.url || ""}
|
||||||
markers={block.markers || []}
|
markers={block.markers || []}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
|
isGraded={isGraded}
|
||||||
onChange={(updates) => updateBlock(block.id, updates)}
|
onChange={(updates) => updateBlock(block.id, updates)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,22 +12,31 @@ interface MediaPlayerProps {
|
|||||||
} | null;
|
} | null;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
onEnded?: () => void;
|
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 videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [currentCaption, setCurrentCaption] = useState("");
|
const [currentCaption, setCurrentCaption] = useState("");
|
||||||
const [language, setLanguage] = useState<"en" | "es">("en");
|
const [language, setLanguage] = useState<"en" | "es">("en");
|
||||||
|
|
||||||
|
// Hide everything if graded activity or explicitely disabled
|
||||||
|
const shouldShowTranscription = transcription && !locked && !isGraded && showInteractive;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const media = type === "video" ? videoRef.current : audioRef.current;
|
const media = type === "video" ? videoRef.current : audioRef.current;
|
||||||
if (!media) return;
|
if (!media) return;
|
||||||
|
|
||||||
const handleTimeUpdate = () => {
|
const handleTimeUpdate = () => {
|
||||||
|
const time = media.currentTime;
|
||||||
|
setCurrentTime(time);
|
||||||
|
|
||||||
if (transcription?.cues) {
|
if (transcription?.cues) {
|
||||||
const activeCue = transcription.cues.find(cue =>
|
const activeCue = transcription.cues.find(cue =>
|
||||||
media.currentTime >= cue.start && media.currentTime <= cue.end
|
time >= cue.start && time <= cue.end
|
||||||
);
|
);
|
||||||
setCurrentCaption(activeCue?.text || "");
|
setCurrentCaption(activeCue?.text || "");
|
||||||
}
|
}
|
||||||
@@ -45,6 +54,14 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
|||||||
};
|
};
|
||||||
}, [type, transcription, 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) {
|
if (!src) {
|
||||||
return (
|
return (
|
||||||
<div className="glass aspect-video flex flex-col items-center justify-center border-dashed border-2 border-white/10 text-gray-500">
|
<div className="glass aspect-video flex flex-col items-center justify-center border-dashed border-2 border-white/10 text-gray-500">
|
||||||
@@ -68,6 +85,7 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
|||||||
return match ? match[1] : null;
|
return match ? match[1] : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderMedia = () => {
|
||||||
if (isYouTube || isVimeo) {
|
if (isYouTube || isVimeo) {
|
||||||
let embedUrl = "";
|
let embedUrl = "";
|
||||||
if (isYouTube) {
|
if (isYouTube) {
|
||||||
@@ -79,7 +97,6 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 relative group">
|
|
||||||
<div className={`glass overflow-hidden border border-white/10 aspect-video ${locked ? 'blur-xl grayscale' : ''}`}>
|
<div className={`glass overflow-hidden border border-white/10 aspect-video ${locked ? 'blur-xl grayscale' : ''}`}>
|
||||||
<iframe
|
<iframe
|
||||||
src={embedUrl}
|
src={embedUrl}
|
||||||
@@ -88,45 +105,10 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
|||||||
allowFullScreen
|
allowFullScreen
|
||||||
></iframe>
|
></iframe>
|
||||||
</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>
|
|
||||||
</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 (
|
return (
|
||||||
<div className="space-y-4 relative group">
|
|
||||||
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
|
<div className={`relative glass overflow-hidden border border-white/10 ${locked ? 'blur-xl grayscale' : ''}`}>
|
||||||
{type === "video" ? (
|
{type === "video" ? (
|
||||||
<video
|
<video
|
||||||
@@ -151,6 +133,14 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 && (
|
{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="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">
|
||||||
@@ -162,7 +152,8 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{transcription && !locked && (
|
{/* Simple Transcript (Fallback or Mobile) */}
|
||||||
|
{shouldShowTranscription && !transcription?.cues && (
|
||||||
<div className="glass p-6 space-y-4 border border-blue-500/20">
|
<div className="glass p-6 space-y-4 border border-blue-500/20">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h4 className="text-lg font-semibold flex items-center gap-2">
|
<h4 className="text-lg font-semibold flex items-center gap-2">
|
||||||
@@ -185,5 +176,49 @@ export default function MediaPlayer({ src, type, transcription, locked, onEnded
|
|||||||
</div>
|
</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-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-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="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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ interface MediaBlockProps {
|
|||||||
es?: string;
|
es?: string;
|
||||||
cues?: { start: number; end: number; text: string }[];
|
cues?: { start: number; end: number; text: string }[];
|
||||||
} | null;
|
} | 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 [localPlays, setLocalPlays] = useState(config.currentPlays || 0);
|
||||||
const [sourceType, setSourceType] = useState<"url" | "upload">(url.startsWith("/assets/") ? "upload" : "url");
|
const [sourceType, setSourceType] = useState<"url" | "upload">(url.startsWith("/assets/") ? "upload" : "url");
|
||||||
const maxPlays = config.maxPlays || 0;
|
const maxPlays = config.maxPlays || 0;
|
||||||
@@ -298,6 +299,8 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang
|
|||||||
type={type}
|
type={type}
|
||||||
transcription={transcription}
|
transcription={transcription}
|
||||||
locked={isLocked}
|
locked={isLocked}
|
||||||
|
isGraded={isGraded}
|
||||||
|
showInteractive={config.show_transcript !== false}
|
||||||
onEnded={handleEnded}
|
onEnded={handleEnded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Clock, Plus, Trash2, Play, AlertCircle } from "lucide-react";
|
import { Clock, Plus, Trash2, Play, AlertCircle } from "lucide-react";
|
||||||
|
import MediaPlayer from "../MediaPlayer";
|
||||||
|
|
||||||
interface VideoMarker {
|
interface VideoMarker {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -16,6 +17,7 @@ interface VideoMarkerBlockProps {
|
|||||||
markers: VideoMarker[];
|
markers: VideoMarker[];
|
||||||
onChange: (updates: { title?: string; markers?: VideoMarker[] }) => void;
|
onChange: (updates: { title?: string; markers?: VideoMarker[] }) => void;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
|
isGraded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoMarkerBlock({
|
export default function VideoMarkerBlock({
|
||||||
@@ -23,7 +25,8 @@ export default function VideoMarkerBlock({
|
|||||||
videoUrl,
|
videoUrl,
|
||||||
markers,
|
markers,
|
||||||
onChange,
|
onChange,
|
||||||
editMode
|
editMode,
|
||||||
|
isGraded
|
||||||
}: VideoMarkerBlockProps) {
|
}: VideoMarkerBlockProps) {
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
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>
|
<span className="text-xs font-mono text-gray-500">{formatTime(currentTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<video
|
<div className="rounded-lg overflow-hidden">
|
||||||
|
<MediaPlayer
|
||||||
src={videoUrl}
|
src={videoUrl}
|
||||||
controls
|
type="video"
|
||||||
className="w-full rounded-lg"
|
isGraded={isGraded}
|
||||||
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
showInteractive={false} // Interactive markers are separate here
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={addMarker}
|
onClick={addMarker}
|
||||||
|
|||||||
Reference in New Issue
Block a user