Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 3 additions & 88 deletions src/renderer/src/components/Library/ClipCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/src/components/Storage/StorageClipTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
-->
<!-- Any marks it carries, as on a library card; hidden under the scrub strip. -->
<ClipGoodBitPips v-if="!isScrubbing" :ranges="goodBitRanges" :duration-sec="clip.durationSec" />

<BaseChip v-if="clip.published" class="absolute top-1.5 right-1.5">Published</BaseChip>

<BaseChip v-if="length && !isScrubbing" numeric class="absolute bottom-1.5 right-1.5">
Expand All @@ -189,6 +199,14 @@ const frame = computed(() => [
</span>
</button>

<!--
Where the preview has got to: flush under the picture, two pixels, no
clicks. Playing and scrubbing both move it, exactly as on a library card.
-->
<div class="h-0.5 bg-border/40 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="h-full w-full bg-accent origin-left will-change-transform" :style="fillStyle" />
</div>

<!--
The choice, over the picture's corner. A sibling of the picture's button
rather than inside it, so there are two targets and not one nested in
Expand Down
94 changes: 94 additions & 0 deletions src/renderer/src/composables/clips/usePreviewPlayhead.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue';

/**
* How far through a tile's preview is, from 0 to 1, and the style that draws it.
*
* Lifted out of `Library/ClipCard.vue` so that every tile which previews a clip
* shows where it is the same way. Storage Saver's tiles played and scrubbed
* without it, which left a video moving under the pointer with nothing saying
* how far in it was, on the one screen where that is what decides a delete.
*
* 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 tile's preview is actually playing, and only one
* preview plays at a time, so this is one frame callback for the window.
*
* Scrubbing drives it too: while the pointer is on the scrub band the bar
* follows the pointer rather than the video.
*/
export function usePreviewPlayhead(
videoEl: Ref<HTMLVideoElement | null>,
scrub: { isScrubbing: Ref<boolean>; scrubProgress: Ref<number> },
) {
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 tile sixty times a second. A transform is handed to the
* compositor and costs nothing.
*/
const fillStyle = computed(() => ({ transform: `scaleX(${played.value})` }));

watch(scrub.isScrubbing, (scrubbing) => {
if (scrubbing) {
stopFollowing();
played.value = scrub.scrubProgress.value;
}
});

watch(scrub.scrubProgress, (fraction) => {
if (scrub.isScrubbing.value) played.value = fraction;
});

const events: Array<[string, () => void]> = [
['play', startFollowing],
['playing', startFollowing],
['pause', stopFollowing],
['ended', stopFollowing],
['seeked', readPlayhead],
];

watch(videoEl, (element, previous) => {
for (const [name, handler] of events) previous?.removeEventListener(name, handler);
if (element) {
for (const [name, handler] of events) element.addEventListener(name, handler);
if (!element.paused) startFollowing();
}
});

onBeforeUnmount(() => {
stopFollowing();
for (const [name, handler] of events) videoEl.value?.removeEventListener(name, handler);
});

return { played, fillStyle };
}
Loading