refactor: implement new UI/UX architecture with redesigned desktop and mobile layouts, CSS-in-CSS modularization, and updated components.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user