Files
qbz/src/lib/components/immersive/shared/ProgressBar.svelte
T
vicrodh 7c9f1820f8 feat(immersive): create unified ImmersivePlayer component
New component architecture:
- ImmersivePlayer.svelte: Main container with tab system
- ImmersiveHeader.svelte: Tabs (lyrics/credits/suggestions/visualizer)
- ImmersiveControls.svelte: Bottom bar with all playback controls
- ImmersiveArtwork.svelte: Album art with floating/vinyl variants
- ImmersiveBackground.svelte: Optimized blur background (from Phase 1)

Panel components:
- LyricsPanel: Wrapper for existing LyricsLines
- CreditsPanel: Placeholder (Phase 3)
- SuggestionsPanel: Placeholder (Phase 4)
- VisualizerPanel: Placeholder (Phase 5)

Shared components:
- ProgressBar: Reusable seek bar with keyboard support
- VolumeSlider: Reusable volume control

Features:
- Tab-based navigation (L/C/S/V keyboard shortcuts)
- Auto-hide UI after 4 seconds of inactivity
- Full keyboard control (Space, Arrows, Escape)
- Responsive layout (desktop/tablet/mobile)
- Accessibility: ARIA roles, focus management
2026-01-30 16:11:03 -06:00

180 lines
4.1 KiB
Svelte

<script lang="ts">
interface Props {
currentTime: number;
duration: number;
onSeek: (time: number) => void;
showTime?: boolean;
size?: 'small' | 'normal';
}
let {
currentTime,
duration,
onSeek,
showTime = true,
size = 'normal'
}: Props = $props();
let progressRef: HTMLDivElement | null = $state(null);
let isDragging = $state(false);
const progress = $derived(duration > 0 ? (currentTime / duration) * 100 : 0);
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function handleMouseDown(e: MouseEvent) {
isDragging = true;
updateProgress(e);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
function handleMouseMove(e: MouseEvent) {
if (isDragging) updateProgress(e);
}
function handleMouseUp() {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
function updateProgress(e: MouseEvent) {
if (!progressRef) return;
const rect = progressRef.getBoundingClientRect();
const percentage = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
onSeek(Math.round((percentage / 100) * duration));
}
function handleKeydown(e: KeyboardEvent) {
const step = e.shiftKey ? 10 : 5;
if (e.key === 'ArrowRight') {
e.preventDefault();
onSeek(Math.min(duration, currentTime + step));
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
onSeek(Math.max(0, currentTime - step));
}
}
</script>
<div class="progress-container" class:small={size === 'small'}>
{#if showTime}
<span class="time">{formatTime(currentTime)}</span>
{/if}
<div
class="progress-track"
bind:this={progressRef}
onmousedown={handleMouseDown}
onkeydown={handleKeydown}
role="slider"
tabindex="0"
aria-valuenow={currentTime}
aria-valuemin={0}
aria-valuemax={duration}
aria-label="Seek"
>
<div class="progress-fill" style="width: {progress}%"></div>
<div class="progress-thumb" style="left: {progress}%"></div>
</div>
{#if showTime}
<span class="time">{formatTime(duration)}</span>
{/if}
</div>
<style>
.progress-container {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.progress-container.small {
gap: 8px;
}
.time {
font-size: 12px;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--alpha-60, rgba(255, 255, 255, 0.6));
min-width: 40px;
}
.time:last-of-type {
text-align: right;
}
.progress-track {
flex: 1;
height: 4px;
background: var(--alpha-20, rgba(255, 255, 255, 0.2));
border-radius: 2px;
position: relative;
cursor: pointer;
transition: height 150ms ease;
}
.progress-track:hover,
.progress-track:focus-visible {
height: 6px;
}
.progress-track:focus-visible {
outline: 2px solid var(--accent-primary, #7c3aed);
outline-offset: 2px;
}
.progress-fill {
height: 100%;
background: var(--text-primary, white);
border-radius: 2px;
transition: width 100ms linear;
}
.progress-thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--text-primary, white);
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 150ms ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
.progress-track:hover .progress-thumb,
.progress-track:focus-visible .progress-thumb {
opacity: 1;
}
/* Small variant */
.progress-container.small .time {
font-size: 11px;
min-width: 36px;
}
.progress-container.small .progress-track {
height: 3px;
}
.progress-container.small .progress-track:hover {
height: 5px;
}
.progress-container.small .progress-thumb {
width: 10px;
height: 10px;
}
</style>