Files
materialious/materialious/src/lib/Thumbnail.svelte
T
2024-09-16 00:53:44 +12:00

443 lines
11 KiB
Svelte

<script lang="ts">
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { getChannel, getDeArrow, getThumbnail, getVideo, getVideoProgress } from './Api';
import type { Notification, PlaylistPageVideo, Video, VideoBase, VideoPlay } from './Api/model';
import {
cleanNumber,
getBestThumbnail,
letterCase,
proxyGoogleImage,
proxyVideoUrl,
videoLength
} from './misc';
import type { PlayerEvents } from './player';
import {
authStore,
deArrowEnabledStore,
deArrowTitlesOnly,
interfacePreviewVideoOnHoverStore,
playerProxyVideosStore,
playerSavePlaybackPositionStore,
syncPartyConnectionsStore,
syncPartyPeerStore,
synciousInstanceStore,
synciousStore
} from './store';
const dispatch = createEventDispatcher();
export let video: VideoBase | Video | Notification | PlaylistPageVideo;
export let playlistId: string = '';
export let sideways: boolean = false;
let thumbnailHidden: boolean = false;
let showVideoPreview: boolean = false;
let videoPreview: VideoPlay | null = null;
let videoPreviewMuted: boolean = true;
let videoPreviewVolume: number = 0.4;
let imgHeight: number;
let authorImg: string = '';
let proxyVideos = get(playerProxyVideosStore);
let placeholderHeight: number = 0;
let watchUrl = new URL(`${location.origin}/watch/${video.videoId}`);
if (playlistId !== '') {
watchUrl.searchParams.set('playlist', playlistId);
}
syncPartyPeerStore.subscribe((peer) => {
if (peer) {
watchUrl.searchParams.set('sync', peer.id);
}
});
let loading = true;
let loaded = false;
let img: HTMLImageElement;
let progress: string | null;
if (get(playerSavePlaybackPositionStore)) {
try {
progress = localStorage.getItem(`v_${video.videoId}`);
} catch {
progress = null;
}
} else {
progress = null;
}
let startedSideways = sideways === true;
function disableSideways() {
if (!startedSideways) return;
if (window.innerWidth <= 1500) {
sideways = false;
if (typeof authorImg === 'undefined') loadAuthor();
} else sideways = true;
}
async function loadAuthor() {
const channel = await getChannel(video.authorId);
const imgResp = await fetch(
proxyGoogleImage(getBestThumbnail(channel.authorThumbnails, 75, 75))
);
authorImg = URL.createObjectURL(await imgResp.blob());
}
onMount(async () => {
try {
thumbnailHidden = localStorage.getItem(`v_h_${video.videoId}`) === '1';
} catch {}
if (thumbnailHidden) {
dispatch('videoHidden');
return;
}
calcThumbnailPlaceholderHeight();
// Check if sideways should be enabled or disabled.
disableSideways();
addEventListener('resize', () => {
disableSideways();
calcThumbnailPlaceholderHeight();
});
// Load author details in background.
if (!sideways) {
loadAuthor();
}
let imageSrc = getBestThumbnail(video.videoThumbnails) as string;
if (get(deArrowEnabledStore)) {
let locatedThumbnail = false;
try {
const deArrow = await getDeArrow(video.videoId);
for (const title of deArrow.titles) {
if (title.locked || title.votes > 0) {
video.title = title.title.replace('>', '');
break;
}
}
for (const thumbnail of deArrow.thumbnails) {
if (thumbnail.locked || thumbnail.original || thumbnail.votes > 0) {
if (thumbnail.timestamp !== null) {
imageSrc = await getThumbnail(video.videoId, thumbnail.timestamp);
}
locatedThumbnail = true;
break;
}
}
} catch {}
if (!locatedThumbnail && !get(deArrowTitlesOnly)) {
// Process thumbnail locally.
const canvas = document.getElementById('canvas') as HTMLCanvasElement | null;
function generateThumbnail(): Promise<string> {
return new Promise<string>(async (resolve, reject) => {
if (canvas) {
const context = canvas.getContext('2d');
const mockVideo = document.createElement('video');
const videoContainer = document.getElementById('video-container');
videoContainer?.appendChild(mockVideo);
mockVideo.preload = 'auto';
mockVideo.id = 'video';
mockVideo.crossOrigin = 'anonymous';
const videoDetails = (await getVideo(video.videoId)).formatStreams[0];
mockVideo.src = proxyVideoUrl(videoDetails.url);
mockVideo.addEventListener('loadeddata', () => {
mockVideo.currentTime = mockVideo.duration / 50;
mockVideo.addEventListener('seeked', () => {
if (context) {
context.drawImage(mockVideo, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/png'));
mockVideo.preload = 'none';
mockVideo.pause();
videoContainer?.removeChild(mockVideo);
}
});
});
mockVideo.load();
}
});
}
imageSrc = await generateThumbnail();
}
}
img = new Image();
img.src = imageSrc;
img.onload = () => {
loaded = true;
loading = false;
};
img.onerror = () => {
loading = false;
};
if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) {
try {
progress = (await getVideoProgress(video.videoId))[0].time.toString();
} catch {}
}
});
onDestroy(() => {
removeEventListener('resize', disableSideways);
});
function syncChangeVideo() {
if ($syncPartyConnectionsStore) {
const events = {
events: [{ type: 'change-video', videoId: video.videoId }]
} as PlayerEvents;
if (playlistId) {
events.events.unshift({
type: 'playlist',
playlistId: playlistId
});
}
$syncPartyConnectionsStore.forEach((conn) => {
conn.send(events);
});
}
}
async function previewVideo() {
if (!get(interfacePreviewVideoOnHoverStore)) return;
showVideoPreview = true;
try {
videoPreview = await getVideo(video.videoId);
if (videoPreview.hlsUrl) {
showVideoPreview = false;
videoPreview = null;
} else {
try {
const vidstackDetails = localStorage.getItem('video-player');
if (vidstackDetails) {
const vidstackDetailsParsed = JSON.parse(vidstackDetails);
if ('volume' in vidstackDetailsParsed)
videoPreviewVolume = vidstackDetailsParsed.volume;
}
} catch {}
}
} catch {
showVideoPreview = false;
}
imgHeight = document.getElementById('thumbnail-container')?.clientHeight as number;
}
function calcThumbnailPlaceholderHeight() {
if (!sideways) {
if (innerWidth < 1000) {
if (innerWidth < 600) {
placeholderHeight = innerWidth / 2;
} else {
placeholderHeight = innerWidth / 4;
}
} else {
placeholderHeight = innerWidth / 12;
}
} else {
placeholderHeight = 115;
}
}
</script>
{#if !thumbnailHidden}
<div class:sideways-root={sideways}>
<div
on:mouseover={previewVideo}
on:mouseleave={() => (showVideoPreview = false)}
on:focus={() => {}}
id="thumbnail-container"
role="region"
>
<a
class="wave thumbnail"
href={watchUrl.toString()}
data-sveltekit-preload-data="off"
on:click={syncChangeVideo}
>
{#if loading}
<div class="secondary-container" style="width: 100%;height: {placeholderHeight}px;"></div>
{:else if loaded}
{#if showVideoPreview && videoPreview}
<div style="max-width: 100%; max-height: {imgHeight}px;">
<video
id="video-preview"
style="max-width: 100%; max-height: {imgHeight}px;"
autoplay
poster={img.src}
width="100%"
height="100%"
muted={videoPreviewMuted}
controls={false}
volume={videoPreviewVolume}
src={proxyVideos
? proxyVideoUrl(videoPreview.formatStreams[0].url)
: videoPreview.formatStreams[0].url}
>
</video>
</div>
{:else}
<img class="responsive" src={img.src} alt="Thumbnail for video" />
{/if}
{:else}
<p>{$_('thumbnail.failedToLoadImage')}</p>
{/if}
{#if progress}
<progress
class="absolute right bottom"
style="z-index: 1;"
value={progress}
max={video.lengthSeconds}
></progress>
{/if}
{#if !('liveVideo' in video) || !video.liveVideo}
{#if video.lengthSeconds !== 0}
<div
class="absolute right bottom small-margin black white-text small-text thumbnail-corner"
>
&nbsp;{videoLength(video.lengthSeconds)}&nbsp;
</div>
{/if}
{:else}
<div
class="absolute right bottom small-margin red white-text small-text thumbnail-corner"
>
{$_('thumbnail.live')}
</div>
{/if}
</a>
{#if showVideoPreview && videoPreview}
<button
class="no-padding"
style="position: absolute; bottom: 10px; left: 10px; width: 30px; height: 30px;"
on:click={() => {
videoPreviewMuted = !videoPreviewMuted;
}}
>
<i>
{#if videoPreviewMuted}
volume_off
{:else}
volume_up
{/if}
</i>
</button>
{/if}
</div>
<div class="thumbnail-details video-title">
{#if !sideways}
<div style="margin-right: 1em;">
{#if authorImg !== ''}
<img class="circle small" src={authorImg} alt="Author" />
{:else}
<progress style="padding: 15px;" class="circle small"></progress>
{/if}
</div>
{/if}
<div class="video-title">
<a
style="padding-left: 1px;"
class="video-title"
data-sveltekit-preload-data="off"
href={watchUrl.toString()}
>
<span class="bold">{letterCase(video.title.trimEnd())}</span>
</a>
<div>
<a class:author={!sideways} href={`/channel/${video.authorId}`}>{video.author}</a>
{#if !('publishedText' in video) && 'viewCountText' in video}
<span style="margin-top: 1em;">
• {video.viewCountText}
{$_('views')}
</span>
{/if}
{#if 'publishedText' in video}
<div class="max">
{cleanNumber(video.viewCount)} • {video.publishedText}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
<canvas id="canvas" style="display: none;"></canvas>
<div id="video-container" style="display: none;"></div>
<style>
.thumbnail {
width: 100%;
overflow: hidden;
}
.thumbnail img {
object-fit: contain;
}
.sideways-root {
height: 115px;
display: flex;
align-items: flex-start;
}
.sideways-root .thumbnail {
width: 200px;
}
#video-preview[poster] {
object-fit: contain;
}
.video-title {
display: inline-block;
white-space: nowrap;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.thumbnail-details {
display: flex;
align-items: start;
justify-content: start;
padding: 0 1em 1em 1em;
line-height: 1.5;
}
@media screen and (max-width: 1499px) {
.sideways-root .thumbnail {
width: 100%;
max-height: 100%;
}
}
</style>