now-playing component scrolls text when hovering
This commit is contained in:
@@ -36,6 +36,9 @@ import './shortcut-capture';
|
|||||||
import { shortcutsStore } from '../../store/shortcuts-store';
|
import { shortcutsStore } from '../../store/shortcuts-store';
|
||||||
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
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)
|
// 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 ---
|
// --- Favorites state ---
|
||||||
@state() private playlists: playlist.Summary[] = [];
|
@state() private playlists: playlist.Summary[] = [];
|
||||||
|
|
||||||
@@ -868,6 +874,8 @@ export class ConfigPage extends LitElement {
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.loadLibraryConfig();
|
this.loadLibraryConfig();
|
||||||
void this.loadPlaylists();
|
void this.loadPlaylists();
|
||||||
|
this.scrollMode =
|
||||||
|
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
|
||||||
|
|
||||||
this.cancelScanStarted = EventsOn(
|
this.cancelScanStarted = EventsOn(
|
||||||
Events.LibraryScanStarted,
|
Events.LibraryScanStarted,
|
||||||
@@ -1286,6 +1294,21 @@ export class ConfigPage extends LitElement {
|
|||||||
await shortcutsStore.resetAll();
|
await shortcutsStore.resetAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// NOW PLAYING HANDLERS
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private handleScrollModeChange = (
|
||||||
|
e: CustomEvent<ConfigFieldChangeEvent>,
|
||||||
|
): 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
|
// TRACK LIST COLUMN HANDLERS
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -1404,6 +1427,7 @@ export class ConfigPage extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
|
|
||||||
|
${this.renderNowPlayingSection()}
|
||||||
${this.renderThemeSection()}
|
${this.renderThemeSection()}
|
||||||
${this.renderFavoritesSection()}
|
${this.renderFavoritesSection()}
|
||||||
${this.renderTrackListSection()}
|
${this.renderTrackListSection()}
|
||||||
@@ -1412,6 +1436,43 @@ export class ConfigPage extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Now Playing section ---
|
||||||
|
|
||||||
|
private renderNowPlayingSection() {
|
||||||
|
return html`
|
||||||
|
<config-section
|
||||||
|
heading="Now Playing"
|
||||||
|
description="Configure the now-playing display in the bottom bar."
|
||||||
|
>
|
||||||
|
<config-field
|
||||||
|
.schema=${{
|
||||||
|
key: 'scrollMode',
|
||||||
|
label: 'Text Scroll Behaviour',
|
||||||
|
description:
|
||||||
|
'How overflowing track title and artist text is handled when it exceeds the available width.',
|
||||||
|
type: 'select' as const,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: 'hover',
|
||||||
|
label: 'Scroll on Hover (Default)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'always',
|
||||||
|
label: 'Always Scroll',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'never',
|
||||||
|
label: 'Never (Ellipsis)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
.value=${this.scrollMode}
|
||||||
|
@config-change=${this.handleScrollModeChange}
|
||||||
|
></config-field>
|
||||||
|
</config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Theme section ---
|
// --- Theme section ---
|
||||||
|
|
||||||
private renderThemeSection() {
|
private renderThemeSection() {
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ const MIN_WIDTH = 120;
|
|||||||
const MAX_WIDTH = 350;
|
const MAX_WIDTH = 350;
|
||||||
const DEFAULT_WIDTH = 200;
|
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')
|
@customElement('now-playing')
|
||||||
export class NowPlaying extends LitElement {
|
export class NowPlaying extends LitElement {
|
||||||
private player = new PlayerController(this);
|
private player = new PlayerController(this);
|
||||||
@@ -22,6 +32,23 @@ export class NowPlaying extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private showCoverPreview = false;
|
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`
|
static override styles = [designTokens, css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -128,22 +155,67 @@ export class NowPlaying extends LitElement {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Scrollable text containers --- */
|
||||||
|
|
||||||
|
.track-title,
|
||||||
|
.track-artist {
|
||||||
|
position: relative;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.track-title {
|
.track-title {
|
||||||
font-size: var(--yj-text-lg);
|
font-size: var(--yj-text-lg);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-artist {
|
.track-artist {
|
||||||
font-size: var(--yj-text-sm);
|
font-size: var(--yj-text-sm);
|
||||||
color: var(--yj-text-tertiary, #666);
|
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;
|
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 {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -164,15 +236,29 @@ export class NowPlaying extends LitElement {
|
|||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
this.loadScrollMode();
|
||||||
this.updateWidth(DEFAULT_WIDTH);
|
this.updateWidth(DEFAULT_WIDTH);
|
||||||
document.addEventListener('mousemove', this.handleMouseMove);
|
document.addEventListener('mousemove', this.handleMouseMove);
|
||||||
document.addEventListener('mouseup', this.handleMouseUp);
|
document.addEventListener('mouseup', this.handleMouseUp);
|
||||||
|
window.addEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
|
||||||
|
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
this.checkOverflows();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
document.removeEventListener('mousemove', this.handleMouseMove);
|
||||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
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() {
|
override render() {
|
||||||
@@ -197,6 +283,9 @@ export class NowPlaying extends LitElement {
|
|||||||
: false;
|
: false;
|
||||||
const favVariant = isFav ? 'solid' : 'regular';
|
const favVariant = isFav ? 'solid' : 'regular';
|
||||||
|
|
||||||
|
const titleScrolling = this.shouldScroll('title');
|
||||||
|
const artistScrolling = this.shouldScroll('artist');
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="now-playing">
|
<div class="now-playing">
|
||||||
<div class="cover-art-wrapper">
|
<div class="cover-art-wrapper">
|
||||||
@@ -244,11 +333,19 @@ export class NowPlaying extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
<div class="track-info-wrapper">
|
<div class="track-info-wrapper">
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<span class="track-title">
|
<span
|
||||||
${track.title}
|
class="track-title ${titleScrolling ? 'will-scroll' : ''}"
|
||||||
|
@mouseenter=${this.handleTitleMouseEnter}
|
||||||
|
@mouseleave=${this.handleTitleMouseLeave}
|
||||||
|
>
|
||||||
|
<span class="scroll-content">${track.title}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="track-artist">
|
<span
|
||||||
${track.artist || 'Unknown Artist'}
|
class="track-artist ${artistScrolling ? 'will-scroll' : ''}"
|
||||||
|
@mouseenter=${this.handleArtistMouseEnter}
|
||||||
|
@mouseleave=${this.handleArtistMouseLeave}
|
||||||
|
>
|
||||||
|
<span class="scroll-content">${track.artist || 'Unknown Artist'}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${track.filePath
|
${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<HTMLElement>('.track-title');
|
||||||
|
const artistEl = this.shadowRoot?.querySelector<HTMLElement>('.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<HTMLElement>(container);
|
||||||
|
const content = el?.querySelector<HTMLElement>('.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 = () => {
|
private handleCoverMouseEnter = () => {
|
||||||
const track = this.player.currentTrack;
|
const track = this.player.currentTrack;
|
||||||
|
|
||||||
@@ -303,6 +506,10 @@ export class NowPlaying extends LitElement {
|
|||||||
this.showCoverPreview = false;
|
this.showCoverPreview = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// RESIZE
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
private handleMouseDown = (e: MouseEvent) => {
|
private handleMouseDown = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user