refactor(components): co-locate remaining UI/feature components (TracklistColumnPicker+LosslessModeBanner->ui, fixedThemes->utils/themes, ThemeUpdateBanner/WindowButtonPreview/AboutPsysonicLol->settings, BottomNav/MobileMoreOverlay->sidebar, MobilePlayerView->nowPlaying, WhatsNewBanner->whatsNew)

This commit is contained in:
Psychotoxical
2026-06-30 19:07:46 +02:00
parent ba9e0d6541
commit 7d2645171b
26 changed files with 32 additions and 32 deletions
@@ -13,7 +13,7 @@ import {
import { useAlbumTrackListSelection } from '@/features/album/hooks/useAlbumTrackListSelection';
import { TrackRow } from '@/features/album/components/TrackRow';
import { AlbumTrackListMobile } from '@/features/album/components/AlbumTrackListMobile';
import { TracklistColumnPicker } from '@/components/albumTrackList/TracklistColumnPicker';
import { TracklistColumnPicker } from '@/ui/TracklistColumnPicker';
import { TracklistHeaderRow } from '@/features/album/components/TracklistHeaderRow';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
+1 -1
View File
@@ -42,7 +42,7 @@ import { albumArtistDisplayName, deriveAlbumHeaderArtistRefs } from '@/features/
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { albumGridWarmCovers } from '@/cover/layoutSizes';
import { VirtualCardGrid } from '@/ui/VirtualCardGrid';
import LosslessModeBanner from '@/components/LosslessModeBanner';
import LosslessModeBanner from '@/ui/LosslessModeBanner';
import { isLosslessSuffix } from '@/lib/library/losslessFormats';
import { isLosslessMode } from '@/lib/library/losslessMode';
import { readDetailServerId } from '@/utils/navigation/detailServerScope';
+1 -1
View File
@@ -29,7 +29,7 @@ import ArtistDetailHero from '@/features/artist/components/ArtistDetailHero';
import ArtistDetailTopTracks from '@/features/artist/components/ArtistDetailTopTracks';
import ArtistDetailSimilarArtists from '@/features/artist/components/ArtistDetailSimilarArtists';
import { ArtistCard } from '@/features/nowPlaying';
import LosslessModeBanner from '@/components/LosslessModeBanner';
import LosslessModeBanner from '@/ui/LosslessModeBanner';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { albumGridWarmCovers, COVER_DENSE_GRID_MIN_CELL_CSS_PX, GRID_COVER_WARM_LIMIT } from '@/cover/layoutSizes';
import { artistDetailCoverWarmAlbums } from '@/features/artist/components/topSongAlbumForCover';
@@ -1,7 +1,7 @@
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import FavoriteSongRow, { type FavoriteSongRowCallbacks } from '@/features/favorites/components/FavoriteSongRow';
import { TracklistColumnPicker } from '@/components/albumTrackList/TracklistColumnPicker';
import { TracklistColumnPicker } from '@/ui/TracklistColumnPicker';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { ColDef } from '@/utils/useTracklistColumns';
@@ -0,0 +1,495 @@
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
import { usePlaybackCoverArt } from '@/hooks/usePlaybackCoverArt';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import type { Track } from '@/lib/media/trackTypes';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/features/playback/store/playbackProgress';
import React, { useState, useCallback, useRef, useEffect, useSyncExternalStore, CSSProperties } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePlaybackLibraryNavigate } from '@/hooks/usePlaybackLibraryNavigate';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
ChevronDown, Play, Pause, SkipBack, SkipForward,
Shuffle, Repeat, Repeat1, Heart, Music, MicVocal, ListMusic, X,
Moon, Sunrise,
} from 'lucide-react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useCachedUrl } from '@/ui/CachedImage';
import { OpenArtistRefInline } from '@/features/artist';
import { formatTrackTime } from '@/lib/format/formatDuration';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import {
getQueueResolverVersion,
subscribeQueueResolver,
} from '@/features/playback/store/queueTrackResolver';
import LyricsPane from '@/components/LyricsPane';
import { usePlaybackDelayPress } from '@/hooks/usePlaybackDelayPress';
import PlaybackDelayModal from '@/features/playback/components/PlaybackDelayModal';
import PlaybackScheduleBadge from '@/features/playback/components/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(() => {
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
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 ──────────────────────────────────────────────────────────────
// Stable initial rect so the virtualizer never re-initializes on re-render (an
// inline literal would be a new ref each render → render loop). Replaced by the
// real height on first ResizeObserver measure.
const QUEUE_INITIAL_RECT = { width: 0, height: 600 };
function QueueDrawer({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const queue = usePlayerStore(s => s.queueItems);
const queueIndex = usePlayerStore(s => s.queueIndex);
const playTrack = usePlayerStore(s => s.playTrack);
const listRef = useRef<HTMLDivElement>(null);
// Thin-state: the queue is the canonical `QueueItemRef[]`; each row's Track
// comes from the resolver (cache → placeholder), matching the desktop
// QueueList. Subscribe once so rows re-render as the resolver cache fills.
useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion);
// Virtualize so a multi-thousand-track queue keeps DOM at O(visible rows) on
// mobile too (matches the desktop QueuePanel).
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
// eslint-disable-next-line react-hooks/incompatible-library
const rowVirtualizer = useVirtualizer({
count: queue.length,
getScrollElement: () => listRef.current,
estimateSize: () => 56,
overscan: 10,
getItemKey: i => `${queue[i].trackId}:${i}`,
initialRect: QUEUE_INITIAL_RECT,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
// Scroll the active track into view on open. Rows are uniform height, so the
// virtualizer's estimate lands the centred index accurately.
useEffect(() => {
if (queueIndex >= 0 && queue.length > 0) {
rowVirtualizer.scrollToIndex(queueIndex, { align: 'center' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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>
) : (
<div style={{ height: totalSize, width: '100%', position: 'relative' }}>
{virtualItems.map(vi => {
const idx = vi.index;
const track = resolveQueueTrack(queue[idx]);
const isActive = idx === queueIndex;
return (
<div
key={vi.key}
data-index={idx}
ref={rowVirtualizer.measureElement}
className={`mq-item${isActive ? ' active' : ''}`}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
onClick={() => { playTrack(track, undefined, undefined, undefined, idx); 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>
</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 duration = currentTrack?.duration ?? 0;
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(playbackCoverRef, 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(() => {
if (!currentTrack) return;
queueSongStar(currentTrack.id, !isStarred, currentTrack.serverId);
}, [currentTrack, isStarred]);
// 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, seek]);
useEffect(() => {
pendingSeekRef.current = null;
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
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>
);
}
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import PlaylistRow, { type PlaylistRowCallbacks } from '@/features/playlist/components/PlaylistRow';
import { TracklistColumnPicker } from '@/components/albumTrackList/TracklistColumnPicker';
import { TracklistColumnPicker } from '@/ui/TracklistColumnPicker';
import { useTranslation } from 'react-i18next';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useElementClientHeightById } from '@/lib/hooks/useResizeClientHeight';
@@ -0,0 +1,162 @@
import React, { useCallback, useEffect, useId, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import PsysonicLogo from '@/ui/PsysonicLogo';
const TAPS_TO_REVEAL_HINT = 10;
const TARGET_CLICKS_IN_WINDOW = 100;
const WINDOW_MS = 60_000;
/** Hardcoded About lol copy — intentionally not in locale files. */
const MSG_HINT =
'To become a developer, you need to click the Psysonic logo 100 times within one minute.';
const MSG_CONGRATS_TITLE = 'Congratulations.';
const MSG_CONGRATS_SIGN_OFF = 'Sincerely, your maintainers.';
const MSG_CONGRATS_PS = "PS: Don't forget to star the repo! ★";
/**
* About page brand row + Settings → System → About lol (logo taps + modal).
* Modal copy is English and hardcoded by design.
*/
export function AboutPsysonicBrandHeader({
appVersion,
aboutVersionLabel,
}: {
appVersion: string;
aboutVersionLabel: string;
}) {
const modalWordmarkGradSuffix = useId().replace(/:/g, '');
const [phase, setPhase] = useState<'idle' | 'hint' | 'done'>('idle');
const [, setIdleTaps] = useState(0);
const [, setHintTimestamps] = useState<number[]>([]);
const [overlayOpen, setOverlayOpen] = useState(false);
const onLogoClick = useCallback(() => {
if (phase === 'done') return;
if (phase === 'idle') {
setIdleTaps(prev => {
const next = prev + 1;
if (next >= TAPS_TO_REVEAL_HINT) queueMicrotask(() => setPhase('hint'));
return next;
});
return;
}
if (phase === 'hint') {
const now = Date.now();
setHintTimestamps(prev => {
const inWindow = prev.filter(t => t > now - WINDOW_MS);
const nextTimes = [...inWindow, now];
if (nextTimes.length >= TARGET_CLICKS_IN_WINDOW) {
queueMicrotask(() => {
setPhase('done');
setOverlayOpen(true);
});
}
return nextTimes;
});
}
}, [phase]);
const closeOverlay = useCallback(() => setOverlayOpen(false), []);
useEffect(() => {
if (!overlayOpen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prevOverflow;
};
}, [overlayOpen]);
return (
<>
<div className="settings-about-header">
<button
type="button"
onClick={onLogoClick}
className="about-psysonic-logo-lol-hit"
aria-label="Psysonic"
>
<img src="/logo-psysonic.png" width={52} height={52} alt="" decoding="async" style={{ borderRadius: 14, display: 'block' }} />
</button>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.25rem', fontWeight: 700, color: 'var(--text-primary)' }}>
Psysonic
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{aboutVersionLabel} {appVersion}
</div>
</div>
</div>
{phase === 'hint' && !overlayOpen && (
<p
style={{
fontSize: 12,
color: 'var(--text-secondary)',
marginTop: '0.5rem',
lineHeight: 1.5,
}}
>
{MSG_HINT}
</p>
)}
{overlayOpen &&
createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="about-psysonic-lol-title"
className="about-psysonic-lol-overlay"
>
<button
type="button"
className="about-psysonic-lol-close"
aria-label="Close"
onClick={closeOverlay}
>
<X size={26} strokeWidth={2.25} aria-hidden />
</button>
<div className="about-psysonic-lol-panel">
<div className="about-psysonic-lol-logo-slot">
<PsysonicLogo
gradientIdSuffix={modalWordmarkGradSuffix}
className="about-psysonic-lol-logo-mark"
style={{
height: 'clamp(3.25rem, 14vw, 5.75rem)',
width: 'auto',
maxWidth: 'min(100%, 420px)',
display: 'block',
}}
/>
</div>
<div className="about-psysonic-lol-copy">
<h2 id="about-psysonic-lol-title" className="about-psysonic-lol-title">
{MSG_CONGRATS_TITLE}
</h2>
<p className="about-psysonic-lol-lede">
{"We're very much looking forward to you as a developer — join us on "}
<button
type="button"
className="about-psysonic-lol-inline-link"
onClick={() => void openUrl('https://github.com/Psychotoxical/psysonic')}
>
GitHub
</button>
{' and build great features!'}
</p>
<p className="about-psysonic-lol-signoff">{MSG_CONGRATS_SIGN_OFF}</p>
<p className="about-psysonic-lol-ps">{MSG_CONGRATS_PS}</p>
</div>
</div>
</div>,
document.body,
)}
</>
);
}
@@ -17,7 +17,7 @@ import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField, SettingsValue } from '@/features/settings/components/SettingsSubCard';
import { SettingsSegmented, type SegmentedOption } from '@/features/settings/components/SettingsSegmented';
import { SeekbarPreview } from '@/features/waveform';
import WindowButtonPreview from '@/components/WindowButtonPreview';
import WindowButtonPreview from '@/features/settings/components/WindowButtonPreview';
export function AppearanceTab() {
const { t } = useTranslation();
@@ -9,7 +9,7 @@ import { useThemeUpdates } from '@/hooks/useThemeUpdates';
import { useThemeAnimationRisk } from '@/hooks/useThemeAnimationRisk';
import { showToast } from '@/utils/ui/toast';
import { AnimatedThemeBadge } from '@/features/settings/components/AnimatedThemeBadge';
import { FIXED_THEMES } from '@/components/settings/fixedThemes';
import { FIXED_THEMES } from '@/utils/themes/fixedThemes';
/** Pull a 3-band swatch (bg / card / accent) out of an installed theme's CSS. */
function swatch(css: string): { bg: string; card: string; accent: string } {
@@ -11,7 +11,7 @@ import { useAuthStore } from '@/store/authStore';
import type { ClockFormat, LinuxWaylandTextRenderProfile, LoggingMode } from '@/store/authStoreTypes';
import { IS_LINUX } from '@/lib/util/platform';
import { showToast } from '@/utils/ui/toast';
import { AboutPsysonicBrandHeader } from '@/components/AboutPsysonicLol';
import { AboutPsysonicBrandHeader } from '@/features/settings/components/AboutPsysonicLol';
import CustomSelect from '@/ui/CustomSelect';
import LicensesPanel from '@/features/settings/components/LicensesPanel';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
@@ -0,0 +1,70 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Paintbrush, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import { useThemeUpdates, themeUpdateSignature } from '@/hooks/useThemeUpdates';
interface Props {
collapsed?: boolean;
}
/**
* Sidebar pill shown above Now Playing while one or more installed community
* themes have a newer version in the store. Clicking opens Settings → Themes;
* X dismisses until a new update changes the set (see {@link themeUpdateSignature}).
*
* Sibling of {@link WhatsNewBanner}; reuses its `.whats-new-banner` styling.
*/
export default function ThemeUpdateBanner({ collapsed }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const updates = useThemeUpdates();
const lastDismissed = useAuthStore(s => s.lastDismissedThemeUpdateSig);
const setDismissed = useAuthStore(s => s.setLastDismissedThemeUpdateSig);
const count = updates.length;
const sig = themeUpdateSignature(updates);
if (count === 0 || sig === lastDismissed) return null;
const open = () => navigate('/settings', { state: { tab: 'themes' } });
const dismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setDismissed(sig);
};
if (collapsed) {
return (
<button
type="button"
className="whats-new-banner whats-new-banner--collapsed theme-update-banner--collapsed"
onClick={open}
data-tooltip={t('sidebar.themeUpdatesTooltip')}
data-tooltip-pos="bottom"
>
<Paintbrush size={16} />
<span className="theme-update-banner__count theme-update-banner__count--dot" aria-hidden>
{count > 9 ? '9+' : count}
</span>
</button>
);
}
return (
<button type="button" className="whats-new-banner theme-update-banner" onClick={open}>
<Paintbrush size={14} className="whats-new-banner__icon" />
<span className="whats-new-banner__text">
<span className="whats-new-banner__title">{t('sidebar.themeUpdatesTitle')}</span>
</span>
<span className="theme-update-banner__count" aria-hidden>{count}</span>
<span
className="whats-new-banner__dismiss"
role="button"
aria-label={t('sidebar.themeUpdatesDismiss')}
onClick={dismiss}
>
<X size={12} />
</span>
</button>
);
}
@@ -5,7 +5,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
import CustomSelect from '@/ui/CustomSelect';
import BackToTopButton from '@/ui/BackToTopButton';
import { FIXED_THEMES } from '@/components/settings/fixedThemes';
import { FIXED_THEMES } from '@/utils/themes/fixedThemes';
import { InstalledThemes } from '@/features/settings/components/InstalledThemes';
import { ThemeImportSection } from '@/features/settings/components/ThemeImportSection';
import { ThemeStoreSection } from '@/features/settings/components/ThemeStoreSection';
@@ -0,0 +1,69 @@
import React from 'react';
import { Minus, Square, X } from 'lucide-react';
import type { WindowButtonStyle } from '@/store/authStoreTypes';
interface Props {
style: WindowButtonStyle;
label: string;
selected: boolean;
onClick: () => void;
}
/**
* Selection tile for the custom-title-bar window-button style picker. Renders
* the real `.titlebar-controls` / `.titlebar-btn` classes inside a mini title
* bar so the preview is exactly what the chosen style produces.
*/
export default function WindowButtonPreview({ style, label, selected, onClick }: Props) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
style={{
border: `2px solid ${selected ? 'var(--accent)' : 'var(--bg-hover)'}`,
borderRadius: 8,
background: selected
? 'color-mix(in srgb, var(--accent) 12%, transparent)'
: 'var(--bg-card, var(--bg-app))',
padding: '10px 12px 8px',
cursor: 'pointer',
width: 130,
display: 'flex',
flexDirection: 'column',
gap: 6,
alignItems: 'stretch',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
minHeight: 34,
background: 'var(--bg-sidebar)',
border: '1px solid var(--border-subtle)',
borderRadius: 6,
overflow: 'hidden',
}}
>
<div className="titlebar-controls" data-btnstyle={style} aria-hidden>
<span className="titlebar-btn titlebar-btn-minimize"><Minus size={10} strokeWidth={2.5} /></span>
<span className="titlebar-btn titlebar-btn-maximize"><Square size={9} strokeWidth={2.5} /></span>
<span className="titlebar-btn titlebar-btn-close"><X size={10} strokeWidth={2.5} /></span>
</div>
</div>
<span
style={{
fontSize: 11,
color: selected ? 'var(--accent)' : 'var(--text-secondary)',
textAlign: 'center',
fontWeight: selected ? 600 : 400,
}}
>
{label}
</span>
</button>
);
}
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Disc3, Search, Music4, AudioLines, MoreHorizontal } from 'lucide-react';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { MobileSearchOverlay } from '@/features/search';
import MobileMoreOverlay from '@/features/sidebar/components/MobileMoreOverlay';
const NAV_ITEMS = [
{ to: '/', end: true, icon: Disc3, labelKey: 'sidebar.mainstage' },
{ to: '/albums', end: false, icon: Music4, labelKey: 'sidebar.allAlbums' },
{ to: '/now-playing', end: false, icon: AudioLines, labelKey: 'sidebar.nowPlaying' },
] as const;
export default function BottomNav() {
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const [searchOpen, setSearchOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
return (
<>
<nav className="bottom-nav" aria-label="Mobile navigation">
{NAV_ITEMS.map(({ to, end, icon: Icon, labelKey }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) => `bottom-nav-item${isActive ? ' active' : ''}`}
>
<span className="bottom-nav-icon-wrap">
<Icon size={22} />
{to === '/now-playing' && isPlaying && currentTrack && (
<span className="bottom-nav-np-dot" />
)}
</span>
<span className="bottom-nav-label">{t(labelKey)}</span>
</NavLink>
))}
<button
className="bottom-nav-item"
onClick={() => setSearchOpen(true)}
aria-label={t('search.title')}
>
<span className="bottom-nav-icon-wrap">
<Search size={22} />
</span>
<span className="bottom-nav-label">{t('search.title')}</span>
</button>
<button
className={`bottom-nav-item${moreOpen ? ' active' : ''}`}
onClick={() => setMoreOpen(v => !v)}
aria-label={t('sidebar.more')}
>
<span className="bottom-nav-icon-wrap">
<MoreHorizontal size={22} />
</span>
<span className="bottom-nav-label">{t('sidebar.more')}</span>
</button>
</nav>
{searchOpen && <MobileSearchOverlay onClose={() => setSearchOpen(false)} />}
{moreOpen && <MobileMoreOverlay onClose={() => setMoreOpen(false)} />}
</>
);
}
@@ -0,0 +1,89 @@
import { createPortal } from 'react-dom';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Settings, HardDriveDownload } from 'lucide-react';
import { useSidebarStore } from '@/features/sidebar';
import { useAuthStore } from '@/store/authStore';
import { ALL_NAV_ITEMS } from '@/config/navItems';
import { useLuckyMixAvailable } from '@/features/randomMix';
import { isOfflineSidebarNavAllowed } from '@/features/offline';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineBrowseNavFlags } from '@/features/offline';
const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']);
export default function MobileMoreOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const sidebarItems = useSidebarStore(s => s.items);
const randomNavMode = useAuthStore(s => s.randomNavMode);
const offlineCtx = useOfflineBrowseContext();
const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities);
const isServerOffline = offlineCtx.active;
const hasOfflineContent = offlineCtx.capabilities.manualPins;
const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
const items = sidebarItems
.filter(cfg => {
if (!cfg?.visible) return false;
const item = ALL_NAV_ITEMS[cfg.id];
if (!item) return false;
if (BOTTOM_NAV_ROUTES.has(item.to)) return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false;
if (isServerOffline && !isOfflineSidebarNavAllowed(
cfg.id,
offlineNav.favoritesOfflineBrowse,
offlineNav.localLibraryBrowse,
offlineNav.playerStatsBrowse,
offlineNav.playlistsOfflineBrowse,
)) {
return false;
}
return true;
})
.map(cfg => ALL_NAV_ITEMS[cfg.id]);
return createPortal(
<>
<div className="mobile-more-backdrop" onClick={onClose} />
<div className="mobile-more-sheet">
<div className="mobile-more-handle" />
<div className="mobile-more-grid">
{items.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
onClick={onClose}
>
<span className="mobile-more-icon"><item.icon size={24} /></span>
<span className="mobile-more-label">{t(item.labelKey)}</span>
</NavLink>
))}
{hasOfflineContent && (
<NavLink
to="/offline"
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
onClick={onClose}
>
<span className="mobile-more-icon"><HardDriveDownload size={24} /></span>
<span className="mobile-more-label">{t('sidebar.offlineLibrary')}</span>
</NavLink>
)}
<NavLink
to="/settings"
className={({ isActive }) => `mobile-more-item${isActive ? ' active' : ''}`}
onClick={onClose}
>
<span className="mobile-more-icon"><Settings size={24} /></span>
<span className="mobile-more-label">{t('sidebar.settings')}</span>
</NavLink>
</div>
</div>
</>,
document.body
);
}
@@ -4,8 +4,8 @@ import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, HardDriveDownload, Settings } from 'lucide-react';
import type { SidebarItemConfig } from '@/features/sidebar/store/sidebarStore';
import { ALL_NAV_ITEMS } from '@/config/navItems';
import WhatsNewBanner from '@/components/WhatsNewBanner';
import ThemeUpdateBanner from '@/components/ThemeUpdateBanner';
import WhatsNewBanner from '@/features/whatsNew/components/WhatsNewBanner';
import ThemeUpdateBanner from '@/features/settings/components/ThemeUpdateBanner';
import SidebarLibraryPicker from '@/features/sidebar/components/SidebarLibraryPicker';
import SidebarPlaylistsSection from '@/features/sidebar/components/SidebarPlaylistsSection';
import SidebarActiveJobs from '@/features/sidebar/components/SidebarActiveJobs';
@@ -0,0 +1,65 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Sparkles, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { version } from '@/../package.json';
import { useAuthStore } from '@/store/authStore';
interface Props {
collapsed?: boolean;
}
/**
* Sidebar pill shown above Now Playing while the current app version hasn't
* been opened yet. Clicking opens the What's New page; X dismisses.
*
* Uses a fixed neutral palette so it looks identical across every theme.
*/
export default function WhatsNewBanner({ collapsed }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const lastSeen = useAuthStore(s => s.lastSeenChangelogVersion);
const setLastSeen = useAuthStore(s => s.setLastSeenChangelogVersion);
const showOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
if (!showOnUpdate || lastSeen === version) return null;
const dismiss = (e: React.MouseEvent) => {
e.stopPropagation();
setLastSeen(version);
};
const open = () => navigate('/whats-new');
if (collapsed) {
return (
<button
type="button"
className="whats-new-banner whats-new-banner--collapsed"
onClick={open}
data-tooltip={t('whatsNew.bannerCollapsed', { version })}
data-tooltip-pos="bottom"
>
<Sparkles size={16} />
</button>
);
}
return (
<button type="button" className="whats-new-banner" onClick={open}>
<Sparkles size={14} className="whats-new-banner__icon" />
<span className="whats-new-banner__text">
<span className="whats-new-banner__title">{t('whatsNew.bannerTitle')}</span>
<span className="whats-new-banner__version">v{version}</span>
</span>
<span
className="whats-new-banner__dismiss"
role="button"
aria-label={t('whatsNew.dismiss')}
onClick={dismiss}
>
<X size={12} />
</span>
</button>
);
}