mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
- Add PreviewPanel dispatch hub routing by entity_kind + artifact extension - VideoPreview: HTML5 <video> for YouTube/Instagram/TikTok/Reddit/X posts - TweetPreview: tweet card, thread, and X article renderer (ported from x-article-renderer) - IframePreview: sandboxed iframe for SingleFile web pages and PDFs - ImagePreview: image viewer with click-to-open-fullsize - AudioBar: persistent fixed-bottom player (Spotify-style) that survives entry navigation - Lift entryDetail to App.jsx, shared between PreviewPanel and ContextRail - 3-column layout (workspace | 300px preview | 340px rail) when preview active - Stale-guard fixes: seq incremented before early returns in all async effects - handleRearchive: capture startSeq/entryUid at call time, guard every async resume - TweetPreview: reset loading/error/tweets before early-return branches
223 lines
6.1 KiB
JavaScript
223 lines
6.1 KiB
JavaScript
import { useEffect, useRef, useState } from 'react';
|
|
import { sourceIconSvg } from '../utils';
|
|
|
|
function formatTime(secs) {
|
|
if (!isFinite(secs) || isNaN(secs)) return '--:--';
|
|
const m = Math.floor(secs / 60);
|
|
const s = Math.floor(secs % 60);
|
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
|
|
export default function AudioBar({ entry, src, archiveId, onClose }) {
|
|
const audioRef = useRef(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(NaN);
|
|
const [volume, setVolume] = useState(1.0);
|
|
|
|
// Auto-play whenever src changes
|
|
useEffect(() => {
|
|
if (!audioRef.current || !src) return;
|
|
audioRef.current.load();
|
|
audioRef.current.play().then(() => {
|
|
setIsPlaying(true);
|
|
}).catch(() => {
|
|
// Autoplay blocked — silently remain paused
|
|
setIsPlaying(false);
|
|
});
|
|
setCurrentTime(0);
|
|
setDuration(NaN);
|
|
}, [src]);
|
|
|
|
// Sync volume to audio element
|
|
useEffect(() => {
|
|
if (audioRef.current) {
|
|
audioRef.current.volume = volume;
|
|
}
|
|
}, [volume]);
|
|
|
|
function handlePlayPause() {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
if (isPlaying) {
|
|
audio.pause();
|
|
} else {
|
|
audio.play().catch(() => {});
|
|
}
|
|
}
|
|
|
|
function handleSeek(e) {
|
|
const audio = audioRef.current;
|
|
if (!audio || !isFinite(duration)) return;
|
|
const t = Number(e.target.value);
|
|
audio.currentTime = t;
|
|
setCurrentTime(t);
|
|
}
|
|
|
|
function handleVolumeChange(e) {
|
|
setVolume(Number(e.target.value));
|
|
}
|
|
|
|
const title = entry?.title || entry?.entry_uid || 'Unknown';
|
|
const kind = entry?.source_kind || 'other';
|
|
|
|
return (
|
|
<>
|
|
{/* Hidden audio element */}
|
|
<audio
|
|
ref={audioRef}
|
|
src={src || undefined}
|
|
preload="auto"
|
|
onPlay={() => setIsPlaying(true)}
|
|
onPause={() => setIsPlaying(false)}
|
|
onEnded={() => setIsPlaying(false)}
|
|
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
|
|
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? NaN)}
|
|
onDurationChange={() => setDuration(audioRef.current?.duration ?? NaN)}
|
|
style={{ display: 'none' }}
|
|
/>
|
|
|
|
{/* Fixed bottom bar */}
|
|
<div
|
|
className="audio-bar"
|
|
style={{
|
|
position: 'fixed',
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 100,
|
|
background: 'var(--paper-3)',
|
|
borderTop: '1px solid var(--line)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '16px',
|
|
padding: '0 16px',
|
|
height: '56px',
|
|
fontFamily: 'var(--sans)',
|
|
}}
|
|
>
|
|
{/* Left: icon + title */}
|
|
<div
|
|
className="audio-bar-info"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
minWidth: 0,
|
|
flex: '0 1 220px',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<span
|
|
className="source-icon"
|
|
style={{ flexShrink: 0, width: '18px', height: '18px', display: 'flex', alignItems: 'center' }}
|
|
dangerouslySetInnerHTML={{ __html: sourceIconSvg(kind) }}
|
|
/>
|
|
<span
|
|
style={{
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
fontSize: '0.85rem',
|
|
color: 'var(--ink)',
|
|
}}
|
|
title={title}
|
|
>
|
|
{title}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Center: play/pause + seek + time */}
|
|
<div
|
|
className="audio-bar-controls"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '10px',
|
|
flex: '1 1 0',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<button
|
|
onClick={handlePlayPause}
|
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
padding: '4px',
|
|
color: 'var(--ink)',
|
|
flexShrink: 0,
|
|
fontSize: '1.2rem',
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{isPlaying ? '⏸' : '▶'}
|
|
</button>
|
|
|
|
<span
|
|
style={{ fontSize: '0.75rem', color: 'var(--muted)', whiteSpace: 'nowrap', flexShrink: 0 }}
|
|
>
|
|
{formatTime(currentTime)}
|
|
</span>
|
|
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={isFinite(duration) ? duration : 0}
|
|
step={0.1}
|
|
value={isFinite(currentTime) ? currentTime : 0}
|
|
onChange={handleSeek}
|
|
aria-label="Seek"
|
|
style={{ flex: 1, minWidth: 0, accentColor: 'var(--accent)' }}
|
|
/>
|
|
|
|
<span
|
|
style={{ fontSize: '0.75rem', color: 'var(--muted)', whiteSpace: 'nowrap', flexShrink: 0 }}
|
|
>
|
|
{formatTime(duration)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Right: volume + close */}
|
|
<div
|
|
className="audio-bar-right"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
flex: '0 1 160px',
|
|
}}
|
|
>
|
|
<span style={{ fontSize: '0.85rem', color: 'var(--muted)', flexShrink: 0 }}>🔊</span>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.01}
|
|
value={volume}
|
|
onChange={handleVolumeChange}
|
|
aria-label="Volume"
|
|
style={{ width: '80px', accentColor: 'var(--accent)' }}
|
|
/>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="Close audio player"
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
padding: '4px',
|
|
color: 'var(--muted)',
|
|
fontSize: '1rem',
|
|
lineHeight: 1,
|
|
marginLeft: '4px',
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|