From e7e0a30c2e53bf0be4f321067aa9ab798b4c955b Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 2 Dec 2024 09:34:14 +0100 Subject: [PATCH 01/12] Refactor: Force elements in info header on 1 line Required for MarqueeText to not be pushed onto a new line --- src/components/InfoHeader.vue | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/InfoHeader.vue b/src/components/InfoHeader.vue index c7a4946a..69b4ca1f 100644 --- a/src/components/InfoHeader.vue +++ b/src/components/InfoHeader.vue @@ -108,7 +108,7 @@ - + {{ item.owner }} - + Date: Mon, 2 Dec 2024 09:35:50 +0100 Subject: [PATCH 02/12] Fix: Make the title in InfoHeader fit on screen Previously a fixed width was used, which made MarqueeText not scroll to the end on some screen sized. Now a flexbox is used to ensure the title fits into the header. --- src/components/InfoHeader.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/InfoHeader.vue b/src/components/InfoHeader.vue index 69b4ca1f..30e5ed1f 100644 --- a/src/components/InfoHeader.vue +++ b/src/components/InfoHeader.vue @@ -40,6 +40,8 @@ padding-left: 15px; align-items: center; padding-right: 15px; + display: flex; + width: 100%; " > @@ -54,6 +56,7 @@ margin-bottom: 15px; margin-right: 24px; align-content: center; + flex-shrink: 0; " >
@@ -70,7 +73,7 @@
-
+
Date: Mon, 2 Dec 2024 09:38:30 +0100 Subject: [PATCH 03/12] Feat: Add MarqueeText for synced scrolling of overflowed text This component allows the containing content to be automatically scrolled. The key differentiator from other Marquee components is the syncing capability for increased readability. --- src/components/MarqueeText.vue | 256 +++++++++++++++++++++++++++++++ src/helpers/marquee_text_sync.ts | 89 +++++++++++ 2 files changed, 345 insertions(+) create mode 100644 src/components/MarqueeText.vue create mode 100644 src/helpers/marquee_text_sync.ts diff --git a/src/components/MarqueeText.vue b/src/components/MarqueeText.vue new file mode 100644 index 00000000..124cb43a --- /dev/null +++ b/src/components/MarqueeText.vue @@ -0,0 +1,256 @@ + + + + + + diff --git a/src/helpers/marquee_text_sync.ts b/src/helpers/marquee_text_sync.ts new file mode 100644 index 00000000..594ed483 --- /dev/null +++ b/src/helpers/marquee_text_sync.ts @@ -0,0 +1,89 @@ +type ComponentId = symbol; +type SyncResolver = () => void; + +class RunningAnimationSync { + private parentSync: MarqueeTextSync; + private readonly id: ComponentId; + // resolve method of the promise returned by `sync()` + private promiseResolver: SyncResolver | null = null; + + constructor(parentSync: MarqueeTextSync) { + this.parentSync = parentSync; + this.id = Symbol(); + } + + // Call this when a animation is started with the time it will take to scroll + // the whole text (without constant delays) + // Must be called if you wan't to use `sync()` + setScrollingDuration(duration: number): void { + if (duration > 0) { + this.parentSync._componentDurations.set(this.id, duration); + } else { + this.unregister(); + } + } + + // Returns the maximum duration set by `setScrollingDuration` of all running animations + maxDuration(): number { + if (this.parentSync._componentDurations.size === 0) return 0; + return Math.max(...this.parentSync._componentDurations.values()); + } + + // Returns a promise that resolves when all currently running animations + // are awaiting this sync point + // the returned promise must be immediatly awaited + sync(): Promise { + return new Promise((resolve) => { + // If the user awaits sync(), we can assume that only a single promiseResolver + // exists at a time, so we can safely overwrite it + this.promiseResolver = resolve; + this.parentSync._pendingSync.push(resolve); + this.parentSync._tryResolveWaiting(); + }); + } + + // Make sure to call this when: + // - the component is unmounted + // - no animation is playing anymore, i.e. the animation was aborted + // do not call `sync()` after calling this, first call `setScrollingDuration` again + unregister(): void { + if (this.promiseResolver) { + const index = this.parentSync._pendingSync.indexOf(this.promiseResolver); + if (index > -1) { + this.parentSync._pendingSync.splice(index, 1); + } + this.promiseResolver(); // resolve to avoid potential memory leaks + this.promiseResolver = null; + } + this.parentSync._componentDurations.delete(this.id); + // free up waiting syncs if needed (now that we compleatly removed ourself) + this.parentSync._tryResolveWaiting(); + } +} + +// Pass this to the `sync` prop of the MarqueeText component to sync all animations +export class MarqueeTextSync { + _componentDurations: Map; // durations of all running animations + _pendingSync: SyncResolver[]; // resolvers of all pending sync promises + + constructor() { + this._componentDurations = new Map(); + this._pendingSync = []; + } + + _registerAnimation(): RunningAnimationSync { + return new RunningAnimationSync(this); + } + + // Will resolve all pending syncs in case all animations are waiting for it + _tryResolveWaiting(): void { + if (this._pendingSync?.length === this._componentDurations.size) { + // Everyone is waiting for this sync point, resolve all + const pending = this._pendingSync; // to avoid race conditions + this._pendingSync = []; + for (const resolve of pending) { + resolve(); + } + } + } +} From e3ecf5bf4d0ab27e18b8affbe8747cb5cdafe567 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 2 Dec 2024 09:42:04 +0100 Subject: [PATCH 04/12] Feat: Use MarqueeText in the Fullscreen Player --- .../default/PlayerOSD/PlayerFullscreen.vue | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/src/layouts/default/PlayerOSD/PlayerFullscreen.vue b/src/layouts/default/PlayerOSD/PlayerFullscreen.vue index 1635746a..03976394 100644 --- a/src/layouts/default/PlayerOSD/PlayerFullscreen.vue +++ b/src/layouts/default/PlayerOSD/PlayerFullscreen.vue @@ -85,26 +85,32 @@ :style="`font-size: ${titleFontSize};cursor:pointer;`" @click="itemClick(store.curQueueItem.media_item as MediaItemType)" > - {{ store.curQueueItem.media_item.name }} - ({{ store.curQueueItem.media_item.version }}) + + {{ store.curQueueItem.media_item.name }} + ({{ store.curQueueItem.media_item.version }}) +
- {{ store.activePlayer?.current_media?.artist }} + + {{ store.activePlayer?.current_media?.artist }} +
- {{ store.activePlayer?.current_media?.title }} + + {{ store.activePlayer?.current_media?.title }} +
@@ -114,7 +120,9 @@ :style="`font-size: ${titleFontSize};cursor:pointer;`" @click="store.showPlayersMenu = true" > - {{ store.activePlayer?.display_name || $t("no_player") }} + + {{ store.activePlayer?.display_name || $t("no_player") }} + @@ -138,7 +146,9 @@ radioTitleClick(store.curQueueItem.streamdetails.stream_title) " > - {{ store.curQueueItem.streamdetails.stream_title }} + + {{ store.curQueueItem.streamdetails.stream_title }} + @@ -151,7 +161,9 @@ :style="`font-size: ${subTitleFontSize};cursor:pointer;`" @click="itemClick(store.curQueueItem.media_item.album as Album)" > - {{ store.curQueueItem.media_item.album.name }} + + {{ store.curQueueItem.media_item.album.name }} + @@ -167,7 +179,9 @@ itemClick(store.curQueueItem.media_item.artists[0] as Artist) " > - {{ store.curQueueItem.media_item.artists[0].name }} + + {{ store.curQueueItem.media_item.artists[0].name }} + @@ -440,6 +454,8 @@ import RepeatBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/RepeatBtn.vu import PlayerVolume from "@/layouts/default/PlayerOSD/PlayerVolume.vue"; import QueueBtn from "./PlayerControlBtn/QueueBtn.vue"; import QualityDetailsBtn from "@/components/QualityDetailsBtn.vue"; +import MarqueeText from "@/components/MarqueeText.vue"; +import { MarqueeTextSync } from "@/helpers/marquee_text_sync"; import { imgCoverLight, imgCoverDark, @@ -466,6 +482,8 @@ interface Props { } const compProps = defineProps(); +const playerMarqueeSync = new MarqueeTextSync(); + // Local refs const queueItems = ref([]); const coverImageColorCode = ref(""); From 590f00d250b0370e32c4f87fa744d64c11990ecb Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 2 Dec 2024 09:43:53 +0100 Subject: [PATCH 05/12] Feat: Use MarqueeText in the minimized Player --- .../default/PlayerOSD/PlayerTrackDetails.vue | 144 +++++++++--------- 1 file changed, 76 insertions(+), 68 deletions(-) diff --git a/src/layouts/default/PlayerOSD/PlayerTrackDetails.vue b/src/layouts/default/PlayerOSD/PlayerTrackDetails.vue index 339aec65..32d3e0eb 100644 --- a/src/layouts/default/PlayerOSD/PlayerTrackDetails.vue +++ b/src/layouts/default/PlayerOSD/PlayerTrackDetails.vue @@ -67,14 +67,16 @@ v-else-if="store.curQueueItem?.media_item" @click="store.showFullscreenPlayer = true" > - {{ store.curQueueItem.media_item.name }} - ({{ store.curQueueItem.media_item.version }}) + + {{ store.curQueueItem.media_item.name }} + ({{ store.curQueueItem.media_item.version }}) +
@@ -141,66 +143,68 @@ class="line-clamp-1" @click="store.showFullscreenPlayer = true" > - -
- {{ $t("off") }} -
- -
- {{ getArtistsString(store.curQueueItem.media_item.artists) }} • - {{ store.curQueueItem.media_item.album.name }} -
- -
- {{ store.curQueueItem.media_item.artists[0].name }} -
- -
- {{ store.curQueueItem?.streamdetails?.stream_title }} -
- -
- {{ store.curQueueItem.media_item.metadata.description }} -
- -
- {{ store.activePlayer.current_media.artist }} -
- -
- {{ - $t("external_source_active", [store.activePlayer?.active_source]) - }} -
+ + +
+ {{ $t("off") }} +
+ +
+ {{ getArtistsString(store.curQueueItem.media_item.artists) }} • + {{ store.curQueueItem.media_item.album.name }} +
+ +
+ {{ store.curQueueItem.media_item.artists[0].name }} +
+ +
+ {{ store.curQueueItem?.streamdetails?.stream_title }} +
+ +
+ {{ store.curQueueItem.media_item.metadata.description }} +
+ +
+ {{ store.activePlayer.current_media.artist }} +
+ +
+ {{ + $t("external_source_active", [store.activePlayer?.active_source]) + }} +
+
{{ getPlayerName(store.activePlayer) }} @@ -228,6 +232,10 @@ import { import PlayerFullscreen from "./PlayerFullscreen.vue"; import { imgCoverDark } from "@/components/QualityDetailsBtn.vue"; import { getBreakpointValue } from "@/plugins/breakpoint"; +import MarqueeText from "@/components/MarqueeText.vue"; +import { MarqueeTextSync } from "@/helpers/marquee_text_sync"; + +const marqueeSync = new MarqueeTextSync(); // properties interface Props { From ac1164972f87042ed079993191aae95a5d7dd1fb Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 2 Dec 2024 09:45:32 +0100 Subject: [PATCH 06/12] Feat: Use MarqueeText in InfoHeader --- src/components/InfoHeader.vue | 47 +++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/src/components/InfoHeader.vue b/src/components/InfoHeader.vue index 30e5ed1f..23509f4c 100644 --- a/src/components/InfoHeader.vue +++ b/src/components/InfoHeader.vue @@ -83,7 +83,9 @@ style="padding-left: 10px" /> - {{ item.name }} + + {{ item.name }} + @@ -119,20 +121,22 @@ color="primary" icon="mdi-account-music" /> - - {{ - artist.name - }} + {{ " / " }} - + {{ + artist.name + }} + {{ " / " }} + + @@ -146,7 +150,9 @@ small icon="mdi-account-music" /> - {{ item.owner }} + + {{ item.owner }} + - {{ item.album.name }} + {{ item.album.name }}
@@ -331,6 +339,8 @@ import { } from "@/layouts/default/ItemContextMenu.vue"; import Toolbar from "@/components/Toolbar.vue"; import { useI18n } from "vue-i18n"; +import MarqueeText from "./MarqueeText.vue"; +import { MarqueeTextSync } from "@/helpers/marquee_text_sync"; // properties export interface Props { @@ -344,6 +354,7 @@ const { mobile } = useDisplay(); const imgGradient = new URL("../assets/info_gradient.jpg", import.meta.url) .href; +const marqueeSync = new MarqueeTextSync(); const router = useRouter(); const { t } = useI18n(); From a04db05f9bf71905a7682115bc81589cc152ccd8 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Dec 2024 13:49:27 +0100 Subject: [PATCH 07/12] Fix: Slow down MarqueeText speed for better readability --- src/components/MarqueeText.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MarqueeText.vue b/src/components/MarqueeText.vue index 124cb43a..09aafce2 100644 --- a/src/components/MarqueeText.vue +++ b/src/components/MarqueeText.vue @@ -29,9 +29,9 @@ import { import { MarqueeTextSync } from "@/helpers/marquee_text_sync"; // Animation Constants -const SCROLL_SPEED = 60; // Scrolling speed in pixels per second +const SCROLL_SPEED = 30; // Scrolling speed in pixels per second const START_DELAY = 2000; // Delay before starting scroll (milliseconds) -const END_DELAY = 2000; // Delay after scroll completion (milliseconds) +const END_DELAY = 4000; // Delay after scroll completion (milliseconds) const RESET_ANIMATION_DURATION_MS = 1000; // Duration for reset animation (milliseconds) // DOM References and State Management From c3a26d4f9809f06cffef33b8dafcaee62203db57 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Dec 2024 14:02:20 +0100 Subject: [PATCH 08/12] Feat: Add `disabled` prop to MarqueeText --- src/components/MarqueeText.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/MarqueeText.vue b/src/components/MarqueeText.vue index 09aafce2..dd8758e6 100644 --- a/src/components/MarqueeText.vue +++ b/src/components/MarqueeText.vue @@ -46,6 +46,7 @@ const isVisible = ref(false); // Props Definition const props = defineProps<{ sync?: MarqueeTextSync; // Optional sync helper for coordinating multiple marquees + disabled?: boolean; // Disable scrolling animation }>(); // Register with the sync helper if provided @@ -104,6 +105,7 @@ const startScrollCycle = async () => { // Check if scrolling is necessary if ( + props.disabled === true || maxOffsetPosition.value <= 0 || scrollDuration.value <= 0 || !isVisible.value @@ -229,7 +231,7 @@ onBeforeUnmount(() => { // Watch for changes that require animation restart watch( - [maxOffsetPosition, isVisible], + [maxOffsetPosition, isVisible, () => props.disabled], () => { startScrollCycle(); }, From 42b210cec7fc67ba8a611e65c0a00ccdfecc0384 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Dec 2024 14:03:06 +0100 Subject: [PATCH 09/12] Feat: Use MarqueeText for the currently playing track in the Player Queue --- .../default/PlayerOSD/PlayerFullscreen.vue | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/layouts/default/PlayerOSD/PlayerFullscreen.vue b/src/layouts/default/PlayerOSD/PlayerFullscreen.vue index 03976394..fea9e214 100644 --- a/src/layouts/default/PlayerOSD/PlayerFullscreen.vue +++ b/src/layouts/default/PlayerOSD/PlayerFullscreen.vue @@ -256,7 +256,7 @@ max-height="90%" :items="activeQueuePanel == 0 ? nextItems : previousItems" > - From 1bfde8caad9724c63601b4ac84b6c39d89b9bff9 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Wed, 25 Dec 2024 14:14:19 +0100 Subject: [PATCH 10/12] Perf: Disconnect observers on disabled MarqueeTexts --- src/components/MarqueeText.vue | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/components/MarqueeText.vue b/src/components/MarqueeText.vue index dd8758e6..4a119475 100644 --- a/src/components/MarqueeText.vue +++ b/src/components/MarqueeText.vue @@ -194,9 +194,9 @@ const visibilityObserver = new IntersectionObserver( ); // Lifecycle Hooks -onMounted(() => { - if (!containerRef.value || !scrollingRef.value) return; +// Setup observers and initial measurements +const setup = () => { nextTick(() => { if (!containerRef.value || !scrollingRef.value) return; @@ -204,7 +204,7 @@ onMounted(() => { scrollingWidth.value = scrollingRef.value.scrollWidth; containerWidth.value = containerRef.value.clientWidth; }); - + if (!containerRef.value || !scrollingRef.value) return; // Setup observers scrollingObserver.observe(scrollingRef.value, { childList: true, @@ -215,10 +215,10 @@ onMounted(() => { }); containerObserver.observe(containerRef.value); visibilityObserver.observe(containerRef.value); -}); +}; -onBeforeUnmount(() => { - // Cleanup observers and animation +// Cleanup observers and animation +const cleanup = () => { if (animationController) { animationController.abort(); animationController = null; @@ -227,6 +227,17 @@ onBeforeUnmount(() => { scrollingObserver.disconnect(); containerObserver.disconnect(); visibilityObserver.disconnect(); + isVisible.value = false; +}; + +onMounted(() => { + if (props.disabled !== true) { + setup(); + } +}); + +onBeforeUnmount(() => { + cleanup(); }); // Watch for changes that require animation restart @@ -237,6 +248,16 @@ watch( }, { flush: "post" }, ); +watch( + () => props.disabled, + () => { + if (props.disabled === true) { + cleanup(); + } else { + setup(); + } + }, +);