mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat: v1.34.0 — Mobile UI Early Preview, Russian 2, macOS network fix
Co-authored-by: kilyabin <kilyabin@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect, CSSProperties } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChevronDown, Play, Pause, SkipBack, SkipForward,
|
||||
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
|
||||
} from 'lucide-react';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
import LyricsPane from './LyricsPane';
|
||||
|
||||
// ── Color extraction ──────────────────────────────────────────────────────────
|
||||
// Samples a 16×16 canvas to find the most vibrant (highest-saturation,
|
||||
// medium-dark) pixel. Returns an "R, G, B" string for use in rgba().
|
||||
|
||||
function extractVibrantColor(imageUrl: string): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 16;
|
||||
canvas.height = 16;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { resolve('0,0,0'); return; }
|
||||
ctx.drawImage(img, 0, 0, 16, 16);
|
||||
const { data } = ctx.getImageData(0, 0, 16, 16);
|
||||
let bestR = 0, bestG = 0, bestB = 0, bestScore = -1;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||
const max = Math.max(r, g, b) / 255;
|
||||
const min = Math.min(r, g, b) / 255;
|
||||
const l = (max + min) / 2;
|
||||
const s = max === min ? 0 : (max - min) / (l > 0.5 ? 2 - max - min : max + min);
|
||||
// Prefer saturated pixels in the medium-dark range (l 0.2–0.6)
|
||||
const score = s * (1 - Math.abs(l - 0.4));
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestR = r; bestG = g; bestB = b;
|
||||
}
|
||||
}
|
||||
resolve(`${bestR},${bestG},${bestB}`);
|
||||
};
|
||||
img.onerror = () => resolve('0,0,0');
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function useAlbumAccentColor(imageUrl: string): string {
|
||||
const [color, setColor] = useState('0,0,0');
|
||||
useEffect(() => {
|
||||
if (!imageUrl) { setColor('0,0,0'); return; }
|
||||
let cancelled = false;
|
||||
extractVibrantColor(imageUrl).then(c => { if (!cancelled) setColor(c); });
|
||||
return () => { cancelled = true; };
|
||||
}, [imageUrl]);
|
||||
return color;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Queue Drawer ──────────────────────────────────────────────────────────────
|
||||
|
||||
function QueueDrawer({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const queueIndex = usePlayerStore(s => s.queueIndex);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll active track into view on open
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector('.mq-item.active');
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mq-drawer-backdrop" onClick={onClose}>
|
||||
<div className="mq-drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="mq-drawer-header">
|
||||
<h3>{t('queue.title')}</h3>
|
||||
<span className="mq-drawer-count">
|
||||
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')}
|
||||
</span>
|
||||
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mq-drawer-list" ref={listRef}>
|
||||
{queue.length === 0 ? (
|
||||
<div className="mq-drawer-empty">{t('queue.emptyQueue')}</div>
|
||||
) : (
|
||||
queue.map((track, idx) => {
|
||||
const isActive = idx === queueIndex;
|
||||
return (
|
||||
<div
|
||||
key={`${track.id}-${idx}`}
|
||||
className={`mq-item${isActive ? ' active' : ''}`}
|
||||
onClick={() => { playTrack(track, queue); onClose(); }}
|
||||
>
|
||||
<div className="mq-item-info">
|
||||
<div className="mq-item-title">
|
||||
{isActive && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
|
||||
<span className="truncate">{track.title}</span>
|
||||
</div>
|
||||
<div className="mq-item-artist truncate">{track.artist}</div>
|
||||
</div>
|
||||
<span className="mq-item-dur">{formatTime(track.duration)}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lyrics Drawer ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LyricsDrawer({ onClose, currentTrack }: { onClose: () => void; currentTrack: Track | null }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mq-drawer-backdrop" onClick={onClose}>
|
||||
<div className="mq-drawer mq-drawer-lyrics" onClick={e => e.stopPropagation()}>
|
||||
<div className="mq-drawer-header">
|
||||
<h3>{t('player.lyrics')}</h3>
|
||||
<button className="mq-drawer-close" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mq-drawer-list">
|
||||
<LyricsPane currentTrack={currentTrack} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mobile Player View ────────────────────────────────────────────────────────
|
||||
|
||||
export default function MobilePlayerView() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Lock body scroll while full-screen player is mounted
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||
const progress = usePlayerStore(s => s.progress);
|
||||
const currentTime = usePlayerStore(s => s.currentTime);
|
||||
const togglePlay = usePlayerStore(s => s.togglePlay);
|
||||
const next = usePlayerStore(s => s.next);
|
||||
const previous = usePlayerStore(s => s.previous);
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const repeatMode = usePlayerStore(s => s.repeatMode);
|
||||
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
|
||||
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
|
||||
const duration = currentTrack?.duration ?? 0;
|
||||
|
||||
// Cover art
|
||||
const coverFetchUrl = useMemo(
|
||||
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '',
|
||||
[currentTrack?.coverArt]
|
||||
);
|
||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
|
||||
// Dynamic background color extracted from cover art
|
||||
const accentColor = useAlbumAccentColor(resolvedCover);
|
||||
|
||||
// Star / favorite
|
||||
const isStarred = currentTrack
|
||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||
: false;
|
||||
|
||||
const toggleStar = useCallback(async () => {
|
||||
if (!currentTrack) return;
|
||||
const nextVal = !isStarred;
|
||||
setStarredOverride(currentTrack.id, nextVal);
|
||||
try {
|
||||
if (nextVal) await star(currentTrack.id, 'song');
|
||||
else await unstar(currentTrack.id, 'song');
|
||||
} catch {
|
||||
setStarredOverride(currentTrack.id, !nextVal);
|
||||
}
|
||||
}, [currentTrack, isStarred, setStarredOverride]);
|
||||
|
||||
// Scrubber touch/mouse drag
|
||||
const scrubberRef = useRef<HTMLDivElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
|
||||
const seekFromX = useCallback((clientX: number) => {
|
||||
const el = scrubberRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
seek(pct);
|
||||
}, [seek]);
|
||||
|
||||
const onScrubStart = useCallback((clientX: number) => {
|
||||
isDragging.current = true;
|
||||
seekFromX(clientX);
|
||||
}, [seekFromX]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: TouchEvent | MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
seekFromX(clientX);
|
||||
};
|
||||
const onEnd = () => { isDragging.current = false; };
|
||||
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onEnd);
|
||||
window.addEventListener('touchmove', onMove, { passive: true });
|
||||
window.addEventListener('touchend', onEnd);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onEnd);
|
||||
window.removeEventListener('touchmove', onMove);
|
||||
window.removeEventListener('touchend', onEnd);
|
||||
};
|
||||
}, [seekFromX]);
|
||||
|
||||
// Drawers
|
||||
const [showQueue, setShowQueue] = useState(false);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
|
||||
// ── Empty state ──
|
||||
if (!currentTrack) {
|
||||
return (
|
||||
<div className="mp-view">
|
||||
<div className="mp-header">
|
||||
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
|
||||
<div style={{ width: 44 }} />
|
||||
</div>
|
||||
<div className="mp-empty">
|
||||
<Music size={56} style={{ opacity: 0.25 }} />
|
||||
<p>{t('nowPlaying.nothingPlaying')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bgStyle: CSSProperties = {
|
||||
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mp-view" style={bgStyle}>
|
||||
{/* Header */}
|
||||
<div className="mp-header">
|
||||
<button className="mp-back" onClick={() => navigate(-1)} aria-label={t('player.back')}>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="mp-header-title">{t('sidebar.nowPlaying')}</span>
|
||||
<div style={{ width: 44 }} />
|
||||
</div>
|
||||
|
||||
{/* Cover Art */}
|
||||
<div className="mp-cover-wrap">
|
||||
{resolvedCover ? (
|
||||
<img src={resolvedCover} alt="" className="mp-cover" />
|
||||
) : (
|
||||
<div className="mp-cover mp-cover-fallback">
|
||||
<Music size={64} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track Metadata */}
|
||||
<div className="mp-meta">
|
||||
<div className="mp-meta-text">
|
||||
<div className="mp-title truncate">{currentTrack.title}</div>
|
||||
<div
|
||||
className="mp-artist truncate"
|
||||
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
|
||||
>
|
||||
{currentTrack.artist}
|
||||
</div>
|
||||
{(() => {
|
||||
const parts = [
|
||||
currentTrack.year,
|
||||
currentTrack.genre,
|
||||
currentTrack.suffix?.toUpperCase(),
|
||||
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0
|
||||
? <div className="mp-track-info truncate">{parts.join(' • ')}</div>
|
||||
: null;
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
className={`mp-heart${isStarred ? ' active' : ''}`}
|
||||
onClick={toggleStar}
|
||||
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
|
||||
>
|
||||
<Heart size={22} fill={isStarred ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrubber */}
|
||||
<div className="mp-scrubber-wrap">
|
||||
<div
|
||||
className="mp-scrubber"
|
||||
ref={scrubberRef}
|
||||
onMouseDown={e => onScrubStart(e.clientX)}
|
||||
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
|
||||
>
|
||||
<div className="mp-scrubber-bg" />
|
||||
<div className="mp-scrubber-fill" style={{ width: `${progress * 100}%` }} />
|
||||
<div className="mp-scrubber-thumb" style={{ left: `${progress * 100}%` }} />
|
||||
</div>
|
||||
<div className="mp-scrubber-times">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="mp-controls">
|
||||
<button
|
||||
className="mp-ctrl-btn mp-ctrl-sm"
|
||||
onClick={() => shuffleQueue()}
|
||||
aria-label={t('queue.shuffle')}
|
||||
>
|
||||
<Shuffle size={20} />
|
||||
</button>
|
||||
<button className="mp-ctrl-btn" onClick={() => previous()} aria-label={t('player.prev')}>
|
||||
<SkipBack size={28} />
|
||||
</button>
|
||||
<button className="mp-ctrl-btn mp-ctrl-play" onClick={togglePlay} aria-label={isPlaying ? t('player.pause') : t('player.play')}>
|
||||
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
|
||||
</button>
|
||||
<button className="mp-ctrl-btn" onClick={() => next()} aria-label={t('player.next')}>
|
||||
<SkipForward size={28} />
|
||||
</button>
|
||||
<button
|
||||
className={`mp-ctrl-btn mp-ctrl-sm`}
|
||||
onClick={toggleRepeat}
|
||||
aria-label={t('player.repeat')}
|
||||
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={20} /> : <Repeat size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Utility Footer */}
|
||||
<div className="mp-footer">
|
||||
<button className="mp-footer-btn" onClick={() => setShowLyrics(true)}>
|
||||
<MicVocal size={20} />
|
||||
<span>{t('player.lyrics')}</span>
|
||||
</button>
|
||||
<button className="mp-footer-btn" onClick={() => setShowQueue(true)}>
|
||||
<ListMusic size={20} />
|
||||
<span>{t('queue.title')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue Drawer */}
|
||||
{showQueue && <QueueDrawer onClose={() => setShowQueue(false)} />}
|
||||
|
||||
{/* Lyrics Drawer */}
|
||||
{showLyrics && <LyricsDrawer onClose={() => setShowLyrics(false)} currentTrack={currentTrack} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user