feat(miniplayer): refine micro surface layout and controls

cx
This commit is contained in:
vicrodh committed 2026-02-26 01:45:59 -06:00
1 parent 99c8ae1ed3
commit d6b0e0f5ea
8 files changed
+491 -140

No files matched your search

@@ -1,6 +1,8 @@
<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;
@@ -10,6 +12,11 @@
isShuffle: boolean;
repeatMode: 'off' | 'all' | 'one';
compact?: boolean;
micro?: boolean;
trackTitle?: string;
trackArtist?: string;
activeSurface?: MiniPlayerSurface;
isPinned?: boolean;
onTogglePlay: () => void;
onSkipBack: () => void;
onSkipForward: () => void;
@@ -17,6 +24,11 @@
onVolumeChange: (volume: number) => void;
onToggleShuffle: () => void;
onToggleRepeat: () => void;
onSurfaceChange?: (surface: MiniPlayerSurface) => void;
onTogglePin?: () => void;
onExpand?: () => void;
onClose?: () => void;
onStartDrag?: (event: MouseEvent) => void;
}
let {
@@ -27,26 +39,52 @@
isShuffle,
repeatMode,
compact = false,
micro = false,
trackTitle,
trackArtist,
activeSurface,
isPinned = false,
onTogglePlay,
onSkipBack,
onSkipForward,
onSeek,
onVolumeChange,
onToggleShuffle,
onToggleRepeat
onToggleRepeat,
onSurfaceChange,
onTogglePin,
onExpand,
onClose,
onStartDrag
}: 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(
micro && !!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';
@@ -55,9 +93,19 @@
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
function updateSeek(event: MouseEvent): void {
if (!seekRef || duration <= 0) return;
const rect = seekRef.getBoundingClientRect();
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));
}
@@ -65,7 +113,7 @@
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 percentage = Math.max(0, Math.min(100, ((event.clientX - rect.left) / rect.width) * 100));
const nextVolume = Math.round(percentage);
onVolumeChange(nextVolume);
if (nextVolume > 0) {
@@ -97,7 +145,7 @@
function handleMouseMove(event: MouseEvent): void {
if (isDraggingSeek) {
updateSeek(event);
updateSeek(event, micro ? seekBottomRef : seekRef);
}
if (isDraggingVolume) {
updateVolume(event);
@@ -129,42 +177,95 @@
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>
<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 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>
{#if canRenderInlineWindowControls}
<div class="micro-window-controls">
<MiniPlayerWindowControls
micro
activeSurface={activeSurface!}
isPinned={isPinned}
onSurfaceChange={onSurfaceChange!}
onTogglePin={onTogglePin!}
onExpand={onExpand!}
onClose={onClose!}
onStartDrag={onStartDrag!}
/>
</div>
{/if}
</div>
<div class="seek-thumb" style="left: {progress}%" class:visible={isDraggingSeek}></div>
</div>
{/if}
<div class="times">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
{#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="controls-row">
<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={compact ? 13 : 16} />
<Shuffle size={micro ? 8 : compact ? 13 : 16} />
</button>
<button class="ctrl-btn" onclick={onSkipBack} title={$t('player.previous')}>
<SkipBack size={compact ? 15 : 18} fill="currentColor" />
<SkipBack size={micro ? 9 : compact ? 15 : 18} fill="currentColor" />
</button>
<button
@@ -174,14 +275,14 @@
aria-label={isPlaying ? $t('player.pause') : $t('player.play')}
>
{#if isPlaying}
<Pause size={compact ? 16 : 20} fill="currentColor" />
<Pause size={micro ? 9 : compact ? 16 : 20} fill="currentColor" />
{:else}
<Play size={compact ? 16 : 20} fill="currentColor" class="play-icon" />
<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={compact ? 15 : 18} fill="currentColor" />
<SkipForward size={micro ? 9 : compact ? 15 : 18} fill="currentColor" />
</button>
<button
@@ -191,9 +292,9 @@
title={repeatMode === 'off' ? $t('player.repeat') : repeatMode === 'all' ? $t('player.repeatAll') : $t('player.repeatOne')}
>
{#if repeatMode === 'one'}
<Repeat1 size={compact ? 13 : 16} />
<Repeat1 size={micro ? 8 : compact ? 13 : 16} />
{:else}
<Repeat size={compact ? 13 : 16} />
<Repeat size={micro ? 8 : compact ? 13 : 16} />
{/if}
</button>
</div>
@@ -208,17 +309,19 @@
aria-label={$t('player.volume')}
>
{#if displayVolume === 0}
<VolumeX size={compact ? 13 : 14} />
<VolumeX size={micro ? 10 : compact ? 15 : 18} strokeWidth={2.25} />
{:else if displayVolume < 50}
<Volume1 size={compact ? 13 : 14} />
<Volume1 size={micro ? 10 : compact ? 15 : 18} strokeWidth={2.25} />
{:else}
<Volume2 size={compact ? 13 : 14} />
<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')}>
<span class="volume-value">{displayVolume}</span>
<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"
@@ -233,17 +336,37 @@
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 class="volume-level" style="width: {displayVolume}%"></div>
<div class="volume-thumb" style="left: {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>
<span class="volume-value">{displayVolume}</span>
</div>
{/if}
</div>
</div>
{#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>
@@ -260,6 +383,84 @@
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: calc(100% - 52px);
max-width: calc(100% - 52px);
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);
}
}
.micro-window-controls {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
-webkit-app-region: no-drag;
app-region: no-drag;
opacity: 0;
pointer-events: none;
contain: paint;
transition: opacity 120ms ease;
}
.micro-header:hover .micro-window-controls,
.micro-window-controls:hover {
opacity: 1;
pointer-events: auto;
}
.seek-wrapper {
position: relative;
cursor: pointer;
@@ -281,21 +482,7 @@
}
.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;
display: none;
}
.times {
@@ -309,14 +496,16 @@
.controls-row {
position: relative;
display: flex;
justify-content: center;
align-items: center;
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;
@@ -364,42 +553,46 @@
.volume-anchor {
position: absolute;
right: 0;
left: 0;
top: 50%;
transform: translateY(-50%);
}
.volume-trigger {
width: 28px;
height: 28px;
width: 30px;
height: 30px;
}
.volume-popover {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 10px 8px;
gap: 7px;
padding: 7px 8px;
background: var(--bg-secondary);
border: 1px solid var(--alpha-12);
border-radius: 10px;
border-radius: 999px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 50;
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: 6px;
height: 78px;
width: 96px;
height: 5px;
background: var(--alpha-12);
border-radius: 999px;
cursor: pointer;
@@ -407,20 +600,20 @@
.volume-level {
position: absolute;
bottom: 0;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--accent-primary);
border-radius: 999px;
transition: height 100ms linear;
transition: width 100ms linear;
}
.volume-thumb {
position: absolute;
left: 50%;
transform: translate(-50%, 50%);
width: 11px;
height: 11px;
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);
@@ -435,8 +628,8 @@
}
.mute-btn {
width: 24px;
height: 24px;
width: 20px;
height: 20px;
}
.footer.compact .seek-wrapper {
@@ -471,7 +664,78 @@
}
.footer.compact .volume-trigger {
width: 24px;
height: 24px;
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.play {
width: 16px;
height: 16px;
background: transparent;
color: var(--alpha-70);
box-shadow: none;
}
.footer.micro .ctrl-btn.play:hover {
background: var(--alpha-8);
color: var(--text-primary);
transform: none;
}
.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>
@@ -75,45 +75,56 @@
}: Props = $props();
const compactSurface = $derived(activeSurface === 'compact');
const microSurface = $derived(activeSurface === 'micro');
</script>
<div
class="mini-player-window"
class:compact={compactSurface}
class:micro={microSurface}
>
<div class="titlebar-row">
<div class="controls-slot">
<MiniPlayerWindowControls
{activeSurface}
isPinned={isPinned}
onSurfaceChange={onSurfaceChange}
onTogglePin={onTogglePin}
onExpand={onExpand}
onClose={onClose}
onStartDrag={onStartDrag}
/>
{#if !microSurface}
<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>
{/if}
<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>
{#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} />
{: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}
@@ -127,6 +138,11 @@
onVolumeChange={onVolumeChange}
onToggleShuffle={onToggleShuffle}
onToggleRepeat={onToggleRepeat}
onSurfaceChange={onSurfaceChange}
onTogglePin={onTogglePin}
onExpand={onExpand}
onClose={onClose}
onStartDrag={onStartDrag}
/>
</div>
@@ -149,6 +165,10 @@
border-radius: 9px;
}
.mini-player-window.micro {
border-radius: 9px;
}
.titlebar-row {
position: absolute;
top: 6px;
@@ -1,5 +1,5 @@
<script lang="ts">
import { Disc3, Image, ListMusic, AlignLeft, Maximize2, Pin, Move, X, Square } from 'lucide-svelte';
import { Minus, Disc3, Image, ListMusic, AlignLeft, Maximize2, Pin, Move, X, Square } from 'lucide-svelte';
import { t } from '$lib/i18n';
import type { MiniPlayerSurface } from './types';
@@ -11,14 +11,20 @@
onExpand: () => void;
onClose: () => void;
onStartDrag: (event: MouseEvent) => void;
micro?: boolean;
}
let { activeSurface, isPinned, onSurfaceChange, onTogglePin, onExpand, onClose, onStartDrag }: Props = $props();
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' },
@@ -43,6 +49,7 @@
<div
class="window-controls"
class:micro
class:expanded={isExpanded}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
@@ -59,7 +66,7 @@
title={$t(surfaceTab.labelKey)}
aria-label={$t(surfaceTab.labelKey)}
>
<surfaceTab.icon size={13} />
<surfaceTab.icon size={iconSize} />
</button>
{/each}
</div>
@@ -73,7 +80,7 @@
title={$t('player.miniExpand')}
aria-label={$t('player.miniExpand')}
>
<Maximize2 size={13} />
<Maximize2 size={iconSize} />
</button>
<button
@@ -83,7 +90,7 @@
title={isPinned ? $t('player.miniAlwaysOnTopDisable') : $t('player.miniAlwaysOnTopEnable')}
aria-label={isPinned ? $t('player.miniAlwaysOnTopDisable') : $t('player.miniAlwaysOnTopEnable')}
>
<Pin size={13} />
<Pin size={iconSize} />
</button>
<button
@@ -95,7 +102,7 @@
title={$t('player.dragWindow')}
aria-label={$t('player.dragWindow')}
>
<Move size={12} />
<Move size={dragIconSize} />
</button>
<button
@@ -104,13 +111,13 @@
title={$t('player.miniClose')}
aria-label={$t('player.miniClose')}
>
<X size={13} />
<X size={iconSize} />
</button>
</div>
</div>
<button class="window-trigger" title={$t('player.miniWindowControls')} aria-label={$t('player.miniWindowControls')}>
<Square size={12} />
<Square size={triggerIconSize} />
</button>
</div>
@@ -146,6 +153,13 @@
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 {
@@ -164,11 +178,36 @@
}
.window-controls.expanded .expanded-buttons {
max-width: 280px;
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;
+1
View File
@@ -484,6 +484,7 @@
"normalizationOff": "Volume Normalization: Off",
"normalizationOn": "Volume Normalization: On",
"normalizationApplied": "Volume Normalization: Active",
"miniSurfaceMicro": "Micro",
"miniSurfaceCompact": "Compact",
"miniSurfaceArtwork": "Artwork",
"miniSurfaceQueue": "Queue",
+1
View File
@@ -484,6 +484,7 @@
"normalizationOff": "Normalización de volumen: Desactivada",
"normalizationOn": "Normalización de volumen: Activada",
"normalizationApplied": "Normalización de volumen: Activa",
"miniSurfaceMicro": "Micro",
"miniSurfaceCompact": "Compacto",
"miniSurfaceArtwork": "Portada",
"miniSurfaceQueue": "Cola",
+1 -1
View File
@@ -66,7 +66,7 @@ async function getOrCreateMiniPlayerWindow(): Promise<Window> {
x: geometry.x,
y: geometry.y,
minWidth: 340,
minHeight: 200,
minHeight: 57,
resizable: true,
decorations: getUseSystemTitleBar(),
alwaysOnTop: miniState.alwaysOnTop,
+6 -3
View File
@@ -13,7 +13,7 @@ let isFullScreenOpen = false;
let isFocusModeOpen = false;
let isCastPickerOpen = false;
export type MiniPlayerSurface = 'compact' | 'artwork' | 'queue' | 'lyrics';
export type MiniPlayerSurface = 'micro' | 'compact' | 'artwork' | 'queue' | 'lyrics';
export interface MiniPlayerGeometry {
width: number;
@@ -30,6 +30,7 @@ export interface MiniPlayerState {
}
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',
@@ -78,11 +79,13 @@ function loadMiniPlayerState(): void {
const height = parsed.geometry?.height;
miniPlayer = {
open: false,
surface: parsed.surface ?? DEFAULT_MINI_PLAYER_STATE.surface,
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 >= 200 ? height : DEFAULT_MINI_PLAYER_STATE.geometry.height,
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
}
+34 -11
View File
@@ -65,11 +65,24 @@
height: 194
};
const MICRO_GEOMETRY = {
width: 380,
height: 57
};
const MICRO_MIN_HEIGHT = 57;
const COMPACT_MIN_HEIGHT = 170;
const EXPANDED_MIN_HEIGHT = 420;
const EXPANDED_DEFAULT_GEOMETRY = {
width: 380,
height: 540
};
function isCondensedSurface(surface: MiniPlayerSurface): boolean {
return surface === 'micro' || surface === 'compact';
}
function formatQualityLabel(track: PlayerState['currentTrack']): string | undefined {
if (!track) return undefined;
if (track.bitDepth && track.samplingRate) {
@@ -121,15 +134,25 @@
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 });
if (isCondensedSurface(surface)) {
const condensedGeometry = surface === 'micro' ? MICRO_GEOMETRY : COMPACT_GEOMETRY;
const condensedMinHeight = surface === 'micro' ? MICRO_MIN_HEIGHT : COMPACT_MIN_HEIGHT;
await appWindow.setMinSize(new LogicalSize(340, condensedMinHeight));
if (!isCondensedSurface(miniState.surface)) {
const currentSize = await appWindow.innerSize();
rememberExpandedGeometry(currentSize.width, currentSize.height);
}
await appWindow.setSize(new LogicalSize(condensedGeometry.width, condensedGeometry.height));
setMiniPlayerGeometry({ width: condensedGeometry.width, height: condensedGeometry.height });
miniState = getMiniPlayerState();
return;
}
await appWindow.setMinSize(new LogicalSize(340, EXPANDED_MIN_HEIGHT));
const target = {
width: lastExpandedGeometry.width || EXPANDED_DEFAULT_GEOMETRY.width,
height: lastExpandedGeometry.height || EXPANDED_DEFAULT_GEOMETRY.height
@@ -153,8 +176,8 @@
miniState = getMiniPlayerState();
setMiniPlayerOpen(true);
if (miniState.surface === 'compact') {
await applySurfaceGeometry('compact');
if (isCondensedSurface(miniState.surface)) {
await applySurfaceGeometry(miniState.surface);
} else {
rememberExpandedGeometry(miniState.geometry.width, miniState.geometry.height);
}
@@ -184,7 +207,7 @@
unlistenResized = await appWindow.onResized(({ payload }) => {
setMiniPlayerGeometry({ width: payload.width, height: payload.height });
if (miniState.surface !== 'compact') {
if (!isCondensedSurface(miniState.surface)) {
rememberExpandedGeometry(payload.width, payload.height);
}
});
@@ -215,9 +238,9 @@
async function handleSurfaceChange(surface: MiniPlayerSurface): Promise<void> {
if (surface === miniState.surface) return;
if (surface === 'compact') {
await applySurfaceGeometry('compact');
} else if (miniState.surface === 'compact') {
const targetCondensed = isCondensedSurface(surface);
const currentCondensed = isCondensedSurface(miniState.surface);
if (targetCondensed || currentCondensed) {
await applySurfaceGeometry(surface);
}