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

@@ -0,0 +1,188 @@
<script lang="ts">
import { t } from '$lib/i18n';
interface Props {
artwork?: string;
title?: string;
artist?: string;
album?: string;
}
let { artwork, title, artist, album }: Props = $props();
let titleRef: HTMLDivElement | null = $state(null);
let titleTextRef: HTMLSpanElement | null = $state(null);
let artistRef: HTMLDivElement | null = $state(null);
let artistTextRef: HTMLSpanElement | null = $state(null);
let albumRef: HTMLDivElement | null = $state(null);
let albumTextRef: HTMLSpanElement | null = $state(null);
let titleOverflow = $state(0);
let artistOverflow = $state(0);
let albumOverflow = $state(0);
const tickerSpeed = 40;
const titleOffset = $derived(titleOverflow > 0 ? `-${titleOverflow + 16}px` : '0px');
const artistOffset = $derived(artistOverflow > 0 ? `-${artistOverflow + 16}px` : '0px');
const albumOffset = $derived(albumOverflow > 0 ? `-${albumOverflow + 16}px` : '0px');
const titleDuration = $derived(titleOverflow > 0 ? `${(titleOverflow + 16) / tickerSpeed}s` : '0s');
const artistDuration = $derived(artistOverflow > 0 ? `${(artistOverflow + 16) / tickerSpeed}s` : '0s');
const albumDuration = $derived(albumOverflow > 0 ? `${(albumOverflow + 16) / tickerSpeed}s` : '0s');
function updateOverflow(): void {
if (titleRef && titleTextRef) {
const overflow = titleTextRef.scrollWidth - titleRef.clientWidth;
titleOverflow = overflow > 0 ? overflow : 0;
}
if (artistRef && artistTextRef) {
const overflow = artistTextRef.scrollWidth - artistRef.clientWidth;
artistOverflow = overflow > 0 ? overflow : 0;
}
if (albumRef && albumTextRef) {
const overflow = albumTextRef.scrollWidth - albumRef.clientWidth;
albumOverflow = overflow > 0 ? overflow : 0;
}
}
$effect(() => {
title;
artist;
album;
requestAnimationFrame(() => {
updateOverflow();
});
});
</script>
<div class="artwork-surface">
{#if artwork}
<img src={artwork} alt={title ?? $t('player.noTrackPlaying')} class="artwork" />
{:else}
<div class="artwork-placeholder" aria-hidden="true"></div>
{/if}
<div class="meta">
<div
class="title"
class:scrollable={titleOverflow > 0}
style="--ticker-offset: {titleOffset}; --ticker-duration: {titleDuration};"
bind:this={titleRef}
>
<span class="title-text" bind:this={titleTextRef}>{title ?? $t('player.noTrackPlaying')}</span>
</div>
<div
class="subtitle"
class:scrollable={artistOverflow > 0}
style="--ticker-offset: {artistOffset}; --ticker-duration: {artistDuration};"
bind:this={artistRef}
>
<span class="subtitle-text" bind:this={artistTextRef}>{artist ?? '—'}</span>
</div>
<div
class="subtitle album-line"
class:scrollable={albumOverflow > 0}
style="--ticker-offset: {albumOffset}; --ticker-duration: {albumDuration};"
bind:this={albumRef}
>
<span class="subtitle-text" bind:this={albumTextRef}>{album ?? '—'}</span>
</div>
</div>
</div>
<style>
.artwork-surface {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px 20px 12px;
overflow: hidden;
}
.artwork,
.artwork-placeholder {
width: 100%;
max-width: 320px;
aspect-ratio: 1 / 1;
border-radius: 10px;
flex-shrink: 1;
min-height: 0;
}
.artwork {
object-fit: cover;
}
.artwork-placeholder {
background: var(--alpha-8);
border: 1px solid var(--alpha-12);
}
.meta {
width: 100%;
max-width: 320px;
text-align: center;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.title,
.subtitle {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.subtitle {
font-size: 12px;
color: var(--text-muted);
}
.subtitle.album-line {
color: var(--alpha-55);
}
.title.scrollable,
.subtitle.scrollable {
text-overflow: clip;
}
.title-text,
.subtitle-text {
display: inline-block;
white-space: nowrap;
}
.artwork-surface:hover .title.scrollable .title-text,
.artwork-surface:hover .subtitle.scrollable .subtitle-text {
animation: mini-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
@keyframes mini-ticker {
0%, 20% {
transform: translateX(0);
}
70%, 80% {
transform: translateX(var(--ticker-offset));
}
90%, 100% {
transform: translateX(0);
}
}
</style>
@@ -0,0 +1,80 @@
<script lang="ts">
import { t } from '$lib/i18n';
interface Props {
artwork?: string;
title?: string;
artist?: string;
}
let { artwork, title, artist }: Props = $props();
</script>
<div class="compact-surface">
{#if artwork}
<img src={artwork} alt={title ?? $t('player.noTrackPlaying')} class="artwork" />
{:else}
<div class="artwork-placeholder" aria-hidden="true"></div>
{/if}
<div class="track-meta">
<div class="title">{title ?? $t('player.noTrackPlaying')}</div>
<div class="artist">{artist ?? '—'}</div>
</div>
</div>
<style>
.compact-surface {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px 6px;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.artwork,
.artwork-placeholder {
width: 50px;
height: 50px;
border-radius: 7px;
flex-shrink: 0;
}
.artwork {
object-fit: cover;
}
.artwork-placeholder {
background: var(--alpha-8);
border: 1px solid var(--alpha-12);
}
.track-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.title,
.artist {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
font-size: 13px;
line-height: 1.2;
font-weight: 600;
color: var(--text-primary);
}
.artist {
font-size: 11px;
line-height: 1.2;
color: var(--text-muted);
}
</style>
@@ -0,0 +1,477 @@
<script lang="ts">
import { Shuffle, SkipBack, Play, Pause, SkipForward, Repeat, Repeat1, Volume2, VolumeX, Volume1 } from 'lucide-svelte';
import { t } from '$lib/i18n';
interface Props {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isShuffle: boolean;
repeatMode: 'off' | 'all' | 'one';
compact?: boolean;
onTogglePlay: () => void;
onSkipBack: () => void;
onSkipForward: () => void;
onSeek: (time: number) => void;
onVolumeChange: (volume: number) => void;
onToggleShuffle: () => void;
onToggleRepeat: () => void;
}
let {
isPlaying,
currentTime,
duration,
volume,
isShuffle,
repeatMode,
compact = false,
onTogglePlay,
onSkipBack,
onSkipForward,
onSeek,
onVolumeChange,
onToggleShuffle,
onToggleRepeat
}: Props = $props();
let seekRef: HTMLDivElement | null = $state(null);
let volumeRef: HTMLDivElement | null = $state(null);
let volumeButtonRef: HTMLButtonElement | null = $state(null);
let isDraggingSeek = $state(false);
let isDraggingVolume = $state(false);
let volumePopoverOpen = $state(false);
let isMuted = $state(false);
let previousVolume = $state(75);
const progress = $derived(duration > 0 ? Math.max(0, Math.min(100, (currentTime / duration) * 100)) : 0);
const displayVolume = $derived(isMuted ? 0 : volume);
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00';
const minutes = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
function updateSeek(event: MouseEvent): void {
if (!seekRef || duration <= 0) return;
const rect = seekRef.getBoundingClientRect();
const percentage = Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100));
onSeek(Math.round((percentage / 100) * duration));
}
function updateVolume(event: MouseEvent): void {
if (!volumeRef) return;
const rect = volumeRef.getBoundingClientRect();
const percentage = Math.max(0, Math.min(100, ((rect.bottom - event.clientY) / rect.height) * 100));
const nextVolume = Math.round(percentage);
onVolumeChange(nextVolume);
if (nextVolume > 0) {
isMuted = false;
}
}
function toggleMute(): void {
if (isMuted) {
isMuted = false;
onVolumeChange(previousVolume || 75);
return;
}
previousVolume = volume;
isMuted = true;
onVolumeChange(0);
}
function handleDocumentMouseDown(event: MouseEvent): void {
const target = event.target as Node | null;
if (!target) return;
const clickedVolume = volumeRef?.contains(target);
const clickedButton = volumeButtonRef?.contains(target);
if (!clickedVolume && !clickedButton) {
volumePopoverOpen = false;
}
}
function handleMouseMove(event: MouseEvent): void {
if (isDraggingSeek) {
updateSeek(event);
}
if (isDraggingVolume) {
updateVolume(event);
}
}
function handleMouseUp(): void {
isDraggingSeek = false;
isDraggingVolume = false;
}
$effect(() => {
if (!isDraggingSeek && !isDraggingVolume) return;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
});
$effect(() => {
if (!volumePopoverOpen) return;
document.addEventListener('mousedown', handleDocumentMouseDown);
return () => {
document.removeEventListener('mousedown', handleDocumentMouseDown);
};
});
</script>
<div class="footer" class:compact>
<div
class="seek-wrapper"
bind:this={seekRef}
onmousedown={(event) => {
isDraggingSeek = true;
updateSeek(event);
}}
role="slider"
tabindex="0"
aria-valuenow={Math.round(currentTime)}
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-label={$t('player.nowPlaying')}
>
<div class="seek-track">
<div class="seek-fill" style="width: {progress}%"></div>
</div>
<div class="seek-thumb" style="left: {progress}%" class:visible={isDraggingSeek}></div>
</div>
<div class="times">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
<div class="controls-row">
<div class="transport">
<button class="ctrl-btn" class:active={isShuffle} onclick={onToggleShuffle} title={$t('player.shuffle')}>
<Shuffle size={compact ? 13 : 16} />
</button>
<button class="ctrl-btn" onclick={onSkipBack} title={$t('player.previous')}>
<SkipBack size={compact ? 15 : 18} fill="currentColor" />
</button>
<button
class="ctrl-btn play"
onclick={onTogglePlay}
title={isPlaying ? $t('player.pause') : $t('player.play')}
aria-label={isPlaying ? $t('player.pause') : $t('player.play')}
>
{#if isPlaying}
<Pause size={compact ? 16 : 20} fill="currentColor" />
{:else}
<Play size={compact ? 16 : 20} fill="currentColor" class="play-icon" />
{/if}
</button>
<button class="ctrl-btn" onclick={onSkipForward} title={$t('player.next')}>
<SkipForward size={compact ? 15 : 18} fill="currentColor" />
</button>
<button
class="ctrl-btn"
class:active={repeatMode !== 'off'}
onclick={onToggleRepeat}
title={repeatMode === 'off' ? $t('player.repeat') : repeatMode === 'all' ? $t('player.repeatAll') : $t('player.repeatOne')}
>
{#if repeatMode === 'one'}
<Repeat1 size={compact ? 13 : 16} />
{:else}
<Repeat size={compact ? 13 : 16} />
{/if}
</button>
</div>
<div class="volume-anchor">
<button
class="ctrl-btn volume-trigger"
bind:this={volumeButtonRef}
class:active={volumePopoverOpen}
onclick={() => (volumePopoverOpen = !volumePopoverOpen)}
title={$t('player.volume')}
aria-label={$t('player.volume')}
>
{#if displayVolume === 0}
<VolumeX size={compact ? 13 : 14} />
{:else if displayVolume < 50}
<Volume1 size={compact ? 13 : 14} />
{:else}
<Volume2 size={compact ? 13 : 14} />
{/if}
</button>
{#if volumePopoverOpen}
<div class="volume-popover" role="group" aria-label={$t('player.volume')}>
<span class="volume-value">{displayVolume}</span>
<div
class="volume-rail"
bind:this={volumeRef}
onmousedown={(event) => {
isDraggingVolume = true;
updateVolume(event);
}}
role="slider"
tabindex="0"
aria-valuenow={Math.round(displayVolume)}
aria-valuemin={0}
aria-valuemax={100}
>
<div class="volume-level" style="height: {displayVolume}%"></div>
<div class="volume-thumb" style="bottom: {displayVolume}%" class:visible={isDraggingVolume}></div>
</div>
<button class="ctrl-btn mute-btn" onclick={toggleMute} title={isMuted ? $t('player.unmute') : $t('player.mute')}>
<VolumeX size={13} />
</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.footer {
position: relative;
flex-shrink: 0;
border-top: 1px solid var(--alpha-10);
background: var(--bg-secondary);
padding: 0 14px 10px;
overflow: visible;
}
.footer.compact {
padding: 0 10px 6px;
}
.seek-wrapper {
position: relative;
cursor: pointer;
padding: 6px 0;
}
.seek-track {
height: 3px;
background: var(--alpha-12);
border-radius: 999px;
overflow: hidden;
}
.seek-fill {
height: 100%;
background: var(--accent-primary);
border-radius: 999px;
transition: width 120ms linear;
}
.seek-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-primary);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.seek-wrapper:hover .seek-thumb,
.seek-thumb.visible {
opacity: 1;
}
.times {
display: flex;
justify-content: space-between;
margin-top: -1px;
color: var(--text-muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.controls-row {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin-top: 3px;
min-height: 40px;
}
.transport {
display: flex;
align-items: center;
gap: 18px;
}
.ctrl-btn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 999px;
background: transparent;
color: var(--alpha-70);
cursor: pointer;
transition: background 120ms ease, color 120ms ease, transform 120ms ease;
}
.ctrl-btn:hover {
background: var(--alpha-8);
color: var(--text-primary);
}
.ctrl-btn.active {
color: var(--accent-primary);
}
.ctrl-btn.play {
width: 40px;
height: 40px;
background: var(--accent-primary);
color: var(--btn-primary-text, #ffffff);
box-shadow: 0 4px 14px color-mix(in srgb, var(--accent-primary) 38%, transparent);
}
.ctrl-btn.play:hover {
background: color-mix(in srgb, var(--accent-primary) 88%, white);
transform: scale(1.04);
}
.ctrl-btn :global(.play-icon) {
margin-left: 2px;
}
.volume-anchor {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.volume-trigger {
width: 28px;
height: 28px;
}
.volume-popover {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 8px;
background: var(--bg-secondary);
border: 1px solid var(--alpha-12);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 50;
}
.volume-value {
color: var(--text-muted);
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.volume-rail {
position: relative;
width: 6px;
height: 78px;
background: var(--alpha-12);
border-radius: 999px;
cursor: pointer;
}
.volume-level {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background: var(--accent-primary);
border-radius: 999px;
transition: height 100ms linear;
}
.volume-thumb {
position: absolute;
left: 50%;
transform: translate(-50%, 50%);
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--accent-primary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.28);
opacity: 0;
transition: opacity 120ms ease;
pointer-events: none;
}
.volume-rail:hover .volume-thumb,
.volume-thumb.visible {
opacity: 1;
}
.mute-btn {
width: 24px;
height: 24px;
}
.footer.compact .seek-wrapper {
padding: 4px 0;
}
.footer.compact .seek-track {
height: 2px;
}
.footer.compact .times {
font-size: 10px;
}
.footer.compact .controls-row {
margin-top: 2px;
min-height: 34px;
}
.footer.compact .transport {
gap: 14px;
}
.footer.compact .ctrl-btn {
width: 26px;
height: 26px;
}
.footer.compact .ctrl-btn.play {
width: 34px;
height: 34px;
}
.footer.compact .volume-trigger {
width: 24px;
height: 24px;
}
</style>
@@ -0,0 +1,72 @@
<script lang="ts">
import { Mic2 } from 'lucide-svelte';
import { t } from '$lib/i18n';
import LyricsLines from '$lib/components/lyrics/LyricsLines.svelte';
import type { LyricsLine } from '$lib/stores/lyricsStore';
interface Props {
lines: LyricsLine[];
activeIndex: number;
activeProgress: number;
isSynced: boolean;
}
let { lines, activeIndex, activeProgress, isSynced }: Props = $props();
const hasLines = $derived(lines.length > 0);
</script>
<div class="lyrics-surface">
{#if !hasLines}
<div class="lyrics-empty">
<Mic2 size={30} strokeWidth={1.6} />
<span>{$t('player.noLyrics')}</span>
</div>
{:else}
<div class="lyrics-list">
<LyricsLines
{lines}
{activeIndex}
{activeProgress}
{isSynced}
compact={true}
center={false}
scrollToActive={true}
/>
</div>
{/if}
</div>
<style>
.lyrics-surface {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.lyrics-empty {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 24px;
}
.lyrics-list {
flex: 1;
min-height: 0;
}
.lyrics-list :global(.lyrics-lines) {
padding: 12px 18px 26px;
gap: 10px;
}
</style>
@@ -0,0 +1,159 @@
<script lang="ts">
import { t } from '$lib/i18n';
import type { MiniPlayerQueueTrack } from './types';
interface Props {
tracks: MiniPlayerQueueTrack[];
currentTrackId?: string;
}
let { tracks, currentTrackId }: Props = $props();
</script>
<div class="queue-surface">
{#if tracks.length === 0}
<div class="queue-empty">{$t('player.queueEmpty')}</div>
{:else}
<div class="queue-list mini-scrollbar">
{#each tracks as queueTrack (queueTrack.id)}
<div class="queue-row" class:active={queueTrack.id === currentTrackId}>
{#if queueTrack.id === currentTrackId}
<div class="active-bar" aria-hidden="true"></div>
{/if}
{#if queueTrack.artwork}
<img src={queueTrack.artwork} alt={queueTrack.title} class="row-artwork" />
{:else}
<div class="row-artwork placeholder" aria-hidden="true"></div>
{/if}
<div class="row-meta">
<div class="row-title">{queueTrack.title}</div>
<div class="row-artist">{queueTrack.artist}</div>
</div>
{#if queueTrack.quality}
<span class="quality">{queueTrack.quality}</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
.queue-surface {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.queue-empty {
flex: 1;
display: grid;
place-items: center;
color: var(--text-muted);
font-size: 13px;
padding: 16px;
}
.queue-list {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 8px 0 10px;
}
.queue-row {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
transition: background 120ms ease;
}
.queue-row:hover {
background: var(--alpha-8);
}
.queue-row.active {
background: color-mix(in srgb, var(--accent-primary) 10%, transparent);
}
.active-bar {
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 3px;
background: var(--accent-primary);
}
.row-artwork {
width: 36px;
height: 36px;
border-radius: 5px;
object-fit: cover;
flex-shrink: 0;
}
.row-artwork.placeholder {
background: var(--alpha-10);
}
.row-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
flex: 1;
}
.row-title,
.row-artist {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.queue-row.active .row-title {
color: var(--text-primary);
}
.row-artist {
font-size: 12px;
color: var(--text-muted);
}
.quality {
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.mini-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--alpha-18) transparent;
}
.mini-scrollbar::-webkit-scrollbar {
width: 5px;
}
.mini-scrollbar::-webkit-scrollbar-thumb {
background: var(--alpha-18);
border-radius: 4px;
}
</style>
@@ -0,0 +1,188 @@
<script lang="ts">
import type { LyricsLine } from '$lib/stores/lyricsStore';
import type { MiniPlayerSurface, MiniPlayerQueueTrack } from './types';
import MiniPlayerWindowControls from './MiniPlayerWindowControls.svelte';
import MiniPlayerCompactSurface from './MiniPlayerCompactSurface.svelte';
import MiniPlayerArtworkSurface from './MiniPlayerArtworkSurface.svelte';
import MiniPlayerQueueSurface from './MiniPlayerQueueSurface.svelte';
import MiniPlayerLyricsSurface from './MiniPlayerLyricsSurface.svelte';
import MiniPlayerFooter from './MiniPlayerFooter.svelte';
interface Props {
activeSurface: MiniPlayerSurface;
isPinned: boolean;
artwork?: string;
title?: string;
artist?: string;
album?: string;
queueTracks: MiniPlayerQueueTrack[];
currentTrackId?: string;
lyricsLines: LyricsLine[];
lyricsActiveIndex: number;
lyricsActiveProgress: number;
lyricsIsSynced: boolean;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isShuffle: boolean;
repeatMode: 'off' | 'all' | 'one';
onSurfaceChange: (surface: MiniPlayerSurface) => void;
onTogglePin: () => void;
onExpand: () => void;
onClose: () => void;
onStartDrag: (event: MouseEvent) => void;
onTogglePlay: () => void;
onSkipBack: () => void;
onSkipForward: () => void;
onSeek: (time: number) => void;
onVolumeChange: (volume: number) => void;
onToggleShuffle: () => void;
onToggleRepeat: () => void;
}
let {
activeSurface,
isPinned,
artwork,
title,
artist,
album,
queueTracks,
currentTrackId,
lyricsLines,
lyricsActiveIndex,
lyricsActiveProgress,
lyricsIsSynced,
isPlaying,
currentTime,
duration,
volume,
isShuffle,
repeatMode,
onSurfaceChange,
onTogglePin,
onExpand,
onClose,
onStartDrag,
onTogglePlay,
onSkipBack,
onSkipForward,
onSeek,
onVolumeChange,
onToggleShuffle,
onToggleRepeat
}: Props = $props();
const compactSurface = $derived(activeSurface === 'compact');
</script>
<div
class="mini-player-window"
class:compact={compactSurface}
>
<div class="titlebar-row">
<div class="controls-slot">
<MiniPlayerWindowControls
{activeSurface}
isPinned={isPinned}
onSurfaceChange={onSurfaceChange}
onTogglePin={onTogglePin}
onExpand={onExpand}
onClose={onClose}
onStartDrag={onStartDrag}
/>
</div>
</div>
<div class="surface-area">
{#if activeSurface === 'compact'}
<MiniPlayerCompactSurface {artwork} {title} {artist} />
{:else if activeSurface === 'artwork'}
<MiniPlayerArtworkSurface {artwork} {title} {artist} {album} />
{:else if activeSurface === 'queue'}
<MiniPlayerQueueSurface tracks={queueTracks} {currentTrackId} />
{:else}
<MiniPlayerLyricsSurface
lines={lyricsLines}
activeIndex={lyricsActiveIndex}
activeProgress={lyricsActiveProgress}
isSynced={lyricsIsSynced}
/>
{/if}
</div>
<MiniPlayerFooter
compact={compactSurface}
{isPlaying}
{currentTime}
{duration}
{volume}
{isShuffle}
{repeatMode}
onTogglePlay={onTogglePlay}
onSkipBack={onSkipBack}
onSkipForward={onSkipForward}
onSeek={onSeek}
onVolumeChange={onVolumeChange}
onToggleShuffle={onToggleShuffle}
onToggleRepeat={onToggleRepeat}
/>
</div>
<style>
.mini-player-window {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--alpha-10);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.34);
}
.mini-player-window.compact {
border-radius: 9px;
}
.titlebar-row {
position: absolute;
top: 6px;
right: 8px;
z-index: 40;
display: flex;
align-items: center;
}
.mini-player-window.compact .titlebar-row {
top: 4px;
right: 8px;
}
.controls-slot {
display: flex;
align-items: center;
opacity: 0;
pointer-events: none;
transform: translateY(-3px);
transition: opacity 140ms ease, transform 140ms ease;
}
.mini-player-window:hover .controls-slot {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.surface-area {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
@@ -0,0 +1,224 @@
<script lang="ts">
import { Disc3, Image, ListMusic, AlignLeft, Maximize2, Pin, Move, X, Square } from 'lucide-svelte';
import { t } from '$lib/i18n';
import type { MiniPlayerSurface } from './types';
interface Props {
activeSurface: MiniPlayerSurface;
isPinned: boolean;
onSurfaceChange: (surface: MiniPlayerSurface) => void;
onTogglePin: () => void;
onExpand: () => void;
onClose: () => void;
onStartDrag: (event: MouseEvent) => void;
}
let { activeSurface, isPinned, onSurfaceChange, onTogglePin, onExpand, onClose, onStartDrag }: Props = $props();
let isExpanded = $state(false);
let collapseTimer: ReturnType<typeof setTimeout> | null = null;
const surfaceTabs: { id: MiniPlayerSurface; icon: typeof Disc3; labelKey: string }[] = [
{ id: 'compact', icon: Disc3, labelKey: 'player.miniSurfaceCompact' },
{ id: 'artwork', icon: Image, labelKey: 'player.miniSurfaceArtwork' },
{ id: 'queue', icon: ListMusic, labelKey: 'player.miniSurfaceQueue' },
{ id: 'lyrics', icon: AlignLeft, labelKey: 'player.miniSurfaceLyrics' }
];
function handleMouseEnter(): void {
if (collapseTimer) {
clearTimeout(collapseTimer);
collapseTimer = null;
}
isExpanded = true;
}
function handleMouseLeave(): void {
collapseTimer = setTimeout(() => {
isExpanded = false;
collapseTimer = null;
}, 250);
}
</script>
<div
class="window-controls"
class:expanded={isExpanded}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
role="group"
aria-label={$t('player.miniWindowControls')}
>
<div class="expanded-buttons">
<div class="surface-tabs">
{#each surfaceTabs as surfaceTab (surfaceTab.id)}
<button
class="surface-tab"
class:active={activeSurface === surfaceTab.id}
onclick={() => onSurfaceChange(surfaceTab.id)}
title={$t(surfaceTab.labelKey)}
aria-label={$t(surfaceTab.labelKey)}
>
<surfaceTab.icon size={13} />
</button>
{/each}
</div>
<div class="separator"></div>
<div class="window-actions">
<button
class="window-btn"
onclick={onExpand}
title={$t('player.miniExpand')}
aria-label={$t('player.miniExpand')}
>
<Maximize2 size={13} />
</button>
<button
class="window-btn"
class:active={isPinned}
onclick={onTogglePin}
title={isPinned ? $t('player.miniAlwaysOnTopDisable') : $t('player.miniAlwaysOnTopEnable')}
aria-label={isPinned ? $t('player.miniAlwaysOnTopDisable') : $t('player.miniAlwaysOnTopEnable')}
>
<Pin size={13} />
</button>
<button
class="window-btn drag-btn"
onmousedown={(event) => {
event.preventDefault();
onStartDrag(event);
}}
title={$t('player.dragWindow')}
aria-label={$t('player.dragWindow')}
>
<Move size={12} />
</button>
<button
class="window-btn close"
onclick={onClose}
title={$t('player.miniClose')}
aria-label={$t('player.miniClose')}
>
<X size={13} />
</button>
</div>
</div>
<button class="window-trigger" title={$t('player.miniWindowControls')} aria-label={$t('player.miniWindowControls')}>
<Square size={12} />
</button>
</div>
<style>
.window-controls {
position: relative;
display: flex;
align-items: center;
background: rgba(6, 7, 10, 0.9);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
padding: 2px;
overflow: hidden;
-webkit-app-region: no-drag;
app-region: no-drag;
transition: box-shadow 180ms ease, opacity 140ms ease, transform 140ms ease;
}
.window-controls.expanded {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4);
}
.window-trigger {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 999px;
background: transparent;
color: var(--alpha-75);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.window-trigger:hover {
background: var(--alpha-12);
color: var(--text-primary);
}
.expanded-buttons {
display: flex;
align-items: center;
gap: 3px;
max-width: 0;
opacity: 0;
overflow: hidden;
transition: max-width 220ms cubic-bezier(0.4, 0, 0.2, 1), opacity 160ms ease;
}
.window-controls.expanded .expanded-buttons {
max-width: 280px;
opacity: 1;
margin-right: 3px;
}
.surface-tabs,
.window-actions {
display: flex;
align-items: center;
gap: 2px;
}
.surface-tab,
.window-btn {
width: 22px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: var(--alpha-65);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.surface-tab:hover,
.window-btn:hover {
background: var(--alpha-12);
color: var(--text-primary);
}
.surface-tab.active,
.window-btn.active {
color: var(--accent-primary);
}
.window-btn.close:hover {
background: rgba(239, 68, 68, 0.8);
color: white;
}
.window-btn.drag-btn {
cursor: grab;
}
.window-btn.drag-btn:active {
cursor: grabbing;
}
.separator {
width: 1px;
height: 12px;
background: rgba(255, 255, 255, 0.16);
margin: 0 1px;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
import type { MiniPlayerSurface } from '$lib/stores/uiStore';
export type { MiniPlayerSurface };
export interface MiniPlayerQueueTrack {
id: string;
title: string;
artist: string;
artwork?: string;
quality?: string;
}
+11 -1
View File
@@ -483,7 +483,17 @@
"dragWindow": "Move Window",
"normalizationOff": "Volume Normalization: Off",
"normalizationOn": "Volume Normalization: On",
"normalizationApplied": "Volume Normalization: Active"
"normalizationApplied": "Volume Normalization: Active",
"miniSurfaceCompact": "Compact",
"miniSurfaceArtwork": "Artwork",
"miniSurfaceQueue": "Queue",
"miniSurfaceLyrics": "Lyrics",
"miniExpand": "Expand to Full App",
"miniClose": "Close Mini Player",
"miniWindowControls": "Mini Player Controls",
"miniAlwaysOnTopEnable": "Always on Top",
"miniAlwaysOnTopDisable": "Disable Always on Top",
"miniAlwaysOnTopLimited": "Always-on-top is limited by your window manager"
},
"audioBadges": {
"castingTo": "Casting to",
+11 -1
View File
@@ -483,7 +483,17 @@
"dragWindow": "Mover ventana",
"normalizationOff": "Normalización de volumen: Desactivada",
"normalizationOn": "Normalización de volumen: Activada",
"normalizationApplied": "Normalización de volumen: Activa"
"normalizationApplied": "Normalización de volumen: Activa",
"miniSurfaceCompact": "Compacto",
"miniSurfaceArtwork": "Portada",
"miniSurfaceQueue": "Cola",
"miniSurfaceLyrics": "Letras",
"miniExpand": "Expandir a la app completa",
"miniClose": "Cerrar mini reproductor",
"miniWindowControls": "Controles de mini reproductor",
"miniAlwaysOnTopEnable": "Siempre encima",
"miniAlwaysOnTopDisable": "Desactivar siempre encima",
"miniAlwaysOnTopLimited": "Siempre encima está limitado por tu gestor de ventanas"
},
"audioBadges": {
"castingTo": "Transmitiendo a",
+135 -142
View File
@@ -1,178 +1,171 @@
/**
* MiniPlayer Mode Service
* Mini Player Window Service
*
* Handles switching between normal and miniplayer modes by resizing
* the main window instead of creating a separate window.
*
* Uses localStorage to persist original window state across navigation.
* New strategy:
* - Keep main window mounted/alive
* - Open mini player as a second Tauri window
* - Hide/show + focus windows without route remount in main
*/
import { getCurrentWindow, PhysicalSize, PhysicalPosition } from '@tauri-apps/api/window';
import { goto } from '$app/navigation';
import { Window, getCurrentWindow } from '@tauri-apps/api/window';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import {
getMiniPlayerState,
initMiniPlayerState,
setMiniPlayerAlwaysOnTop as setMiniPlayerAlwaysOnTopState,
setMiniPlayerOpen,
setMiniPlayerSurface,
type MiniPlayerSurface
} from '$lib/stores/uiStore';
import { getUseSystemTitleBar } from '$lib/stores/titleBarStore';
// Miniplayer dimensions
const MINIPLAYER_WIDTH = 400;
const MINIPLAYER_HEIGHT = 200;
const MINI_PLAYER_LABEL = 'miniplayer';
const MAIN_WINDOW_LABEL = 'main';
// Original app minimum size (from tauri.conf.json)
const ORIGINAL_MIN_WIDTH = 800;
const ORIGINAL_MIN_HEIGHT = 600;
// LocalStorage key for persisting window state
const STORAGE_KEY = 'miniplayer_original_state';
interface OriginalState {
width: number;
height: number;
x: number;
y: number;
maximized: boolean;
export interface AlwaysOnTopResult {
applied: boolean;
reason?: string;
}
function saveOriginalState(state: OriginalState): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
console.log('[MiniPlayer] Saved state to localStorage:', state);
} catch (e) {
console.error('[MiniPlayer] Failed to save state:', e);
}
function normalizeRoute(url: string): string {
// adapter-static fallback setup expects the app shell entrypoint.
// route segment is resolved by the SPA router after bootstrap.
return url.startsWith('/') ? url : `/${url}`;
}
function loadOriginalState(): OriginalState | null {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const state = JSON.parse(data) as OriginalState;
console.log('[MiniPlayer] Loaded state from localStorage:', state);
return state;
}
} catch (e) {
console.error('[MiniPlayer] Failed to load state:', e);
}
return null;
}
async function waitForWindowCreation(windowRef: WebviewWindow): Promise<void> {
await new Promise<void>((resolve, reject) => {
let done = false;
function clearOriginalState(): void {
try {
localStorage.removeItem(STORAGE_KEY);
console.log('[MiniPlayer] Cleared state from localStorage');
} catch (e) {
console.error('[MiniPlayer] Failed to clear state:', e);
}
}
/**
* Enter miniplayer mode - resize window and navigate to miniplayer route
*/
export async function enterMiniplayerMode(): Promise<void> {
console.log('[MiniPlayer] Entering miniplayer mode...');
try {
const window = getCurrentWindow();
// Store current window state
const maximized = await window.isMaximized();
const size = await window.innerSize();
const position = await window.innerPosition();
// Save to localStorage so it survives navigation
saveOriginalState({
width: size.width,
height: size.height,
x: position.x,
y: position.y,
maximized
void windowRef.once('tauri://created', () => {
if (done) return;
done = true;
resolve();
});
// If maximized, unmaximize first
if (maximized) {
await window.unmaximize();
void windowRef.once<string>('tauri://error', (event) => {
if (done) return;
done = true;
reject(new Error(String(event.payload)));
});
});
}
async function getOrCreateMiniPlayerWindow(): Promise<Window> {
const existing = await Window.getByLabel(MINI_PLAYER_LABEL);
if (existing) return existing;
const miniState = getMiniPlayerState();
const geometry = miniState.geometry;
const miniWindow = new WebviewWindow(MINI_PLAYER_LABEL, {
url: normalizeRoute('/miniplayer'),
title: 'QBZ Mini Player',
width: geometry.width,
height: geometry.height,
x: geometry.x,
y: geometry.y,
minWidth: 340,
minHeight: 200,
resizable: true,
decorations: getUseSystemTitleBar(),
alwaysOnTop: miniState.alwaysOnTop,
transparent: true,
focus: true
});
await waitForWindowCreation(miniWindow);
return miniWindow;
}
async function focusMainWindow(): Promise<void> {
const mainWindow = await Window.getByLabel(MAIN_WINDOW_LABEL);
if (!mainWindow) return;
await mainWindow.show();
await mainWindow.unminimize();
await mainWindow.setFocus();
}
/**
* Enter mini player mode:
* create/show mini player window and hide main window.
*/
export async function enterMiniplayerMode(surface?: MiniPlayerSurface): Promise<void> {
initMiniPlayerState();
if (surface) {
setMiniPlayerSurface(surface);
}
try {
const currentWindow = getCurrentWindow();
const miniWindow = await getOrCreateMiniPlayerWindow();
await miniWindow.show();
await miniWindow.unminimize();
await miniWindow.setFocus();
// Hide main window only after mini is visible to avoid a blank app moment.
if (currentWindow.label === MAIN_WINDOW_LABEL) {
await currentWindow.hide();
} else {
const mainWindow = await Window.getByLabel(MAIN_WINDOW_LABEL);
if (mainWindow) await mainWindow.hide();
}
// IMPORTANT: Remove minimum size constraint first, then set size
console.log('[MiniPlayer] Removing min size constraint...');
await window.setMinSize(new PhysicalSize(MINIPLAYER_WIDTH, MINIPLAYER_HEIGHT));
// Small delay to ensure min size is applied before setting size
await new Promise(resolve => setTimeout(resolve, 50));
console.log('[MiniPlayer] Setting size to', MINIPLAYER_WIDTH, 'x', MINIPLAYER_HEIGHT);
await window.setSize(new PhysicalSize(MINIPLAYER_WIDTH, MINIPLAYER_HEIGHT));
await window.setResizable(true);
// Note: decorations stay false - app uses custom title bar (CSD)
await window.setAlwaysOnTop(true);
// Navigate to miniplayer route
await goto('/miniplayer');
console.log('[MiniPlayer] Entered miniplayer mode');
setMiniPlayerOpen(true);
console.info('[MiniPlayer] Entered mini player mode');
} catch (err) {
console.error('[MiniPlayer] Failed to enter miniplayer mode:', err);
console.error('[MiniPlayer] Failed to enter mini player mode:', err);
}
}
/**
* Exit miniplayer mode - restore original window state
* Exit mini player mode:
* hide mini player window and show/focus main window.
*/
export async function exitMiniplayerMode(): Promise<void> {
console.log('[MiniPlayer] exitMiniplayerMode called');
// Load original state from localStorage
const originalState = loadOriginalState();
console.log('[MiniPlayer] Original state:', originalState);
try {
const window = getCurrentWindow();
// Restore window properties
await window.setAlwaysOnTop(false);
// Note: decorations stay false - app uses custom title bar (CSD)
await window.setResizable(true);
// Restore minimum size constraint
console.log('[MiniPlayer] Restoring min size constraint...');
await window.setMinSize(new PhysicalSize(ORIGINAL_MIN_WIDTH, ORIGINAL_MIN_HEIGHT));
// Restore size
if (originalState) {
console.log('[MiniPlayer] Restoring size:', originalState.width, 'x', originalState.height);
await window.setSize(new PhysicalSize(originalState.width, originalState.height));
console.log('[MiniPlayer] Restoring position:', originalState.x, ',', originalState.y);
await window.setPosition(new PhysicalPosition(originalState.x, originalState.y));
if (originalState.maximized) {
console.log('[MiniPlayer] Restoring maximized state');
await window.maximize();
}
} else {
// Fallback to default size
console.log('[MiniPlayer] No original state, using defaults');
await window.setSize(new PhysicalSize(1280, 800));
const miniWindow = await Window.getByLabel(MINI_PLAYER_LABEL);
if (miniWindow) {
await miniWindow.hide();
}
// Clear saved state
clearOriginalState();
await focusMainWindow();
// Navigate back to main
console.log('[MiniPlayer] Navigating to /');
await goto('/');
console.log('[MiniPlayer] Exited miniplayer mode');
setMiniPlayerOpen(false);
console.info('[MiniPlayer] Restored main window');
} catch (err) {
console.error('[MiniPlayer] Failed to exit miniplayer mode:', err);
console.error('[MiniPlayer] Failed to restore main window:', err);
}
}
/**
* Set miniplayer always on top
* Best-effort always-on-top toggle.
*
* On some Wayland compositors this can be ignored or denied.
*/
export async function setMiniplayerAlwaysOnTop(alwaysOnTop: boolean): Promise<void> {
export async function setMiniplayerAlwaysOnTop(alwaysOnTop: boolean): Promise<AlwaysOnTopResult> {
try {
const window = getCurrentWindow();
await window.setAlwaysOnTop(alwaysOnTop);
const miniWindow = await Window.getByLabel(MINI_PLAYER_LABEL);
if (!miniWindow) {
setMiniPlayerAlwaysOnTopState(alwaysOnTop);
return { applied: false, reason: 'mini_window_not_open' };
}
await miniWindow.setAlwaysOnTop(alwaysOnTop);
setMiniPlayerAlwaysOnTopState(alwaysOnTop);
return { applied: true };
} catch (err) {
console.error('[MiniPlayer] Failed to set always on top:', err);
// Wayland or WM policy may reject always-on-top requests.
setMiniPlayerAlwaysOnTopState(false);
return {
applied: false,
reason: err instanceof Error ? err.message : String(err)
};
}
}
export async function closeMiniplayerWindow(): Promise<void> {
await exitMiniplayerMode();
}
+112 -1
View File
@@ -13,6 +13,38 @@ let isFullScreenOpen = false;
let isFocusModeOpen = false;
let isCastPickerOpen = false;
export type MiniPlayerSurface = 'compact' | 'artwork' | 'queue' | 'lyrics';
export interface MiniPlayerGeometry {
width: number;
height: number;
x?: number;
y?: number;
}
export interface MiniPlayerState {
open: boolean;
surface: MiniPlayerSurface;
alwaysOnTop: boolean;
geometry: MiniPlayerGeometry;
}
const MINI_PLAYER_STORAGE_KEY = 'qbz-mini-player-state';
const DEFAULT_MINI_PLAYER_STATE: MiniPlayerState = {
open: false,
surface: 'artwork',
alwaysOnTop: false,
geometry: {
width: 380,
height: 540
}
};
let miniPlayer: MiniPlayerState = {
...DEFAULT_MINI_PLAYER_STATE,
geometry: { ...DEFAULT_MINI_PLAYER_STATE.geometry }
};
// Playlist modal states
let isPlaylistModalOpen = false;
let playlistModalMode: 'create' | 'edit' | 'addTrack' = 'create';
@@ -29,6 +61,40 @@ function notifyListeners(): void {
}
}
function persistMiniPlayerState(): void {
try {
localStorage.setItem(MINI_PLAYER_STORAGE_KEY, JSON.stringify(miniPlayer));
} catch {
// ignore localStorage errors
}
}
function loadMiniPlayerState(): void {
try {
const raw = localStorage.getItem(MINI_PLAYER_STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Partial<MiniPlayerState>;
const width = parsed.geometry?.width;
const height = parsed.geometry?.height;
miniPlayer = {
open: false,
surface: parsed.surface ?? DEFAULT_MINI_PLAYER_STATE.surface,
alwaysOnTop: parsed.alwaysOnTop ?? DEFAULT_MINI_PLAYER_STATE.alwaysOnTop,
geometry: {
width: typeof width === 'number' && width >= 320 ? width : DEFAULT_MINI_PLAYER_STATE.geometry.width,
height: typeof height === 'number' && height >= 200 ? height : DEFAULT_MINI_PLAYER_STATE.geometry.height,
x: typeof parsed.geometry?.x === 'number' ? parsed.geometry.x : undefined,
y: typeof parsed.geometry?.y === 'number' ? parsed.geometry.y : undefined
}
};
} catch {
miniPlayer = {
...DEFAULT_MINI_PLAYER_STATE,
geometry: { ...DEFAULT_MINI_PLAYER_STATE.geometry }
};
}
}
/**
* Subscribe to UI state changes
*/
@@ -38,6 +104,10 @@ export function subscribe(listener: () => void): () => void {
return () => listeners.delete(listener);
}
export function initMiniPlayerState(): void {
loadMiniPlayerState();
}
// ============ Queue Panel ============
export function getQueueOpen(): boolean {
@@ -126,6 +196,45 @@ export function toggleCastPicker(): void {
notifyListeners();
}
// ============ Mini Player ============
export function getMiniPlayerState(): MiniPlayerState {
return {
...miniPlayer,
geometry: { ...miniPlayer.geometry }
};
}
export function setMiniPlayerOpen(open: boolean): void {
miniPlayer = { ...miniPlayer, open };
persistMiniPlayerState();
notifyListeners();
}
export function setMiniPlayerSurface(surface: MiniPlayerSurface): void {
miniPlayer = { ...miniPlayer, surface };
persistMiniPlayerState();
notifyListeners();
}
export function setMiniPlayerAlwaysOnTop(alwaysOnTop: boolean): void {
miniPlayer = { ...miniPlayer, alwaysOnTop };
persistMiniPlayerState();
notifyListeners();
}
export function setMiniPlayerGeometry(geometry: Partial<MiniPlayerGeometry>): void {
miniPlayer = {
...miniPlayer,
geometry: {
...miniPlayer.geometry,
...geometry
}
};
persistMiniPlayerState();
notifyListeners();
}
// ============ Playlist Modal ============
export function getPlaylistModalOpen(): boolean {
@@ -221,6 +330,7 @@ export interface UIState {
playlistModalTrackIds: number[];
playlistModalTracksAreLocal: boolean;
isPlaylistImportOpen: boolean;
miniPlayer: MiniPlayerState;
}
export function getUIState(): UIState {
@@ -233,6 +343,7 @@ export function getUIState(): UIState {
playlistModalMode,
playlistModalTrackIds,
playlistModalTracksAreLocal,
isPlaylistImportOpen
isPlaylistImportOpen,
miniPlayer: getMiniPlayerState()
};
}
+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>