Skip to content

Commit bf15e15

Browse files
committed
feat: tentative support for clickable timestamps in descriptions
1 parent 59a9512 commit bf15e15

4 files changed

Lines changed: 94 additions & 16 deletions

File tree

resources/js/components/video/VideoInfoPanel.vue

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getUserViewCount } from '@/service/mediaAPI';
88
import { useContentStore } from '@/stores/ContentStore';
99
import { useAuthStore } from '@/stores/AuthStore';
1010
import { storeToRefs } from 'pinia';
11+
import { emitSeek } from '@/service/video/seekBus';
1112
import { useRoute } from 'vue-router';
1213
1314
import ButtonClipboard from '@/components/pinesUI/ButtonClipboard.vue';
@@ -61,6 +62,10 @@ const handleSeriesUpdate = (res: any) => {
6162
editFolderModal.toggleModal(false);
6263
};
6364
65+
function handleSeek(seconds: number) {
66+
emitSeek(seconds);
67+
}
68+
6469
watch(
6570
() => stateVideo.value,
6671
async () => {
@@ -181,7 +186,7 @@ watch(
181186
</span>
182187
</span>
183188
</section>
184-
<section id="mp4-folder-info" class="hidden xs:block h-32 my-auto object-cover rounded-md shadow-md aspect-2/3 mb-auto relative group">
189+
<section id="mp4-folder-info" class="hidden xs:block h-32 object-cover rounded-md shadow-md aspect-2/3 relative group">
185190
<img
186191
id="folder-thumbnail"
187192
class="h-full object-cover rounded-md aspect-2/3 ring-1 ring-gray-900/5"
@@ -230,10 +235,29 @@ watch(
230235
</ButtonIcon>
231236
</section>
232237
</section>
233-
<HoverCard :content="metaData?.fields?.description" :hover-card-delay="800" :margin="10">
238+
<HoverCard :content="stateVideo.description ?? defaultDescription" :hover-card-delay="800" :margin="10">
234239
<template #trigger>
235-
<div :class="`h-[3.75rem] overflow-y-auto overflow-x-clip text-sm whitespace-pre-wrap scrollbar-minimal scrollbar-hover `">
236-
{{ metaData?.fields?.description || defaultDescription }}
240+
<div :class="[`overflow-y-auto overflow-x-clip text-sm whitespace-pre-wrap scrollbar-minimal scrollbar-hover`, { 'h-[3.75rem]': false }]">
241+
<template v-if="metaData?.fields?.description">
242+
<span v-for="(segment, i) in metaData?.fields?.description" :key="i">
243+
<template v-if="segment.type === 'timestamp' && segment.seconds !== undefined">
244+
<a
245+
:href="`?video=${stateVideo.id}&t=${segment.seconds}`"
246+
@click.prevent="handleSeek(segment.seconds)"
247+
class="text-purple-600 dark:text-white hover:underline"
248+
:title="`Seek to ${segment.seconds}`"
249+
>
250+
{{ segment.raw }}
251+
</a>
252+
</template>
253+
<template v-else>
254+
{{ segment.text }}
255+
</template>
256+
</span>
257+
</template>
258+
<template v-else>
259+
{{ metaData?.fields?.description || defaultDescription }}
260+
</template>
237261
</div>
238262
</template>
239263
</HoverCard>

resources/js/components/video/VideoPlayer.vue

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<script setup lang="ts">
22
import type { FolderResource, VideoResource } from '@/types/resources';
3-
import { type ContextMenuItem, type PopoverItem, MediaType } from '@/types/types';
4-
import type { Series } from '@/types/model';
3+
import type { ContextMenuItem, PopoverItem } from '@/types/types';
54
65
import { getScreenSize, handleStorageURL, isInputLikeElement, isMobileDevice, toFormattedDate, toFormattedDuration } from '@/service/util';
76
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch, type ComputedRef, type Ref } from 'vue';
@@ -13,6 +12,7 @@ import { useAuthStore } from '@/stores/AuthStore';
1312
import { useAppStore } from '@/stores/AppStore';
1413
import { storeToRefs } from 'pinia';
1514
import { getMediaUrl } from '@/service/api';
15+
import { MediaType } from '@/types/types';
1616
import { useRouter } from 'vue-router';
1717
import { toast } from '@/service/toaster/toastService';
1818
@@ -49,6 +49,7 @@ import ProiconsCancel from '~icons/proicons/cancel';
4949
import ProiconsPlay from '~icons/proicons/play';
5050
import MagePlaylist from '~icons/mage/playlist';
5151
import CircumTimer from '~icons/circum/timer';
52+
import { onSeek } from '@/service/video/seekBus';
5253
5354
/**
5455
* Z Index Layout:
@@ -73,7 +74,7 @@ const { setContextMenu } = useAppStore();
7374
const { userData } = storeToRefs(useAuthStore());
7475
const { stateVideo, stateFolder, nextVideoURL, previousVideoURL } = storeToRefs(useContentStore()) as unknown as {
7576
stateVideo: Ref<VideoResource>;
76-
stateFolder: Ref<FolderResource | { id?: number; name?: string; series?: Series; path?: string }>;
77+
stateFolder: Ref<FolderResource>;
7778
nextVideoURL: ComputedRef<string>;
7879
previousVideoURL: ComputedRef<string>;
7980
};
@@ -164,7 +165,7 @@ const keyBinds = computed(() => {
164165
play: `${isPaused.value ? 'Play' : 'Pause'}${keys.play}`,
165166
next: `Play Next${keys.next}`,
166167
fullscreen: `${isFullScreen.value ? 'Exit Full Screen' : 'Full Screen'}${keys.fullscreen}`,
167-
lyrics: `${isShowingLyrics.value ? 'Disable' : 'Enable'} ${isAudio.value ? 'Lyrics' : 'Captions'}${keys.lyrics}`,
168+
lyrics: `${isShowingLyrics.value ? 'Disable' : 'Enable'} ${isAudio.value || stateFolder.value.is_majority_audio ? 'Lyrics' : 'Captions'}${keys.lyrics}`,
168169
};
169170
});
170171
@@ -236,7 +237,7 @@ const videoPopoverItems = computed(() => {
236237
selectedIcon: ProiconsCheckmark,
237238
selected: isShowingLyrics.value ?? false,
238239
selectedIconStyle: 'text-purple-600 stroke-none',
239-
disabled: getScreenSize() !== 'default' || isAudio.value,
240+
disabled: getScreenSize() !== 'default' || isAudio.value || stateFolder.value.is_majority_audio,
240241
action: () => {
241242
isShowingLyrics.value = !isShowingLyrics.value;
242243
},
@@ -249,7 +250,7 @@ const videoPopoverItems = computed(() => {
249250
selectedIcon: ProiconsCheckmark,
250251
selected: isShowingLyrics.value ?? false,
251252
selectedIconStyle: 'text-purple-600 stroke-none',
252-
disabled: getScreenSize() !== 'default' || !isAudio.value,
253+
disabled: getScreenSize() !== 'default' || (!isAudio.value && !stateFolder.value.is_majority_audio),
253254
action: () => {
254255
isShowingLyrics.value = !isShowingLyrics.value;
255256
},
@@ -921,15 +922,19 @@ watch(isPictureInPicture, async (value) => {
921922
922923
watch(stateVideo, initVideoPlayer);
923924
925+
let unSub: () => boolean;
926+
924927
onMounted(() => {
925928
if (document.pictureInPictureElement) document.exitPictureInPicture();
926929
handleLoadSavedVolume();
927930
handleMediaSessionEvents();
928931
window.addEventListener('keydown', handleKeyBinds);
929932
document.addEventListener('fullscreenchange', handleFullScreenChange);
933+
unSub = onSeek(handleManualSeek);
930934
});
931935
932936
onUnmounted(() => {
937+
unSub();
933938
window.removeEventListener('keydown', handleKeyBinds);
934939
document.removeEventListener('fullscreenchange', handleFullScreenChange);
935940
debouncedCacheVolume.cancel();
@@ -1234,7 +1239,7 @@ defineExpose({
12341239
:controls="isShowingControls"
12351240
:offset="videoButtonOffset"
12361241
>
1237-
<template #icon v-if="isAudio">
1242+
<template #icon v-if="isAudio || stateFolder.is_majority_audio">
12381243
<TablerMicrophone2 v-if="isShowingLyrics" class="w-4 h-4 [&>*]:stroke-[1.4px]" />
12391244
<TablerMicrophone2Off v-else class="w-4 h-4 [&>*]:stroke-[1.4px]" />
12401245
</template>
@@ -1358,9 +1363,10 @@ defineExpose({
13581363
leave-active-class="transition ease-in duration-300"
13591364
leave-from-class="translate-y-0 opacity-100"
13601365
leave-to-class="translate-y-full opacity-0"
1361-
><div :class="`absolute w-full h-full top-0 flex transition-all opacity-0`" style="z-index: 5" v-show="isShowingLyrics">
1366+
>
1367+
<div :class="`absolute w-full h-full top-0 flex transition-all opacity-0`" style="z-index: 5" v-show="isShowingLyrics">
13621368
<VideoLyrics
1363-
v-if="isAudio"
1369+
v-if="isAudio || stateFolder.is_majority_audio"
13641370
@seek="handleManualSeek"
13651371
:raw-lyrics="stateVideo?.metadata?.lyrics ?? ''"
13661372
:time-duration="timeDuration"
@@ -1443,7 +1449,11 @@ defineExpose({
14431449
leave-active-class="transition ease-in duration-300"
14441450
leave-from-class="opacity-100"
14451451
leave-to-class="opacity-0"
1446-
><div :class="`absolute w-full h-full top-0 transition-all backdrop-blur-lg bg-neutral-950/10`" style="z-index: 3" v-show="isAudio && isShowingLyrics"></div>
1452+
><div
1453+
:class="`absolute w-full h-full top-0 transition-all backdrop-blur-lg bg-neutral-950/10`"
1454+
style="z-index: 3"
1455+
v-show="(isAudio || stateFolder.is_majority_audio) && isShowingLyrics"
1456+
></div>
14471457
</Transition>
14481458
</section>
14491459
<!-- Is a blurred copy of the thumbnail or poster as a backdrop to the clear poster -->

resources/js/composables/useMetaData.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useRoute } from 'vue-router';
66
import { reactive } from 'vue';
77

88
// This so does not work lol
9+
910
export default function useMetaData(data: VideoResource, skipBaseURL: boolean = false) {
1011
const route = useRoute();
1112

@@ -14,7 +15,7 @@ export default function useMetaData(data: VideoResource, skipBaseURL: boolean =
1415
title: `${generateEpisodeTag(data)}${data?.title ?? data?.name}`,
1516
duration: toFormattedDuration(data?.duration) ?? 'N/A',
1617
views: generateViewsTag(data?.view_count),
17-
description: data?.description ?? '',
18+
description: generateDescription(data?.description ?? ''),
1819
url: encodeURI((skipBaseURL ? '' : document.location.origin) + route.path + `?video=${data.id}`),
1920
file_size: data.file_size ? formatFileSize(data.file_size) : '',
2021
},
@@ -24,7 +25,7 @@ export default function useMetaData(data: VideoResource, skipBaseURL: boolean =
2425
title: `${generateEpisodeTag(props)}${props?.title ?? props?.name}`,
2526
duration: toFormattedDuration(props?.duration) ?? 'N/A',
2627
views: generateViewsTag(props?.view_count),
27-
description: props?.description ?? '',
28+
description: generateDescription(props?.description ?? ''),
2829
url: encodeURI((skipBaseURL ? '' : document.location.origin) + route.path + `?video=${data.id}`),
2930
file_size: data.file_size ? formatFileSize(data.file_size) : '',
3031
};
@@ -39,5 +40,36 @@ export default function useMetaData(data: VideoResource, skipBaseURL: boolean =
3940
return `${viewCount} view${viewCount !== 1 ? 's' : ''}`;
4041
}
4142

43+
function generateDescription(description: string) {
44+
const parts: { type: 'text' | 'timestamp'; text?: string; raw?: string; seconds?: number }[] = [];
45+
46+
let lastIndex = 0;
47+
let match: RegExpExecArray | null;
48+
49+
const regex = /(?:(\d{1,2}):)?(\d{1,2}):(\d{2}(?:\.\d+)?)/g;
50+
51+
while ((match = regex.exec(description)) !== null) {
52+
const [full, hour, min, sec] = match;
53+
const start = match.index;
54+
const end = regex.lastIndex;
55+
56+
if (start > lastIndex) {
57+
parts.push({ type: 'text', text: description.slice(lastIndex, start) });
58+
}
59+
60+
const seconds = parseInt(hour ?? '0') * 3600 + parseInt(min) * 60 + parseFloat(sec);
61+
62+
parts.push({ type: 'timestamp', raw: full, seconds });
63+
64+
lastIndex = end;
65+
}
66+
67+
if (lastIndex < description.length) {
68+
parts.push({ type: 'text', text: description.slice(lastIndex) });
69+
}
70+
71+
return parts;
72+
}
73+
4274
return metadata;
4375
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const subscribers = new Set<(seconds: number) => void>();
2+
3+
export function onSeek(callback: (seconds: number) => void) {
4+
subscribers.add(callback);
5+
return () => subscribers.delete(callback); // unsubscribe
6+
}
7+
8+
export function emitSeek(seconds: number) {
9+
for (const callback of subscribers) {
10+
callback(seconds);
11+
}
12+
}

0 commit comments

Comments
 (0)