feat: Extracted shared copyToClipboard utility and created EmbedPlayer…
- "frontend/src/utils/clipboard.ts" - "frontend/src/pages/EmbedPlayer.tsx" - "frontend/src/pages/EmbedPlayer.module.css" - "frontend/src/pages/ShortPlayer.tsx" GSD-Task: S03/T01
This commit is contained in:
parent
86d554a56f
commit
cf7c8b26a0
4 changed files with 202 additions and 25 deletions
73
frontend/src/pages/EmbedPlayer.module.css
Normal file
73
frontend/src/pages/EmbedPlayer.module.css
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/* ── Embed Player (iframe) ─────────────────────────────────────────────────── */
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: var(--color-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.playerArea {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Stretch video/audio to fill available space */
|
||||
.playerArea video,
|
||||
.playerArea canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.branding {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.branding a {
|
||||
color: var(--color-accent, #00ffd1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.branding a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Loading / Error ──────────────────────────────────────────────────────── */
|
||||
|
||||
.loadingState,
|
||||
.errorState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
color: var(--color-text-muted, #64748b);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.errorState {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
104
frontend/src/pages/EmbedPlayer.tsx
Normal file
104
frontend/src/pages/EmbedPlayer.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useMediaSync } from "../hooks/useMediaSync";
|
||||
import { fetchVideo } from "../api/videos";
|
||||
import { BASE, ApiError } from "../api/client";
|
||||
import type { VideoDetail } from "../api/videos";
|
||||
import VideoPlayer from "../components/VideoPlayer";
|
||||
import AudioWaveform from "../components/AudioWaveform";
|
||||
import PlayerControls from "../components/PlayerControls";
|
||||
import styles from "./EmbedPlayer.module.css";
|
||||
|
||||
/**
|
||||
* Minimal embed page for iframe use — renders the player
|
||||
* without app chrome (no header, nav, or footer).
|
||||
* Route: /embed/:videoId
|
||||
*/
|
||||
export default function EmbedPlayer() {
|
||||
const { videoId } = useParams<{ videoId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Parse ?t= start time
|
||||
const rawT = parseFloat(searchParams.get("t") ?? "");
|
||||
const startTime = Number.isFinite(rawT) && rawT > 0 ? rawT : 0;
|
||||
|
||||
const [video, setVideo] = useState<VideoDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mediaSync = useMediaSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoId) {
|
||||
setError("No video ID provided");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchVideo(videoId)
|
||||
.then((v) => {
|
||||
if (!cancelled) setVideo(v);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setError("Video not found");
|
||||
} else {
|
||||
setError("Failed to load video");
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [videoId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loadingState}>Loading…</div>;
|
||||
}
|
||||
|
||||
if (error || !video) {
|
||||
return (
|
||||
<div className={styles.errorState}>
|
||||
<p className={styles.errorMessage}>{error ?? "Video not found"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const streamUrl = `${BASE}/videos/${videoId}/stream`;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.playerArea}>
|
||||
{video.video_url ? (
|
||||
<VideoPlayer
|
||||
src={video.video_url}
|
||||
startTime={startTime}
|
||||
mediaSync={mediaSync}
|
||||
/>
|
||||
) : (
|
||||
<AudioWaveform src={streamUrl} mediaSync={mediaSync} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<PlayerControls mediaSync={mediaSync} />
|
||||
</div>
|
||||
|
||||
<div className={styles.branding}>
|
||||
Powered by{" "}
|
||||
<a href={window.location.origin} target="_blank" rel="noopener noreferrer">
|
||||
Chrysopedia
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +1,9 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { fetchPublicShort, type PublicShortResponse } from "../api/shorts";
|
||||
import { copyToClipboard } from "../utils/clipboard";
|
||||
import styles from "./ShortPlayer.module.css";
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with execCommand fallback for older browsers.
|
||||
* Returns true on success.
|
||||
*/
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Clipboard API failed — fall through to fallback
|
||||
}
|
||||
}
|
||||
// Fallback: hidden textarea + execCommand
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function buildEmbedSnippet(
|
||||
token: string,
|
||||
width: number,
|
||||
|
|
|
|||
24
frontend/src/utils/clipboard.ts
Normal file
24
frontend/src/utils/clipboard.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Copy text to clipboard with execCommand fallback for older browsers.
|
||||
* Returns true on success.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Clipboard API failed — fall through to fallback
|
||||
}
|
||||
}
|
||||
// Fallback: hidden textarea + execCommand
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue