Spaces:
Sleeping
Sleeping
Update client/src/Player.jsx
Browse files- client/src/Player.jsx +70 -128
client/src/Player.jsx
CHANGED
|
@@ -1,123 +1,39 @@
|
|
| 1 |
-
|
| 2 |
-
import
|
| 3 |
-
import {
|
| 4 |
import { useToasts } from './Toasts.jsx';
|
| 5 |
-
import { log } from './logger.js';
|
| 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 |
-
|
| 12 |
-
const [err, setErr] = useState(null);
|
| 13 |
const [useProxy, setUseProxy] = useState(false);
|
| 14 |
-
const busyRef = useRef(false);
|
| 15 |
-
const lastAppliedRef = useRef({ url: null });
|
| 16 |
-
|
| 17 |
-
const mediaType = useMemo(() => {
|
| 18 |
-
if (!state?.track?.url) return 'none';
|
| 19 |
-
if (state?.track?.kind) return state.track.kind;
|
| 20 |
-
return detectMediaTypeFromUrl(state.track.url) || 'audio';
|
| 21 |
-
}, [state?.track?.url, state?.track?.kind]);
|
| 22 |
-
|
| 23 |
-
const logicalTime = () => {
|
| 24 |
-
if (!state) return 0;
|
| 25 |
-
const { anchor = 0, anchorAt = 0, isPlaying = false } = state;
|
| 26 |
-
if (!isPlaying) return anchor;
|
| 27 |
-
const elapsed = (Date.now() - anchorAt) / 1000;
|
| 28 |
-
return Math.max(0, anchor + elapsed);
|
| 29 |
-
};
|
| 30 |
|
| 31 |
-
const
|
| 32 |
if (!state?.track?.url) return '';
|
| 33 |
-
return useProxy
|
|
|
|
|
|
|
| 34 |
}, [state?.track?.url, useProxy]);
|
| 35 |
|
| 36 |
-
const
|
| 37 |
-
|
| 38 |
-
if (busyRef.current) return;
|
| 39 |
-
busyRef.current = true;
|
| 40 |
-
try {
|
| 41 |
-
const marker = `${state.track.url}|${useProxy ? 'px' : 'dir'}`;
|
| 42 |
-
if (lastAppliedRef.current.url !== marker) {
|
| 43 |
-
el.src = currentSrc;
|
| 44 |
-
el.load();
|
| 45 |
-
lastAppliedRef.current.url = marker;
|
| 46 |
-
}
|
| 47 |
-
const target = logicalTime();
|
| 48 |
-
if (Number.isFinite(target)) {
|
| 49 |
-
try { el.currentTime = Math.max(0, target); } catch {}
|
| 50 |
-
}
|
| 51 |
-
if (state.isPlaying) {
|
| 52 |
-
await el.play().catch(() => {});
|
| 53 |
-
} else {
|
| 54 |
-
el.pause();
|
| 55 |
-
}
|
| 56 |
-
setErr(null);
|
| 57 |
-
} catch (e) {
|
| 58 |
-
log.error('Playback error', e);
|
| 59 |
-
setErr(e?.message || 'Playback failed');
|
| 60 |
-
push(`Playback failed: ${e?.message || 'Unknown error'}`, 'bad', 4500);
|
| 61 |
-
} finally {
|
| 62 |
-
busyRef.current = false;
|
| 63 |
-
}
|
| 64 |
-
};
|
| 65 |
-
|
| 66 |
-
// When track or type changes, reset proxy and apply
|
| 67 |
-
useEffect(() => {
|
| 68 |
-
setUseProxy(false);
|
| 69 |
-
lastAppliedRef.current.url = null;
|
| 70 |
-
const el = mediaType === 'video' ? videoRef.current : audioRef.current;
|
| 71 |
-
if (el) applyMediaState(el);
|
| 72 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 73 |
-
}, [state?.track?.url, mediaType]);
|
| 74 |
-
|
| 75 |
-
// Sync drift and play/pause
|
| 76 |
-
useEffect(() => {
|
| 77 |
-
const el = mediaType === 'video' ? videoRef.current : audioRef.current;
|
| 78 |
-
if (!el) return;
|
| 79 |
-
const target = logicalTime();
|
| 80 |
-
const delta = target - el.currentTime;
|
| 81 |
-
if (Math.abs(delta) > 0.35) {
|
| 82 |
-
try { el.currentTime = Math.max(0, target); } catch {}
|
| 83 |
-
}
|
| 84 |
-
if (state.isPlaying && el.paused) {
|
| 85 |
-
el.play().catch(() => {});
|
| 86 |
-
} else if (!state.isPlaying && !el.paused) {
|
| 87 |
-
el.pause();
|
| 88 |
-
}
|
| 89 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 90 |
-
}, [state.isPlaying, state.anchor, state.anchorAt]);
|
| 91 |
|
| 92 |
-
// Auto-advance
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
a?.addEventListener('ended', onEnded);
|
| 97 |
-
v?.addEventListener('ended', onEnded);
|
| 98 |
-
return () => {
|
| 99 |
-
a?.removeEventListener('ended', onEnded);
|
| 100 |
-
v?.removeEventListener('ended', onEnded);
|
| 101 |
-
};
|
| 102 |
-
}, [socket, roomId, isHost]);
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
if (!useProxy) {
|
| 107 |
setUseProxy(true);
|
| 108 |
-
|
| 109 |
-
const el = mediaType === 'video' ? videoRef.current : audioRef.current;
|
| 110 |
-
if (el) applyMediaState(el);
|
| 111 |
} else {
|
| 112 |
-
|
| 113 |
}
|
| 114 |
};
|
| 115 |
|
| 116 |
-
const title = state?.track ? safeTitle(state.track) : 'No track selected';
|
| 117 |
-
const thumb = state?.track?.thumb || getThumb(state?.track?.meta);
|
| 118 |
-
|
| 119 |
return (
|
| 120 |
-
<div style={{ position:'relative' }}>
|
| 121 |
<div
|
| 122 |
className="hero-thumb"
|
| 123 |
style={{ backgroundImage: thumb ? `url("${thumb}")` : undefined }}
|
|
@@ -125,46 +41,72 @@ export default function Player({ socket, roomId, state, isHost }) {
|
|
| 125 |
/>
|
| 126 |
<div className="ambient"></div>
|
| 127 |
|
| 128 |
-
<div className="now-playing" style={{ marginBottom: 10
|
| 129 |
-
<div
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
{!isHost && <div className="tag">Listener</div>}
|
| 137 |
</div>
|
| 138 |
|
| 139 |
-
{
|
| 140 |
-
<
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
onError={onMediaError}
|
| 144 |
controls={isHost}
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
crossOrigin="anonymous"
|
| 157 |
/>
|
| 158 |
) : (
|
| 159 |
-
<div style={{ color:'var(--muted)', padding:'12px 0' }}>
|
|
|
|
|
|
|
| 160 |
)}
|
| 161 |
|
| 162 |
{useProxy && (
|
| 163 |
-
<div style={{ marginTop:8, fontSize:12, color:'var(--muted)' }}>
|
| 164 |
Using proxy due to CORS on the original media URL.
|
| 165 |
</div>
|
| 166 |
)}
|
| 167 |
-
{err && <div style={{ marginTop:8, color:'var(--bad)' }}>{err}</div>}
|
| 168 |
</div>
|
| 169 |
);
|
| 170 |
}
|
|
|
|
| 1 |
+
import React, { useEffect, useMemo, useState } from 'react';
|
| 2 |
+
import ReactPlayer from 'react-player';
|
| 3 |
+
import { getThumb, safeTitle } from './utils.js';
|
| 4 |
import { useToasts } from './Toasts.jsx';
|
|
|
|
| 5 |
|
| 6 |
export default function Player({ socket, roomId, state, isHost }) {
|
| 7 |
const { push } = useToasts();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
const [useProxy, setUseProxy] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
+
const mediaUrl = useMemo(() => {
|
| 11 |
if (!state?.track?.url) return '';
|
| 12 |
+
return useProxy
|
| 13 |
+
? `/api/proxy?url=${encodeURIComponent(state.track.url)}`
|
| 14 |
+
: state.track.url;
|
| 15 |
}, [state?.track?.url, useProxy]);
|
| 16 |
|
| 17 |
+
const title = state?.track ? safeTitle(state.track) : 'No track selected';
|
| 18 |
+
const thumb = state?.track?.thumb || getThumb(state?.track?.meta);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
// Auto-advance when ended (host only)
|
| 21 |
+
const handleEnded = () => {
|
| 22 |
+
if (isHost) socket.emit('ended', { roomId });
|
| 23 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
const handleError = (e) => {
|
| 26 |
+
console.warn('ReactPlayer error', e);
|
| 27 |
if (!useProxy) {
|
| 28 |
setUseProxy(true);
|
| 29 |
+
push('CORS blocked direct URL, retrying via proxy…', 'warn', 3000);
|
|
|
|
|
|
|
| 30 |
} else {
|
| 31 |
+
push('Playback failed (even via proxy)', 'bad', 4000);
|
| 32 |
}
|
| 33 |
};
|
| 34 |
|
|
|
|
|
|
|
|
|
|
| 35 |
return (
|
| 36 |
+
<div style={{ position: 'relative' }}>
|
| 37 |
<div
|
| 38 |
className="hero-thumb"
|
| 39 |
style={{ backgroundImage: thumb ? `url("${thumb}")` : undefined }}
|
|
|
|
| 41 |
/>
|
| 42 |
<div className="ambient"></div>
|
| 43 |
|
| 44 |
+
<div className="now-playing" style={{ marginBottom: 10 }}>
|
| 45 |
+
<div
|
| 46 |
+
className="thumb"
|
| 47 |
+
style={{
|
| 48 |
+
width: 72,
|
| 49 |
+
height: 72,
|
| 50 |
+
backgroundImage: thumb ? `url("${thumb}")` : undefined
|
| 51 |
+
}}
|
| 52 |
+
/>
|
| 53 |
+
<div style={{ minWidth: 0, flex: 1 }}>
|
| 54 |
+
<div
|
| 55 |
+
style={{
|
| 56 |
+
fontWeight: 700,
|
| 57 |
+
overflow: 'hidden',
|
| 58 |
+
textOverflow: 'ellipsis',
|
| 59 |
+
whiteSpace: 'nowrap'
|
| 60 |
+
}}
|
| 61 |
+
>
|
| 62 |
+
{title}
|
| 63 |
+
</div>
|
| 64 |
+
<div
|
| 65 |
+
style={{
|
| 66 |
+
fontSize: 12,
|
| 67 |
+
color: 'var(--muted)',
|
| 68 |
+
overflow: 'hidden',
|
| 69 |
+
textOverflow: 'ellipsis',
|
| 70 |
+
whiteSpace: 'nowrap'
|
| 71 |
+
}}
|
| 72 |
+
>
|
| 73 |
+
{state?.track?.meta?.artists?.join?.(', ') ||
|
| 74 |
+
state?.track?.meta?.artist ||
|
| 75 |
+
state?.track?.meta?.album ||
|
| 76 |
+
state?.track?.kind?.toUpperCase()}
|
| 77 |
</div>
|
| 78 |
</div>
|
| 79 |
{!isHost && <div className="tag">Listener</div>}
|
| 80 |
</div>
|
| 81 |
|
| 82 |
+
{state?.track?.url ? (
|
| 83 |
+
<ReactPlayer
|
| 84 |
+
url={mediaUrl}
|
| 85 |
+
playing={state.isPlaying}
|
|
|
|
| 86 |
controls={isHost}
|
| 87 |
+
onEnded={handleEnded}
|
| 88 |
+
onError={handleError}
|
| 89 |
+
width="100%"
|
| 90 |
+
height={state.track.kind === 'video' ? '56vh' : '50px'}
|
| 91 |
+
config={{
|
| 92 |
+
file: {
|
| 93 |
+
attributes: {
|
| 94 |
+
crossOrigin: 'anonymous'
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
}}
|
|
|
|
| 98 |
/>
|
| 99 |
) : (
|
| 100 |
+
<div style={{ color: 'var(--muted)', padding: '12px 0' }}>
|
| 101 |
+
No track selected
|
| 102 |
+
</div>
|
| 103 |
)}
|
| 104 |
|
| 105 |
{useProxy && (
|
| 106 |
+
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--muted)' }}>
|
| 107 |
Using proxy due to CORS on the original media URL.
|
| 108 |
</div>
|
| 109 |
)}
|
|
|
|
| 110 |
</div>
|
| 111 |
);
|
| 112 |
}
|