feat: auth & admin
This commit is contained in:
@@ -1,6 +1,17 @@
|
||||
import { IconButton, Slider, Tooltip } from 'modern-sk';
|
||||
import { Slider } from 'modern-sk';
|
||||
import { Icon } from '../common/Icon';
|
||||
import { ArtTile } from '../common/ArtTile';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import { pause, resume, toggleMute, setVolume, toggleNowPlaying, toggleQueue } from '../../store/slices/player';
|
||||
import {
|
||||
pause,
|
||||
resume,
|
||||
toggleMute,
|
||||
setVolume,
|
||||
toggleShuffle,
|
||||
setRepeat,
|
||||
toggleNowPlaying,
|
||||
toggleQueue,
|
||||
} from '../../store/slices/player';
|
||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||
@@ -13,94 +24,140 @@ export function PersistentPlayer() {
|
||||
const currentEntry = queue.entries[queue.currentIndex];
|
||||
|
||||
if (!currentEntry && !player.currentTrackId) {
|
||||
return (
|
||||
<div style={{ height: '4rem', borderTop: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 1.5rem', color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
|
||||
Nothing playing
|
||||
</div>
|
||||
);
|
||||
return <div className="player empty">Nothing playing</div>;
|
||||
}
|
||||
|
||||
const artUrl = currentEntry?.albumArtUrl ? getCoverUrl(currentEntry.albumArtUrl) : undefined;
|
||||
const progressPercent = player.duration > 0 ? (player.position / player.duration) * 100 : 0;
|
||||
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
|
||||
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
|
||||
// Streaming is the web default; local playback is a mobile-client concern.
|
||||
const onStream = true;
|
||||
|
||||
return (
|
||||
<div style={{ height: '4rem', borderTop: '1px solid var(--color-border)', display: 'grid', gridTemplateColumns: '1fr auto 1fr', alignItems: 'center', padding: '0 1rem', gap: '1rem', background: 'var(--color-surface-1)' }}>
|
||||
{/* track info */}
|
||||
<div className="player">
|
||||
{/* now-playing identity */}
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', minWidth: 0, cursor: 'pointer' }}
|
||||
className="pl-now"
|
||||
onClick={() => dispatch(toggleNowPlaying())}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{artUrl ? (
|
||||
<img src={artUrl} alt="" width={40} height={40} style={{ borderRadius: 4, objectFit: 'cover', flexShrink: 0 }} />
|
||||
) : (
|
||||
<div style={{ width: 40, height: 40, borderRadius: 4, background: 'var(--color-surface-3)', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
|
||||
{currentEntry?.title ?? '—'}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{currentEntry?.artistName ?? ''}
|
||||
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
|
||||
<div className="pl-now-tt">
|
||||
<div className="t">{currentEntry?.title ?? '—'}</div>
|
||||
<div className="a">{currentEntry?.artistName ?? ''}</div>
|
||||
<div
|
||||
className="pl-srcbadge"
|
||||
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
|
||||
>
|
||||
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
|
||||
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* controls + scrubber */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem', minWidth: '20rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<IconButton variant="ghost" size="sm" onClick={playPrev} aria-label="Previous">⏮</IconButton>
|
||||
<IconButton
|
||||
variant="primary"
|
||||
onClick={() => player.isPlaying ? dispatch(pause()) : dispatch(resume())}
|
||||
aria-label={player.isPlaying ? 'Pause' : 'Play'}
|
||||
{/* transport + scrubber */}
|
||||
<div className="pl-center">
|
||||
<div className="pl-transport">
|
||||
<button
|
||||
type="button"
|
||||
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
|
||||
onClick={() => dispatch(toggleShuffle())}
|
||||
title="Shuffle"
|
||||
>
|
||||
{player.isPlaying ? '⏸' : '▶'}
|
||||
</IconButton>
|
||||
<IconButton variant="ghost" size="sm" onClick={playNext} aria-label="Next">⏭</IconButton>
|
||||
<Icon name="shuffle" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pl-tbtn"
|
||||
onClick={playPrev}
|
||||
title="Previous"
|
||||
>
|
||||
<Icon name="skip-back" fill />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pl-play"
|
||||
onClick={() =>
|
||||
player.isPlaying ? dispatch(pause()) : dispatch(resume())
|
||||
}
|
||||
title={player.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pl-tbtn"
|
||||
onClick={playNext}
|
||||
title="Next"
|
||||
>
|
||||
<Icon name="skip-forward" fill />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`pl-tbtn${player.repeat !== 'none' ? ' on' : ''}`}
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setRepeat(
|
||||
player.repeat === 'none'
|
||||
? 'all'
|
||||
: player.repeat === 'all'
|
||||
? 'one'
|
||||
: 'none',
|
||||
),
|
||||
)
|
||||
}
|
||||
title={`Repeat: ${player.repeat}`}
|
||||
>
|
||||
<Icon name="repeat" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-3)', minWidth: '2.5rem', textAlign: 'right' }}>
|
||||
<div className="pl-seek">
|
||||
<span className="pl-time">
|
||||
{formatDuration(player.position * 1000)}
|
||||
</span>
|
||||
<Slider
|
||||
className="pl-seek-slider"
|
||||
min={0}
|
||||
max={player.duration || 1}
|
||||
step={1}
|
||||
value={[player.position]}
|
||||
onValueChange={([v]) => seek(v)}
|
||||
style={{ flex: 1 }}
|
||||
aria-label="Seek"
|
||||
/>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-3)', minWidth: '2.5rem' }}>
|
||||
<span className="pl-time">
|
||||
{formatDuration(player.duration * 1000)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* volume + queue */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<Tooltip content={player.muted ? 'Unmute' : 'Mute'}>
|
||||
<IconButton variant="ghost" size="sm" onClick={() => dispatch(toggleMute())} aria-label="Toggle mute">
|
||||
{player.muted ? '🔇' : '🔊'}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={[player.muted ? 0 : player.volume]}
|
||||
onValueChange={([v]) => dispatch(setVolume(v))}
|
||||
style={{ width: '6rem' }}
|
||||
/>
|
||||
<Tooltip content="Queue">
|
||||
<IconButton variant={player.isQueueOpen ? 'primary' : 'ghost'} size="sm" onClick={() => dispatch(toggleQueue())} aria-label="Toggle queue">
|
||||
≡
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* progress bar at bottom */}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'var(--color-surface-3)' }}>
|
||||
<div style={{ width: `${progressPercent}%`, height: '100%', background: 'var(--color-accent)', transition: 'width 0.5s linear' }} />
|
||||
<div className="pl-right">
|
||||
<button
|
||||
type="button"
|
||||
className="pl-tbtn"
|
||||
onClick={() => dispatch(toggleMute())}
|
||||
title={player.muted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
|
||||
</button>
|
||||
<div className="pl-vol">
|
||||
<Slider
|
||||
className="pl-vol-slider"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={[player.muted ? 0 : player.volume]}
|
||||
onValueChange={([v]) => dispatch(setVolume(v))}
|
||||
aria-label="Volume"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
|
||||
onClick={() => dispatch(toggleQueue())}
|
||||
title="Play queue"
|
||||
>
|
||||
<Icon name="queue" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,60 +1,169 @@
|
||||
import { ScrollArea, IconButton, Badge } from 'modern-sk';
|
||||
import { Slider, Badge } from 'modern-sk';
|
||||
import { Icon } from '../common/Icon';
|
||||
import { ArtTile } from '../common/ArtTile';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import { goToIndex, removeFromQueue, clearQueue } from '../../store/slices/queue';
|
||||
import {
|
||||
goToIndex,
|
||||
removeFromQueue,
|
||||
clearQueue,
|
||||
} from '../../store/slices/queue';
|
||||
import { toggleQueue } from '../../store/slices/player';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
|
||||
export function QueuePanel() {
|
||||
const dispatch = useAppDispatch();
|
||||
const queue = useAppSelector((s) => s.queue);
|
||||
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const now =
|
||||
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
|
||||
const upNext = queue.entries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ index }) => index > queue.currentIndex);
|
||||
const isRadio = queue.source === 'radio';
|
||||
const sourceLabel = queue.sourceName ?? queue.source;
|
||||
|
||||
return (
|
||||
<div style={{ width: '20rem', borderLeft: '1px solid var(--color-border)', display: 'flex', flexDirection: 'column', background: 'var(--color-surface-1)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.75rem 1rem', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>Queue</span>
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
{queue.sourceName && <Badge variant="neutral">{queue.sourceName}</Badge>}
|
||||
<IconButton variant="ghost" size="sm" onClick={() => dispatch(clearQueue())} aria-label="Clear queue">✕</IconButton>
|
||||
<IconButton variant="ghost" size="sm" onClick={() => dispatch(toggleQueue())} aria-label="Close">✕</IconButton>
|
||||
<aside className={`qd${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
|
||||
<div className="qd-inner">
|
||||
<div className="qd-head">
|
||||
<div className="row">
|
||||
<h3>Play queue</h3>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(clearQueue())}
|
||||
title="Clear queue"
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(toggleQueue())}
|
||||
title="Close"
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="qd-src">
|
||||
<Icon
|
||||
name={isRadio ? 'radio' : 'playlist'}
|
||||
style={{ color: isRadio ? 'var(--lime)' : 'var(--fg-3)' }}
|
||||
/>
|
||||
{isRadio ? (
|
||||
<span style={{ color: 'var(--lime)' }}>
|
||||
Radio · {sourceLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span>From {sourceLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="qd-scroll">
|
||||
{now ? (
|
||||
<>
|
||||
<span
|
||||
className="msk-label"
|
||||
style={{ display: 'block', marginBottom: 8 }}
|
||||
>
|
||||
Now playing
|
||||
</span>
|
||||
<div className="qd-now">
|
||||
<ArtTile
|
||||
seed={now.albumTitle}
|
||||
size={44}
|
||||
label={now.albumTitle}
|
||||
/>
|
||||
<div className="qt">
|
||||
<div className="t">{now.title}</div>
|
||||
<div className="r">{now.artistName}</div>
|
||||
</div>
|
||||
<Icon name="cloud" style={{ color: 'var(--fg-3)' }} />
|
||||
</div>
|
||||
|
||||
{isRadio && (
|
||||
<div className="qd-radio">
|
||||
<div className="row">
|
||||
<Icon name="radio" />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--fg-1)',
|
||||
}}
|
||||
>
|
||||
Radio active
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Badge variant="neutral">∞ mixing</Badge>
|
||||
</div>
|
||||
{/* exploration balance — stub under the future ML contract */}
|
||||
<div className="expl">
|
||||
<span className="lab">Familiar</span>
|
||||
<Slider
|
||||
className="expl-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={[42]}
|
||||
aria-label="Exploration"
|
||||
/>
|
||||
<span className="lab">New</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className="msk-label"
|
||||
style={{ display: 'block', margin: '4px 0 8px' }}
|
||||
>
|
||||
Next up
|
||||
</span>
|
||||
{upNext.length === 0 ? (
|
||||
<div className="qd-empty">Nothing queued next</div>
|
||||
) : (
|
||||
upNext.map(({ entry, index }) => (
|
||||
<div
|
||||
key={`${entry.trackId}-${index}`}
|
||||
className="qrow"
|
||||
onDoubleClick={() => dispatch(goToIndex(index))}
|
||||
title="Double-click to play"
|
||||
>
|
||||
<span className="grip">
|
||||
<Icon name="dots-six-vertical" />
|
||||
</span>
|
||||
<ArtTile
|
||||
seed={entry.albumTitle}
|
||||
size={36}
|
||||
label={entry.albumTitle}
|
||||
/>
|
||||
<div className="qt">
|
||||
<div className="t">{entry.title}</div>
|
||||
<div className="r">{entry.artistName}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(removeFromQueue(index))}
|
||||
title="Remove from queue"
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{isRadio && (
|
||||
<div className="qd-loadmore">Loading more from radio…</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="qd-empty">Queue is empty</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
{queue.entries.length === 0 ? (
|
||||
<p style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-3)', fontSize: '0.875rem' }}>Queue is empty</p>
|
||||
) : (
|
||||
queue.entries.map((entry, i) => (
|
||||
<div
|
||||
key={`${entry.trackId}-${i}`}
|
||||
onDoubleClick={() => dispatch(goToIndex(i))}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
padding: '0.5rem 1rem',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
background: i === queue.currentIndex ? 'var(--color-surface-2)' : undefined,
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.8125rem', fontWeight: i === queue.currentIndex ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: i === queue.currentIndex ? 'var(--color-accent)' : 'var(--color-text-1)' }}>
|
||||
{entry.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{entry.artistName}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>{formatDuration(entry.durationMs)}</span>
|
||||
<IconButton variant="ghost" size="sm" onClick={() => dispatch(removeFromQueue(i))} aria-label="Remove from queue">✕</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user