499 lines
13 KiB
Svelte
499 lines
13 KiB
Svelte
<script lang="ts">
|
|
import 'vidstack/bundle';
|
|
|
|
import { page } from '$app/stores';
|
|
import { Capacitor } from '@capacitor/core';
|
|
import type { Page } from '@sveltejs/kit';
|
|
import { SponsorBlock, type Category, type Segment } from 'sponsorblock-api';
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import { _ } from 'svelte-i18n';
|
|
import { get } from 'svelte/store';
|
|
import type { MediaTimeUpdateEvent, PlayerSrc, VideoSrc } from 'vidstack';
|
|
import type { MediaPlayerElement } from 'vidstack/elements';
|
|
import { deleteVideoProgress, getVideoProgress, saveVideoProgress } from './Api';
|
|
import type { VideoPlay } from './Api/model';
|
|
import {
|
|
getBestThumbnail,
|
|
proxyVideoUrl,
|
|
pullBitratePreference,
|
|
videoLength,
|
|
type PhasedDescription
|
|
} from './misc';
|
|
import {
|
|
authStore,
|
|
instanceStore,
|
|
miniPlayerSrcStore,
|
|
playerAlwaysLoopStore,
|
|
playerAutoPlayStore,
|
|
playerDashStore,
|
|
playerProxyVideosStore,
|
|
playerSavePlaybackPositionStore,
|
|
silenceSkipperStore,
|
|
sponsorBlockCategoriesStore,
|
|
sponsorBlockDisplayToastStore,
|
|
sponsorBlockStore,
|
|
sponsorBlockTimelineStore,
|
|
sponsorBlockUrlStore,
|
|
synciousInstanceStore,
|
|
synciousStore
|
|
} from './store';
|
|
import { getDynamicTheme } from './theme';
|
|
|
|
export let data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
|
|
export let audioMode = false;
|
|
export let player: MediaPlayerElement;
|
|
export let isSyncing: boolean = false;
|
|
export let isEmbed: boolean = false;
|
|
export let segments: Segment[] = [];
|
|
|
|
let src: PlayerSrc = [];
|
|
let categoryBeingSkipped = '';
|
|
let playerIsLive = false;
|
|
let playerPosSet = false;
|
|
|
|
let userVideoSpeed = 1;
|
|
let silenceIsFastForwarding = false;
|
|
|
|
let silenceSkipperInterval: NodeJS.Timeout;
|
|
|
|
let sponsorBlockElements: Element[] = [];
|
|
|
|
function setSponsorTimeline() {
|
|
if (get(sponsorBlockTimelineStore)) return;
|
|
if (segments.length === 0) return;
|
|
|
|
const timeline = document.getElementsByClassName('vds-time-slider')[0];
|
|
|
|
if (sponsorBlockElements.length > 0) {
|
|
sponsorBlockElements.forEach((barDiv) => {
|
|
if (timeline.contains(barDiv)) {
|
|
timeline.removeChild(barDiv);
|
|
}
|
|
});
|
|
}
|
|
|
|
const segmentColors = {
|
|
sponsor: '00d400',
|
|
selfpromo: 'ffff00',
|
|
interaction: 'cc00ff',
|
|
intro: '00ffff',
|
|
outro: '0202ed',
|
|
preview: '008fd6',
|
|
music_offtopic: 'ff9900',
|
|
filler: '7300FF'
|
|
};
|
|
|
|
segments.forEach((segment) => {
|
|
const startPercent = (segment.startTime / data.video.lengthSeconds) * 100;
|
|
const endPercent = (segment.endTime / data.video.lengthSeconds) * 100;
|
|
const widthPercent = endPercent - startPercent;
|
|
|
|
const barDiv = document.createElement('div');
|
|
barDiv.classList.add('sponsorskip-bar');
|
|
barDiv.style.left = `${startPercent}%`;
|
|
barDiv.style.width = `${widthPercent}%`;
|
|
barDiv.style.backgroundColor =
|
|
segment.category in segmentColors ? `#${segmentColors[segment.category]}` : 'grey';
|
|
|
|
timeline.appendChild(barDiv);
|
|
sponsorBlockElements.push(barDiv);
|
|
});
|
|
}
|
|
|
|
function loadTimeFromUrl(page: Page): boolean {
|
|
if (player) {
|
|
const timeGivenUrl = page.url.searchParams.get('time');
|
|
if (timeGivenUrl && !isNaN(parseFloat(timeGivenUrl))) {
|
|
player.currentTime = Number(timeGivenUrl);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
page.subscribe((pageUpdate) => loadTimeFromUrl(pageUpdate));
|
|
|
|
let initStoreSilence = true;
|
|
silenceSkipperStore.subscribe((value) => {
|
|
if (initStoreSilence) {
|
|
initStoreSilence = false;
|
|
return;
|
|
}
|
|
|
|
if (typeof silenceSkipperInterval !== 'undefined') {
|
|
clearInterval(silenceSkipperInterval);
|
|
}
|
|
|
|
if (value) {
|
|
initSilenceSkipper();
|
|
}
|
|
});
|
|
|
|
const proxyVideos = get(playerProxyVideosStore);
|
|
|
|
function fastForwardToSound(
|
|
videoElement: HTMLAudioElement,
|
|
threshold: number = 0.01,
|
|
checkInterval: number = 100,
|
|
fastForwardRate: number = 4
|
|
): void {
|
|
if (!(videoElement instanceof HTMLMediaElement)) {
|
|
console.error('Provided element is not a valid HTMLMediaElement');
|
|
return;
|
|
}
|
|
|
|
const audioContext = new AudioContext();
|
|
const source = audioContext.createMediaElementSource(videoElement);
|
|
const analyser = audioContext.createAnalyser();
|
|
|
|
source.connect(analyser);
|
|
analyser.connect(audioContext.destination);
|
|
analyser.fftSize = 256;
|
|
|
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
|
|
const checkForSound = () => {
|
|
analyser.getByteFrequencyData(dataArray);
|
|
|
|
const averageVolume = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
|
const volume = averageVolume / 255;
|
|
|
|
if (volume < threshold) {
|
|
if (!silenceIsFastForwarding) {
|
|
videoElement.playbackRate = fastForwardRate;
|
|
silenceIsFastForwarding = true;
|
|
}
|
|
} else {
|
|
if (silenceIsFastForwarding) {
|
|
videoElement.playbackRate = userVideoSpeed;
|
|
silenceIsFastForwarding = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
silenceSkipperInterval = setInterval(checkForSound, checkInterval);
|
|
}
|
|
|
|
function initSilenceSkipper() {
|
|
const videoContainer = document.getElementById('video') as HTMLElement;
|
|
const videoElement = videoContainer.querySelector('video') as HTMLMediaElement;
|
|
|
|
fastForwardToSound(videoElement);
|
|
}
|
|
|
|
onMount(async () => {
|
|
miniPlayerSrcStore.set(null);
|
|
|
|
if (!data.video.hlsUrl) {
|
|
playerIsLive = false;
|
|
if (data.video.captions) {
|
|
data.video.captions.forEach(async (caption) => {
|
|
player.textTracks.add({
|
|
label: caption.label,
|
|
kind: 'captions',
|
|
language: caption.language_code,
|
|
// Need if captions are generated when youtube.js is being used.
|
|
src: caption.url.startsWith('blob:')
|
|
? caption.url
|
|
: `${get(instanceStore)}${caption.url}`
|
|
});
|
|
});
|
|
}
|
|
|
|
if (data.content.timestamps) {
|
|
let chapterWebVTT = 'WEBVTT\n\n';
|
|
|
|
let timestampIndex = 0;
|
|
data.content.timestamps.forEach((timestamp) => {
|
|
let endTime: string;
|
|
if (timestampIndex === data.content.timestamps.length - 1) {
|
|
endTime = videoLength(data.video.lengthSeconds);
|
|
} else {
|
|
endTime = data.content.timestamps[timestampIndex + 1].timePretty;
|
|
}
|
|
|
|
chapterWebVTT += `${timestamp.timePretty} --> ${endTime}\n${timestamp.title.replaceAll('-', '').trim()}\n\n`;
|
|
|
|
timestampIndex += 1;
|
|
});
|
|
|
|
if (timestampIndex > 0) {
|
|
player.textTracks.add({
|
|
kind: 'chapters',
|
|
src: URL.createObjectURL(new Blob([chapterWebVTT])),
|
|
default: true
|
|
});
|
|
}
|
|
}
|
|
|
|
player.addEventListener('pause', () => {
|
|
savePlayerPos();
|
|
if (get(silenceSkipperStore)) {
|
|
clearInterval(silenceSkipperInterval);
|
|
}
|
|
});
|
|
|
|
player.addEventListener('end', () => {
|
|
savePlayerPos();
|
|
if (get(silenceSkipperStore)) {
|
|
clearInterval(silenceSkipperInterval);
|
|
}
|
|
});
|
|
|
|
player.addEventListener('play', () => {
|
|
if (get(silenceSkipperStore)) {
|
|
initSilenceSkipper();
|
|
}
|
|
});
|
|
|
|
player.addEventListener('seeked', () => {
|
|
if (get(silenceSkipperStore)) {
|
|
clearInterval(silenceSkipperInterval);
|
|
|
|
initSilenceSkipper();
|
|
}
|
|
});
|
|
|
|
player.addEventListener('rate-change', () => {
|
|
if (silenceIsFastForwarding) return;
|
|
userVideoSpeed = player.playbackRate;
|
|
});
|
|
|
|
player.addEventListener('provider-change', (event) => {
|
|
const provider = event.detail;
|
|
const bitrate = pullBitratePreference();
|
|
if (provider?.type === 'dash') {
|
|
(provider as any).config = {
|
|
streaming: {
|
|
abr: {
|
|
ABRStrategy: 'abrBola',
|
|
movingAverageMethod: 'ewma',
|
|
autoSwitchBitrate: {
|
|
video: false,
|
|
audio: false
|
|
},
|
|
initialBitrate: {
|
|
video: bitrate,
|
|
audio: bitrate
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
});
|
|
|
|
if (get(sponsorBlockStore) && get(sponsorBlockCategoriesStore)) {
|
|
const currentCategories = get(sponsorBlockCategoriesStore);
|
|
|
|
const sponsorBlockUrl = get(sponsorBlockUrlStore);
|
|
|
|
if (currentCategories.length > 0 && sponsorBlockUrl && sponsorBlockUrl !== '') {
|
|
const sponsorBlock = new SponsorBlock('', { baseURL: sponsorBlockUrl });
|
|
|
|
try {
|
|
segments = await sponsorBlock.getSegments(
|
|
data.video.videoId,
|
|
get(sponsorBlockCategoriesStore) as Category[]
|
|
);
|
|
|
|
setSponsorTimeline();
|
|
|
|
addEventListener('resize', () => {
|
|
setSponsorTimeline();
|
|
});
|
|
|
|
player.addEventListener('time-update', (event: MediaTimeUpdateEvent) => {
|
|
segments.forEach((segment) => {
|
|
if (
|
|
event.detail.currentTime >= segment.startTime &&
|
|
event.detail.currentTime <= segment.endTime
|
|
) {
|
|
if (Math.round(player.currentTime) >= Math.round(player.duration)) {
|
|
return;
|
|
}
|
|
categoryBeingSkipped = segment.category;
|
|
player.currentTime = segment.endTime + 1;
|
|
ui('#sponsorblock-alert');
|
|
}
|
|
});
|
|
});
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
if (get(playerDashStore)) {
|
|
src = [{ src: data.video.dashUrl, type: 'application/dash+xml' }];
|
|
|
|
if (Capacitor.getPlatform() !== 'electron' || proxyVideos) {
|
|
(src[0] as { src: string }).src += '?local=true';
|
|
}
|
|
|
|
player.addEventListener('dash-can-play', async () => {
|
|
if (get(playerAutoPlayStore)) {
|
|
player.play();
|
|
}
|
|
await loadPlayerPos();
|
|
});
|
|
} else {
|
|
let formattedSrc;
|
|
src = data.video.formatStreams.map((format) => {
|
|
if (proxyVideos) {
|
|
formattedSrc = proxyVideoUrl(format.url);
|
|
} else {
|
|
formattedSrc = format.url;
|
|
}
|
|
const quality = format.size.split('x');
|
|
return {
|
|
src: formattedSrc,
|
|
type: format.type.split(';')[0],
|
|
height: Number(quality[1]),
|
|
width: Number(quality[0])
|
|
};
|
|
}) as VideoSrc[];
|
|
|
|
await loadPlayerPos();
|
|
}
|
|
} else {
|
|
playerIsLive = true;
|
|
src = [
|
|
{
|
|
src: data.video.hlsUrl + '?local=true',
|
|
type: 'application/x-mpegurl'
|
|
}
|
|
];
|
|
}
|
|
|
|
player.storage = 'video-player';
|
|
|
|
const currentTheme = await getDynamicTheme();
|
|
|
|
document.documentElement.style.setProperty(
|
|
'--media-slider-track-fill-bg',
|
|
currentTheme['--primary']
|
|
);
|
|
document.documentElement.style.setProperty('--media-menu-bg', currentTheme['--background']);
|
|
document.documentElement.style.setProperty(
|
|
'--media-menu-top-bar-bg',
|
|
currentTheme['--surface']
|
|
);
|
|
document.documentElement.style.setProperty(
|
|
'--media-menu-text-color',
|
|
currentTheme['--on-background']
|
|
);
|
|
document.documentElement.style.setProperty(
|
|
'--media-menu-item-info-color',
|
|
currentTheme['--on-background']
|
|
);
|
|
document.documentElement.style.setProperty(
|
|
'--media-menu-section-bg',
|
|
currentTheme['--surface']
|
|
);
|
|
document.documentElement.style.setProperty(
|
|
'--media-menu-surface-container',
|
|
currentTheme['--surface']
|
|
);
|
|
document.documentElement.style.setProperty('--audio-bg', currentTheme['--surface']);
|
|
});
|
|
|
|
async function loadPlayerPos() {
|
|
if (playerPosSet) return;
|
|
playerPosSet = true;
|
|
|
|
if (loadTimeFromUrl($page)) return;
|
|
|
|
let toSetTime = 0;
|
|
|
|
if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) {
|
|
try {
|
|
toSetTime = (await getVideoProgress(data.video.videoId))[0].time;
|
|
} catch {}
|
|
}
|
|
|
|
if (get(playerSavePlaybackPositionStore)) {
|
|
try {
|
|
const playerPos = localStorage.getItem(`v_${data.video.videoId}`);
|
|
if (playerPos && Number(playerPos) > toSetTime) {
|
|
toSetTime = Number(playerPos);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (toSetTime > 0) player.currentTime = toSetTime;
|
|
}
|
|
|
|
function savePlayerPos() {
|
|
if (data.video.hlsUrl) return;
|
|
|
|
if (get(playerSavePlaybackPositionStore) && player.currentTime) {
|
|
if (player.currentTime < player.duration - 10 && player.currentTime > 10) {
|
|
try {
|
|
localStorage.setItem(`v_${data.video.videoId}`, player.currentTime.toString());
|
|
} catch {}
|
|
|
|
if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) {
|
|
saveVideoProgress(data.video.videoId, player.currentTime);
|
|
}
|
|
} else {
|
|
try {
|
|
localStorage.removeItem(`v_${data.video.videoId}`);
|
|
} catch {}
|
|
|
|
if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) {
|
|
deleteVideoProgress(data.video.videoId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onDestroy(() => {
|
|
if (typeof silenceSkipperInterval !== 'undefined') {
|
|
clearInterval(silenceSkipperInterval);
|
|
}
|
|
savePlayerPos();
|
|
player.pause();
|
|
player.destroy();
|
|
playerPosSet = false;
|
|
});
|
|
</script>
|
|
|
|
{#if audioMode}
|
|
<div style="margin-top: 40vh;"></div>
|
|
{/if}
|
|
|
|
<media-player
|
|
bind:this={player}
|
|
id="video"
|
|
autoPlay={$playerAutoPlayStore && !isSyncing}
|
|
loop={$playerAlwaysLoopStore}
|
|
title={data.video.title}
|
|
streamType={playerIsLive ? 'live' : 'on-demand'}
|
|
viewType={audioMode ? 'audio' : 'video'}
|
|
fullscreen={false}
|
|
keep-alive
|
|
{src}
|
|
>
|
|
<media-provider>
|
|
{#if !audioMode}
|
|
<media-poster class="vds-poster" src={getBestThumbnail(data.video.videoThumbnails, 1251, 781)}
|
|
></media-poster>
|
|
{/if}
|
|
</media-provider>
|
|
<media-audio-layout></media-audio-layout>
|
|
{#if data.video.storyboards && data.video.storyboards.length > 3}
|
|
<media-video-layout thumbnails={`${get(instanceStore)}${data.video.storyboards[3].url}`}
|
|
></media-video-layout>
|
|
{:else}
|
|
<media-video-layout></media-video-layout>
|
|
{/if}
|
|
</media-player>
|
|
|
|
{#if !isEmbed && !$sponsorBlockDisplayToastStore}
|
|
<div class="snackbar" id="sponsorblock-alert">
|
|
<span
|
|
>{$_('skipping')}
|
|
<span class="bold" style="text-transform: capitalize;">{categoryBeingSkipped}</span></span
|
|
>
|
|
</div>
|
|
{/if}
|