feat(player): add volume sliders to FocusMode and MiniPlayer

cx
This commit is contained in:
vicrodh committed 2026-01-19 11:26:08 -06:00
1 parent 318e3d795a
commit 395fd165d4
3 files changed
+216 -4

No files matched your search

+111 -4
View File
@@ -22,6 +22,8 @@
currentTime: number;
duration: number;
onSeek: (time: number) => void;
volume: number;
onVolumeChange: (volume: number) => void;
onContextClick?: () => void;
// Lyrics props
lyricsLines?: LyricsLine[];
@@ -45,6 +47,8 @@
currentTime,
duration,
onSeek,
volume,
onVolumeChange,
onContextClick,
lyricsLines = [],
lyricsActiveIndex = -1,
@@ -67,6 +71,8 @@
let showControls = $state(true);
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
let progressRef: HTMLDivElement | null = $state(null);
let volumeRef: HTMLDivElement | null = $state(null);
let isDraggingVolume = $state(false);
const progress = $derived((currentTime / duration) * 100 || 0);
const hasLyrics = $derived(lyricsLines.length > 0);
@@ -96,6 +102,31 @@
}
}
function handleVolumeMouseDown(e: MouseEvent) {
e.stopPropagation();
isDraggingVolume = true;
updateVolume(e);
document.addEventListener('mousemove', handleVolumeMouseMove);
document.addEventListener('mouseup', handleVolumeMouseUp);
}
function handleVolumeMouseMove(e: MouseEvent) {
if (isDraggingVolume) updateVolume(e);
}
function handleVolumeMouseUp() {
isDraggingVolume = false;
document.removeEventListener('mousemove', handleVolumeMouseMove);
document.removeEventListener('mouseup', handleVolumeMouseUp);
}
function updateVolume(e: MouseEvent) {
if (!volumeRef) return;
const rect = volumeRef.getBoundingClientRect();
const percentage = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
onVolumeChange(Math.round(percentage));
}
function showControlsTemporarily() {
showControls = true;
if (hideTimeout) clearTimeout(hideTimeout);
@@ -239,8 +270,26 @@
</div>
</div>
<!-- Right: Spacer for balance -->
<div class="right-spacer"></div>
<!-- Right: Volume Control -->
<div class="right-controls">
<div class="volume-control">
<div class="volume-value" class:visible={isDraggingVolume}>{volume}</div>
<div
class="volume-bar"
bind:this={volumeRef}
onmousedown={handleVolumeMouseDown}
role="slider"
tabindex="0"
aria-valuenow={volume}
aria-valuemin={0}
aria-valuemax={100}
title={$t('player.volume')}
>
<div class="volume-fill" style="width: {volume}%"></div>
<div class="volume-thumb" style="left: {volume}%"></div>
</div>
</div>
</div>
</div>
</div>
{/if}
@@ -620,8 +669,66 @@
opacity: 1;
}
.right-spacer {
.right-controls {
min-width: 200px;
display: flex;
justify-content: flex-end;
}
.volume-control {
position: relative;
display: flex;
align-items: center;
}
.volume-bar {
width: 140px;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.15);
cursor: pointer;
position: relative;
}
.volume-fill {
height: 100%;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
}
.volume-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
opacity: 0;
transition: opacity 150ms ease;
}
.volume-bar:hover .volume-thumb {
opacity: 1;
}
.volume-value {
position: absolute;
right: 0;
top: -26px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 12px;
opacity: 0;
transform: translateY(4px);
transition: opacity 150ms ease, transform 150ms ease;
}
.volume-value.visible {
opacity: 1;
transform: translateY(0);
}
/* Responsive Breakpoints */
@@ -667,7 +774,7 @@
display: none;
}
.right-spacer {
.right-controls {
display: none;
}
+2
View File
@@ -2391,6 +2391,8 @@
{currentTime}
{duration}
onSeek={handleSeek}
{volume}
onVolumeChange={handleVolumeChange}
lyricsLines={lyricsLines.map(l => ({ text: l.text }))}
lyricsActiveIndex={lyricsActiveIndex}
lyricsActiveProgress={lyricsActiveProgress}
+103
View File
@@ -40,10 +40,12 @@
let repeatMode = $state<RepeatMode>('off');
let isDragging = $state(false);
let isDraggingProgress = $state(false);
let isDraggingVolume = $state(false);
let queueCount = $state(0);
// Refs
let progressRef: HTMLDivElement;
let volumeRef: HTMLDivElement | null = null;
// Derived state
const progress = $derived(playerState.duration > 0 ? (playerState.currentTime / playerState.duration) * 100 : 0);
@@ -164,6 +166,31 @@
}
}
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();
@@ -304,6 +331,24 @@
</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} />
@@ -479,6 +524,7 @@
justify-content: space-between;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.media-controls {
@@ -493,6 +539,63 @@
gap: 2px;
}
.volume-control {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
}
.volume-bar {
width: 90px;
height: 4px;
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
cursor: pointer;
position: relative;
}
.volume-fill {
height: 100%;
border-radius: 999px;
background: rgba(255, 255, 255, 0.8);
}
.volume-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
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: #fff;
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;