mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.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(() => {
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user