1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00
archivr/frontend/src/components/AudioBar.jsx
TheGeneralist 5de9e291b0
feat: entry previews (video, tweet, article, iframe, image, audio bar)
- 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
2026-07-11 18:29:36 +02:00

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>
</>
);
}