Files
rsspipe/web/src/components/mobile/DockedAudioPlayer.tsx
T

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