From 810f5b29b1331ec5e156c020e260edb8264be23f Mon Sep 17 00:00:00 2001 From: nerix Date: Fri, 4 Aug 2023 13:18:01 +0200 Subject: [PATCH] feat: add marquee effect (#337) --- README.md | 2 +- js/client/src/dom/animation.ts | 2 + js/client/src/index.css | 58 +++++++++++++++++-- js/client/src/index.ts | 49 +++++++++++++++- js/client/src/text/marquee.ts | 103 +++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 js/client/src/text/marquee.ts diff --git a/README.md b/README.md index 95ca107..7b68385 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CurrentSong 2 -![example screenshot with default theme](https://i.imgur.com/qAs7xXh.png) +![example screenshot with default theme](https://github.com/Nerixyz/current-song2/assets/19953266/9b2ac5cd-4135-4eea-8383-bc738c865da9) _For more examples, look at the [example themes](themes)!_ diff --git a/js/client/src/dom/animation.ts b/js/client/src/dom/animation.ts index 14d26dc..a131744 100644 --- a/js/client/src/dom/animation.ts +++ b/js/client/src/dom/animation.ts @@ -1,10 +1,12 @@ export function animateOnChange( el: HTMLElement, updated: string, + resetAnimations: () => void, keyframes: Keyframe[] | PropertyIndexedKeyframes | null, options?: number | KeyframeAnimationOptions, ) { if (el.textContent !== updated) { + resetAnimations(); el.textContent = updated; el.animate(keyframes, options); } diff --git a/js/client/src/index.css b/js/client/src/index.css index a862ab2..72d5e74 100644 --- a/js/client/src/index.css +++ b/js/client/src/index.css @@ -1,19 +1,30 @@ :root { - --theme-color: #c3f831; + --theme-color: #c7cedb; --text-color: #000; --font: 'Arial', sans; - --shadow-color: #0008; + --shadow-color: #0006; --rounded: 10px; - /*--max-width: 40rem;*/ + --max-width: 30rem; --height: 4.4rem; --max-height: 4.4rem; --min-height: 3rem; + --container-shadow: 0 2px 22px 0 var(--shadow-color); + --progress-height: 0.4rem; - --progress-color: #007e7a; + --progress-color: #2e3532; --progress-border-radius: 3px; --progress-shadow: 0 0 3px 0 var(--shadow-color); + + --use-marquee: true; + --marquee-speed: 0.2; + --marquee-pause-duration: 1200; + --marquee-repeat-pause-duration: 5000; +} + +* { + box-sizing: border-box; } body { @@ -34,6 +45,7 @@ body { transition: 250ms cubic-bezier(0.33, 1, 0.68, 1); transition-property: opacity, transform; transform-origin: left; + box-shadow: var(--container-shadow); } #song-container.with-image { @@ -50,6 +62,7 @@ body { justify-content: center; padding: 0.25rem 1rem 0.3rem 1rem; white-space: nowrap; + min-width: 1; } h1, @@ -65,6 +78,7 @@ h1 { } h2 { font-size: large; + font-weight: normal; } #progress { @@ -82,10 +96,11 @@ h2 { } #image-container { - flex-shrink: 1; + flex: 1 1; height: 70%; max-height: 100%; max-width: 100%; + min-width: fit-content; margin: auto 0 auto 1rem; overflow: hidden; box-shadow: 0 2px 10px 0 var(--shadow-color); @@ -121,3 +136,36 @@ img.spotify { .hidden { display: none; } + +/* marquee */ +.mq-wrap { + --content-padding: 6px; + width: 100%; +} + +.mq-mask { + margin-left: -6px; + margin-right: -6px; + mask-image: linear-gradient( + 90deg, + transparent 0, + #000 var(--content-padding), + #000 calc(100% - 2 * var(--content-padding)), + transparent + ); + overflow: hidden; + position: relative; +} + +.mq-overflow-guard { + overflow: hidden; +} + +.mq-user-wrap { + display: flex; + white-space: nowrap; + width: fit-content; + padding-inline-end: calc(2 * var(--content-padding)); + padding-inline-start: var(--content-padding); + transform: translateX(var(--marquee)); +} diff --git a/js/client/src/index.ts b/js/client/src/index.ts index f940e44..6c65819 100644 --- a/js/client/src/index.ts +++ b/js/client/src/index.ts @@ -22,6 +22,46 @@ import { ReconnectingWebsocket, } from '../../shared/reconnecting-websocket'; import { startUserScript } from './user-scripts'; +import { MarqueeEl, MarqueeOptions, wrapMarquee } from './text/marquee'; + +function wrapMarqueeElements( + root: HTMLElement, + titleEl: HTMLElement, + subtitleEl: HTMLElement, +): MarqueeEl { + const style = getComputedStyle(root); + const useIt = style.getPropertyValue('--use-marquee') === 'true'; + if (!useIt) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + return { pause() {}, reset() {}, start() {} }; + } + const opt = (name: string, defaultValue: number) => { + const parsed = parseInt(style.getPropertyValue(name)); + return Number.isNaN(parsed) ? defaultValue : parsed; + }; + const opts: MarqueeOptions = { + speed: opt('--marquee-speed', 0.2), + pauseDuration: opt('--marquee-pause-duration', 1200), + repeatPauseDuration: opt('--marquee-repeat-pause-duration', 2000), + }; + const title = wrapMarquee(titleEl, opts); + const subtitle = wrapMarquee(subtitleEl, opts); + + return { + pause: () => { + title.pause(); + subtitle.pause(); + }, + start: () => { + title.start(); + subtitle.start(); + }, + reset: () => { + title.reset(); + subtitle.reset(); + }, + }; +} (async function main() { const [container, imageContainer, imageEl, titleEl, subtitleEl, progressEl] = getElements< @@ -34,6 +74,7 @@ import { startUserScript } from './user-scripts'; HTMLDivElement, ] >('song-container', 'image-container', 'image', 'title', 'subtitle', 'progress'); + const resetMarquee = wrapMarqueeElements(container, titleEl, subtitleEl); const progressManager = createProgress(progressEl); @@ -63,9 +104,12 @@ import { startUserScript } from './user-scripts'; container.classList.remove('vanish'); const state = makeState(data); tree.update(state); + resetMarquee.start(); - animateOnChange(titleEl, state.title, ...TextChangeAnimation); - if (state.subtitle) animateOnChange(subtitleEl, state.subtitle, ...TextChangeAnimation); + animateOnChange(titleEl, state.title, resetMarquee.reset, ...TextChangeAnimation); + if (state.subtitle) { + animateOnChange(subtitleEl, state.subtitle, resetMarquee.reset, ...TextChangeAnimation); + } if (state.imageUrl) { imageEl.src = state.imageUrl; @@ -93,6 +137,7 @@ import { startUserScript } from './user-scripts'; progressManager.pause(); userScript.onPause(); + resetMarquee.pause(); }); await ws.connect(); })(); diff --git a/js/client/src/text/marquee.ts b/js/client/src/text/marquee.ts new file mode 100644 index 0000000..b36a70c --- /dev/null +++ b/js/client/src/text/marquee.ts @@ -0,0 +1,103 @@ +export interface MarqueeOptions { + speed: number; + pauseDuration: number; + repeatPauseDuration: number; +} + +export interface MarqueeEl { + pause: () => void; + start: () => void; + reset: () => void; +} + +export function wrapMarquee(el: HTMLElement, opts: MarqueeOptions): MarqueeEl { + const wrap = make('div', 'mq-wrap'); + wrap.id = `mq-${el.id}`; + const mask = make('div', 'mq-mask'); + const guard = make('div', 'mq-overflow-guard'); + const userWrap = make('div', 'mq-user-wrap'); + + const parent = el.parentElement ?? document.documentElement; + const sibling = el.nextSibling; + el.remove(); + parent.insertBefore(wrap, sibling); + + wrap.appendChild(mask); + mask.appendChild(guard); + guard.appendChild(userWrap); + userWrap.appendChild(el); + + const diffWidth = () => userWrap.clientWidth - mask.clientWidth; + + let cbID: null | number = null; + let lastTime = performance.now(); + let pos = 0; + let waitFrom = lastTime; + let dir = 1; + const applyPos = () => userWrap.style.setProperty('--marquee', `${-pos}px`); + + const actualAnimationFrame = (time: number) => { + const deltaTime = time - lastTime; + lastTime = time; + const overflow = diffWidth(); + + if (overflow <= 0) { + if (pos != 0) { + pos = 0; + applyPos(); + } + return; + } + + if (waitFrom > 0) { + if (time > waitFrom + opts.pauseDuration) { + waitFrom = 0; + } + } else { + pos += dir * ((60 * deltaTime) / 1000) * opts.speed; + if (pos > overflow) { + dir *= -1; + waitFrom = time; + pos = overflow; + } else if (pos < 0) { + dir *= -1; + waitFrom = time + opts.repeatPauseDuration; + pos = 0; + } + } + applyPos(); + }; + const onAnimationFrame = (time: number) => { + actualAnimationFrame(time); + if (cbID) { + cbID = requestAnimationFrame(onAnimationFrame); + } + }; + + return { + start: () => { + if (!cbID) { + lastTime = performance.now(); + cbID = requestAnimationFrame(onAnimationFrame); + } + }, + pause: () => { + if (cbID) { + cancelAnimationFrame(cbID); + cbID = null; + } + }, + reset: () => { + dir = 1; + pos = 0; + waitFrom = lastTime; + applyPos(); + }, + }; +} + +function make(ty: keyof HTMLElementTagNameMap, ...classes: string[]): HTMLElement { + const el = document.createElement(ty); + el.classList.add(...classes); + return el; +}