From 963a936a08801f0ecaa252a9a90ebb5bfc556be4 Mon Sep 17 00:00:00 2001 From: Darrell van Swinderen Date: Wed, 23 Sep 2026 10:11:49 +0200 Subject: [PATCH] feat(storage): tiles show where the preview is, like a library card Storage Saver's tiles played on hover and scrubbed, but drew nothing saying how far in they were: a video moving under the pointer with no position, on the screen where the position is what decides a delete. The two pixel playhead line under a library card's picture is now `composables/clips/usePreviewPlayhead.ts`, lifted out of `ClipCard.vue` unchanged (read once per frame while playing, driven by the pointer while scrubbing, drawn as a transform), and both tiles use it. The storage tile also draws a clip's GoodBit pips the way a card does. Driven: the line grows while a preview plays and sits under the pointer while scrubbing. `screens`, `features`, `primitives` and `infiniteScroll` specs: 27 passed, so the card itself is unchanged. `npm run check` 849. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/Library/ClipCard.vue | 91 +----------------- .../components/Storage/StorageClipTile.vue | 18 ++++ .../composables/clips/usePreviewPlayhead.ts | 94 +++++++++++++++++++ 3 files changed, 115 insertions(+), 88 deletions(-) create mode 100644 src/renderer/src/composables/clips/usePreviewPlayhead.ts diff --git a/src/renderer/src/components/Library/ClipCard.vue b/src/renderer/src/components/Library/ClipCard.vue index 349c718..5c8192c 100644 --- a/src/renderer/src/components/Library/ClipCard.vue +++ b/src/renderer/src/components/Library/ClipCard.vue @@ -9,6 +9,7 @@ import { useClipDetail } from '@renderer/composables/clips/useClipDetail'; import { useConfiguration } from '@renderer/composables/app/useConfiguration'; import { useClipActionsHandlers } from '@renderer/composables/clips/useClipActionsHandlers'; import { useHoverScrub, scrubBand } from '@renderer/composables/clips/useHoverScrub'; +import { usePreviewPlayhead } from '@renderer/composables/clips/usePreviewPlayhead'; import { useBatchOperationsStore } from '@renderer/stores/batchOperations'; import { useCollectionsStore } from '@renderer/stores/collections'; import { useToastStore } from '@renderer/stores/toast'; @@ -133,94 +134,8 @@ const scrubStripStyle = computed(() => { return { bottom: `${band.bottomPx}px`, height: `${band.heightPx}px` }; }); -/** - * How far through the preview is, from 0 to 1. - * - * Read once per animation frame while something is playing, not from - * `timeupdate`. That event fires about four times a second at irregular - * intervals, so a bar driven by it steps rather than moves, and a CSS - * transition laid over the top only smears the steps into each other. - * - * The loop only runs while this card's preview is actually playing, and only - * one preview plays at a time, so this is one frame callback for the window. - */ -const played = ref(0); -let frame: number | null = null; - -function readPlayhead(): void { - const video = videoEl.value; - if (video && Number.isFinite(video.duration) && video.duration > 0) { - played.value = Math.min(1, video.currentTime / video.duration); - } -} - -function follow(): void { - readPlayhead(); - frame = requestAnimationFrame(follow); -} - -function startFollowing(): void { - if (frame === null) frame = requestAnimationFrame(follow); -} - -function stopFollowing(): void { - if (frame !== null) cancelAnimationFrame(frame); - frame = null; - // One last read, so a pause lands on the frame it paused at rather than - // wherever the previous tick left the bar. - readPlayhead(); -} - -/** - * Scaled rather than resized. - * - * `width` is a layout property: changing it every frame makes the browser - * reflow the card sixty times a second. A transform is handed to the - * compositor and costs nothing. - */ -const fillStyle = computed(() => ({ - transform: `scaleX(${played.value})`, -})); - -watch(isScrubbing, (scrubbing) => { - if (scrubbing) { - stopFollowing(); - played.value = scrubProgress.value; - } -}); - -watch(scrubProgress, (fraction) => { - if (isScrubbing.value) played.value = fraction; -}); - -watch(videoEl, (element, previous) => { - if (previous) { - previous.removeEventListener('play', startFollowing); - previous.removeEventListener('playing', startFollowing); - previous.removeEventListener('pause', stopFollowing); - previous.removeEventListener('ended', stopFollowing); - previous.removeEventListener('seeked', readPlayhead); - } - if (element) { - element.addEventListener('play', startFollowing); - element.addEventListener('playing', startFollowing); - element.addEventListener('pause', stopFollowing); - element.addEventListener('ended', stopFollowing); - element.addEventListener('seeked', readPlayhead); - if (!element.paused) startFollowing(); - } -}); - -onBeforeUnmount(() => { - stopFollowing(); - const video = videoEl.value; - if (!video) return; - video.removeEventListener('play', startFollowing); - video.removeEventListener('playing', startFollowing); - video.removeEventListener('pause', stopFollowing); - video.removeEventListener('ended', stopFollowing); - video.removeEventListener('seeked', readPlayhead); -}); +// Where the preview has got to, shared with every tile that previews a clip. +const { fillStyle } = usePreviewPlayhead(videoEl, { isScrubbing, scrubProgress }); /** * Drag the file itself out of the window, into Discord, Explorer, anything. diff --git a/src/renderer/src/components/Storage/StorageClipTile.vue b/src/renderer/src/components/Storage/StorageClipTile.vue index a3188c7..ad4bd45 100644 --- a/src/renderer/src/components/Storage/StorageClipTile.vue +++ b/src/renderer/src/components/Storage/StorageClipTile.vue @@ -7,6 +7,9 @@ import { thumbnailUrl, videoUrl } from '@renderer/utils/mediaUrl'; import { useFormat } from '@renderer/composables/ui/useFormat'; import { useConfiguration } from '@renderer/composables/app/useConfiguration'; import { useHoverScrub } from '@renderer/composables/clips/useHoverScrub'; +import { usePreviewPlayhead } from '@renderer/composables/clips/usePreviewPlayhead'; +import ClipGoodBitPips from '@renderer/components/Library/ClipGoodBitPips.vue'; +import { clipGoodBitRanges } from '@renderer/utils/goodBits'; import { useClipDetail } from '@renderer/composables/clips/useClipDetail'; import { formatRelativeTime } from '@renderer/helpers/dateFormat'; import type { Clip } from '@renderer/types/clip'; @@ -72,6 +75,10 @@ const { isScrubbing, scrubProgress, scrubTime, formatTime, handleMouseMove, hand const src = computed(() => videoUrl(props.clip.id, props.clip.fileModifiedAt)); +// The line under the picture, the same one a library card draws. +const { fillStyle } = usePreviewPlayhead(videoEl, { isScrubbing, scrubProgress }); +const goodBitRanges = computed(() => clipGoodBitRanges(props.clip)); + /* * Hover plays, like a library card, under the same setting. * @@ -173,6 +180,9 @@ const frame = computed(() => [ link somebody may already have shared, and no Recycle Bin covers that, so it is said on the tile rather than only in the question. --> + + + Published @@ -189,6 +199,14 @@ const frame = computed(() => [ + +