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 { useEffect, useState, useCallback } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { fetchPublicShort, type PublicShortResponse } from "../api/shorts";
|
import { fetchPublicShort, type PublicShortResponse } from "../api/shorts";
|
||||||
|
import { copyToClipboard } from "../utils/clipboard";
|
||||||
import styles from "./ShortPlayer.module.css";
|
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(
|
function buildEmbedSnippet(
|
||||||
token: string,
|
token: string,
|
||||||
width: number,
|
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