Merge branch 'feature/miniplayer' into pre-release

This commit is contained in:
vicrodh committed 2026-02-26 02:45:28 -06:00
commit d21a8a3a49
18 files changed
+2692 -704

No files matched your search

+2 -2
View File
@@ -781,9 +781,9 @@
padding: 2px;
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 436px;
min-width: 354px;
flex: 1;
max-width: 800px;
max-width: 718px;
}
.artwork-container {
@@ -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: 12px;
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,746 @@
<script lang="ts">
import { Shuffle, SkipBack, Play, Pause, SkipForward, Repeat, Repeat1, Volume2, VolumeX, Volume1 } from 'lucide-svelte';
import { t } from '$lib/i18n';
import MiniPlayerWindowControls from './MiniPlayerWindowControls.svelte';
import type { MiniPlayerSurface } from './types';
interface Props {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isShuffle: boolean;
repeatMode: 'off' | 'all' | 'one';
compact?: boolean;
micro?: boolean;
trackTitle?: string;
trackArtist?: string;
activeSurface?: MiniPlayerSurface;
isPinned?: boolean;
onTogglePlay: () => void;
onSkipBack: () => void;
onSkipForward: () => void;
onSeek: (time: number) => void;
onVolumeChange: (volume: number) => void;
onToggleShuffle: () => void;
onToggleRepeat: () => void;
onSurfaceChange?: (surface: MiniPlayerSurface) => void;
onTogglePin?: () => void;
onExpand?: () => void;
onClose?: () => void;
onStartDrag?: (event: MouseEvent) => void;
showWindowControls?: boolean;
}
let {
isPlaying,
currentTime,
duration,
volume,
isShuffle,
repeatMode,
compact = false,
micro = false,
trackTitle,
trackArtist,
activeSurface,
isPinned = false,
onTogglePlay,
onSkipBack,
onSkipForward,
onSeek,
onVolumeChange,
onToggleShuffle,
onToggleRepeat,
onSurfaceChange,
onTogglePin,
onExpand,
onClose,
onStartDrag,
showWindowControls = false
}: Props = $props();
let seekRef: HTMLDivElement | null = $state(null);
let seekBottomRef: HTMLDivElement | null = $state(null);
let volumeRef: HTMLDivElement | null = $state(null);
let volumeButtonRef: HTMLButtonElement | null = $state(null);
let microTrackRef: HTMLDivElement | null = $state(null);
let microTrackTextRef: HTMLSpanElement | null = $state(null);
let isDraggingSeek = $state(false);
let isDraggingVolume = $state(false);
let volumePopoverOpen = $state(false);
let isMuted = $state(false);
let previousVolume = $state(75);
let microTrackOverflow = $state(0);
const progress = $derived(duration > 0 ? Math.max(0, Math.min(100, (currentTime / duration) * 100)) : 0);
const displayVolume = $derived(isMuted ? 0 : volume);
const tickerSpeed = 40;
const microTrackOffset = $derived(microTrackOverflow > 0 ? `-${microTrackOverflow + 16}px` : '0px');
const microTrackDuration = $derived(microTrackOverflow > 0 ? `${(microTrackOverflow + 16) / tickerSpeed}s` : '0s');
const canRenderInlineWindowControls = $derived(
!!activeSurface && !!onSurfaceChange && !!onTogglePin && !!onExpand && !!onClose && !!onStartDrag
);
function getMicroTrackLine(): string {
if (!trackTitle) return $t('player.noTrackPlaying');
const artistName = trackArtist?.trim();
return artistName ? `${trackTitle} - ${artistName}` : trackTitle;
}
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 updateMicroTrackOverflow(): void {
if (!microTrackRef || !microTrackTextRef) {
microTrackOverflow = 0;
return;
}
const overflow = microTrackTextRef.scrollWidth - microTrackRef.clientWidth;
microTrackOverflow = overflow > 0 ? overflow : 0;
}
function updateSeek(event: MouseEvent, targetRef: HTMLDivElement | null): void {
if (!targetRef || duration <= 0) return;
const rect = targetRef.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, ((event.clientX - rect.left) / rect.width) * 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, micro ? seekBottomRef : seekRef);
}
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);
};
});
$effect(() => {
if (!micro) return;
trackTitle;
trackArtist;
requestAnimationFrame(() => {
updateMicroTrackOverflow();
});
});
$effect(() => {
if (!micro || !microTrackRef || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
updateMicroTrackOverflow();
});
observer.observe(microTrackRef);
return () => {
observer.disconnect();
};
});
</script>
<div class="footer" class:compact class:micro>
{#if micro}
<div class="micro-header" data-tauri-drag-region>
<div
class="micro-track"
class:scrollable={microTrackOverflow > 0}
style="--ticker-offset: {microTrackOffset}; --ticker-duration: {microTrackDuration};"
bind:this={microTrackRef}
title={getMicroTrackLine()}
>
<span class="micro-track-text" bind:this={microTrackTextRef}>{getMicroTrackLine()}</span>
</div>
</div>
{/if}
{#if !micro}
<div
class="seek-wrapper"
bind:this={seekRef}
onmousedown={(event) => {
isDraggingSeek = true;
updateSeek(event, seekRef);
}}
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>
{/if}
<div class="controls-row" class:micro-controls={micro}>
<div class="transport">
<button class="ctrl-btn" class:active={isShuffle} onclick={onToggleShuffle} title={$t('player.shuffle')}>
<Shuffle size={micro ? 8 : compact ? 13 : 16} />
</button>
<button class="ctrl-btn" onclick={onSkipBack} title={$t('player.previous')}>
<SkipBack size={micro ? 9 : compact ? 15 : 18} fill="currentColor" />
</button>
<button
class="ctrl-btn"
onclick={onTogglePlay}
title={isPlaying ? $t('player.pause') : $t('player.play')}
aria-label={isPlaying ? $t('player.pause') : $t('player.play')}
>
{#if isPlaying}
<Pause size={micro ? 9 : compact ? 16 : 20} fill="currentColor" />
{:else}
<Play size={micro ? 9 : compact ? 16 : 20} fill="currentColor" class="play-icon" />
{/if}
</button>
<button class="ctrl-btn" onclick={onSkipForward} title={$t('player.next')}>
<SkipForward size={micro ? 9 : 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={micro ? 8 : compact ? 13 : 16} />
{:else}
<Repeat size={micro ? 8 : 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={micro ? 10 : compact ? 15 : 18} strokeWidth={2.25} />
{:else if displayVolume < 50}
<Volume1 size={micro ? 10 : compact ? 15 : 18} strokeWidth={2.25} />
{:else}
<Volume2 size={micro ? 10 : compact ? 15 : 18} strokeWidth={2.25} />
{/if}
</button>
{#if volumePopoverOpen}
<div class="volume-popover" role="group" aria-label={$t('player.volume')} onmousedown={(event) => event.stopPropagation()}>
<button class="ctrl-btn mute-btn" onclick={toggleMute} title={isMuted ? $t('player.unmute') : $t('player.mute')}>
<VolumeX size={micro ? 10 : 14} strokeWidth={2.25} />
</button>
<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="width: {displayVolume}%"></div>
<div class="volume-thumb" style="left: {displayVolume}%" class:visible={isDraggingVolume}></div>
</div>
<span class="volume-value">{displayVolume}</span>
</div>
{/if}
</div>
{#if canRenderInlineWindowControls && !micro}
<div class="window-controls-anchor" class:visible={showWindowControls}>
<MiniPlayerWindowControls
activeSurface={activeSurface!}
isPinned={isPinned}
onSurfaceChange={onSurfaceChange!}
onTogglePin={onTogglePin!}
onExpand={onExpand!}
onClose={onClose!}
onStartDrag={onStartDrag!}
/>
</div>
{/if}
</div>
{#if canRenderInlineWindowControls && micro}
<div class="window-controls-float" class:visible={showWindowControls}>
<MiniPlayerWindowControls
activeSurface={activeSurface!}
isPinned={isPinned}
onSurfaceChange={onSurfaceChange!}
onTogglePin={onTogglePin!}
onExpand={onExpand!}
onClose={onClose!}
onStartDrag={onStartDrag!}
/>
</div>
{/if}
{#if micro}
<div
class="seek-wrapper micro-seek"
bind:this={seekBottomRef}
onmousedown={(event) => {
isDraggingSeek = true;
updateSeek(event, seekBottomRef);
}}
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>
{/if}
</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;
}
.footer.micro {
border-top: none;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 1px 8px 0;
}
.micro-header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 17px;
margin-bottom: 0;
}
.micro-track {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
color: var(--alpha-70);
font-size: 11px;
line-height: 1.15;
letter-spacing: 0.01em;
}
.micro-track.scrollable {
text-overflow: clip;
}
.micro-track-text {
display: inline-block;
white-space: nowrap;
}
.micro-header:hover .micro-track.scrollable .micro-track-text {
animation: micro-track-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
@keyframes micro-track-ticker {
0%, 20% {
transform: translateX(0);
}
70%, 80% {
transform: translateX(var(--ticker-offset));
}
90%, 100% {
transform: translateX(0);
}
}
.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 {
display: none;
}
.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;
width: 100%;
margin-top: 3px;
min-height: 40px;
}
.transport {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
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 :global(.play-icon) {
margin-left: 2px;
}
.volume-anchor {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
}
.window-controls-anchor {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
opacity: 0;
pointer-events: none;
-webkit-app-region: no-drag;
app-region: no-drag;
transition: opacity 120ms ease;
z-index: 12;
}
.window-controls-anchor.visible {
opacity: 1;
pointer-events: auto;
}
.window-controls-float {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
opacity: 0;
pointer-events: none;
-webkit-app-region: no-drag;
app-region: no-drag;
transition: opacity 120ms ease;
z-index: 14;
}
.window-controls-float.visible {
opacity: 1;
pointer-events: auto;
}
.volume-trigger {
width: 30px;
height: 30px;
}
.volume-popover {
position: absolute;
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: row;
align-items: center;
gap: 7px;
padding: 7px 8px;
background: var(--bg-secondary);
border: 1px solid var(--alpha-12);
border-radius: 999px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 120;
pointer-events: auto;
}
.volume-value {
color: var(--text-muted);
font-size: 10px;
font-variant-numeric: tabular-nums;
min-width: 22px;
text-align: center;
}
.volume-rail {
position: relative;
width: 96px;
height: 5px;
background: var(--alpha-12);
border-radius: 999px;
cursor: pointer;
}
.volume-level {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--accent-primary);
border-radius: 999px;
transition: width 100ms linear;
}
.volume-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 9px;
height: 9px;
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: 20px;
height: 20px;
}
.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 .volume-trigger {
width: 26px;
height: 26px;
}
.footer.micro .controls-row {
margin-top: 1px;
min-height: 16px;
}
.footer.micro .transport {
gap: 9px;
}
.footer.micro .ctrl-btn {
width: 16px;
height: 16px;
}
.footer.micro .ctrl-btn :global(.play-icon) {
margin-left: 0;
}
.footer.micro .volume-trigger {
width: 16px;
height: 16px;
}
.footer.micro .volume-anchor {
left: 0;
}
.footer.micro .volume-popover {
left: calc(100% + 5px);
right: auto;
top: 50%;
transform: translateY(-50%);
gap: 5px;
padding: 6px 7px;
}
.footer.micro .volume-rail {
width: 74px;
height: 4px;
}
.footer.micro .seek-wrapper.micro-seek {
padding: 0;
margin: auto -8px 0;
}
.footer.micro .seek-wrapper.micro-seek .seek-track,
.footer.micro .seek-wrapper.micro-seek .seek-fill {
height: 1px;
border-radius: 0;
}
.footer.micro .seek-wrapper.micro-seek .seek-thumb {
width: 7px;
height: 7px;
}
</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,272 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { Play } from 'lucide-svelte';
import type { MiniPlayerQueueTrack } from './types';
interface Props {
tracks: MiniPlayerQueueTrack[];
currentTrackId?: string;
isPlaying?: boolean;
onTrackPlay?: (trackId: string) => void;
}
let { tracks, currentTrackId, isPlaying = false, onTrackPlay }: 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)}
{@const activeTrack = queueTrack.id === currentTrackId}
<div class="queue-row" class:active={activeTrack}>
{#if activeTrack}
<div class="active-bar" aria-hidden="true"></div>
{/if}
<div class="row-artwork-wrap">
{#if queueTrack.artwork}
<img src={queueTrack.artwork} alt={queueTrack.title} class="row-artwork" />
{:else}
<div class="row-artwork placeholder" aria-hidden="true"></div>
{/if}
{#if activeTrack && isPlaying}
<div class="artwork-playing-indicator" aria-hidden="true">
<div class="bar"></div>
<div class="bar"></div>
<div class="bar"></div>
</div>
{/if}
<button
class="artwork-play-btn"
type="button"
title={$t('player.play')}
aria-label={$t('player.play')}
onclick={(event) => {
event.stopPropagation();
onTrackPlay?.(queueTrack.id);
}}
>
<Play size={14} />
</button>
</div>
<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-wrap {
position: relative;
width: 36px;
height: 36px;
border-radius: 5px;
overflow: hidden;
flex-shrink: 0;
}
.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 {
margin-left: auto;
flex-shrink: 0;
font-size: 11px;
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.artwork-play-btn {
position: absolute;
inset: 0;
border: none;
border-radius: 5px;
padding: 0;
cursor: pointer;
background: color-mix(in srgb, black 52%, transparent);
color: var(--text-primary);
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 120ms ease;
}
.row-artwork-wrap:hover .artwork-play-btn,
.row-artwork-wrap:focus-within .artwork-play-btn {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.artwork-playing-indicator {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 2px;
padding: 3px 4px;
border-radius: 999px;
background: color-mix(in srgb, black 48%, transparent);
transition: opacity 120ms ease;
}
.row-artwork-wrap:hover .artwork-playing-indicator {
opacity: 0;
}
.artwork-playing-indicator .bar {
width: 2px;
background-color: var(--accent-primary);
border-radius: 9999px;
transform-origin: bottom;
animation: equalize 1s ease-in-out infinite;
}
.artwork-playing-indicator .bar:nth-child(1) {
height: 10px;
}
.artwork-playing-indicator .bar:nth-child(2) {
height: 13px;
animation-delay: 0.15s;
}
.artwork-playing-indicator .bar:nth-child(3) {
height: 8px;
animation-delay: 0.3s;
}
@keyframes equalize {
0%, 100% {
transform: scaleY(0.5);
opacity: 0.7;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
.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,177 @@
<script lang="ts">
import type { LyricsLine } from '$lib/stores/lyricsStore';
import type { MiniPlayerSurface, MiniPlayerQueueTrack } from './types';
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;
onQueueTrackPlay?: (trackId: string) => 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,
onQueueTrackPlay
}: Props = $props();
const compactSurface = $derived(activeSurface === 'compact');
const microSurface = $derived(activeSurface === 'micro');
let windowHovered = $state(false);
</script>
<div
class="mini-player-window"
class:compact={compactSurface}
class:micro={microSurface}
onmouseenter={() => {
windowHovered = true;
}}
onmouseleave={() => {
windowHovered = false;
}}
>
{#if !microSurface}
<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}
isPlaying={isPlaying}
onTrackPlay={onQueueTrackPlay}
/>
{:else}
<MiniPlayerLyricsSurface
lines={lyricsLines}
activeIndex={lyricsActiveIndex}
activeProgress={lyricsActiveProgress}
isSynced={lyricsIsSynced}
/>
{/if}
</div>
{/if}
<MiniPlayerFooter
compact={compactSurface}
micro={microSurface}
trackTitle={title}
trackArtist={artist}
activeSurface={activeSurface}
isPinned={isPinned}
{isPlaying}
{currentTime}
{duration}
{volume}
{isShuffle}
{repeatMode}
onTogglePlay={onTogglePlay}
onSkipBack={onSkipBack}
onSkipForward={onSkipForward}
onSeek={onSeek}
onVolumeChange={onVolumeChange}
onToggleShuffle={onToggleShuffle}
onToggleRepeat={onToggleRepeat}
onSurfaceChange={onSurfaceChange}
onTogglePin={onTogglePin}
onExpand={onExpand}
onClose={onClose}
onStartDrag={onStartDrag}
showWindowControls={windowHovered}
/>
</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;
}
.mini-player-window.micro {
border-radius: 9px;
}
.surface-area {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
@@ -0,0 +1,263 @@
<script lang="ts">
import { Minus, 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;
micro?: boolean;
}
let { activeSurface, isPinned, onSurfaceChange, onTogglePin, onExpand, onClose, onStartDrag, micro = false }: Props = $props();
let isExpanded = $state(false);
let collapseTimer: ReturnType<typeof setTimeout> | null = null;
const iconSize = $derived(micro ? 10 : 13);
const dragIconSize = $derived(micro ? 9 : 12);
const triggerIconSize = $derived(micro ? 9 : 12);
const surfaceTabs: { id: MiniPlayerSurface; icon: typeof Disc3; labelKey: string }[] = [
{ id: 'micro', icon: Minus, labelKey: 'player.miniSurfaceMicro' },
{ 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:micro
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={iconSize} />
</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={iconSize} />
</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={iconSize} />
</button>
<button
class="window-btn drag-btn"
onmousedown={(event) => {
event.preventDefault();
onStartDrag(event);
}}
title={$t('player.dragWindow')}
aria-label={$t('player.dragWindow')}
>
<Move size={dragIconSize} />
</button>
<button
class="window-btn close"
onclick={onClose}
title={$t('player.miniClose')}
aria-label={$t('player.miniClose')}
>
<X size={iconSize} />
</button>
</div>
</div>
<button class="window-trigger" title={$t('player.miniWindowControls')} aria-label={$t('player.miniWindowControls')}>
<Square size={triggerIconSize} />
</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;
padding: 0;
line-height: 0;
}
.window-trigger :global(svg) {
display: block;
margin: 0 auto;
}
.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: 328px;
opacity: 1;
margin-right: 3px;
}
.window-controls.micro {
padding: 1px;
}
.window-controls.micro .window-trigger {
width: 18px;
height: 18px;
}
.window-controls.micro .surface-tab,
.window-controls.micro .window-btn {
width: 18px;
height: 17px;
border-radius: 5px;
}
.window-controls.micro .separator {
height: 10px;
}
.window-controls.micro.expanded .expanded-buttons {
max-width: 260px;
margin-right: 2px;
}
.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;
}
@@ -1040,6 +1040,30 @@
}
}
// Mini player default view
const MINIPLAYER_VIEW_KEYS = ['remember', 'micro', 'compact', 'artwork', 'queue', 'lyrics'] as const;
let miniPlayerDefaultView = $state(
getUserItem('qbz-miniplayer-default-view') || 'remember'
);
function getMiniPlayerViewOptions(): string[] {
return MINIPLAYER_VIEW_KEYS.map(key => $t(`settings.appearance.miniplayerViews.${key}`));
}
function getMiniPlayerViewDisplayValue(): string {
return $t(`settings.appearance.miniplayerViews.${miniPlayerDefaultView}`);
}
function handleMiniPlayerViewChange(displayValue: string) {
const options = getMiniPlayerViewOptions();
const index = options.indexOf(displayValue);
if (index >= 0) {
const key = MINIPLAYER_VIEW_KEYS[index];
miniPlayerDefaultView = key;
setUserItem('qbz-miniplayer-default-view', key);
}
}
// Startup page setting
const STARTUP_PAGE_KEYS = ['home', 'last-view'] as const;
let startupPage = $state(
@@ -4075,6 +4099,14 @@
onchange={handleImmersiveViewChange}
/>
</div>
<div class="setting-row">
<span class="setting-label">{$t('settings.appearance.miniplayerDefaultView')}</span>
<Dropdown
value={getMiniPlayerViewDisplayValue()}
options={getMiniPlayerViewOptions()}
onchange={handleMiniPlayerViewChange}
/>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{$t('settings.appearance.startupPage')}</span>
+21 -1
View File
@@ -483,7 +483,18 @@
"dragWindow": "Move Window",
"normalizationOff": "Volume Normalization: Off",
"normalizationOn": "Volume Normalization: On",
"normalizationApplied": "Volume Normalization: Active"
"normalizationApplied": "Volume Normalization: Active",
"miniSurfaceMicro": "Micro",
"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",
@@ -777,6 +788,15 @@
"split-suggestions": "Split: Suggestions",
"split-queue": "Split: Queue"
},
"miniplayerDefaultView": "Mini player default view",
"miniplayerViews": {
"remember": "Remember last used",
"micro": "Micro",
"compact": "Compact",
"artwork": "Artwork",
"queue": "Queue",
"lyrics": "Lyrics"
},
"startupPage": "Startup page",
"startupPageDesc": "Choose which page to show when the app starts",
"startupPages": {
+21 -1
View File
@@ -483,7 +483,18 @@
"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",
"miniSurfaceMicro": "Micro",
"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",
@@ -777,6 +788,15 @@
"split-suggestions": "Dividido: Sugerencias",
"split-queue": "Dividido: Cola"
},
"miniplayerDefaultView": "Vista predeterminada del mini reproductor",
"miniplayerViews": {
"remember": "Recordar la última usada",
"micro": "Micro",
"compact": "Compacta",
"artwork": "Carátula",
"queue": "Cola",
"lyrics": "Letras"
},
"startupPage": "Página de inicio",
"startupPageDesc": "Elige qué página mostrar al iniciar la app",
"startupPages": {
+153 -135
View File
@@ -1,178 +1,196 @@
/**
* 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';
import { getUserItem } from '$lib/utils/userStorage';
// Miniplayer dimensions
const MINIPLAYER_WIDTH = 400;
const MINIPLAYER_HEIGHT = 200;
const MINI_PLAYER_LABEL = 'miniplayer';
const MAIN_WINDOW_LABEL = 'main';
const MINI_PLAYER_DEFAULT_VIEW_KEY = 'qbz-miniplayer-default-view';
const MINI_PLAYER_SURFACE_OPTIONS: MiniPlayerSurface[] = ['micro', 'compact', 'artwork', 'queue', 'lyrics'];
// 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;
function isMiniPlayerSurface(value: string): value is MiniPlayerSurface {
return MINI_PLAYER_SURFACE_OPTIONS.includes(value as MiniPlayerSurface);
}
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 resolveInitialSurface(requestedSurface?: MiniPlayerSurface): MiniPlayerSurface | null {
if (requestedSurface) {
return requestedSurface;
}
}
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);
const configuredDefault = getUserItem(MINI_PLAYER_DEFAULT_VIEW_KEY);
if (!configuredDefault || configuredDefault === 'remember') {
return null;
}
if (isMiniPlayerSurface(configuredDefault)) {
return configuredDefault;
}
return null;
}
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);
}
export interface AlwaysOnTopResult {
applied: boolean;
reason?: string;
}
/**
* Enter miniplayer mode - resize window and navigate to miniplayer route
*/
export async function enterMiniplayerMode(): Promise<void> {
console.log('[MiniPlayer] Entering miniplayer mode...');
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}`;
}
try {
const window = getCurrentWindow();
async function waitForWindowCreation(windowRef: WebviewWindow): Promise<void> {
await new Promise<void>((resolve, reject) => {
let done = false;
// 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: 57,
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();
const initialSurface = resolveInitialSurface(surface);
if (initialSurface) {
setMiniPlayerSurface(initialSurface);
}
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();
}
+115 -1
View File
@@ -13,6 +13,39 @@ let isFullScreenOpen = false;
let isFocusModeOpen = false;
let isCastPickerOpen = false;
export type MiniPlayerSurface = 'micro' | '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 MINI_PLAYER_SURFACES: MiniPlayerSurface[] = ['micro', 'compact', 'artwork', 'queue', 'lyrics'];
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 +62,42 @@ 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: MINI_PLAYER_SURFACES.includes(parsed.surface as MiniPlayerSurface)
? (parsed.surface as MiniPlayerSurface)
: 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 >= 57 ? 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 +107,10 @@ export function subscribe(listener: () => void): () => void {
return () => listeners.delete(listener);
}
export function initMiniPlayerState(): void {
loadMiniPlayerState();
}
// ============ Queue Panel ============
export function getQueueOpen(): boolean {
@@ -126,6 +199,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 +333,7 @@ export interface UIState {
playlistModalTrackIds: number[];
playlistModalTracksAreLocal: boolean;
isPlaylistImportOpen: boolean;
miniPlayer: MiniPlayerState;
}
export function getUIState(): UIState {
@@ -233,6 +346,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}
+75 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
@@ -6,6 +7,78 @@
}
let { children }: Props = $props();
const THEME_STORAGE_KEY = 'qbz-theme';
const AUTO_THEME_VARS_STORAGE_KEY = 'qbz-auto-theme-vars';
const FONT_STORAGE_KEY = 'qbz-font-family';
let appliedAutoThemeVarNames: string[] = [];
function clearAutoThemeVars(): void {
const root = document.documentElement;
for (const varName of appliedAutoThemeVarNames) {
root.style.removeProperty(varName);
}
appliedAutoThemeVarNames = [];
}
function applyThemeFromStorage(): void {
const root = document.documentElement;
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) ?? '';
const savedFont = localStorage.getItem(FONT_STORAGE_KEY) ?? '';
if (savedTheme) {
root.setAttribute('data-theme', savedTheme);
} else {
root.removeAttribute('data-theme');
}
if (savedFont) {
root.setAttribute('data-font', savedFont);
} else {
root.removeAttribute('data-font');
}
clearAutoThemeVars();
if (savedTheme !== 'auto') return;
const rawAutoVars = localStorage.getItem(AUTO_THEME_VARS_STORAGE_KEY);
if (!rawAutoVars) return;
try {
const parsed = JSON.parse(rawAutoVars) as Record<string, string>;
for (const [varName, value] of Object.entries(parsed)) {
root.style.setProperty(varName, value);
}
appliedAutoThemeVarNames = Object.keys(parsed);
} catch {
// Ignore invalid storage payloads.
}
}
onMount(() => {
applyThemeFromStorage();
const handleStorage = (event: StorageEvent): void => {
if (!event.key) {
applyThemeFromStorage();
return;
}
if (
event.key === THEME_STORAGE_KEY ||
event.key === AUTO_THEME_VARS_STORAGE_KEY ||
event.key === FONT_STORAGE_KEY
) {
applyThemeFromStorage();
}
};
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener('storage', handleStorage);
clearAutoThemeVars();
};
});
</script>
<!-- Minimal layout for MiniPlayer - no sidebar, titlebar, etc. -->
@@ -30,10 +103,10 @@
overflow: hidden;
}
.miniplayer-frame {
.miniplayer-frame {
width: 100%;
height: 100%;
background: #18181b;
background: transparent;
overflow: hidden;
}
</style>
File diff suppressed because it is too large. Load diff