Files
materialious/materialious/src/lib/components/Player.svelte
T
2025-12-23 16:54:14 +13:00

1368 lines
36 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { getBestThumbnail } from '$lib/images';
import { padTime, videoLength } from '$lib/numbers';
import { type PhasedDescription } from '$lib/timestamps';
import { SafeArea } from '@capacitor-community/safe-area';
import { Capacitor } from '@capacitor/core';
import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation';
import { error, type Page } from '@sveltejs/kit';
import ui from 'beercss';
import ISO6391 from 'iso-639-1';
import Mousetrap from 'mousetrap';
import { CapacitorMusicControls } from 'capacitor-music-controls-plugin';
import shaka from 'shaka-player/dist/shaka-player.ui';
import { SponsorBlock, type Category, type Segment } from 'sponsorblock-api';
import { onDestroy, onMount, tick } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { deleteVideoProgress, getVideoProgress, saveVideoProgress } from '../api';
import type { VideoPlay } from '../api/model';
import {
authStore,
instanceStore,
isAndroidTvStore,
playerAlwaysLoopStore,
playerAndroidLockOrientation,
playerAutoPlayStore,
playerCCByDefault,
playerDefaultLanguage,
playerDefaultPlaybackSpeed,
playerDefaultQualityStore,
playerProxyVideosStore,
playerSavePlaybackPositionStore,
playerState,
playertheatreModeIsActive,
playerYouTubeJsFallback,
sponsorBlockCategoriesStore,
sponsorBlockDisplayToastStore,
sponsorBlockStore,
sponsorBlockUrlStore,
synciousInstanceStore,
synciousStore
} from '../store';
import { setStatusBarColor } from '../theme';
import { patchYoutubeJs } from '$lib/patches/youtubejs';
import { goToNextVideo, goToPreviousVideo, playbackRates } from '$lib/player';
import { dashManifestDomainInclusion } from '$lib/android/youtube/dash';
import { injectSabr } from '$lib/sabr';
import type { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
isEmbed?: boolean;
playerElement?: HTMLMediaElement | undefined;
hideControls?: boolean;
}
let {
data,
isEmbed = false,
playerElement = $bindable(undefined),
hideControls = false
}: Props = $props();
let segments: Segment[] = [];
let snackBarAlert = $state('');
let originalOrigination: ScreenOrientationResult | undefined;
// eslint-disable-next-line no-undef
let watchProgressTimeout: NodeJS.Timeout;
let showVideoRetry = $state(false);
let player: shaka.Player;
let sabrAdapter: SabrStreamingAdapter | null;
let playerContainer: HTMLElement;
let playerCurrentPlaybackState = $state(false);
let playerCurrentTime = $state(0);
let playerMaxKnownTime = $state(0);
let playerIsBuffering = $state(false);
let playerVolume = $state(0);
let playerSettings: 'quality' | 'speed' | 'language' | 'root' = $state('root');
let playerTextTracks: shaka.extern.TextTrack[] | undefined = $state(undefined);
let playerCurrentVideoTrack: shaka.extern.VideoTrack | undefined = $state(undefined);
let playerCurrentAudioTrack: shaka.extern.AudioTrack | undefined = $state(undefined);
let playerLoop = $state($playerAlwaysLoopStore);
let playerTimelineTooltipVisible: boolean = $state(false);
let playerTimelineTimeHover: number = $state(0);
let playerTimelineMouseX: number = $state(0);
let playerTimelineLastUpdate: number = 0;
const STORAGE_KEY_VOLUME = 'shaka-preferred-volume';
playertheatreModeIsActive.subscribe(async () => {
await tick();
updateVideoPlayerHeight();
});
function restoreDefaultLanguage() {
if (!$playerDefaultLanguage || $playerDefaultLanguage === 'original') {
const languageAndRole = player
.getAudioLanguagesAndRoles()
.find(({ role }) => role === 'main');
if (languageAndRole !== undefined) {
player.selectAudioLanguage(languageAndRole.language);
return;
}
} else if ($playerDefaultLanguage) {
const audioLanguages = player.getAudioLanguages();
const langCode = ISO6391.getCode($playerDefaultLanguage);
for (const audioLanguage of audioLanguages) {
if (audioLanguage.startsWith(langCode)) {
player.selectAudioLanguage(audioLanguage);
break;
}
}
}
}
function filterUniqueAudioTracks(tracks: shaka.extern.AudioTrack[]): shaka.extern.AudioTrack[] {
const uniqueTracks: shaka.extern.AudioTrack[] = [];
const seen = new SvelteSet<string>();
for (const track of tracks) {
const identifier = `${track.language}-${track.label || 'No Label'}`;
if (!seen.has(identifier)) {
seen.add(identifier);
uniqueTracks.push(track);
}
}
return uniqueTracks;
}
function saveVolumePreference() {
if (!playerElement) return;
playerVolume = playerElement.volume;
localStorage.setItem(STORAGE_KEY_VOLUME, playerElement.volume.toString());
}
function restoreVolumePreference() {
const savedVolume = localStorage.getItem(STORAGE_KEY_VOLUME);
if (savedVolume && playerElement) {
playerElement.volume = parseFloat(savedVolume);
playerVolume = playerElement.volume;
}
}
function restoreQualityPreference() {
const numericValue = parseInt($playerDefaultQualityStore, 10);
if (isNaN(numericValue)) {
player.configure({ abr: { enabled: true } });
return;
}
// Get video-only variant tracks
const tracks = player.getVariantTracks().filter((t) => t.height !== null);
// Sort by resolution descending
const sortedTracks = tracks.sort((a, b) => (b.height as number) - (a.height as number));
// Try exact match
let selectedTrack = sortedTracks.find((t) => t.height === numericValue);
// Try next best (lower than target)
if (!selectedTrack) {
selectedTrack = sortedTracks.find((t) => (t.height as number) < numericValue);
}
// Try next higher
if (!selectedTrack) {
selectedTrack = sortedTracks.find((t) => (t.height as number) > numericValue);
}
if (selectedTrack) {
player.configure({ abr: { enabled: false } });
player.selectVariantTrack(selectedTrack, true);
} else {
player.configure({ abr: { enabled: true } });
}
}
async function onAndroidFullscreenChange() {
const videoFormats = data.video.adaptiveFormats.filter((format) =>
format.type.startsWith('video/')
);
const isFullScreen = !!document.fullscreenElement;
if (isFullScreen) {
// Ensure bar color is black while in fullscreen
await SafeArea.enable({
config: {
customColorsForSystemBars: true,
statusBarColor: '#00000000',
statusBarContent: 'light',
navigationBarColor: '#00000000',
navigationBarContent: 'light'
}
});
} else {
await setStatusBarColor();
}
if (!$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 ScreenOrientation.lock({ orientation: 'landscape' });
} else {
await ScreenOrientation.lock({ orientation: 'portrait' });
}
} else {
await ScreenOrientation.lock({
orientation: (originalOrigination as ScreenOrientationResult).type
});
}
}
async function androidHandleRotate() {
if (
Capacitor.getPlatform() !== 'android' ||
data.video.adaptiveFormats.length === 0 ||
$isAndroidTvStore
)
return;
originalOrigination = await ScreenOrientation.orientation();
document.addEventListener('fullscreenchange', onAndroidFullscreenChange);
}
async function setupSponsorSkip() {
if (!$sponsorBlockUrlStore || !$sponsorBlockCategoriesStore || !$sponsorBlockStore) return;
if (
$sponsorBlockCategoriesStore.length > 0 &&
$sponsorBlockUrlStore &&
$sponsorBlockUrlStore !== ''
) {
const sponsorBlock = new SponsorBlock('', { baseURL: $sponsorBlockUrlStore });
try {
segments = await sponsorBlock.getSegments(
data.video.videoId,
$sponsorBlockCategoriesStore as Category[]
);
playerElement?.addEventListener('timeupdate', () => {
segments.forEach((segment) => {
if (!playerElement) return;
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 (error) {
console.error('Sponsorskip errored with:', error);
}
}
}
function loadTimeFromUrl(page: Page): boolean {
if (player) {
const timeGivenUrl = page.url.searchParams.get('time');
if (timeGivenUrl && !isNaN(parseFloat(timeGivenUrl))) {
if (!playerElement) return false;
playerElement.currentTime = Number(timeGivenUrl);
return true;
}
}
return false;
}
function toggleSubtitles() {
const isVisible = player.isTextTrackVisible();
if (isVisible) {
player.setTextTrackVisibility(false);
} else {
let langCode: string;
if ($playerDefaultLanguage === 'original') {
const languageAndRole = player
.getAudioLanguagesAndRoles()
.find(({ role }) => role === 'main');
if (!languageAndRole) {
return;
}
langCode = languageAndRole.language;
} else {
const defaultLanguage = get(playerDefaultLanguage);
langCode = ISO6391.getCode(defaultLanguage);
}
const tracks = player.getTextTracks();
const subtitleTrack = tracks.find((track) => track.language === langCode);
if (subtitleTrack) {
player.selectTextTrack(subtitleTrack);
player.setTextTrackVisibility(true);
}
}
}
page.subscribe((pageUpdate) => loadTimeFromUrl(pageUpdate));
function setActiveVideoTrack() {
const videoTracks = player.getVideoTracks();
playerCurrentVideoTrack = videoTracks.find((track) => track.active);
}
function setActiveAudioTrack() {
const audioTracks = player.getAudioTracks();
playerCurrentAudioTrack = audioTracks.find((track) => track.active);
}
function handleTimeChange(event: Event) {
playerCurrentTime = parseFloat((event.target as HTMLInputElement).value);
if (playerElement) playerElement.currentTime = playerCurrentTime;
}
async function loadVideo() {
showVideoRetry = false;
sabrAdapter = await injectSabr(data.video, player);
player.addEventListener('loaded', () => {
restoreQualityPreference();
restoreDefaultLanguage();
setActiveVideoTrack();
setActiveAudioTrack();
if ($playerCCByDefault) {
toggleSubtitles();
}
});
if (!data.video.liveNow) {
if (watchProgressTimeout) {
clearInterval(watchProgressTimeout);
}
// Auto save watch progress every minute.
watchProgressTimeout = setInterval(() => savePlayerPos(), 60000);
setupSponsorSkip();
let dashUrl: string;
// Due to CORs issues with redirects, hosted instances of Materialious
// dirctly provide the companion instance
// while clients can just use the reirect provided by Invidious' API
if (import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE) {
dashUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}/api/manifest/dash/id/${data.video.videoId}`;
} else {
if (!data.video.dashUrl) {
error(500, 'No dash manifest found');
}
dashUrl = data.video.dashUrl;
}
if (!data.video.fallbackPatch && (!Capacitor.isNativePlatform() || $playerProxyVideosStore)) {
dashUrl += '?local=true';
}
if (
Capacitor.getPlatform() === 'android' &&
$playerProxyVideosStore &&
!data.video.fallbackPatch
) {
const manifest = await dashManifestDomainInclusion(dashUrl);
await player.load(manifest, await getLastPlayPos());
} else {
await player.load(dashUrl, await getLastPlayPos());
}
if (data.video.captions) {
for (const caption of data.video.captions) {
let captionUrl: string;
if (!import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE) {
captionUrl = caption.url.startsWith('http')
? caption.url
: `${new URL(get(instanceStore)).origin}${caption.url}`;
} else {
captionUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}${caption.url}`;
}
await player.addTextTrackAsync(
captionUrl,
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)}.000 --> ${padTime(endTime)}.000\n${timestamp.title.replaceAll('-', '').trim()}\n\n`;
timestampIndex += 1;
});
if (timestampIndex > 0) {
player.addChaptersTrack(
`data:text/vtt;base64,${btoa(chapterWebVTT)}`,
get(playerDefaultLanguage)
);
}
}
} else {
if (data.video.fallbackPatch === 'youtubejs') {
if (!data.video.dashUrl) {
error(500, 'No dash manifest found');
return;
}
await player.load(data.video.dashUrl);
} else {
await player.load(data.video.hlsUrl + '?local=true');
}
}
if (data.video.fallbackPatch === 'youtubejs') {
snackBarAlert = get(_)('player.youtubeJsFallBack');
ui('#snackbar-alert');
}
if ($playerDefaultPlaybackSpeed && playerElement) {
playerElement.playbackRate = $playerDefaultPlaybackSpeed;
}
}
async function reloadVideo() {
showVideoRetry = false;
data.video = await patchYoutubeJs(data.video.videoId);
await loadVideo();
}
function toggleVideoPlaybackStatus() {
if (playerCurrentPlaybackState) {
playerElement?.pause();
} else {
playerElement?.play();
}
}
// Due to how our player is rendered in layout for stateful pip
// we calaculate player height to then allow children pages
// to wrap around it.
function updateVideoPlayerHeight() {
if (playerContainer) {
const height = playerContainer.getBoundingClientRect().height;
document.documentElement.style.setProperty('--video-player-height', `${height + 10}px`);
}
}
onMount(async () => {
shaka.polyfill.installAll();
if (!shaka.Player.isBrowserSupported()) {
return;
}
player = new shaka.Player();
player.configure({
abr: {
enabled: true
},
streaming: {
failureCallback: (error: shaka.util.Error) => {
console.error('Streaming failure:', error);
player.retryStreaming(5);
},
bufferingGoal: 120,
rebufferingGoal: 2,
bufferBehind: 300,
retryParameters: {
maxAttempts: 8,
fuzzFactor: 0.5,
timeout: 30 * 1000
}
}
});
playerElement = document.getElementById('player') as HTMLMediaElement;
playerElement.loop = playerLoop;
if ($playerState) {
playerState.set({ ...$playerState, playerElement: playerElement });
}
// Change instantly to stop video from being loud for a second
restoreVolumePreference();
playerContainer = document.getElementById('player-container') as HTMLElement;
window.addEventListener('resize', updateVideoPlayerHeight);
updateVideoPlayerHeight();
await player.attach(playerElement);
player?.addEventListener('error', (event) => {
const error = (event as CustomEvent).detail as shaka.util.Error;
console.error('Player error:', error);
});
player.getNetworkingEngine()?.registerResponseFilter((type, response) => {
if (
type !== shaka.net.NetworkingEngine.RequestType.SEGMENT ||
!response.uri.includes('/api/timedtext')
) {
return;
}
const url = new URL(response.uri);
// Fix positioning for auto-generated subtitles
// Credit to Freetube!
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;
}
});
playerElement?.addEventListener('volumechange', saveVolumePreference);
await androidHandleRotate();
if (Capacitor.getPlatform() === 'android') {
await CapacitorMusicControls.create({
track: data.video.title,
artist: data.video.author,
cover: getBestThumbnail(data.video.videoThumbnails, 800, 800),
hasPrev: data.playlistId !== null,
hasNext: data.playlistId !== null,
isPlaying: true,
hasClose: true,
dismissable: true,
playIcon: 'media_play',
pauseIcon: 'media_pause',
prevIcon: 'media_prev',
nextIcon: 'media_next',
closeIcon: 'media_close',
notificationIcon: 'notification',
ticker: `Now playing "${data.video.title}"`
});
// @ts-expect-error: Is handle correctly via https://github.com/ingageco/capacitor-music-controls-plugin
document.addEventListener('controlsNotification', (event: { message: string }) => {
if (
event.message === 'music-controls-headset-unplugged' ||
event.message === 'music-controls-pause'
) {
playerElement?.pause();
return;
} else if (event.message === 'music-controls-play') {
playerElement?.play();
return;
}
if (data.playlistId) {
if (event.message === 'music-controls-next') {
goToNextVideo(data.video, data.playlistId);
} else if (event.message === 'music-controls-previous') {
goToPreviousVideo(data.playlistId);
}
}
});
playerElement?.addEventListener('play', () => {
CapacitorMusicControls.updateIsPlaying({ isPlaying: true });
});
playerElement?.addEventListener('pause', () => {
CapacitorMusicControls.updateIsPlaying({ isPlaying: false });
});
} else if ('mediaSession' in navigator) {
const metadataArtwork: MediaImage[] = [];
data.video.videoThumbnails.forEach((thumbnail) => {
metadataArtwork.push({
type: 'image/jpeg',
sizes: `${thumbnail.width}x${thumbnail.height}`,
src: thumbnail.url
});
});
navigator.mediaSession.metadata = new MediaMetadata({
title: data.video.title,
artist: data.video.author,
artwork: metadataArtwork
});
navigator.mediaSession.setActionHandler('play', () => playerElement?.play());
navigator.mediaSession.setActionHandler('pause', () => playerElement?.pause());
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
if (!playerElement) return;
playerElement.currentTime = Math.max(
playerElement.currentTime - (details.seekOffset || 10),
0
);
});
navigator.mediaSession.setActionHandler('seekforward', (details) => {
if (!playerElement) return;
playerElement.currentTime = Math.min(
playerElement.currentTime + (details.seekOffset || 10),
playerElement.duration
);
});
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (!playerElement) return;
if (details.fastSeek && 'fastSeek' in playerElement) {
playerElement.fastSeek(details.seekTime ?? 0);
} else {
playerElement.currentTime = details.seekTime ?? 0;
}
});
navigator.mediaSession.setActionHandler('stop', () => {
if (!playerElement) return;
playerElement.pause();
playerElement.currentTime = 0;
});
if (data.playlistId) {
navigator.mediaSession.setActionHandler('previoustrack', () => {
goToPreviousVideo(data.playlistId);
});
navigator.mediaSession.setActionHandler('nexttrack', async () => {
await goToNextVideo(data.video, data.playlistId);
});
}
}
Mousetrap.bind('space', () => {
if (!playerElement) return;
if (playerElement.paused) {
playerElement.play();
} else {
playerElement.pause();
}
return false;
});
if (!$isAndroidTvStore) {
Mousetrap.bind('right', () => {
if (!playerElement) return;
playerElement.currentTime = playerElement.currentTime + 10;
return false;
});
Mousetrap.bind('left', () => {
if (!playerElement) return;
playerElement.currentTime = playerElement.currentTime - 10;
return false;
});
}
Mousetrap.bind('c', () => {
toggleSubtitles();
return false;
});
Mousetrap.bind('f', () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
playerContainer.requestFullscreen();
}
return false;
});
Mousetrap.bind('shift+left', () => {
if (!playerElement) return;
playerElement.playbackRate = playerElement.playbackRate - 0.25;
return false;
});
Mousetrap.bind('shift+right', () => {
if (!playerElement) return;
playerElement.playbackRate = playerElement.playbackRate + 0.25;
return false;
});
Mousetrap.bind(',', () => {
if (!playerElement) return;
const currentTrack = player.getVariantTracks().find((track) => track.active);
const frameTime = 1 / (currentTrack?.frameRate || 30);
playerElement.currentTime -= frameTime;
});
Mousetrap.bind('.', () => {
if (!playerElement) return;
const currentTrack = player.getVariantTracks().find((track) => track.active);
const frameTime = 1 / (currentTrack?.frameRate || 30);
playerElement.currentTime += frameTime;
});
const volumeContainer = document.getElementById('volume-slider');
volumeContainer?.addEventListener('mousewheel', (event) => {
event.preventDefault();
const delta = Math.sign((event as any).deltaY);
const newVolume = Math.max(
0,
Math.min(1, (playerElement as HTMLMediaElement).volume - delta * 0.05)
);
(playerElement as HTMLMediaElement).volume = newVolume;
playerVolume = newVolume;
});
playerElement?.addEventListener('pause', async () => {
playerCurrentPlaybackState = false;
playerIsBuffering = false;
savePlayerPos();
});
playerElement?.addEventListener('ended', async () => {
playerCurrentPlaybackState = false;
playerIsBuffering = false;
await goToNextVideo(data.video, data.playlistId);
});
playerElement?.addEventListener('playing', () => {
playerCurrentPlaybackState = true;
playerIsBuffering = false;
});
playerElement?.addEventListener('waiting', () => {
playerCurrentPlaybackState = false;
playerIsBuffering = true;
});
playerElement?.addEventListener('timeupdate', () => {
if (!playerElement) return;
playerCurrentTime = playerElement.currentTime ?? 0;
if (playerMaxKnownTime === 0 || playerCurrentTime > playerMaxKnownTime) {
playerMaxKnownTime = Number(playerElement?.currentTime);
}
});
try {
await loadVideo();
} catch (error: unknown) {
if (
!Capacitor.isNativePlatform() ||
data.video.fallbackPatch === 'youtubejs' ||
(error as shaka.extern.Error).code !== 1001
)
return;
showVideoRetry = true;
if ($playerYouTubeJsFallback) {
await reloadVideo();
}
}
// Update video player height again on video loaded.
updateVideoPlayerHeight();
playerTextTracks = player.getTextTracks();
});
async function getLastPlayPos(): Promise<number> {
if (loadTimeFromUrl($page) || !$playerSavePlaybackPositionStore) return 0;
let toSetTime = 0;
try {
const playerPos = localStorage.getItem(`v_${data.video.videoId}`);
if (playerPos && Number(playerPos) > toSetTime) {
toSetTime = Number(playerPos);
}
} catch {
// Continue regardless of error
}
if ($synciousStore && $synciousInstanceStore && $authStore) {
try {
toSetTime = (await getVideoProgress(data.video.videoId))[0].time;
} catch {
// Continue regardless of error
}
}
return toSetTime;
}
function savePlayerPos() {
if (data.video.liveNow) return;
const synciousEnabled = $synciousStore && $synciousInstanceStore && $authStore;
if ($playerSavePlaybackPositionStore && playerElement) {
if (
playerElement.currentTime < playerElement.duration - 10 &&
playerElement.currentTime > 10
) {
try {
localStorage.setItem(`v_${data.video.videoId}`, playerElement.currentTime.toString());
} catch {
// Continue regardless of error
}
if (synciousEnabled) {
saveVideoProgress(data.video.videoId, playerElement.currentTime);
}
} else {
try {
localStorage.removeItem(`v_${data.video.videoId}`);
} catch {
// Continue regardless of error
}
if (synciousEnabled) {
deleteVideoProgress(data.video.videoId);
}
}
}
}
function handleMouseMove(event: MouseEvent) {
const currentTime = Date.now();
if (currentTime - playerTimelineLastUpdate < 60) return;
playerTimelineLastUpdate = currentTime;
const input = event.target as HTMLInputElement;
const boundingRect = input.getBoundingClientRect();
playerTimelineMouseX = event.clientX - boundingRect.left;
const percent = playerTimelineMouseX / input.clientWidth;
playerTimelineTimeHover = Math.round(percent * (data.video.lengthSeconds || 0));
playerTimelineTooltipVisible = true;
}
function handleMouseLeave(): void {
playerTimelineTooltipVisible = false;
}
onDestroy(async () => {
if (Capacitor.getPlatform() === 'android') {
if (!$isAndroidTvStore) {
document.removeEventListener('fullscreenchange', onAndroidFullscreenChange);
if (originalOrigination) {
await ScreenOrientation.lock({
orientation: originalOrigination.type
});
}
}
await CapacitorMusicControls.destroy();
}
try {
savePlayerPos();
} catch {
// Continue regardless of error
}
window.removeEventListener('resize', updateVideoPlayerHeight);
Mousetrap.unbind(['left', 'right', 'space', 'c', 'f', 'shift+left', 'shift+right']);
if (watchProgressTimeout) clearTimeout(watchProgressTimeout);
if (sabrAdapter) sabrAdapter.dispose();
if (player) {
player.unload();
player.destroy();
}
});
</script>
<div
id="player-container"
class:contain-video={!$isAndroidTvStore}
class:tv-contain-video={$isAndroidTvStore}
class:hide={showVideoRetry}
role="presentation"
onclick={(event) => {
if (
event.target &&
event.target instanceof HTMLElement &&
event.target.id === 'player-center'
) {
toggleVideoPlaybackStatus();
}
}}
>
<video
controls={false}
autoplay={$playerAutoPlayStore}
id="player"
poster={getBestThumbnail(data.video.videoThumbnails, 9999, 9999)}
></video>
{#if isEmbed && !$isAndroidTvStore}
<div class="chip blur embed" style="position: absolute;top: 10px;left: 10px;font-size: 18px;">
{data.video.title}
</div>
{/if}
{#if !hideControls}
<p id="mobile-time" class="chip secondary s">
{#if data.video.liveNow}
{$_('thumbnail.live')}
{:else}
{videoLength(playerCurrentTime)} / {videoLength(data.video.lengthSeconds)}
{/if}
</p>
{/if}
<div id="player-center">
{#if playerIsBuffering}
<progress class="circle large indeterminate" value="50" max="100"></progress>
{:else if !playerCurrentPlaybackState}
<button class="extra secondary" onclick={toggleVideoPlaybackStatus}>
<i>play_arrow</i>
</button>
{/if}
</div>
{#if !hideControls}
<div id="player-controls">
<article class="round" style="width: 100%;padding: 0;height: 10px;">
<label id="progress-slider" class="slider max">
{#key playerCurrentTime}
<input
oninput={handleTimeChange}
type="range"
min={0}
step={0.1}
bind:value={playerCurrentTime}
max={data.video.liveNow ? playerMaxKnownTime : data.video.lengthSeconds}
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
/>
{/key}
<span></span>
</label>
</article>
{#if playerTimelineTooltipVisible}
<div class="tooltip" style="position: absolute; left: {playerTimelineMouseX}px;">
{videoLength(playerTimelineTimeHover)}
</div>
{/if}
<nav>
<nav class="no-wrap">
<button class="secondary" onclick={toggleVideoPlaybackStatus}>
<i>
{#if playerCurrentPlaybackState}
pause
{:else}
play_arrow
{/if}
</i>
</button>
{#if Capacitor.getPlatform() !== 'android'}
<article
id="volume-slider"
class="round m l"
style="padding: 0;height: 10px;width: 150px;"
>
<label class="slider max">
{#key playerVolume}
<input
oninput={() => {
if (!playerElement) return;
playerElement.volume = playerVolume;
}}
bind:value={playerVolume}
type="range"
step="0.1"
max="1"
/>
{/key}
<span></span>
</label>
</article>
{/if}
</nav>
<div class="max"></div>
<nav class="no-wrap">
<p class="chip secondary m l" style="border: none;">
{#if data.video.liveNow}
{$_('thumbnail.live')}
{:else}
{videoLength(playerCurrentTime)} / {videoLength(data.video.lengthSeconds)}
{/if}
</p>
{#if playerTextTracks && playerTextTracks.length > 0 && !data.video.liveNow}
<button class="secondary">
<i>closed_caption</i>
<menu class="no-wrap mobile" id="cc-menu" data-ui="#cc-menu">
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => player.setTextTrackVisibility(false)}
>
{$_('player.controls.off')}
</li>
{#each playerTextTracks as track (track)}
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => {
player.selectTextTrack(track);
player.setTextTrackVisibility(true);
}}
>
{track.label}
</li>
{/each}
</menu>
</button>
{/if}
<button class="secondary">
<i>settings</i>
<menu class="no-wrap mobile" id="settings-menu">
{#if playerSettings !== 'root'}
<li role="presentation" onclick={() => (playerSettings = 'root')}>
<i>arrow_back</i>
{$_('player.controls.back')}
</li>
{/if}
{#if playerSettings === 'root'}
<li role="presentation" onclick={() => (playerSettings = 'quality')}>
<nav class="no-wrap" style="width: 100%;">
<i>high_quality</i>
{$_('player.controls.quality')}
<div class="max"></div>
<span class="chip">
{#if playerCurrentVideoTrack}
{playerCurrentVideoTrack.height}p
{:else}
{$_('player.controls.auto')}
{/if}
</span>
</nav>
</li>
<li role="presentation" onclick={() => (playerSettings = 'speed')}>
<nav class="no-wrap" style="width: 100%;">
<i>speed</i>
{$_('player.controls.playbackSpeed')}
<div class="max"></div>
<span class="chip">
{playerElement?.playbackRate}x
</span>
</nav>
</li>
{#if playerCurrentAudioTrack && playerCurrentAudioTrack.label !== null}
<li role="presentation" onclick={() => (playerSettings = 'language')}>
<nav class="no-wrap" style="width: 100%;">
<i>language</i>
{$_('player.controls.language')}
<div class="max"></div>
<span class="chip">
{#if playerCurrentAudioTrack}
{playerCurrentAudioTrack.language !== 'und'
? ISO6391.getName(playerCurrentAudioTrack.language)
: playerCurrentAudioTrack.label}
{/if}
</span>
</nav>
</li>
{/if}
<li
role="presentation"
onclick={() => {
if (playerElement) playerElement.loop = !playerLoop;
playerLoop = !playerLoop;
}}
>
<nav class="no-wrap" style="width: 100%;">
<i>all_inclusive</i>
{$_('player.controls.loop')}
<div class="max"></div>
<span class="chip">
{playerLoop ? $_('player.controls.on') : $_('player.controls.off')}
</span>
</nav>
</li>
{:else if playerSettings === 'quality'}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.configure({ abr: true });
playerCurrentVideoTrack = undefined;
}}
>
{$_('player.controls.auto')}
</li>
{#each player.getVideoTracks().sort((a, b) => {
const heightA = a.height || 0;
const heightB = b.height || 0;
const widthA = a.width || 0;
const widthB = b.width || 0;
return heightB - heightA || widthB - widthA;
}) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectVideoTrack(track, true);
setActiveVideoTrack();
}}
>
{track.height}p
</li>
{/each}
{:else if playerSettings === 'speed'}
{#each playbackRates as playbackRate (playbackRate)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
if (playerElement) playerElement.playbackRate = playbackRate;
}}
>
{playbackRate}
</li>
{/each}
{:else if playerSettings === 'language'}
{#each filterUniqueAudioTracks(player.getAudioTracks()) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectAudioTrack(track);
setActiveAudioTrack();
}}
>
{#if track.language !== 'und'}
{ISO6391.getName(track.language)} -
{/if}
{track.label}
</li>
{/each}
{/if}
</menu>
</button>
{#if document.pictureInPictureEnabled}
<button
class="secondary"
onclick={() => {
(playerElement as HTMLVideoElement).requestPictureInPicture();
}}
>
<i>pip</i>
</button>
{/if}
<button
class="secondary"
onclick={() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
playerContainer.requestFullscreen();
}
}}
>
<i>
{#if document.fullscreenElement}
fullscreen_exit
{:else}
fullscreen
{/if}
</i>
</button>
</nav>
</nav>
</div>
{/if}
</div>
{#if showVideoRetry}
<article class="video-placeholder">
{#if $playerYouTubeJsFallback}
<p>{$_('player.youtubeJsLoading')}</p>
<progress class="circle large"></progress>
{:else}
<p>{$_('player.retryText')}</p>
<button onclick={reloadVideo}>{$_('player.enableYoutubejsTemp')}</button>
<div class="space"></div>
<button
class="border"
onclick={async () => {
playerYouTubeJsFallback.set(true);
await reloadVideo();
}}>{$_('player.enableYoutubejsPerm')}</button
>
{/if}
</article>
{/if}
{#if !isEmbed}
<div class="snackbar" id="snackbar-alert">
<span class="bold" style="text-transform: capitalize;">{snackBarAlert}</span>
</div>
{/if}
<style>
#player-container {
position: relative;
width: 100%;
}
#player-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
opacity: 0;
transition: opacity 2s ease;
padding: 10px;
}
#player-center {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#mobile-time {
position: absolute;
top: 0;
right: 0;
padding: 10px;
border: none;
opacity: 0;
transition: opacity 2s ease;
}
#progress-slider > span {
transition: 0.25s;
}
#player-container:focus-within #player-controls,
#player-container:active #player-controls,
#player-container:hover #player-controls {
opacity: 1;
transition: opacity 0.3s ease;
}
#player-container:focus-within #mobile-time,
#player-container:active #mobile-time,
#player-container:hover #mobile-time {
opacity: 1;
transition: opacity 0.3s ease;
}
menu.mobile {
transform: scale(1) translateY(-40%) translateX(0);
width: 300px;
height: 200px;
}
.contain-video {
max-height: 80vh;
max-width: calc(80vh * 16 / 9);
overflow: hidden;
position: relative;
flex: 1;
background-color: black;
aspect-ratio: 16 / 9;
}
.tv-contain-video {
height: 100vh;
width: calc(100vh * 16 / 9);
max-width: 100vw;
max-height: 100vh;
overflow: hidden;
position: relative;
flex: 1;
background-color: black;
aspect-ratio: 16 / 9;
}
video[poster] {
height: 100%;
width: 100%;
}
video {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-height: 100%;
max-width: 100%;
object-fit: contain;
background-color: black;
}
.hide {
display: none;
}
</style>