Merge branch 'feature/miniplayer' into pre-release
This commit is contained in:
18 files changed
+2692
-704
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
+391
-545
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user