feat(lyrics): add auto-scroll, karaoke effects, and FocusMode integration
- LyricsLines: improved auto-scroll with prevActiveIndex tracking - LyricsLines: added immersive mode for larger text in FocusMode - LyricsLines: enhanced opacity fading and text-shadow for active line - FocusMode: redesigned with blurred artwork background like Cider - FocusMode: integrated lyrics panel on right side with proper styling - FocusMode: added bottom bar with track info, controls, and progress - +page.svelte: wire lyrics props to FocusMode component - +page.svelte: start lyrics updates when either sidebar or FocusMode open
This commit is contained in:
1 parent
c32f25ab5e
commit
0676989c71
3 files changed
+408
-186
No files matched your search
+340
-138
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import { X, SkipBack, Play, Pause, SkipForward } from 'lucide-svelte';
|
import { X, SkipBack, Play, Pause, SkipForward } from 'lucide-svelte';
|
||||||
|
import LyricsLines from './lyrics/LyricsLines.svelte';
|
||||||
|
|
||||||
|
interface LyricsLine {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -10,9 +14,17 @@
|
|||||||
artist: string;
|
artist: string;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
onTogglePlay: () => void;
|
onTogglePlay: () => void;
|
||||||
|
onSkipBack?: () => void;
|
||||||
|
onSkipForward?: () => void;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
onSeek: (time: number) => void;
|
onSeek: (time: number) => void;
|
||||||
|
// Lyrics props
|
||||||
|
lyricsLines?: LyricsLine[];
|
||||||
|
lyricsActiveIndex?: number;
|
||||||
|
lyricsActiveProgress?: number;
|
||||||
|
lyricsLoading?: boolean;
|
||||||
|
lyricsError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -23,20 +35,30 @@
|
|||||||
artist,
|
artist,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
onTogglePlay,
|
onTogglePlay,
|
||||||
|
onSkipBack,
|
||||||
|
onSkipForward,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
onSeek
|
onSeek,
|
||||||
|
lyricsLines = [],
|
||||||
|
lyricsActiveIndex = -1,
|
||||||
|
lyricsActiveProgress = 0,
|
||||||
|
lyricsLoading = false,
|
||||||
|
lyricsError = null
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let showControls = $state(false);
|
let showControls = $state(true);
|
||||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let progressRef: HTMLDivElement;
|
let progressRef: HTMLDivElement;
|
||||||
|
|
||||||
const progress = $derived((currentTime / duration) * 100);
|
const progress = $derived((currentTime / duration) * 100 || 0);
|
||||||
|
const hasLyrics = $derived(lyricsLines.length > 0);
|
||||||
|
const showLyricsPane = $derived(hasLyrics || lyricsLoading);
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
|
if (!seconds || !isFinite(seconds)) return '0:00';
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = seconds % 60;
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +73,7 @@
|
|||||||
function showControlsTemporarily() {
|
function showControlsTemporarily() {
|
||||||
showControls = true;
|
showControls = true;
|
||||||
if (hideTimeout) clearTimeout(hideTimeout);
|
if (hideTimeout) clearTimeout(hideTimeout);
|
||||||
hideTimeout = setTimeout(() => (showControls = false), 3000);
|
hideTimeout = setTimeout(() => (showControls = false), 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyPress(e: KeyboardEvent) {
|
function handleKeyPress(e: KeyboardEvent) {
|
||||||
@@ -70,6 +92,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
showControlsTemporarily();
|
||||||
document.addEventListener('keydown', handleKeyPress);
|
document.addEventListener('keydown', handleKeyPress);
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -83,66 +106,105 @@
|
|||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div class="focus-mode" onclick={showControlsTemporarily} role="presentation">
|
<div class="focus-mode" onclick={showControlsTemporarily} role="presentation">
|
||||||
|
<!-- Blurred Background Artwork -->
|
||||||
|
<div class="background">
|
||||||
|
<img src={artwork} alt="" aria-hidden="true" />
|
||||||
|
<div class="background-overlay"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Close Button -->
|
<!-- Close Button -->
|
||||||
<button class="close-btn" class:visible={showControls} onclick={onClose}>
|
<button class="close-btn" class:visible={showControls} onclick={onClose}>
|
||||||
<X size={32} />
|
<X size={28} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Album Artwork -->
|
<!-- Main Content Grid -->
|
||||||
<div class="artwork-container">
|
<div class="content" class:has-lyrics={showLyricsPane}>
|
||||||
<img src={artwork} alt={trackTitle} />
|
<!-- Left Side: Artwork -->
|
||||||
|
<div class="artwork-side">
|
||||||
|
<div class="artwork-container">
|
||||||
|
<img src={artwork} alt={trackTitle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Overlay Controls -->
|
<!-- Right Side: Lyrics -->
|
||||||
<div class="overlay" class:visible={showControls}>
|
{#if showLyricsPane}
|
||||||
|
<div class="lyrics-side">
|
||||||
|
{#if lyricsLoading}
|
||||||
|
<div class="lyrics-loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Loading lyrics...</span>
|
||||||
|
</div>
|
||||||
|
{:else if lyricsError}
|
||||||
|
<div class="lyrics-error">{lyricsError}</div>
|
||||||
|
{:else}
|
||||||
|
<LyricsLines
|
||||||
|
lines={lyricsLines}
|
||||||
|
activeIndex={lyricsActiveIndex}
|
||||||
|
activeProgress={lyricsActiveProgress}
|
||||||
|
center={false}
|
||||||
|
compact={false}
|
||||||
|
immersive={true}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Bar: Track Info + Controls + Progress -->
|
||||||
|
<div class="bottom-bar" class:visible={showControls}>
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-artwork">
|
||||||
|
<img src={artwork} alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="track-meta">
|
||||||
|
<div class="track-title">{trackTitle}</div>
|
||||||
|
<div class="track-artist">{artist}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="center-controls">
|
||||||
<div class="playback-controls">
|
<div class="playback-controls">
|
||||||
<button class="nav-btn">
|
<button class="control-btn" onclick={onSkipBack} disabled={!onSkipBack}>
|
||||||
<SkipBack size={40} />
|
<SkipBack size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="play-btn"
|
class="control-btn play"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onTogglePlay();
|
onTogglePlay();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if isPlaying}
|
{#if isPlaying}
|
||||||
<Pause size={36} fill="white" color="white" />
|
<Pause size={28} />
|
||||||
{:else}
|
{:else}
|
||||||
<Play size={36} fill="white" color="white" class="play-icon" />
|
<Play size={28} class="play-icon" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn">
|
<button class="control-btn" onclick={onSkipForward} disabled={!onSkipForward}>
|
||||||
<SkipForward size={40} />
|
<SkipForward size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Track Info -->
|
<div class="progress-section">
|
||||||
<div class="track-info">
|
<span class="time">{formatTime(currentTime)}</span>
|
||||||
<h1 class="title">{trackTitle}</h1>
|
<div
|
||||||
<h2 class="artist">{artist}</h2>
|
class="progress-bar"
|
||||||
</div>
|
bind:this={progressRef}
|
||||||
|
onclick={handleProgressClick}
|
||||||
|
role="slider"
|
||||||
|
tabindex="0"
|
||||||
|
aria-valuenow={currentTime}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={duration}
|
||||||
|
>
|
||||||
|
<div class="progress-fill" style="width: {progress}%"></div>
|
||||||
|
<div class="progress-thumb" style="left: {progress}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="time">{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Progress Bar -->
|
<div class="spacer"></div>
|
||||||
<div class="progress-container" class:visible={showControls}>
|
|
||||||
<div
|
|
||||||
class="progress-bar"
|
|
||||||
bind:this={progressRef}
|
|
||||||
onclick={handleProgressClick}
|
|
||||||
role="slider"
|
|
||||||
tabindex="0"
|
|
||||||
aria-valuenow={currentTime}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={duration}
|
|
||||||
>
|
|
||||||
<div class="progress-fill" style="width: {progress}%"></div>
|
|
||||||
<div class="progress-thumb" style="left: {progress}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="time-display">
|
|
||||||
<span>{formatTime(currentTime)}</span>
|
|
||||||
<span>{formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -154,9 +216,7 @@
|
|||||||
z-index: 110;
|
z-index: 110;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
background-color: #000;
|
||||||
justify-content: center;
|
|
||||||
background-color: #0a0a0a;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
animation: fadeIn 300ms ease-out;
|
animation: fadeIn 300ms ease-out;
|
||||||
}
|
}
|
||||||
@@ -166,19 +226,49 @@
|
|||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Blurred Background */
|
||||||
|
.background {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
filter: blur(60px) saturate(1.2);
|
||||||
|
transform: scale(1.2);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.3) 0%,
|
||||||
|
rgba(0, 0, 0, 0.5) 50%,
|
||||||
|
rgba(0, 0, 0, 0.8) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close Button */
|
||||||
.close-btn {
|
.close-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 24px;
|
top: 20px;
|
||||||
right: 24px;
|
right: 20px;
|
||||||
width: 40px;
|
z-index: 10;
|
||||||
height: 40px;
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: none;
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: #666666;
|
color: rgba(255, 255, 255, 0.7);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 300ms ease;
|
transition: all 300ms ease;
|
||||||
@@ -189,30 +279,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: var(--text-primary);
|
color: white;
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content Grid */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 48px;
|
||||||
|
padding: 60px 60px 120px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content.has-lyrics {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Artwork Side */
|
||||||
|
.artwork-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content.has-lyrics .artwork-side {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-container {
|
.artwork-container {
|
||||||
position: relative;
|
width: min(55vh, 500px);
|
||||||
width: 500px;
|
aspect-ratio: 1;
|
||||||
height: 500px;
|
border-radius: 12px;
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||||
margin-bottom: 32px;
|
|
||||||
animation: scaleIn 400ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scaleIn {
|
|
||||||
from {
|
|
||||||
transform: scale(0.95);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.artwork-container img {
|
.artwork-container img {
|
||||||
@@ -221,102 +328,201 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay {
|
/* Lyrics Side */
|
||||||
position: absolute;
|
.lyrics-side {
|
||||||
inset: 0;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
padding-left: 24px;
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 300ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay.visible {
|
.lyrics-side :global(.lyrics-lines) {
|
||||||
|
--text-primary: rgba(255, 255, 255, 0.95);
|
||||||
|
--text-secondary: rgba(255, 255, 255, 0.5);
|
||||||
|
--text-muted: rgba(255, 255, 255, 0.3);
|
||||||
|
--bg-tertiary: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-loading,
|
||||||
|
.lyrics-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom Bar */
|
||||||
|
.bottom-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 16px 24px 20px;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: all 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar.visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-controls {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playback-controls {
|
.playback-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn {
|
.control-btn {
|
||||||
background: none;
|
width: 40px;
|
||||||
border: none;
|
height: 40px;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:hover {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-btn {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background: none;
|
||||||
backdrop-filter: blur(8px);
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 150ms ease;
|
transition: all 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn:hover {
|
.control-btn:disabled {
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn :global(.play-icon) {
|
.control-btn:not(:disabled):hover {
|
||||||
margin-left: 3px;
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-info {
|
.control-btn.play {
|
||||||
text-align: center;
|
width: 48px;
|
||||||
margin-bottom: 24px;
|
height: 48px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.control-btn.play:hover {
|
||||||
font-size: 28px;
|
background: rgba(255, 255, 255, 0.25);
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist {
|
.control-btn.play :global(.play-icon) {
|
||||||
font-size: 20px;
|
margin-left: 2px;
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-container {
|
.progress-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 300ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-container.visible {
|
.time {
|
||||||
opacity: 1;
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time:last-child {
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background-color: #333333;
|
background: rgba(255, 255, 255, 0.2);
|
||||||
border-radius: 9999px;
|
border-radius: 2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--accent-primary);
|
background: white;
|
||||||
border-radius: 9999px;
|
border-radius: 2px;
|
||||||
|
transition: width 100ms linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-thumb {
|
.progress-thumb {
|
||||||
@@ -325,22 +531,18 @@
|
|||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-color: white;
|
background: white;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 150ms ease;
|
transition: opacity 150ms ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-thumb {
|
.progress-bar:hover .progress-thumb {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-display {
|
.spacer {
|
||||||
display: flex;
|
min-width: 200px;
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666666;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
center?: boolean;
|
center?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
scrollToActive?: boolean;
|
scrollToActive?: boolean;
|
||||||
|
immersive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -22,11 +23,12 @@
|
|||||||
dimInactive = true,
|
dimInactive = true,
|
||||||
center = false,
|
center = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
scrollToActive = true
|
scrollToActive = true,
|
||||||
|
immersive = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let container: HTMLDivElement | null = null;
|
let container: HTMLDivElement | null = null;
|
||||||
let lastScrolledIndex = -1;
|
let prevActiveIndex = -1;
|
||||||
|
|
||||||
// Calculate opacity based on distance from active line
|
// Calculate opacity based on distance from active line
|
||||||
function getLineOpacity(index: number, active: number): number {
|
function getLineOpacity(index: number, active: number): number {
|
||||||
@@ -34,36 +36,21 @@
|
|||||||
if (index === active) return 1;
|
if (index === active) return 1;
|
||||||
|
|
||||||
const distance = Math.abs(index - active);
|
const distance = Math.abs(index - active);
|
||||||
// Fade out based on distance: 1 line away = 0.6, 2 = 0.4, 3+ = 0.25
|
if (distance === 1) return 0.5;
|
||||||
if (distance === 1) return 0.6;
|
if (distance === 2) return 0.35;
|
||||||
if (distance === 2) return 0.4;
|
if (distance === 3) return 0.25;
|
||||||
if (distance === 3) return 0.3;
|
return 0.15;
|
||||||
return 0.2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate blur based on distance (subtle effect)
|
// Scroll active line into view (centered)
|
||||||
function getLineBlur(index: number, active: number): number {
|
async function scrollActiveIntoView(index: number) {
|
||||||
if (!dimInactive || active < 0) return 0;
|
if (!container || index < 0) return;
|
||||||
if (index === active) return 0;
|
|
||||||
|
|
||||||
const distance = Math.abs(index - active);
|
await tick();
|
||||||
if (distance <= 2) return 0;
|
|
||||||
if (distance <= 4) return 0.5;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll active line into view
|
const target = container.querySelector<HTMLElement>(`[data-line-index="${index}"]`);
|
||||||
async function scrollActiveIntoView() {
|
|
||||||
if (!container || activeIndex < 0 || activeIndex === lastScrolledIndex) return;
|
|
||||||
|
|
||||||
await tick(); // Wait for DOM update
|
|
||||||
|
|
||||||
const target = container.querySelector<HTMLElement>(`[data-line-index="${activeIndex}"]`);
|
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
lastScrolledIndex = activeIndex;
|
|
||||||
|
|
||||||
// Calculate scroll position to center the active line
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const targetRect = target.getBoundingClientRect();
|
const targetRect = target.getBoundingClientRect();
|
||||||
const targetCenter = targetRect.top + targetRect.height / 2;
|
const targetCenter = targetRect.top + targetRect.height / 2;
|
||||||
@@ -76,10 +63,11 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// React to activeIndex changes
|
// React to activeIndex changes - always scroll when it changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (scrollToActive && activeIndex >= 0) {
|
if (scrollToActive && activeIndex >= 0 && activeIndex !== prevActiveIndex) {
|
||||||
scrollActiveIntoView();
|
prevActiveIndex = activeIndex;
|
||||||
|
scrollActiveIntoView(activeIndex);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -88,6 +76,7 @@
|
|||||||
class="lyrics-lines"
|
class="lyrics-lines"
|
||||||
class:compact
|
class:compact
|
||||||
class:center
|
class:center
|
||||||
|
class:immersive
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
>
|
>
|
||||||
{#if lines.length === 0}
|
{#if lines.length === 0}
|
||||||
@@ -100,16 +89,11 @@
|
|||||||
{@const isActive = index === activeIndex}
|
{@const isActive = index === activeIndex}
|
||||||
{@const isPast = index < activeIndex}
|
{@const isPast = index < activeIndex}
|
||||||
{@const opacity = getLineOpacity(index, activeIndex)}
|
{@const opacity = getLineOpacity(index, activeIndex)}
|
||||||
{@const blur = getLineBlur(index, activeIndex)}
|
|
||||||
<div
|
<div
|
||||||
class="lyrics-line"
|
class="lyrics-line"
|
||||||
class:active={isActive}
|
class:active={isActive}
|
||||||
class:past={isPast}
|
class:past={isPast}
|
||||||
style="
|
style="--line-opacity: {opacity}; {isActive ? `--line-progress: ${Math.max(0, Math.min(1, activeProgress))}` : ''}"
|
||||||
--line-opacity: {opacity};
|
|
||||||
--line-blur: {blur}px;
|
|
||||||
{isActive ? `--line-progress: ${Math.max(0, Math.min(1, activeProgress))}` : ''}
|
|
||||||
"
|
|
||||||
data-line-index={index}
|
data-line-index={index}
|
||||||
>
|
>
|
||||||
<span class="line-text">{line.text}</span>
|
<span class="line-text">{line.text}</span>
|
||||||
@@ -125,7 +109,7 @@
|
|||||||
.lyrics-lines {
|
.lyrics-lines {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--lyrics-line-gap, 20px);
|
gap: 16px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -157,23 +141,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-lines.compact {
|
.lyrics-lines.compact {
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-lines.compact .lyrics-line {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-lines.compact .lyrics-line.active {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Immersive mode - larger text, center aligned */
|
||||||
|
.lyrics-lines.immersive {
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-lines.immersive .lyrics-line {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-lines.immersive .lyrics-line.active {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-line {
|
.lyrics-line {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
opacity: var(--line-opacity, 1);
|
opacity: var(--line-opacity, 1);
|
||||||
filter: blur(var(--line-blur, 0));
|
|
||||||
transition:
|
transition:
|
||||||
opacity 300ms ease-out,
|
opacity 400ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
filter 300ms ease-out,
|
transform 400ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
transform 300ms ease-out,
|
font-size 300ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
font-size 200ms ease-out,
|
font-weight 300ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
color 200ms ease-out;
|
color 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,11 +195,15 @@
|
|||||||
|
|
||||||
.lyrics-line.active {
|
.lyrics-line.active {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 22px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: blur(0);
|
|
||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
|
text-shadow: 0 0 30px rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-lines.center .lyrics-line.active {
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Karaoke progress effect on active line */
|
/* Karaoke progress effect on active line */
|
||||||
|
|||||||
+10
-2
@@ -940,9 +940,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start/stop lyrics active line updates based on playback state
|
// Start/stop lyrics active line updates based on playback state and visibility
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isPlaying && lyricsIsSynced && lyricsSidebarVisible) {
|
const lyricsVisible = lyricsSidebarVisible || isFocusModeOpen;
|
||||||
|
if (isPlaying && lyricsIsSynced && lyricsVisible) {
|
||||||
startActiveLineUpdates();
|
startActiveLineUpdates();
|
||||||
} else {
|
} else {
|
||||||
stopActiveLineUpdates();
|
stopActiveLineUpdates();
|
||||||
@@ -1206,9 +1207,16 @@
|
|||||||
artist={currentTrack.artist}
|
artist={currentTrack.artist}
|
||||||
{isPlaying}
|
{isPlaying}
|
||||||
onTogglePlay={togglePlay}
|
onTogglePlay={togglePlay}
|
||||||
|
onSkipBack={handleSkipBack}
|
||||||
|
onSkipForward={handleSkipForward}
|
||||||
{currentTime}
|
{currentTime}
|
||||||
{duration}
|
{duration}
|
||||||
onSeek={handleSeek}
|
onSeek={handleSeek}
|
||||||
|
lyricsLines={lyricsLines.map(l => ({ text: l.text }))}
|
||||||
|
lyricsActiveIndex={lyricsActiveIndex}
|
||||||
|
lyricsActiveProgress={lyricsActiveProgress}
|
||||||
|
lyricsLoading={lyricsStatus === 'loading'}
|
||||||
|
lyricsError={lyricsStatus === 'error' ? lyricsError : (lyricsStatus === 'not_found' ? 'No lyrics found' : null)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user