diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index 2a26550..1816ae0 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -36,6 +36,9 @@ import './shortcut-capture'; import { shortcutsStore } from '../../store/shortcuts-store'; import { ShortcutsController } from '../../store/controllers/shortcuts-controller'; +const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode'; +const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed'; + // =================================================================== // Scan metrics types and helpers (carried over from library-manager) // =================================================================== @@ -322,6 +325,9 @@ export class ConfigPage extends LitElement { }, }; + // --- Now Playing state --- + @state() private scrollMode = 'hover'; + // --- Favorites state --- @state() private playlists: playlist.Summary[] = []; @@ -868,6 +874,8 @@ export class ConfigPage extends LitElement { super.connectedCallback(); this.loadLibraryConfig(); void this.loadPlaylists(); + this.scrollMode = + localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover'; this.cancelScanStarted = EventsOn( Events.LibraryScanStarted, @@ -1286,6 +1294,21 @@ export class ConfigPage extends LitElement { await shortcutsStore.resetAll(); } + // =================================================================== + // NOW PLAYING HANDLERS + // =================================================================== + + private handleScrollModeChange = ( + e: CustomEvent, + ): void => { + const mode = String(e.detail.value); + this.scrollMode = mode; + localStorage.setItem(SCROLL_STORAGE_KEY, mode); + window.dispatchEvent( + new CustomEvent(SCROLL_CHANGE_EVENT), + ); + }; + // =================================================================== // TRACK LIST COLUMN HANDLERS // =================================================================== @@ -1404,6 +1427,7 @@ export class ConfigPage extends LitElement { return html`

Settings

+ ${this.renderNowPlayingSection()} ${this.renderThemeSection()} ${this.renderFavoritesSection()} ${this.renderTrackListSection()} @@ -1412,6 +1436,43 @@ export class ConfigPage extends LitElement { `; } + // --- Now Playing section --- + + private renderNowPlayingSection() { + return html` + + + + `; + } + // --- Theme section --- private renderThemeSection() { diff --git a/frontend/src/components/now-playing/now-playing.ts b/frontend/src/components/now-playing/now-playing.ts index 7227e04..7b68f20 100644 --- a/frontend/src/components/now-playing/now-playing.ts +++ b/frontend/src/components/now-playing/now-playing.ts @@ -11,6 +11,16 @@ const MIN_WIDTH = 120; const MAX_WIDTH = 350; const DEFAULT_WIDTH = 200; +const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode'; +const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed'; + +/** Pixels per second the text scrolls at. */ +const SCROLL_SPEED = 30; +const MIN_DURATION = 3; +const MAX_DURATION = 15; + +type ScrollMode = 'hover' | 'always' | 'never'; + @customElement('now-playing') export class NowPlaying extends LitElement { private player = new PlayerController(this); @@ -22,6 +32,23 @@ export class NowPlaying extends LitElement { @state() private showCoverPreview = false; + @state() + private scrollMode: ScrollMode = 'hover'; + + @state() + private titleOverflows = false; + + @state() + private artistOverflows = false; + + @state() + private titleHovered = false; + + @state() + private artistHovered = false; + + private resizeObserver?: ResizeObserver; + static override styles = [designTokens, css` :host { display: block; @@ -128,22 +155,67 @@ export class NowPlaying extends LitElement { opacity: 0.8; } + /* --- Scrollable text containers --- */ + + .track-title, + .track-artist { + position: relative; + white-space: nowrap; + overflow: hidden; + } + .track-title { font-size: var(--yj-text-lg); font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .track-artist { font-size: var(--yj-text-sm); color: var(--yj-text-tertiary, #666); - white-space: nowrap; - overflow: hidden; + } + + /* Static ellipsis when not scrolling */ + .track-title:not(.will-scroll), + .track-artist:not(.will-scroll) { text-overflow: ellipsis; } + .scroll-content { + display: inline-block; + white-space: nowrap; + } + + /* Fade-out masks on both edges when scrolling */ + .track-title.will-scroll, + .track-artist.will-scroll { + mask-image: linear-gradient( + to right, + transparent 0%, + black 8%, + black 92%, + transparent 100% + ); + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + black 8%, + black 92%, + transparent 100% + ); + } + + .will-scroll .scroll-content { + animation: scroll-text var(--scroll-duration, 5s) linear infinite; + padding-right: 2em; /* gap before the text repeats visually */ + } + + @keyframes scroll-text { + 0% { transform: translateX(0); } + 5% { transform: translateX(0); } + 95% { transform: translateX(var(--scroll-distance, -100%)); } + 100% { transform: translateX(var(--scroll-distance, -100%)); } + } + .resize-handle { position: absolute; top: 0; @@ -164,15 +236,29 @@ export class NowPlaying extends LitElement { override connectedCallback() { super.connectedCallback(); + this.loadScrollMode(); this.updateWidth(DEFAULT_WIDTH); document.addEventListener('mousemove', this.handleMouseMove); document.addEventListener('mouseup', this.handleMouseUp); + window.addEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent); + + this.resizeObserver = new ResizeObserver(() => { + this.checkOverflows(); + }); } override disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('mousemove', this.handleMouseMove); document.removeEventListener('mouseup', this.handleMouseUp); + window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent); + this.resizeObserver?.disconnect(); + } + + protected override updated(): void { + this.checkOverflows(); + this.observeTextContainers(); + this.applyScrollDistances(); } override render() { @@ -197,6 +283,9 @@ export class NowPlaying extends LitElement { : false; const favVariant = isFav ? 'solid' : 'regular'; + const titleScrolling = this.shouldScroll('title'); + const artistScrolling = this.shouldScroll('artist'); + return html`
@@ -244,11 +333,19 @@ export class NowPlaying extends LitElement {
- - ${track.title} + + ${track.title} - - ${track.artist || 'Unknown Artist'} + + ${track.artist || 'Unknown Artist'}
${track.filePath @@ -277,6 +374,112 @@ export class NowPlaying extends LitElement { `; } + // =================================================================== + // SCROLL LOGIC + // =================================================================== + + private loadScrollMode(): void { + const stored = localStorage.getItem(SCROLL_STORAGE_KEY); + + if (stored === 'hover' || stored === 'always' || stored === 'never') { + this.scrollMode = stored; + } + } + + private handleScrollModeEvent = (): void => { + this.loadScrollMode(); + }; + + private shouldScroll(field: 'title' | 'artist'): boolean { + const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows; + + if (!overflows || this.scrollMode === 'never') return false; + if (this.scrollMode === 'always') return true; + + // hover mode + return field === 'title' ? this.titleHovered : this.artistHovered; + } + + private checkOverflows(): void { + const titleEl = this.shadowRoot?.querySelector('.track-title'); + const artistEl = this.shadowRoot?.querySelector('.track-artist'); + + const titleNow = titleEl ? titleEl.scrollWidth > titleEl.clientWidth : false; + const artistNow = artistEl ? artistEl.scrollWidth > artistEl.clientWidth : false; + + // Only update state when changed to avoid infinite loops + if (titleNow !== this.titleOverflows) { + this.titleOverflows = titleNow; + } + + if (artistNow !== this.artistOverflows) { + this.artistOverflows = artistNow; + } + } + + private observeTextContainers(): void { + if (!this.resizeObserver) return; + + const titleEl = this.shadowRoot?.querySelector('.track-title'); + const artistEl = this.shadowRoot?.querySelector('.track-artist'); + + // ResizeObserver automatically deduplicates observed elements + if (titleEl) this.resizeObserver.observe(titleEl); + if (artistEl) this.resizeObserver.observe(artistEl); + } + + /** + * Set CSS custom properties on each scroll-content span so the + * animation knows how far to travel and how long to take. + */ + private applyScrollDistances(): void { + const pairs: Array<{ container: string; overflows: boolean }> = [ + { container: '.track-title', overflows: this.titleOverflows }, + { container: '.track-artist', overflows: this.artistOverflows }, + ]; + + for (const { container, overflows } of pairs) { + if (!overflows) continue; + + const el = this.shadowRoot?.querySelector(container); + const content = el?.querySelector('.scroll-content'); + + if (!el || !content) continue; + + const overflow = content.scrollWidth - el.clientWidth; + + if (overflow > 0) { + const duration = Math.min(MAX_DURATION, Math.max(MIN_DURATION, overflow / SCROLL_SPEED)); + el.style.setProperty('--scroll-distance', `-${overflow}px`); + el.style.setProperty('--scroll-duration', `${duration.toFixed(1)}s`); + } + } + } + + // =================================================================== + // HOVER HANDLERS + // =================================================================== + + private handleTitleMouseEnter = (): void => { + this.titleHovered = true; + }; + + private handleTitleMouseLeave = (): void => { + this.titleHovered = false; + }; + + private handleArtistMouseEnter = (): void => { + this.artistHovered = true; + }; + + private handleArtistMouseLeave = (): void => { + this.artistHovered = false; + }; + + // =================================================================== + // COVER PREVIEW + // =================================================================== + private handleCoverMouseEnter = () => { const track = this.player.currentTrack; @@ -303,6 +506,10 @@ export class NowPlaying extends LitElement { this.showCoverPreview = false; }; + // =================================================================== + // RESIZE + // =================================================================== + private handleMouseDown = (e: MouseEvent) => { e.preventDefault(); this.isDragging = true;