446 lines
13 KiB
Svelte
446 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { getBestThumbnail } from '$lib/images';
|
|
import { padTime, videoLength } from '$lib/time';
|
|
import { Capacitor } from '@capacitor/core';
|
|
import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation';
|
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
|
import { NavigationBar } from '@hugotomazi/capacitor-navigation-bar';
|
|
import { type Page } from '@sveltejs/kit';
|
|
import 'shaka-player/dist/controls.css';
|
|
import shaka from 'shaka-player/dist/shaka-player.ui';
|
|
import { SponsorBlock, type Category, type Segment } from 'sponsorblock-api';
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import { _ } from 'svelte-i18n';
|
|
import { get } from 'svelte/store';
|
|
import { deleteVideoProgress, getVideoProgress, saveVideoProgress } from '../api';
|
|
import type { VideoPlay } from '../api/model';
|
|
import { type PhasedDescription } from '../misc';
|
|
import {
|
|
authStore,
|
|
instanceStore,
|
|
playerAndroidLockOrientation,
|
|
playerAutoPlayStore,
|
|
playerDefaultLanguage,
|
|
playerProxyVideosStore,
|
|
playerSavePlaybackPositionStore,
|
|
poTokenCacheStore,
|
|
sponsorBlockCategoriesStore,
|
|
sponsorBlockDisplayToastStore,
|
|
sponsorBlockStore,
|
|
sponsorBlockUrlStore,
|
|
synciousInstanceStore,
|
|
synciousStore
|
|
} from '../store';
|
|
import { setStatusBarColor } from '../theme';
|
|
|
|
interface Props {
|
|
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
|
|
audioMode?: boolean;
|
|
isSyncing?: boolean;
|
|
isEmbed?: boolean;
|
|
segments?: Segment[];
|
|
}
|
|
|
|
let { data, audioMode = false, isEmbed = false, segments = $bindable([]) }: Props = $props();
|
|
|
|
let snackBarAlert = $state('');
|
|
let playerPosSet = false;
|
|
let originalOrigination: ScreenOrientationResult | undefined;
|
|
let sponsorBlockElements: Element[] = [];
|
|
let watchProgressTimeout: NodeJS.Timeout;
|
|
|
|
let player: shaka.Player;
|
|
let playerElement: HTMLMediaElement;
|
|
|
|
function loadTimeFromUrl(page: Page): boolean {
|
|
if (player) {
|
|
const timeGivenUrl = page.url.searchParams.get('time');
|
|
if (timeGivenUrl && !isNaN(parseFloat(timeGivenUrl))) {
|
|
playerElement.currentTime = Number(timeGivenUrl);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
page.subscribe((pageUpdate) => loadTimeFromUrl(pageUpdate));
|
|
|
|
const proxyVideos = get(playerProxyVideosStore);
|
|
|
|
onMount(async () => {
|
|
shaka.polyfill.installAll();
|
|
if (!shaka.Player.isBrowserSupported()) {
|
|
return;
|
|
}
|
|
|
|
player = new shaka.Player();
|
|
playerElement = document.getElementById('player') as HTMLMediaElement;
|
|
|
|
await player.attach(playerElement);
|
|
|
|
player.configure({
|
|
streaming: {
|
|
bufferingGoal: 180,
|
|
rebufferingGoal: 0.02,
|
|
bufferBehind: 300
|
|
},
|
|
manifest: {
|
|
// disableVideo: format === 'audio',
|
|
segmentRelativeVttTiming: true
|
|
},
|
|
abr: {
|
|
enabled: false,
|
|
restrictToElementSize: true
|
|
},
|
|
autoShowText: shaka.config.AutoShowText.NEVER,
|
|
|
|
preferredDecodingAttributes: !data.video.hlsUrl ? ['smooth', 'powerEfficient'] : []
|
|
});
|
|
|
|
if (data.video.fallbackPatch === 'youtubejs') {
|
|
const networkingEngine = player.getNetworkingEngine();
|
|
|
|
if (!networkingEngine) return;
|
|
|
|
// Based off the following
|
|
// https://github.com/FreeTubeApp/FreeTube/blob/d270c9e251a433f1e4246a3f6a37acef707d22aa/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js#L1206
|
|
// https://github.com/LuanRT/BgUtils/blob/6b121166be1ccb0b952dee1bdac488808365ae6b/examples/browser/web/src/main.ts#L293
|
|
|
|
networkingEngine.registerRequestFilter(async (type, request) => {
|
|
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
|
|
const url = new URL(request.uris[0]);
|
|
|
|
if (url.hostname.endsWith('.googlevideo.com') && url.pathname === '/videoplayback') {
|
|
request.method = 'POST';
|
|
request.body = new Uint8Array([120, 0]);
|
|
|
|
if (request.headers.Range) {
|
|
url.searchParams.set('range', request.headers.Range.split('=')[1]);
|
|
url.searchParams.set('pot', get(poTokenCacheStore));
|
|
url.searchParams.set('ump', '1');
|
|
url.searchParams.set('srfvp', '1');
|
|
delete request.headers.Range;
|
|
}
|
|
|
|
request.uris[0] = url.toString();
|
|
}
|
|
}
|
|
});
|
|
|
|
networkingEngine.registerResponseFilter(async (type, response, context) => {
|
|
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
|
|
if (
|
|
response.data &&
|
|
response.data.byteLength > 4 &&
|
|
new DataView(
|
|
response.data as ArrayBufferLike & {
|
|
BYTES_PER_ELEMENT?: never;
|
|
}
|
|
).getUint32(0) === 0x68747470
|
|
) {
|
|
const responseAsString = shaka.util.StringUtils.fromUTF8(response.data);
|
|
|
|
const retryParameters = player.getConfiguration().streaming.retryParameters;
|
|
const uris = [responseAsString];
|
|
const redirectRequest = shaka.net.NetworkingEngine.makeRequest(uris, retryParameters);
|
|
const requestOperation = networkingEngine.request(type, redirectRequest, context);
|
|
const redirectResponse = await requestOperation.promise;
|
|
|
|
// Modify the original response to contain the results of the redirect
|
|
// response.
|
|
response.data = redirectResponse.data;
|
|
response.headers = redirectResponse.headers;
|
|
response.uri = redirectResponse.uri;
|
|
} else {
|
|
const url = new URL(response.uri);
|
|
if (
|
|
url.hostname.endsWith('.youtube.com') &&
|
|
url.pathname === '/api/timedtext' &&
|
|
url.searchParams.get('caps') === 'asr' &&
|
|
url.searchParams.get('kind') === 'asr' &&
|
|
url.searchParams.get('fmt') === 'vtt'
|
|
) {
|
|
const stringBody = new TextDecoder().decode(response.data);
|
|
// position:0% for LTR text and position:100% for RTL text
|
|
const cleaned = stringBody.replaceAll(/ align:start position:(?:10)?0%$/gm, '');
|
|
|
|
response.data = new TextEncoder().encode(cleaned).buffer as ArrayBuffer;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const shakaUi = new shaka.ui.Overlay(
|
|
player,
|
|
document.getElementById('shaka-container') as HTMLElement,
|
|
playerElement
|
|
);
|
|
|
|
shakaUi.configure({
|
|
controlPanelElements: [
|
|
'play_pause',
|
|
'spacer',
|
|
Capacitor.getPlatform() === 'electron' ? 'volume' : '',
|
|
'chapter',
|
|
'time_and_duration',
|
|
'overflow_menu'
|
|
],
|
|
overflowMenuButtons: ['cast', 'airplay', 'captions', 'quality', 'loop', 'language']
|
|
});
|
|
|
|
if (!data.video.hlsUrl) {
|
|
let dashUrl = data.video.dashUrl;
|
|
|
|
if (!data.video.fallbackPatch && (!Capacitor.isNativePlatform() || proxyVideos)) {
|
|
dashUrl += '?local=true';
|
|
}
|
|
|
|
await player.load(dashUrl);
|
|
|
|
if (data.video.captions) {
|
|
data.video.captions.forEach(async (caption) => {
|
|
player.addTextTrackAsync(
|
|
caption.url.startsWith('http') ? caption.url : `${get(instanceStore)}${caption.url}`,
|
|
caption.language_code,
|
|
'captions',
|
|
undefined,
|
|
undefined,
|
|
caption.label
|
|
);
|
|
});
|
|
}
|
|
|
|
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 += `${padTime(timestamp.timePretty)} --> ${padTime(endTime)}\n${timestamp.title.replaceAll('-', '').trim()}\n\n`;
|
|
|
|
timestampIndex += 1;
|
|
});
|
|
|
|
if (timestampIndex > 0) {
|
|
player.addChaptersTrack(
|
|
URL.createObjectURL(new Blob([chapterWebVTT])),
|
|
get(playerDefaultLanguage)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Auto save watch progress every minute.
|
|
watchProgressTimeout = setInterval(() => savePlayerPos(), 60000);
|
|
|
|
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[]
|
|
);
|
|
|
|
player.addEventListener('time-update', () => {
|
|
segments.forEach((segment) => {
|
|
if (
|
|
playerElement.currentTime >= segment.startTime &&
|
|
playerElement.currentTime <= segment.endTime
|
|
) {
|
|
if (Math.round(playerElement.currentTime) >= Math.round(playerElement.duration)) {
|
|
return;
|
|
}
|
|
playerElement.currentTime = segment.endTime + 1;
|
|
if (!get(sponsorBlockDisplayToastStore)) {
|
|
snackBarAlert = `${get(_)('skipping')} ${segment.category}`;
|
|
ui('#snackbar-alert');
|
|
}
|
|
}
|
|
});
|
|
});
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
await loadPlayerPos();
|
|
|
|
const defaultLanguage = get(playerDefaultLanguage);
|
|
if (defaultLanguage) {
|
|
const audioLanguages = player.getAudioLanguages();
|
|
if (audioLanguages.includes(defaultLanguage)) {
|
|
player.selectAudioLanguage(defaultLanguage);
|
|
}
|
|
}
|
|
|
|
if (Capacitor.getPlatform() === 'android' && data.video.adaptiveFormats.length > 0) {
|
|
const videoFormats = data.video.adaptiveFormats.filter((format) =>
|
|
format.type.startsWith('video/')
|
|
);
|
|
|
|
originalOrigination = await ScreenOrientation.orientation();
|
|
|
|
player.addEventListener('fullscreen-change', async () => {
|
|
const isFullScreen = document.fullscreenElement;
|
|
|
|
if (isFullScreen) {
|
|
// Ensure bar color is black while in fullscreen
|
|
await StatusBar.setBackgroundColor({ color: '#000000' });
|
|
await NavigationBar.setColor({
|
|
color: '#000000'
|
|
});
|
|
await StatusBar.setStyle({ style: Style.Dark });
|
|
} else {
|
|
await setStatusBarColor();
|
|
}
|
|
|
|
if (!get(playerAndroidLockOrientation)) return;
|
|
|
|
if (isFullScreen && videoFormats[0].resolution) {
|
|
const widthHeight = videoFormats[0].resolution.split('x');
|
|
|
|
if (widthHeight.length !== 2) return;
|
|
|
|
if (Number(widthHeight[0]) > Number(widthHeight[1])) {
|
|
await StatusBar.setOverlaysWebView({ overlay: true });
|
|
await StatusBar.hide();
|
|
await NavigationBar.hide();
|
|
await ScreenOrientation.lock({ orientation: 'landscape' });
|
|
} else {
|
|
await ScreenOrientation.lock({ orientation: 'portrait' });
|
|
}
|
|
} else {
|
|
await StatusBar.setOverlaysWebView({ overlay: false });
|
|
await StatusBar.show();
|
|
await NavigationBar.show();
|
|
|
|
await ScreenOrientation.lock({
|
|
orientation: (originalOrigination as ScreenOrientationResult).type
|
|
});
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
await player.load(data.video.hlsUrl + '?local=true');
|
|
}
|
|
|
|
if (data.video.fallbackPatch === 'youtubejs') {
|
|
snackBarAlert = get(_)('player.youtubeJsFallBack');
|
|
ui('#snackbar-alert');
|
|
}
|
|
});
|
|
|
|
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 {}
|
|
} else {
|
|
if (get(playerSavePlaybackPositionStore)) {
|
|
try {
|
|
const playerPos = localStorage.getItem(`v_${data.video.videoId}`);
|
|
if (playerPos && Number(playerPos) > toSetTime) {
|
|
toSetTime = Number(playerPos);
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
if (toSetTime > 0) playerElement.currentTime = toSetTime;
|
|
}
|
|
|
|
function savePlayerPos() {
|
|
if (data.video.hlsUrl) return;
|
|
|
|
if (get(playerSavePlaybackPositionStore) && player && playerElement.currentTime) {
|
|
if (
|
|
playerElement.currentTime < playerElement.duration - 10 &&
|
|
playerElement.currentTime > 10
|
|
) {
|
|
try {
|
|
localStorage.setItem(`v_${data.video.videoId}`, playerElement.currentTime.toString());
|
|
} catch {}
|
|
|
|
if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) {
|
|
saveVideoProgress(data.video.videoId, playerElement.currentTime);
|
|
}
|
|
} else {
|
|
try {
|
|
localStorage.removeItem(`v_${data.video.videoId}`);
|
|
} catch {}
|
|
|
|
if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) {
|
|
deleteVideoProgress(data.video.videoId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onDestroy(async () => {
|
|
if (Capacitor.getPlatform() === 'android') {
|
|
if (originalOrigination) {
|
|
await StatusBar.setOverlaysWebView({ overlay: false });
|
|
await StatusBar.show();
|
|
await ScreenOrientation.lock({
|
|
orientation: originalOrigination.type
|
|
});
|
|
}
|
|
}
|
|
if (watchProgressTimeout) {
|
|
clearTimeout(watchProgressTimeout);
|
|
}
|
|
try {
|
|
savePlayerPos();
|
|
} catch (error) {}
|
|
await playerElement.pause();
|
|
player.destroy();
|
|
playerElement.remove();
|
|
playerPosSet = false;
|
|
});
|
|
</script>
|
|
|
|
{#if audioMode}
|
|
<div style="margin-top: 40vh;"></div>
|
|
{/if}
|
|
|
|
<div
|
|
id="shaka-container"
|
|
class="youtube-theme"
|
|
style="max-height: 80vh; max-width: calc(80vh * 16 / 9); overflow: hidden; position: relative; flex: 1; background-color: black;"
|
|
data-shaka-player-container
|
|
>
|
|
<video
|
|
controls={false}
|
|
autoplay={$playerAutoPlayStore}
|
|
id="player"
|
|
style="width: 100%; height: 100%; object-fit: contain;"
|
|
poster={getBestThumbnail(data.video.videoThumbnails, 1251, 781)}
|
|
></video>
|
|
</div>
|
|
|
|
{#if !isEmbed}
|
|
<div class="snackbar" id="snackbar-alert">
|
|
<span class="bold" style="text-transform: capitalize;">{snackBarAlert}</span>
|
|
</div>
|
|
{/if}
|