Files
psysonic/src/components/MobilePlayerView.tsx
T
cucadmuh db98d30a78 fix(playback): cross-server browse, Lucky Mix, and Now Playing (#768)
* fix(playback): keep browsed server when queue plays elsewhere

Lucky Mix on a non-playback server now clears the old queue and pins
the active server before building. Now Playing metadata uses
apiForServer against the queue server instead of forcing
ensurePlaybackServerActive on every activeServerId change.

* docs: note PR #768 in CHANGELOG and settings credits

* fix(now-playing): gate AudioMuse similar artists on playback server

Match getArtistInfoForServer credentials: when browsing another server
while the queue plays elsewhere, similar-artist count follows the queue
server's AudioMuse flag, not the browsed server.
2026-05-18 11:40:46 +03:00

458 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { star, unstar } from '../api/subsonicStarRating';
import { usePlaybackCoverArt } from '../hooks/usePlaybackCoverArt';
import type { Track } from '../store/playerStoreTypes';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../store/playbackProgress';
import React, { useState, useCallback, useMemo, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate';
import { useTranslation } from 'react-i18next';
import {
ChevronDown, Play, Pause, SkipBack, SkipForward,
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
Moon, Sunrise,
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useCachedUrl } from './CachedImage';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { formatTrackTime } from '../utils/format/formatDuration';
import LyricsPane from './LyricsPane';
import { usePlaybackDelayPress } from '../hooks/usePlaybackDelayPress';
import PlaybackDelayModal from './PlaybackDelayModal';
import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
// ── 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.20.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;
}
// ── 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">{formatTrackTime(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();
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
// 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 playbackProgress = useSyncExternalStore(
onStoreChange => subscribePlaybackProgress(() => onStoreChange()),
getPlaybackProgressSnapshot,
getPlaybackProgressSnapshot,
);
const progress = playbackProgress.progress;
const currentTime = playbackProgress.currentTime;
const togglePlay = usePlayerStore(s => s.togglePlay);
const { delayModalOpen, setDelayModalOpen, playPauseBind } = usePlaybackDelayPress(togglePlay);
const transportAnchorRef = useRef<HTMLDivElement>(null);
const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
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;
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(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 pendingSeekRef = useRef<number | null>(null);
const [previewProgress, setPreviewProgress] = useState<number | null>(null);
const setPreviewSeek = useCallback((pct: number) => {
pendingSeekRef.current = pct;
setPreviewProgress(pct);
}, []);
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));
setPreviewSeek(pct);
}, [setPreviewSeek]);
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 = () => {
if (!isDragging.current) return;
isDragging.current = false;
const pending = pendingSeekRef.current;
pendingSeekRef.current = null;
setPreviewProgress(null);
if (pending !== null) seek(pending);
};
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]);
useEffect(() => {
pendingSeekRef.current = null;
setPreviewProgress(null);
}, [currentTrack?.id]);
// 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%)`,
};
const effectiveProgress = previewProgress ?? progress;
const effectiveTime =
previewProgress !== null && duration > 0
? previewProgress * duration
: currentTime;
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">
{currentTrack.artists && currentTrack.artists.length > 0 ? (
<OpenArtistRefInline
refs={currentTrack.artists}
fallbackName={currentTrack.artist}
onGoArtist={id => { void navigatePlaybackLibrary(`/artist/${id}`); }}
as="none"
linkTag="span"
linkClassName="mp-artist-link"
/>
) : (
<span
role={currentTrack.artistId ? 'link' : undefined}
tabIndex={currentTrack.artistId ? 0 : undefined}
onClick={() => currentTrack.artistId && void navigatePlaybackLibrary(`/artist/${currentTrack.artistId}`)}
onKeyDown={e => {
if (!currentTrack.artistId) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
void navigatePlaybackLibrary(`/artist/${currentTrack.artistId}`);
}
}}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
>
{currentTrack.artist}
</span>
)}
</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: `${effectiveProgress * 100}%` }} />
<div className="mp-scrubber-thumb" style={{ left: `${effectiveProgress * 100}%` }} />
</div>
<div className="mp-scrubber-times">
<span>{formatTrackTime(effectiveTime)}</span>
<span>-{formatTrackTime(Math.max(0, duration - effectiveTime))}</span>
</div>
</div>
{/* Transport Controls */}
<div className="mp-controls" ref={transportAnchorRef}>
<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>
<span className="playback-transport-play-wrap" ref={playSlotRef}>
<PlaybackScheduleBadge layoutAnchorRef={playSlotRef} />
<button
className="mp-ctrl-btn mp-ctrl-play"
type="button"
{...playPauseBind}
aria-label={isPlaying ? t('player.pause') : t('player.play')}
>
{scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode} player-btn-schedule-stack--mobile`}>
{scheduleRemaining.mode === 'pause'
? <Moon size={13} strokeWidth={2.5} />
: <Sunrise size={13} strokeWidth={2.5} />}
<span className="player-btn-schedule-time player-btn-schedule-time--mobile">{scheduleRemaining.remaining}</span>
</span>
) : isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" />}
</button>
</span>
<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} />}
<PlaybackDelayModal open={delayModalOpen} onClose={() => setDelayModalOpen(false)} anchorRef={transportAnchorRef} />
</div>
);
}