Spaces:
Sleeping
Sleeping
Update client/src/Player.jsx
Browse files- client/src/Player.jsx +49 -27
client/src/Player.jsx
CHANGED
|
@@ -2,19 +2,19 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
| 2 |
import { detectMediaTypeFromUrl, getThumb, safeTitle } from './utils.js';
|
| 3 |
import { log } from './logger.js';
|
| 4 |
import { useToasts } from './Toasts.jsx';
|
| 5 |
-
import YouTubePlayer from './YouTubePlayer.jsx';
|
| 6 |
|
| 7 |
export default function Player({ socket, roomId, state, isHost }) {
|
| 8 |
const { push } = useToasts();
|
| 9 |
const audioRef = useRef(null);
|
| 10 |
const videoRef = useRef(null);
|
| 11 |
const [err, setErr] = useState(null);
|
|
|
|
|
|
|
| 12 |
|
| 13 |
const mediaType = useMemo(() => {
|
| 14 |
if (!state?.track?.url) return 'none';
|
| 15 |
if (state?.track?.kind) return state.track.kind;
|
| 16 |
-
|
| 17 |
-
return detected || 'audio';
|
| 18 |
}, [state?.track?.url, state?.track?.kind]);
|
| 19 |
|
| 20 |
const logicalTime = () => {
|
|
@@ -25,44 +25,62 @@ export default function Player({ socket, roomId, state, isHost }) {
|
|
| 25 |
return Math.max(0, (anchor || 0) + elapsed);
|
| 26 |
};
|
| 27 |
|
| 28 |
-
const
|
| 29 |
if (!el || !state?.track?.url) return;
|
| 30 |
-
|
|
|
|
| 31 |
try {
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const target = logicalTime();
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
setErr(null);
|
| 37 |
} catch (e) {
|
| 38 |
-
log.error('Playback error
|
| 39 |
setErr(e?.message || 'Playback failed');
|
| 40 |
push(`Playback failed: ${e?.message || 'Unknown error'}`, 'bad', 4500);
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
};
|
| 43 |
|
|
|
|
| 44 |
useEffect(() => {
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
} else if (mediaType === 'video') {
|
| 49 |
-
const el = videoRef.current;
|
| 50 |
-
if (el) loadInto(el);
|
| 51 |
-
}
|
| 52 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 53 |
}, [state?.track?.url, mediaType]);
|
| 54 |
|
|
|
|
| 55 |
useEffect(() => {
|
| 56 |
-
if (mediaType === 'youtube') return;
|
| 57 |
const el = mediaType === 'video' ? videoRef.current : audioRef.current;
|
| 58 |
if (!el) return;
|
|
|
|
| 59 |
const target = logicalTime();
|
| 60 |
-
const
|
| 61 |
-
if (Math.abs(
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 65 |
-
}, [state.isPlaying, state.anchor, state.anchorAt
|
| 66 |
|
| 67 |
useEffect(() => {
|
| 68 |
const a = audioRef.current;
|
|
@@ -81,10 +99,16 @@ export default function Player({ socket, roomId, state, isHost }) {
|
|
| 81 |
|
| 82 |
return (
|
| 83 |
<div style={{ position:'relative' }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
<div className="ambient"></div>
|
| 85 |
|
| 86 |
-
<div className="now-playing" style={{ marginBottom: 10 }}>
|
| 87 |
-
<div className="thumb" style={{ width:
|
| 88 |
<div style={{ minWidth:0, flex:1 }}>
|
| 89 |
<div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{title}</div>
|
| 90 |
<div style={{ fontSize:12, color:'var(--muted)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
|
@@ -94,9 +118,7 @@ export default function Player({ socket, roomId, state, isHost }) {
|
|
| 94 |
{!isHost && <div className="tag">Listener</div>}
|
| 95 |
</div>
|
| 96 |
|
| 97 |
-
{mediaType === '
|
| 98 |
-
<YouTubePlayer socket={socket} roomId={roomId} state={state} isHost={isHost} />
|
| 99 |
-
) : mediaType === 'video' ? (
|
| 100 |
<video ref={videoRef} controls={isHost} playsInline style={{ width:'100%', maxHeight: '56vh', background:'#000' }} crossOrigin="anonymous" />
|
| 101 |
) : (
|
| 102 |
<audio ref={audioRef} controls={isHost} style={{ width: '100%' }} crossOrigin="anonymous" />
|
|
|
|
| 2 |
import { detectMediaTypeFromUrl, getThumb, safeTitle } from './utils.js';
|
| 3 |
import { log } from './logger.js';
|
| 4 |
import { useToasts } from './Toasts.jsx';
|
|
|
|
| 5 |
|
| 6 |
export default function Player({ socket, roomId, state, isHost }) {
|
| 7 |
const { push } = useToasts();
|
| 8 |
const audioRef = useRef(null);
|
| 9 |
const videoRef = useRef(null);
|
| 10 |
const [err, setErr] = useState(null);
|
| 11 |
+
const busyRef = useRef(false); // gate play/pause race
|
| 12 |
+
const lastAppliedRef = useRef({ url: null });
|
| 13 |
|
| 14 |
const mediaType = useMemo(() => {
|
| 15 |
if (!state?.track?.url) return 'none';
|
| 16 |
if (state?.track?.kind) return state.track.kind;
|
| 17 |
+
return detectMediaTypeFromUrl(state.track.url) || 'audio';
|
|
|
|
| 18 |
}, [state?.track?.url, state?.track?.kind]);
|
| 19 |
|
| 20 |
const logicalTime = () => {
|
|
|
|
| 25 |
return Math.max(0, (anchor || 0) + elapsed);
|
| 26 |
};
|
| 27 |
|
| 28 |
+
const applyMediaState = async (el) => {
|
| 29 |
if (!el || !state?.track?.url) return;
|
| 30 |
+
if (busyRef.current) return;
|
| 31 |
+
busyRef.current = true;
|
| 32 |
try {
|
| 33 |
+
// set src only when url changes
|
| 34 |
+
if (lastAppliedRef.current.url !== state.track.url) {
|
| 35 |
+
el.src = state.track.url;
|
| 36 |
+
el.load();
|
| 37 |
+
lastAppliedRef.current.url = state.track.url;
|
| 38 |
+
}
|
| 39 |
const target = logicalTime();
|
| 40 |
+
if (Number.isFinite(target)) {
|
| 41 |
+
try { el.currentTime = Math.max(0, target); } catch {}
|
| 42 |
+
}
|
| 43 |
+
if (state.isPlaying) {
|
| 44 |
+
await el.play().catch(() => {}); // ignore gesture errors
|
| 45 |
+
} else {
|
| 46 |
+
el.pause();
|
| 47 |
+
}
|
| 48 |
setErr(null);
|
| 49 |
} catch (e) {
|
| 50 |
+
log.error('Playback error', e);
|
| 51 |
setErr(e?.message || 'Playback failed');
|
| 52 |
push(`Playback failed: ${e?.message || 'Unknown error'}`, 'bad', 4500);
|
| 53 |
+
} finally {
|
| 54 |
+
busyRef.current = false;
|
| 55 |
}
|
| 56 |
};
|
| 57 |
|
| 58 |
+
// On track/url or media type change
|
| 59 |
useEffect(() => {
|
| 60 |
+
lastAppliedRef.current.url = null; // force src reload
|
| 61 |
+
const el = mediaType === 'video' ? videoRef.current : audioRef.current;
|
| 62 |
+
if (el) applyMediaState(el);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 64 |
}, [state?.track?.url, mediaType]);
|
| 65 |
|
| 66 |
+
// On play/pause/seek drift
|
| 67 |
useEffect(() => {
|
|
|
|
| 68 |
const el = mediaType === 'video' ? videoRef.current : audioRef.current;
|
| 69 |
if (!el) return;
|
| 70 |
+
// drift correction without causing play/pause races
|
| 71 |
const target = logicalTime();
|
| 72 |
+
const delta = target - el.currentTime;
|
| 73 |
+
if (Math.abs(delta) > 0.35) {
|
| 74 |
+
try { el.currentTime = Math.max(0, target); } catch {}
|
| 75 |
+
}
|
| 76 |
+
// only adjust if needed
|
| 77 |
+
if (state.isPlaying && el.paused) {
|
| 78 |
+
el.play().catch(() => {});
|
| 79 |
+
} else if (!state.isPlaying && !el.paused) {
|
| 80 |
+
el.pause();
|
| 81 |
+
}
|
| 82 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 83 |
+
}, [state.isPlaying, state.anchor, state.anchorAt]);
|
| 84 |
|
| 85 |
useEffect(() => {
|
| 86 |
const a = audioRef.current;
|
|
|
|
| 99 |
|
| 100 |
return (
|
| 101 |
<div style={{ position:'relative' }}>
|
| 102 |
+
{/* DJ ambient + hero artwork */}
|
| 103 |
+
<div
|
| 104 |
+
className="hero-thumb"
|
| 105 |
+
style={{ backgroundImage: thumb ? `url("${thumb}")` : undefined }}
|
| 106 |
+
aria-hidden
|
| 107 |
+
/>
|
| 108 |
<div className="ambient"></div>
|
| 109 |
|
| 110 |
+
<div className="now-playing" style={{ marginBottom: 10, position:'relative' }}>
|
| 111 |
+
<div className="thumb" style={{ width:72, height:72, backgroundImage: thumb ? `url("${thumb}")` : undefined }} />
|
| 112 |
<div style={{ minWidth:0, flex:1 }}>
|
| 113 |
<div style={{ fontWeight:700, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{title}</div>
|
| 114 |
<div style={{ fontSize:12, color:'var(--muted)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
|
|
|
| 118 |
{!isHost && <div className="tag">Listener</div>}
|
| 119 |
</div>
|
| 120 |
|
| 121 |
+
{mediaType === 'video' ? (
|
|
|
|
|
|
|
| 122 |
<video ref={videoRef} controls={isHost} playsInline style={{ width:'100%', maxHeight: '56vh', background:'#000' }} crossOrigin="anonymous" />
|
| 123 |
) : (
|
| 124 |
<audio ref={audioRef} controls={isHost} style={{ width: '100%' }} crossOrigin="anonymous" />
|