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