242 lines
7.8 KiB
TypeScript
242 lines
7.8 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import { FeedEntry } from '../../types';
|
|
import {
|
|
Play,
|
|
Pause,
|
|
RotateCcw,
|
|
RotateCw,
|
|
X,
|
|
ChevronDown,
|
|
Volume2,
|
|
} from 'lucide-react';
|
|
|
|
interface DockedAudioPlayerProps {
|
|
entry: FeedEntry | null;
|
|
streamUrl: string | null;
|
|
isPlaying: boolean;
|
|
onTogglePlay: () => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const DockedAudioPlayer: React.FC<DockedAudioPlayerProps> = ({
|
|
entry,
|
|
streamUrl,
|
|
isPlaying,
|
|
onTogglePlay,
|
|
onClose,
|
|
}) => {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [playbackRate, setPlaybackRate] = useState(1);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!streamUrl) return;
|
|
if (!audioRef.current) {
|
|
audioRef.current = new Audio();
|
|
}
|
|
const audio = audioRef.current;
|
|
audio.src = streamUrl;
|
|
audio.playbackRate = playbackRate;
|
|
|
|
const updateTime = () => setCurrentTime(audio.currentTime);
|
|
const updateDuration = () => setDuration(audio.duration || 0);
|
|
const handleEnded = () => onTogglePlay();
|
|
|
|
audio.addEventListener('timeupdate', updateTime);
|
|
audio.addEventListener('loadedmetadata', updateDuration);
|
|
audio.addEventListener('ended', handleEnded);
|
|
|
|
if (isPlaying) {
|
|
audio.play().catch(() => {});
|
|
}
|
|
|
|
return () => {
|
|
audio.removeEventListener('timeupdate', updateTime);
|
|
audio.removeEventListener('loadedmetadata', updateDuration);
|
|
audio.removeEventListener('ended', handleEnded);
|
|
audio.pause();
|
|
};
|
|
}, [streamUrl]);
|
|
|
|
useEffect(() => {
|
|
if (!audioRef.current) return;
|
|
if (isPlaying) {
|
|
audioRef.current.play().catch(() => {});
|
|
} else {
|
|
audioRef.current.pause();
|
|
}
|
|
}, [isPlaying]);
|
|
|
|
useEffect(() => {
|
|
if (audioRef.current) {
|
|
audioRef.current.playbackRate = playbackRate;
|
|
}
|
|
}, [playbackRate]);
|
|
|
|
if (!entry || !streamUrl) return null;
|
|
|
|
const handleSeek = (newTime: number) => {
|
|
if (audioRef.current) {
|
|
audioRef.current.currentTime = newTime;
|
|
setCurrentTime(newTime);
|
|
}
|
|
};
|
|
|
|
const handleSkip = (seconds: number) => {
|
|
if (audioRef.current) {
|
|
const nextTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration));
|
|
audioRef.current.currentTime = nextTime;
|
|
setCurrentTime(nextTime);
|
|
}
|
|
};
|
|
|
|
const formatTime = (secs: number) => {
|
|
if (isNaN(secs)) return '0:00';
|
|
const m = Math.floor(secs / 60);
|
|
const s = Math.floor(secs % 60);
|
|
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
|
};
|
|
|
|
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
|
|
return (
|
|
<>
|
|
{/* Docked Mini Player (Fixed above Bottom Navigation Bar) */}
|
|
<div className="docked-audio-player" onClick={() => setIsExpanded(true)}>
|
|
<div className="docked-progress-line" style={{ width: `${progressPercent}%` }} />
|
|
|
|
<div className="docked-audio-info">
|
|
<Volume2 size={18} color="var(--badge-tts)" style={{ flexShrink: 0 }} />
|
|
<div className="docked-audio-title">{entry.title}</div>
|
|
</div>
|
|
|
|
<div className="docked-audio-controls" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: '28px', height: '28px', border: 'none', background: 'transparent' }}
|
|
onClick={() => handleSkip(-15)}
|
|
title="Rewind 15s"
|
|
>
|
|
<RotateCcw size={15} />
|
|
</button>
|
|
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: '32px', height: '32px', borderRadius: 'var(--radius-full)', background: 'var(--accent-primary)', color: 'var(--text-inverse)' }}
|
|
onClick={onTogglePlay}
|
|
title={isPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isPlaying ? <Pause size={16} /> : <Play size={16} style={{ marginLeft: 2 }} />}
|
|
</button>
|
|
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: '28px', height: '28px', border: 'none', background: 'transparent' }}
|
|
onClick={() => handleSkip(15)}
|
|
title="Forward 15s"
|
|
>
|
|
<RotateCw size={15} />
|
|
</button>
|
|
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: '24px', height: '24px', border: 'none', background: 'transparent' }}
|
|
onClick={onClose}
|
|
title="Close Player"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Full Player Sheet */}
|
|
{isExpanded && (
|
|
<div className="audio-expanded-modal" onClick={() => setIsExpanded(false)}>
|
|
<div className="audio-expanded-card" onClick={(e) => e.stopPropagation()}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<button
|
|
className="btn-icon"
|
|
style={{ border: 'none', background: 'transparent' }}
|
|
onClick={() => setIsExpanded(false)}
|
|
>
|
|
<ChevronDown size={22} />
|
|
</button>
|
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-muted)' }}>
|
|
Now Playing
|
|
</span>
|
|
<div style={{ width: 22 }} />
|
|
</div>
|
|
|
|
<div style={{ textAlign: 'center', margin: '0.5rem 0' }}>
|
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '0.35rem' }}>
|
|
{entry.title}
|
|
</h2>
|
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
|
{entry.feed_title}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrubber */}
|
|
<div>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={duration || 100}
|
|
value={currentTime}
|
|
onChange={(e) => handleSeek(parseFloat(e.target.value))}
|
|
className="audio-scrubber-slider"
|
|
/>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}>
|
|
<span>{formatTime(currentTime)}</span>
|
|
<span>{formatTime(duration)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Play Controls */}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1.5rem', margin: '0.5rem 0' }}>
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: '40px', height: '40px', border: 'none' }}
|
|
onClick={() => handleSkip(-15)}
|
|
>
|
|
<RotateCcw size={20} />
|
|
</button>
|
|
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: '56px', height: '56px', borderRadius: 'var(--radius-full)', background: 'var(--accent-primary)', color: 'var(--text-inverse)' }}
|
|
onClick={onTogglePlay}
|
|
>
|
|
{isPlaying ? <Pause size={26} /> : <Play size={26} style={{ marginLeft: 3 }} />}
|
|
</button>
|
|
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: '40px', height: '40px', border: 'none' }}
|
|
onClick={() => handleSkip(15)}
|
|
>
|
|
<RotateCw size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Speed Selector */}
|
|
<div className="audio-speed-chips">
|
|
{[0.75, 1.0, 1.25, 1.5, 2.0].map((rate) => (
|
|
<button
|
|
key={rate}
|
|
className={`audio-speed-chip ${playbackRate === rate ? 'active' : ''}`}
|
|
onClick={() => setPlaybackRate(rate)}
|
|
>
|
|
{rate}x
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|