diff --git a/frontend/src/pages/EmbedPlayer.module.css b/frontend/src/pages/EmbedPlayer.module.css new file mode 100644 index 0000000..f7988f5 --- /dev/null +++ b/frontend/src/pages/EmbedPlayer.module.css @@ -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); +} diff --git a/frontend/src/pages/EmbedPlayer.tsx b/frontend/src/pages/EmbedPlayer.tsx new file mode 100644 index 0000000..7541bfc --- /dev/null +++ b/frontend/src/pages/EmbedPlayer.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
Loading…
; + } + + if (error || !video) { + return ( +
+

{error ?? "Video not found"}

+
+ ); + } + + const streamUrl = `${BASE}/videos/${videoId}/stream`; + + return ( +
+
+ {video.video_url ? ( + + ) : ( + + )} +
+ +
+ +
+ +
+ Powered by{" "} + + Chrysopedia + +
+
+ ); +} diff --git a/frontend/src/pages/ShortPlayer.tsx b/frontend/src/pages/ShortPlayer.tsx index a769bcb..b2bd205 100644 --- a/frontend/src/pages/ShortPlayer.tsx +++ b/frontend/src/pages/ShortPlayer.tsx @@ -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 { - 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, diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000..abbd8d0 --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -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 { + 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; +}