feat(miniplayer): rebuild surfaces window with compact overlay polish

cx
This commit is contained in:
vicrodh committed 2026-02-26 00:54:13 -06:00
1 parent ddedca1b5e
commit 99c8ae1ed3
15 files changed
+1949 -698

No files matched your search

+7 -2
View File
@@ -303,8 +303,7 @@
type PersistedSession
} from '$lib/services/sessionService';
// MiniPlayer - DISABLED: incomplete feature, re-enable when ready
// import { enterMiniplayerMode } from '$lib/services/miniplayerService';
import { enterMiniplayerMode } from '$lib/services/miniplayerService';
// Sidebar mutual exclusion
import { closeContentSidebar, subscribeContentSidebar, type ContentSidebarType } from '$lib/stores/sidebarStore';
@@ -4366,6 +4365,9 @@
onToggleFavorite={toggleFavorite}
onAddToPlaylist={openAddToPlaylistModal}
onOpenQueue={toggleQueue}
onOpenMiniPlayer={() => {
void enterMiniplayerMode();
}}
onOpenFullScreen={openFullScreen}
onCast={openCastPicker}
{isCastConnected}
@@ -4401,6 +4403,9 @@
<NowPlayingBar
onTogglePlay={togglePlay}
onOpenQueue={toggleQueue}
onOpenMiniPlayer={() => {
void enterMiniplayerMode();
}}
onOpenFullScreen={openFullScreen}
onCast={openCastPicker}
{isCastConnected}
+2 -2
View File
@@ -30,10 +30,10 @@
overflow: hidden;
}
.miniplayer-frame {
.miniplayer-frame {
width: 100%;
height: 100%;
background: #18181b;
background: transparent;
overflow: hidden;
}
</style>
+272 -549
View File
@@ -1,18 +1,10 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { getCurrentWindow } from '@tauri-apps/api/window';
import {
Shuffle,
SkipBack,
SkipForward,
Play,
Pause,
Repeat,
Repeat1,
Maximize2,
ListMusic
} from 'lucide-svelte';
import StackIcon from '$lib/components/StackIcon.svelte';
import { LogicalSize } from '@tauri-apps/api/dpi';
import { t } from '$lib/i18n';
import MiniPlayerShell from '$lib/components/miniplayer/MiniPlayerShell.svelte';
import type { MiniPlayerQueueTrack, MiniPlayerSurface } from '$lib/components/miniplayer/types';
import {
subscribe as subscribePlayer,
getPlayerState,
@@ -30,606 +22,337 @@
toggleRepeat,
nextTrack,
previousTrack,
type RepeatMode
syncQueueState,
startQueueEventListener,
stopQueueEventListener,
type QueueState
} from '$lib/stores/queueStore';
import { exitMiniplayerMode } from '$lib/services/miniplayerService';
import {
subscribe as subscribeLyrics,
getLyricsState,
startWatching,
stopWatching,
startActiveLineUpdates,
stopActiveLineUpdates,
type LyricsState
} from '$lib/stores/lyricsStore';
import {
getMiniPlayerState,
initMiniPlayerState,
setMiniPlayerGeometry,
setMiniPlayerOpen,
setMiniPlayerSurface
} from '$lib/stores/uiStore';
import { exitMiniplayerMode, setMiniplayerAlwaysOnTop } from '$lib/services/miniplayerService';
// Player state
let playerState = $state<PlayerState>(getPlayerState());
let isShuffle = $state(false);
let repeatMode = $state<RepeatMode>('off');
let isDragging = $state(false);
let isDraggingProgress = $state(false);
let isDraggingVolume = $state(false);
let queueCount = $state(0);
let queueState = $state<QueueState>(getQueueState());
let lyricsState = $state<LyricsState>(getLyricsState());
let miniState = $state(getMiniPlayerState());
let showAlwaysOnTopWarning = $state(false);
// Refs
let progressRef: HTMLDivElement;
let volumeRef: HTMLDivElement | null = null;
let unlistenMoved: (() => void) | null = null;
let unlistenResized: (() => void) | null = null;
let unlistenCloseRequested: (() => void) | null = null;
// Derived state
const progress = $derived(playerState.duration > 0 ? (playerState.currentTime / playerState.duration) * 100 : 0);
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// Store subscriptions
let unsubscribePlayer: (() => void) | null = null;
let unsubscribeQueue: (() => void) | null = null;
let unsubscribeLyrics: (() => void) | null = null;
let lastExpandedGeometry = $state({ width: 380, height: 540 });
const COMPACT_GEOMETRY = {
width: 380,
height: 194
};
const EXPANDED_DEFAULT_GEOMETRY = {
width: 380,
height: 540
};
function formatQualityLabel(track: PlayerState['currentTrack']): string | undefined {
if (!track) return undefined;
if (track.bitDepth && track.samplingRate) {
return `${track.bitDepth}/${Math.round(track.samplingRate / 1000)}`;
}
return track.quality || undefined;
}
const activeSurface = $derived(miniState.surface);
const currentTrackId = $derived(playerState.currentTrack ? String(playerState.currentTrack.id) : undefined);
const queueTracks = $derived.by(() => {
const rows: MiniPlayerQueueTrack[] = [];
if (playerState.currentTrack) {
rows.push({
id: String(playerState.currentTrack.id),
title: playerState.currentTrack.title,
artist: playerState.currentTrack.artist,
artwork: playerState.currentTrack.artwork,
quality: formatQualityLabel(playerState.currentTrack)
});
}
for (const queueTrack of queueState.queue) {
if (rows.some(row => row.id === queueTrack.id)) {
continue;
}
rows.push({
id: queueTrack.id,
title: queueTrack.title,
artist: queueTrack.artist,
artwork: queueTrack.artwork,
quality: queueTrack.duration
});
}
return rows;
});
function rememberExpandedGeometry(width: number, height: number): void {
if (height < 320) return;
lastExpandedGeometry = {
width: Math.max(340, width),
height: Math.max(420, height)
};
}
async function applySurfaceGeometry(surface: MiniPlayerSurface): Promise<void> {
const appWindow = getCurrentWindow();
if (surface === 'compact') {
const currentSize = await appWindow.innerSize();
rememberExpandedGeometry(currentSize.width, currentSize.height);
await appWindow.setSize(new LogicalSize(COMPACT_GEOMETRY.width, COMPACT_GEOMETRY.height));
setMiniPlayerGeometry({ width: COMPACT_GEOMETRY.width, height: COMPACT_GEOMETRY.height });
miniState = getMiniPlayerState();
return;
}
const target = {
width: lastExpandedGeometry.width || EXPANDED_DEFAULT_GEOMETRY.width,
height: lastExpandedGeometry.height || EXPANDED_DEFAULT_GEOMETRY.height
};
await appWindow.setSize(new LogicalSize(target.width, target.height));
setMiniPlayerGeometry({ width: target.width, height: target.height });
miniState = getMiniPlayerState();
}
$effect(() => {
if (lyricsState.isSynced && lyricsState.lines.length > 0 && playerState.isPlaying) {
startActiveLineUpdates();
} else {
stopActiveLineUpdates();
}
});
onMount(async () => {
console.log('[MiniPlayer] Mounting, starting polling...');
initMiniPlayerState();
miniState = getMiniPlayerState();
setMiniPlayerOpen(true);
if (miniState.surface === 'compact') {
await applySurfaceGeometry('compact');
} else {
rememberExpandedGeometry(miniState.geometry.width, miniState.geometry.height);
}
await startPolling();
await syncQueueState();
await startQueueEventListener();
startWatching();
unsubscribePlayer = subscribePlayer(() => {
playerState = getPlayerState();
});
unsubscribeQueue = subscribeQueue(() => {
const qState = getQueueState();
if (qState) {
isShuffle = qState.isShuffle ?? false;
repeatMode = qState.repeatMode ?? 'off';
queueCount = qState.queue?.length ?? 0;
queueState = getQueueState();
});
unsubscribeLyrics = subscribeLyrics(() => {
lyricsState = getLyricsState();
});
const appWindow = getCurrentWindow();
unlistenMoved = await appWindow.onMoved(({ payload }) => {
setMiniPlayerGeometry({ x: payload.x, y: payload.y });
});
unlistenResized = await appWindow.onResized(({ payload }) => {
setMiniPlayerGeometry({ width: payload.width, height: payload.height });
if (miniState.surface !== 'compact') {
rememberExpandedGeometry(payload.width, payload.height);
}
});
const qState = getQueueState();
if (qState) {
isShuffle = qState.isShuffle ?? false;
repeatMode = qState.repeatMode ?? 'off';
queueCount = qState.queue?.length ?? 0;
}
unlistenCloseRequested = await appWindow.onCloseRequested((event) => {
event.preventDefault();
void exitMiniplayerMode();
});
console.info('[MiniPlayer] Window ready');
});
onDestroy(() => {
console.log('[MiniPlayer] Unmounting, stopping polling...');
unsubscribePlayer?.();
unsubscribeQueue?.();
unsubscribeLyrics?.();
unlistenMoved?.();
unlistenResized?.();
unlistenCloseRequested?.();
stopActiveLineUpdates();
stopWatching();
stopQueueEventListener();
stopPolling();
});
// Playback controls
async function handlePlayPause(e: MouseEvent): Promise<void> {
e.stopPropagation();
async function handleSurfaceChange(surface: MiniPlayerSurface): Promise<void> {
if (surface === miniState.surface) return;
if (surface === 'compact') {
await applySurfaceGeometry('compact');
} else if (miniState.surface === 'compact') {
await applySurfaceGeometry(surface);
}
setMiniPlayerSurface(surface);
miniState = getMiniPlayerState();
console.info('[MiniPlayer] Surface changed:', surface);
}
async function handleToggleAlwaysOnTop(): Promise<void> {
const targetValue = !miniState.alwaysOnTop;
const result = await setMiniplayerAlwaysOnTop(targetValue);
miniState = getMiniPlayerState();
showAlwaysOnTopWarning = !result.applied && targetValue;
if (!result.applied) {
console.warn('[MiniPlayer] Always-on-top could not be fully applied:', result.reason);
}
}
async function handleStartDrag(event: MouseEvent): Promise<void> {
event.stopPropagation();
try {
await getCurrentWindow().startDragging();
} catch (err) {
console.error('[MiniPlayer] Drag start failed:', err);
}
}
async function handleTogglePlay(): Promise<void> {
try {
await togglePlay();
} catch (err) {
console.error('[MiniPlayer] Failed to toggle playback:', err);
console.error('[MiniPlayer] togglePlay failed:', err);
}
}
async function handleNext(e: MouseEvent): Promise<void> {
e.stopPropagation();
try {
await nextTrack();
} catch (err) {
console.error('[MiniPlayer] Failed to skip to next:', err);
}
}
async function handlePrevious(e: MouseEvent): Promise<void> {
e.stopPropagation();
async function handleSkipBack(): Promise<void> {
try {
await previousTrack();
} catch (err) {
console.error('[MiniPlayer] Failed to skip to previous:', err);
console.error('[MiniPlayer] previousTrack failed:', err);
}
}
async function handleToggleShuffle(e: MouseEvent): Promise<void> {
e.stopPropagation();
async function handleSkipForward(): Promise<void> {
try {
await nextTrack();
} catch (err) {
console.error('[MiniPlayer] nextTrack failed:', err);
}
}
async function handleToggleShuffle(): Promise<void> {
try {
await toggleShuffle();
} catch (err) {
console.error('[MiniPlayer] Failed to toggle shuffle:', err);
console.error('[MiniPlayer] toggleShuffle failed:', err);
}
}
async function handleToggleRepeat(e: MouseEvent): Promise<void> {
e.stopPropagation();
async function handleToggleRepeat(): Promise<void> {
try {
await toggleRepeat();
} catch (err) {
console.error('[MiniPlayer] Failed to toggle repeat:', err);
console.error('[MiniPlayer] toggleRepeat failed:', err);
}
}
// Progress bar
function handleProgressMouseDown(e: MouseEvent): void {
e.stopPropagation();
isDraggingProgress = true;
updateProgress(e);
document.addEventListener('mousemove', handleProgressMouseMove);
document.addEventListener('mouseup', handleProgressMouseUp);
}
function handleProgressMouseMove(e: MouseEvent): void {
if (isDraggingProgress) updateProgress(e);
}
function handleProgressMouseUp(): void {
isDraggingProgress = false;
document.removeEventListener('mousemove', handleProgressMouseMove);
document.removeEventListener('mouseup', handleProgressMouseUp);
}
function updateProgress(e: MouseEvent): void {
if (progressRef && playerState.duration > 0) {
const rect = progressRef.getBoundingClientRect();
const percentage = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
const newTime = Math.round((percentage / 100) * playerState.duration);
playerSeek(newTime);
}
}
function handleVolumeMouseDown(e: MouseEvent): void {
e.stopPropagation();
isDraggingVolume = true;
updateVolume(e);
document.addEventListener('mousemove', handleVolumeMouseMove);
document.addEventListener('mouseup', handleVolumeMouseUp);
}
function handleVolumeMouseMove(e: MouseEvent): void {
if (isDraggingVolume) updateVolume(e);
}
function handleVolumeMouseUp(): void {
isDraggingVolume = false;
document.removeEventListener('mousemove', handleVolumeMouseMove);
document.removeEventListener('mouseup', handleVolumeMouseUp);
}
function updateVolume(e: MouseEvent): void {
if (!volumeRef) return;
const rect = volumeRef.getBoundingClientRect();
const percentage = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
playerSetVolume(Math.round(percentage));
}
// Window controls - stop propagation to prevent drag from capturing
async function handleRestore(e: MouseEvent): Promise<void> {
e.stopPropagation();
e.preventDefault();
console.log('[MiniPlayer] Restore button clicked');
await exitMiniplayerMode();
}
function handleRestoreMouseDown(e: MouseEvent): void {
e.stopPropagation();
}
async function startDrag(): Promise<void> {
try {
isDragging = true;
const window = getCurrentWindow();
await window.startDragging();
} catch (err) {
console.error('[MiniPlayer] Failed to start dragging:', err);
} finally {
isDragging = false;
}
}
function handleTopMouseDown(e: MouseEvent): void {
const target = e.target as HTMLElement | null;
if (target?.closest('button')) {
return;
}
void startDrag();
}
function handleOpenQueue(e: MouseEvent): void {
e.stopPropagation();
console.log('[MiniPlayer] Queue button clicked, tracks:', queueCount);
}
function handleArtworkClick(e: MouseEvent): void {
e.stopPropagation();
// TODO: Cycle through view modes
console.log('[MiniPlayer] Artwork clicked');
}
</script>
<div
class="miniplayer"
class:dragging={isDragging}
role="application"
aria-label="MiniPlayer"
>
<!-- Top Section: Artwork + Info + Restore -->
<div class="top-section" onmousedown={handleTopMouseDown}>
<!-- Album Art (clickable for view modes) -->
<button class="artwork-section" onclick={handleArtworkClick}>
{#if playerState.currentTrack?.artwork}
<img src={playerState.currentTrack.artwork} alt="Album art" class="artwork" />
{:else}
<div class="artwork-placeholder"></div>
{/if}
</button>
<div class="miniplayer-page">
<MiniPlayerShell
activeSurface={activeSurface}
isPinned={miniState.alwaysOnTop}
artwork={playerState.currentTrack?.artwork}
title={playerState.currentTrack?.title}
artist={playerState.currentTrack?.artist}
album={playerState.currentTrack?.album}
queueTracks={queueTracks}
{currentTrackId}
lyricsLines={lyricsState.lines}
lyricsActiveIndex={lyricsState.activeIndex}
lyricsActiveProgress={lyricsState.activeProgress}
lyricsIsSynced={lyricsState.isSynced}
isPlaying={playerState.isPlaying}
currentTime={playerState.currentTime}
duration={playerState.duration}
volume={playerState.volume}
isShuffle={queueState.isShuffle}
repeatMode={queueState.repeatMode}
onSurfaceChange={(surface) => {
void handleSurfaceChange(surface);
}}
onTogglePin={handleToggleAlwaysOnTop}
onExpand={exitMiniplayerMode}
onClose={exitMiniplayerMode}
onStartDrag={handleStartDrag}
onTogglePlay={handleTogglePlay}
onSkipBack={handleSkipBack}
onSkipForward={handleSkipForward}
onSeek={playerSeek}
onVolumeChange={playerSetVolume}
onToggleShuffle={handleToggleShuffle}
onToggleRepeat={handleToggleRepeat}
/>
<!-- Track Info + Restore Button -->
<div class="info-section">
<button class="restore-btn" onclick={handleRestore} onmousedown={handleRestoreMouseDown} title="Restore">
<Maximize2 size={14} />
</button>
<div class="track-info">
<div class="title">{playerState.currentTrack?.title ?? 'No track'}</div>
<div class="artist-album">
<StackIcon size={12} class="stack-icon" />
{playerState.currentTrack?.artist ?? '—'}
{#if playerState.currentTrack?.album}
<span class="separator">—</span>
<span class="album">{playerState.currentTrack.album}</span>
{/if}
</div>
</div>
</div>
</div>
<!-- Seek Bar (full width) -->
<div class="seek-section">
<div
class="progress-bar"
bind:this={progressRef}
onmousedown={handleProgressMouseDown}
role="slider"
tabindex="0"
aria-valuenow={playerState.currentTime}
aria-valuemin={0}
aria-valuemax={playerState.duration}
>
<div class="progress-track">
<div class="progress-fill" style="width: {progress}%"></div>
</div>
</div>
</div>
<!-- Bottom: Media Controls + Window Controls -->
<div class="bottom-section">
<div class="media-controls">
<button
class="ctrl-btn"
class:active={isShuffle}
onclick={handleToggleShuffle}
title="Shuffle"
>
<Shuffle size={14} />
</button>
<button class="ctrl-btn" onclick={handlePrevious} title="Previous">
<SkipBack size={16} />
</button>
<button class="ctrl-btn play" onclick={handlePlayPause} title={playerState.isPlaying ? 'Pause' : 'Play'}>
{#if playerState.isPlaying}
<Pause size={18} />
{:else}
<Play size={18} />
{/if}
</button>
<button class="ctrl-btn" onclick={handleNext} title="Next">
<SkipForward size={16} />
</button>
<button
class="ctrl-btn"
class:active={repeatMode !== 'off'}
onclick={handleToggleRepeat}
title="Repeat"
>
{#if repeatMode === 'one'}
<Repeat1 size={14} />
{:else}
<Repeat size={14} />
{/if}
</button>
</div>
<div class="volume-control">
<div class="volume-value" class:visible={isDraggingVolume}>{Math.round(playerState.volume)}</div>
<div
class="volume-bar"
bind:this={volumeRef}
onmousedown={handleVolumeMouseDown}
role="slider"
tabindex="0"
aria-valuenow={playerState.volume}
aria-valuemin={0}
aria-valuemax={100}
title="Volume"
>
<div class="volume-fill" style="width: {playerState.volume}%"></div>
<div class="volume-thumb" style="left: {playerState.volume}%"></div>
</div>
</div>
<div class="window-controls">
<button class="ctrl-btn" onclick={handleOpenQueue} title="Queue ({queueCount})">
<ListMusic size={14} />
</button>
</div>
</div>
{#if showAlwaysOnTopWarning}
<div class="aot-warning">{$t('player.miniAlwaysOnTopLimited')}</div>
{/if}
</div>
<style>
.miniplayer {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: transparent;
color: var(--text-primary);
user-select: none;
.miniplayer-page {
width: 100vw;
height: 100vh;
overflow: hidden;
padding: 8px;
background: transparent;
padding: 6px;
box-sizing: border-box;
gap: 6px;
}
.miniplayer.dragging {
cursor: grabbing;
}
.miniplayer.dragging * {
cursor: grabbing;
}
/* Top Section: Artwork + Info */
.top-section {
display: flex;
flex: 1;
gap: 10px;
min-height: 0;
cursor: grab;
}
/* Album Art */
.artwork-section {
aspect-ratio: 1;
height: 100%;
flex-shrink: 0;
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.artwork {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
.artwork-placeholder {
width: 100%;
height: 100%;
background: var(--alpha-6);
border-radius: 6px;
}
/* Info Section */
.info-section {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
}
.restore-btn {
.aot-warning {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--alpha-6);
border: none;
color: var(--alpha-50);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
}
.restore-btn:hover {
background: var(--accent-bg);
color: var(--accent-primary);
}
.track-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
overflow: hidden;
padding-right: 30px;
}
.title {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
background: color-mix(in srgb, #d97706 20%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, #d97706 35%, transparent);
color: var(--text-primary);
line-height: 1.4;
}
.artist-album {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--alpha-50);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.artist-album :global(.stack-icon) {
flex-shrink: 0;
}
.separator {
margin: 0 4px;
opacity: 0.5;
}
.album {
color: var(--alpha-40);
}
/* Seek Bar */
.seek-section {
flex-shrink: 0;
}
.progress-bar {
width: 100%;
height: 14px;
display: flex;
align-items: center;
cursor: pointer;
}
.progress-track {
width: 100%;
height: 4px;
background: var(--alpha-10);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent-primary);
border-radius: 2px;
transition: width 100ms linear;
}
/* Bottom Section */
.bottom-section {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.media-controls {
display: flex;
align-items: center;
gap: 2px;
}
.window-controls {
display: flex;
align-items: center;
gap: 2px;
}
.volume-control {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
}
.volume-bar {
width: 90px;
height: 4px;
background: var(--alpha-18);
border-radius: 999px;
cursor: pointer;
position: relative;
}
.volume-fill {
height: 100%;
border-radius: 999px;
background: var(--alpha-80);
}
.volume-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-primary);
opacity: 0;
transition: opacity 150ms ease;
}
.volume-bar:hover .volume-thumb {
opacity: 1;
}
.volume-value {
position: absolute;
right: 0;
top: -22px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.6);
color: var(--text-primary);
padding: 4px 10px;
font-size: 11px;
opacity: 0;
transform: translateY(4px);
transition: opacity 150ms ease, transform 150ms ease;
}
.volume-value.visible {
opacity: 1;
transform: translateY(0);
}
.ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
color: var(--alpha-70);
cursor: pointer;
border-radius: 50%;
transition: all 0.15s ease;
}
.ctrl-btn:hover {
background: var(--alpha-8);
color: var(--text-primary);
}
.ctrl-btn:active {
transform: scale(0.95);
}
.ctrl-btn.active {
color: var(--accent-primary);
}
.ctrl-btn.play {
width: 32px;
height: 32px;
background: var(--alpha-10);
}
.ctrl-btn.play:hover {
background: var(--alpha-18);
z-index: 90;
white-space: nowrap;
pointer-events: none;
}
</style>