+1
-1
@@ -95,7 +95,7 @@ volumes:
|
||||
```
|
||||
|
||||
### Overwriting Materialious defaults
|
||||
Materialious allows you to overwrite the default values using `VITE_DEFAULT_SETTINGS`, see [SETTINGS](./SETTINGS.md) for more details.
|
||||
Materialious lets you customize the default settings by overriding them with `VITE_DEFAULT_SETTINGS`. To configure this easily, go to **Settings** → **Interface** and click "Export to JSON." For more details, check the [SETTINGS](./SETTINGS.md) page.
|
||||
|
||||
**Please note:** These overwrites only apply on 1st load & won't replace existing configuration stored in browser local storage.
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 217
|
||||
versionName "1.15.9"
|
||||
versionCode 218
|
||||
versionName "1.16.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -82,7 +82,11 @@
|
||||
|
||||
|
||||
|
||||
<release version="1.15.9" date="2026-2-17">
|
||||
|
||||
<release version="1.16.0" date="2026-2-18">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.0</url>
|
||||
</release>
|
||||
<release version="1.15.9" date="2026-2-17">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.15.9</url>
|
||||
</release>
|
||||
<release version="1.15.8" date="2026-2-17">
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.15.7",
|
||||
"version": "1.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.15.7",
|
||||
"version": "1.16.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.15.9",
|
||||
"version": "1.16.0",
|
||||
"description": "Modern material design for YouTube and Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.15.9",
|
||||
"version": "1.16.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run patch:github && vite dev",
|
||||
|
||||
@@ -100,8 +100,6 @@ export async function getVideoYTjs(videoId: string): Promise<VideoPlay> {
|
||||
video.streaming_data.adaptive_formats = video.streaming_data.adaptive_formats.filter(
|
||||
(format) => format.xtags !== 'CgcKAnZiEgEx'
|
||||
);
|
||||
} else {
|
||||
throw new Error('Video did not provide streaming data.');
|
||||
}
|
||||
|
||||
const adaptiveFormats: AdaptiveFormats[] = [];
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { shareURL } from '$lib/misc';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { invidiousInstanceStore } from '$lib/store';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { sentenceCase } from '$lib/letterCasing';
|
||||
|
||||
type ShareLink = {
|
||||
type: 'invidious' | 'youtube' | 'materialious' | 'invidious redirect';
|
||||
path: string;
|
||||
param?: {
|
||||
key: string;
|
||||
value: () => string | number;
|
||||
};
|
||||
};
|
||||
|
||||
let {
|
||||
shares,
|
||||
includePromptText = undefined,
|
||||
iconOnly = true
|
||||
}: {
|
||||
shares: ShareLink[];
|
||||
includePromptText?: string;
|
||||
iconOnly: boolean;
|
||||
} = $props();
|
||||
|
||||
const shareBase = {
|
||||
invidious: $invidiousInstanceStore,
|
||||
youtube: 'https://www.youtube.com',
|
||||
materialious: !Capacitor.isNativePlatform() ? location.origin : undefined,
|
||||
'invidious redirect': 'https://redirect.invidious.io'
|
||||
};
|
||||
|
||||
let includePrompt = $state(false);
|
||||
|
||||
async function onShare(share: ShareLink) {
|
||||
const url = new URL(`${shareBase[share.type]}${share.path}`);
|
||||
|
||||
if (share.param && includePrompt)
|
||||
url.searchParams.append(share.param.key, share.param.value().toString());
|
||||
|
||||
await shareURL(url.toString());
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="surface-container-highest" onclick={(event: Event) => event.stopPropagation()}>
|
||||
<i>share</i>
|
||||
{#if !iconOnly}
|
||||
{$_('player.share.title')}
|
||||
{/if}
|
||||
<div class="tooltip">
|
||||
{$_('player.share.title')}
|
||||
</div>
|
||||
<menu class="no-wrap mobile" data-ui="#share-menu" id="share-menu">
|
||||
{#if includePromptText}
|
||||
<li class="row">
|
||||
<label class="switch">
|
||||
<input type="checkbox" bind:checked={includePrompt} />
|
||||
<span></span>
|
||||
</label>
|
||||
<div class="min">{includePromptText}</div>
|
||||
</li>
|
||||
<div class="divider"></div>
|
||||
{/if}
|
||||
|
||||
{#each shares as share (share)}
|
||||
{#if shareBase[share.type]}
|
||||
<li
|
||||
data-ui="#share-menu"
|
||||
class="row"
|
||||
role="presentation"
|
||||
onclick={() => {
|
||||
onShare(share);
|
||||
}}
|
||||
>
|
||||
<div class="min">
|
||||
{$_('player.share.copyXLink', {
|
||||
linkType: sentenceCase(share.type)
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</menu>
|
||||
</button>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { invidiousInstanceStore } from '$lib/store';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
|
||||
import { isUnrestrictedPlatform, shareURL } from '$lib/misc';
|
||||
import { addToast } from './Toast.svelte';
|
||||
|
||||
interface Props {
|
||||
video: VideoBase | Video | Notification | PlaylistPageVideo;
|
||||
currentTime?: number;
|
||||
}
|
||||
|
||||
let { video, currentTime = $bindable() }: Props = $props();
|
||||
let includeTimestamp: boolean = $state(false);
|
||||
|
||||
async function shareVideo(url: string, param: string = 't') {
|
||||
if (includeTimestamp) url += `?${param}=${Math.floor(currentTime ?? 0)}`;
|
||||
|
||||
await shareURL(url);
|
||||
|
||||
addToast({
|
||||
data: {
|
||||
text: $_('player.share.copiedSuccess')
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<menu class="no-wrap mobile" data-ui="#share-menu" id="share-menu">
|
||||
{#if currentTime !== undefined}
|
||||
<li class="row">
|
||||
<label class="switch">
|
||||
<input type="checkbox" bind:checked={includeTimestamp} />
|
||||
<span></span>
|
||||
</label>
|
||||
<div class="min">{$_('player.share.includeTimestamp')}</div>
|
||||
</li>
|
||||
<div class="divider"></div>
|
||||
{/if}
|
||||
<li
|
||||
data-ui="#share-menu"
|
||||
class="row"
|
||||
role="presentation"
|
||||
onclick={async () => {
|
||||
if (isUnrestrictedPlatform()) {
|
||||
shareVideo(`${get(invidiousInstanceStore)}/watch/${video.videoId}`);
|
||||
} else {
|
||||
shareVideo(
|
||||
`${location.origin}${resolve('/watch/[videoId]', { videoId: video.videoId })}`,
|
||||
'time'
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="min">{$_('player.share.materialiousLink')}</div>
|
||||
</li>
|
||||
<li
|
||||
data-ui="#share-menu"
|
||||
class="row"
|
||||
role="presentation"
|
||||
onclick={async () => {
|
||||
shareVideo(`https://redirect.invidious.io/watch?v=${video.videoId}`);
|
||||
}}
|
||||
>
|
||||
<div class="min">{$_('player.share.invidiousRedirect')}</div>
|
||||
</li>
|
||||
<li
|
||||
data-ui="#share-menu"
|
||||
class="row"
|
||||
role="presentation"
|
||||
onclick={async () => {
|
||||
shareVideo(`https://www.youtube.com/watch?v=${video.videoId}`);
|
||||
}}
|
||||
>
|
||||
<div class="min">{$_('player.share.youtubeLink')}</div>
|
||||
</li>
|
||||
</menu>
|
||||
+8
-8
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { removePlaylistVideo } from '../api';
|
||||
import { invidiousAuthStore, feedLastItemId, isAndroidTvStore } from '../store';
|
||||
import ContentColumn from './ContentColumn.svelte';
|
||||
import { removePlaylistVideo } from '$lib/api';
|
||||
import { invidiousAuthStore, feedLastItemId, isAndroidTvStore } from '$lib/store';
|
||||
import ContentColumn from '$lib/components/layout/ContentColumn.svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import Thumbnail from '$lib/components/thumbnail/VideoThumbnail.svelte';
|
||||
import { extractUniqueId, timeout, type feedItems } from '$lib/misc';
|
||||
import ChannelThumbnail from './ChannelThumbnail.svelte';
|
||||
import PlaylistThumbnail from './PlaylistThumbnail.svelte';
|
||||
import HashtagThumbnail from './HashtagThumbnail.svelte';
|
||||
import NoResults from './NoResults.svelte';
|
||||
import ChannelThumbnail from '$lib/components/thumbnail/ChannelThumbnail.svelte';
|
||||
import PlaylistThumbnail from '$lib/components/thumbnail/PlaylistThumbnail.svelte';
|
||||
import HashtagThumbnail from '$lib/components/thumbnail/HashtagThumbnail.svelte';
|
||||
import NoResults from '$lib/components/NoResults.svelte';
|
||||
|
||||
interface Props {
|
||||
items?: feedItems;
|
||||
+120
-954
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,378 @@
|
||||
<script lang="ts">
|
||||
import type { ParsedDescription, Timestamp } from '$lib/description';
|
||||
import { invidiousInstanceStore, isAndroidTvStore, sponsorBlockTimelineStore } from '$lib/store';
|
||||
import type { Segment } from 'sponsorblock-api';
|
||||
import { Slider } from 'melt/builders';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { ImageCache } from '$lib/images';
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
import {
|
||||
generateThumbnailWebVTT,
|
||||
drawTimelineThumbnail,
|
||||
storyboardThumbnails,
|
||||
type TimelineThumbnail
|
||||
} from '$lib/player/thumbnails';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { videoLength } from '$lib/numbers';
|
||||
import { truncate } from '$lib/misc';
|
||||
|
||||
let {
|
||||
playerElement,
|
||||
currentTime,
|
||||
showPlayerUI,
|
||||
video,
|
||||
content,
|
||||
segments,
|
||||
userManualSeeking = $bindable(false),
|
||||
playerMaxKnownTime = $bindable()
|
||||
}: {
|
||||
currentTime: number;
|
||||
showPlayerUI: () => void;
|
||||
userManualSeeking: boolean;
|
||||
playerElement: HTMLMediaElement | undefined;
|
||||
video: VideoPlay;
|
||||
content: ParsedDescription;
|
||||
segments: Segment[];
|
||||
playerMaxKnownTime: number;
|
||||
} = $props();
|
||||
|
||||
let playerSliderInteracted = $state(false);
|
||||
let playerShowTimelineThumbnail = $state(true);
|
||||
let playerCloestTimestamp: Timestamp | undefined = $state();
|
||||
let playerCloestSponsor: Segment | undefined = $state();
|
||||
let playerSliderElement: HTMLElement | undefined = $state();
|
||||
let playerSliderDebounce: ReturnType<typeof setTimeout>;
|
||||
let playerTimelineTooltip: HTMLDivElement | undefined = $state();
|
||||
let playerTimelineThumbnails: TimelineThumbnail[] = $state([]);
|
||||
let playerTimelineThumbnailsCache = new ImageCache();
|
||||
let playerTimelineThumbnailCanvas: {
|
||||
timeline?: HTMLCanvasElement;
|
||||
thumb?: HTMLCanvasElement;
|
||||
} = $state({});
|
||||
let playerTimelineTimeHover = $state(0);
|
||||
let playerBufferBar: HTMLElement | undefined = $state();
|
||||
let playerBufferedTo: number = $state(0);
|
||||
|
||||
const sponsorSegments = {
|
||||
sponsor: $_('layout.sponsors.sponsor'),
|
||||
selfpromo: $_('layout.sponsors.unpaidSelfPromotion'),
|
||||
interaction: $_('layout.sponsors.interactionReminder'),
|
||||
intro: $_('layout.sponsors.intermissionIntroAnimation'),
|
||||
outro: $_('layout.sponsors.credits'),
|
||||
preview: $_('layout.sponsors.preViewRecapHook'),
|
||||
filler: $_('layout.sponsors.tangentJokes'),
|
||||
music_offtopic: $_('layout.sponsors.musicOffTopic')
|
||||
};
|
||||
|
||||
const playerTimelineSlider = new Slider({
|
||||
min: 0,
|
||||
step: 0.1,
|
||||
value: () => currentTime,
|
||||
onValueChange: async (timeToSet) => {
|
||||
playerSliderInteracted = true;
|
||||
userManualSeeking = true;
|
||||
currentTime = timeToSet;
|
||||
|
||||
showPlayerUI();
|
||||
setPlayerTimelineChapters(currentTime);
|
||||
|
||||
if (playerTimelineThumbnailCanvas.thumb) {
|
||||
await setPlayerTimelineThumbnails(currentTime, playerTimelineThumbnailCanvas.thumb);
|
||||
}
|
||||
|
||||
if (playerSliderDebounce) clearTimeout(playerSliderDebounce);
|
||||
|
||||
playerSliderDebounce = setTimeout(() => {
|
||||
if (playerElement) {
|
||||
playerElement.currentTime = currentTime;
|
||||
userManualSeeking = false;
|
||||
playerSliderInteracted = false;
|
||||
playerShowTimelineThumbnail = false;
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
max: () => playerMaxKnownTime
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
playerElement?.addEventListener('timeupdate', () => {
|
||||
const buffered = playerElement.buffered;
|
||||
|
||||
if (buffered.length > 0 && playerBufferBar) {
|
||||
playerBufferedTo = buffered.end(0);
|
||||
|
||||
const bufferedPercent = (playerBufferedTo / playerMaxKnownTime) * 100;
|
||||
const progressPercent = (currentTime / playerMaxKnownTime) * 100;
|
||||
|
||||
const bufferAhead = Math.max(0, bufferedPercent - progressPercent);
|
||||
|
||||
const effectiveWidth = Math.min(bufferAhead, 100 - progressPercent);
|
||||
|
||||
playerBufferBar.style.left = progressPercent + '%';
|
||||
playerBufferBar.style.width = effectiveWidth + '%';
|
||||
}
|
||||
});
|
||||
|
||||
if (video.storyboards && video.storyboards.length > 2) {
|
||||
let thumbnailVTT: string | undefined;
|
||||
|
||||
const selectedStoryboard = video.storyboards[2];
|
||||
|
||||
if (
|
||||
video.fallbackPatch === 'youtubejs' &&
|
||||
typeof selectedStoryboard.rows !== 'undefined' &&
|
||||
typeof selectedStoryboard.columns !== 'undefined'
|
||||
) {
|
||||
thumbnailVTT = generateThumbnailWebVTT(
|
||||
{
|
||||
...selectedStoryboard,
|
||||
rows: selectedStoryboard.rows,
|
||||
columns: selectedStoryboard.columns
|
||||
},
|
||||
playerMaxKnownTime
|
||||
);
|
||||
} else if (!video.fallbackPatch) {
|
||||
const thumbnailVTTResp = await fetch(`${$invidiousInstanceStore}${selectedStoryboard.url}`);
|
||||
if (thumbnailVTTResp.ok) thumbnailVTT = await thumbnailVTTResp.text();
|
||||
}
|
||||
|
||||
if (thumbnailVTT) {
|
||||
try {
|
||||
storyboardThumbnails(thumbnailVTT).then((thumbnails) => {
|
||||
playerTimelineThumbnails = thumbnails;
|
||||
});
|
||||
} catch {
|
||||
// Continue regardless of error.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
playerTimelineThumbnailsCache.clear();
|
||||
});
|
||||
|
||||
const markerGapSize = 0.1;
|
||||
const minVisiblePercent = 0.05;
|
||||
function timelineMarkerWidth(startTime: number, endTime: number): string {
|
||||
const ratio = (endTime - startTime) / playerMaxKnownTime;
|
||||
if (ratio <= 0) return `0%`;
|
||||
|
||||
let percent = ratio * 100;
|
||||
if (percent - markerGapSize >= minVisiblePercent) {
|
||||
percent = percent - markerGapSize;
|
||||
} else {
|
||||
percent = minVisiblePercent;
|
||||
}
|
||||
|
||||
return `${percent}%`;
|
||||
}
|
||||
|
||||
function setPlayerTimelineChapters(currentTime: number) {
|
||||
if (content.timestamps.length > 0) {
|
||||
playerCloestTimestamp = content.timestamps.find((chapter, chapterIndex) => {
|
||||
let endTime: number;
|
||||
if (chapterIndex === content.timestamps.length - 1) {
|
||||
endTime = video.lengthSeconds;
|
||||
} else {
|
||||
endTime = content.timestamps[chapterIndex + 1].time;
|
||||
}
|
||||
return currentTime >= chapter.time && currentTime < endTime;
|
||||
});
|
||||
}
|
||||
|
||||
if (segments.length > 0) {
|
||||
playerCloestSponsor = segments.find((segment) => {
|
||||
return currentTime >= segment.startTime && currentTime < segment.endTime;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function setPlayerTimelineThumbnails(time: number, canvas: HTMLCanvasElement) {
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
|
||||
if (canvasContext) {
|
||||
await drawTimelineThumbnail(
|
||||
canvasContext,
|
||||
playerTimelineThumbnailsCache,
|
||||
playerTimelineThumbnails,
|
||||
time
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let requestAnimationTooltip: number | undefined;
|
||||
let latestMouseX: number | undefined;
|
||||
|
||||
function timelineMouseMove(event: MouseEvent) {
|
||||
latestMouseX = event.clientX;
|
||||
|
||||
if (!requestAnimationTooltip) {
|
||||
requestAnimationTooltip = requestAnimationFrame(updateTooltip);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTooltip() {
|
||||
if (!playerSliderElement || latestMouseX === undefined) {
|
||||
requestAnimationTooltip = undefined;
|
||||
return;
|
||||
}
|
||||
const rect = playerSliderElement.getBoundingClientRect();
|
||||
const percent = Math.min(Math.max((latestMouseX - rect.left) / rect.width, 0), 1);
|
||||
|
||||
playerTimelineTimeHover = percent * (video.lengthSeconds ?? 0);
|
||||
setPlayerTimelineChapters(playerTimelineTimeHover);
|
||||
|
||||
if (playerTimelineThumbnailCanvas.timeline) {
|
||||
await setPlayerTimelineThumbnails(
|
||||
playerTimelineTimeHover,
|
||||
playerTimelineThumbnailCanvas.timeline
|
||||
);
|
||||
}
|
||||
|
||||
if (playerTimelineTooltip) {
|
||||
const tooltipWidth = playerTimelineTooltip.offsetWidth;
|
||||
const tooltipHeight = playerTimelineTooltip.offsetHeight;
|
||||
const sliderWidth = playerSliderElement.clientWidth;
|
||||
|
||||
let left = percent * sliderWidth;
|
||||
left = Math.min(Math.max(left, tooltipWidth / 2), sliderWidth - tooltipWidth / 2);
|
||||
playerTimelineTooltip.style.transform = `translateX(${left - tooltipWidth / 2}px)`;
|
||||
|
||||
playerTimelineTooltip.style.top = `${-tooltipHeight - 5}px`;
|
||||
}
|
||||
|
||||
playerShowTimelineThumbnail = true;
|
||||
requestAnimationTooltip = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="player-slider full-width"
|
||||
class:disable-tv={$isAndroidTvStore}
|
||||
{...playerTimelineSlider.root}
|
||||
onmousemove={timelineMouseMove}
|
||||
bind:this={playerSliderElement}
|
||||
>
|
||||
{#snippet timelineTooltip(key: 'thumb' | 'timeline', timeInSeconds: number)}
|
||||
{#if playerTimelineThumbnails.length > 0}
|
||||
<canvas
|
||||
bind:this={playerTimelineThumbnailCanvas[key]}
|
||||
width={playerTimelineThumbnails[0].width}
|
||||
height={playerTimelineThumbnails[0].height}
|
||||
></canvas>
|
||||
{/if}
|
||||
{#if playerCloestSponsor}
|
||||
<p class="no-margin" style="padding: 0 0.5rem;">
|
||||
{sponsorSegments[playerCloestSponsor.category]}
|
||||
</p>
|
||||
{:else if playerCloestTimestamp}
|
||||
<p class="no-margin" style="padding: 0 0.5rem;">
|
||||
{truncate(playerCloestTimestamp.title, 20)}
|
||||
</p>
|
||||
{/if}
|
||||
{videoLength(timeInSeconds)}
|
||||
{/snippet}
|
||||
<div class="track">
|
||||
{#if !userManualSeeking && playerShowTimelineThumbnail}
|
||||
<div bind:this={playerTimelineTooltip} class="timeline tooltip">
|
||||
{@render timelineTooltip('timeline', playerTimelineTimeHover)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="range"></div>
|
||||
<div {...playerTimelineSlider.thumb}>
|
||||
{#if playerSliderInteracted}
|
||||
<div class="tooltip thumb">
|
||||
{@render timelineTooltip('thumb', currentTime)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div bind:this={playerBufferBar} class="buffered-bar" class:hide={userManualSeeking}></div>
|
||||
{#each content.timestamps as chapter, index (chapter)}
|
||||
<div
|
||||
class="chapter-marker"
|
||||
style:left="{(chapter.time / playerMaxKnownTime) * 100}%"
|
||||
style:width={timelineMarkerWidth(
|
||||
chapter.time,
|
||||
content.timestamps[index + 1]?.time || playerMaxKnownTime // Next chapter time or end of video
|
||||
)}
|
||||
></div>
|
||||
{/each}
|
||||
{#if !$sponsorBlockTimelineStore}
|
||||
{#each segments as segment (segment)}
|
||||
<div
|
||||
class="chapter-marker segment-marker"
|
||||
style:left="{(segment.startTime / playerMaxKnownTime) * 100}%"
|
||||
style:width={timelineMarkerWidth(segment.startTime, segment.endTime)}
|
||||
></div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--player-timeline-height: 1.1rem;
|
||||
}
|
||||
|
||||
.timeline.tooltip canvas,
|
||||
.tooltip.thumb canvas {
|
||||
display: block;
|
||||
margin-bottom: 0.1rem;
|
||||
height: 100px;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.timeline.tooltip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
transform: translateX(0%);
|
||||
transition: none;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip.thumb {
|
||||
left: var(--percentage);
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.buffered-bar {
|
||||
position: absolute;
|
||||
height: var(--player-timeline-height);
|
||||
background: var(--secondary);
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
border-top-right-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chapter-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 0;
|
||||
height: var(--player-timeline-height);
|
||||
background-color: var(--secondary);
|
||||
border-radius: 0.25rem;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.segment-marker {
|
||||
background-color: var(--tertiary);
|
||||
}
|
||||
|
||||
.disable-tv {
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { isMobile } from '$lib/misc';
|
||||
import { playerDoubleTapSeek } from '$lib/player';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let {
|
||||
playerElement,
|
||||
showPlayerUI,
|
||||
toggleVideoPlaybackStatus,
|
||||
toggleFullscreen,
|
||||
playerMaxKnownTime = $bindable(),
|
||||
playerIsBuffering = $bindable(),
|
||||
playerInitalInteract = $bindable()
|
||||
}: {
|
||||
showPlayerUI: () => void;
|
||||
toggleVideoPlaybackStatus: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
playerElement: HTMLMediaElement | undefined;
|
||||
playerMaxKnownTime: number;
|
||||
playerIsBuffering: boolean;
|
||||
playerInitalInteract: boolean;
|
||||
} = $props();
|
||||
|
||||
let clickCount = $state(0);
|
||||
let clickCounterTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
let seekDirection: 'forwards' | 'backwards' | undefined = $state();
|
||||
|
||||
function onTouchControl(type: 'pause' | 'seekLeft' | 'seekRight') {
|
||||
seekDirection = undefined;
|
||||
|
||||
if (!playerElement) return;
|
||||
|
||||
if (isMobile() && playerInitalInteract) {
|
||||
showPlayerUI();
|
||||
playerInitalInteract = false;
|
||||
clickCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
clickCount++;
|
||||
|
||||
if (clickCounterTimeout) clearTimeout(clickCounterTimeout);
|
||||
|
||||
clickCounterTimeout = setTimeout(() => {
|
||||
if (clickCount == 1) {
|
||||
toggleVideoPlaybackStatus();
|
||||
}
|
||||
clickCount = 0;
|
||||
}, 200);
|
||||
|
||||
if (clickCount < 2) return;
|
||||
|
||||
if (type === 'seekLeft') {
|
||||
seekDirection = 'backwards';
|
||||
playerElement.currentTime = Math.max(0, playerElement.currentTime - playerDoubleTapSeek);
|
||||
} else if (type === 'pause') {
|
||||
toggleFullscreen();
|
||||
} else {
|
||||
seekDirection = 'forwards';
|
||||
playerElement.currentTime = Math.min(
|
||||
playerMaxKnownTime,
|
||||
playerElement.currentTime + playerDoubleTapSeek
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (clickCounterTimeout) clearTimeout(clickCounterTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="player-center">
|
||||
<div class="grid">
|
||||
<div class="s4 m4 l4" onclick={() => onTouchControl('seekLeft')} role="presentation">
|
||||
{#if clickCount > 1 && seekDirection === 'backwards'}
|
||||
<div class="seek-double-click" class:buffer-left={seekDirection === 'backwards'}>
|
||||
<h4>-{(clickCount - 1) * playerDoubleTapSeek}</h4>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="s4 m4 l4" onclick={() => onTouchControl('pause')} role="presentation">
|
||||
<div class="player-status">
|
||||
{#if playerIsBuffering}
|
||||
<progress class="circle large indeterminate" value="50" max="100"></progress>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s4 m4 l4" onclick={() => onTouchControl('seekRight')} role="presentation">
|
||||
{#if clickCount > 1 && seekDirection === 'forwards'}
|
||||
<div class="seek-double-click" class:buffer-right={seekDirection === 'forwards'}>
|
||||
<h4>+{(clickCount - 1) * playerDoubleTapSeek}</h4>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#player-center {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.seek-double-click {
|
||||
background-color: var(--secondary-container);
|
||||
height: var(--video-player-height);
|
||||
color: var(--secondary);
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
padding: 1em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.seek-double-click.buffer-right {
|
||||
border-top-left-radius: 0.25rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.seek-double-click.buffer-left {
|
||||
border-top-right-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.player-status {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: var(--video-player-height);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
let { playerElement }: { playerElement: HTMLMediaElement | undefined } = $props();
|
||||
|
||||
function hasWebkitShowPlaybackTargetPicker(
|
||||
el: HTMLMediaElement
|
||||
): el is HTMLMediaElement & { webkitShowPlaybackTargetPicker: () => void } {
|
||||
return typeof (el as any).webkitShowPlaybackTargetPicker === 'function';
|
||||
}
|
||||
|
||||
function handleAirPlayClick() {
|
||||
if (playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)) {
|
||||
playerElement.webkitShowPlaybackTargetPicker();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!playerElement || playerElement.hasAttribute('x-webkit-airplay')) return;
|
||||
|
||||
if (hasWebkitShowPlaybackTargetPicker(playerElement)) {
|
||||
playerElement.setAttribute('x-webkit-airplay', 'allow');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)}
|
||||
<button class="surface-container-highest" onclick={handleAirPlayClick} title="AirPlay">
|
||||
<i>airplay</i>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
|
||||
let { player, video }: { player: shaka.Player; video: VideoPlay } = $props();
|
||||
|
||||
let playerTextTracks: shaka.extern.TextTrack[] | undefined = $state(undefined);
|
||||
|
||||
onMount(() => {
|
||||
playerTextTracks = player.getTextTracks();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if playerTextTracks && playerTextTracks.length > 0 && !video.liveNow}
|
||||
<button class="surface-container-highest">
|
||||
<i>closed_caption</i>
|
||||
<menu class="no-wrap mobile player-settings" 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}
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
playerIsFullscreen,
|
||||
toggleFullscreen
|
||||
}: { playerIsFullscreen: boolean; toggleFullscreen: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<button class="surface-container-highest" onclick={toggleFullscreen}>
|
||||
<i>
|
||||
{#if playerIsFullscreen}
|
||||
fullscreen_exit
|
||||
{:else}
|
||||
fullscreen
|
||||
{/if}
|
||||
</i>
|
||||
</button>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
let { playerElement }: { playerElement: HTMLMediaElement | undefined } = $props();
|
||||
</script>
|
||||
|
||||
{#if document.pictureInPictureEnabled}
|
||||
<button
|
||||
class="surface-container-highest"
|
||||
onclick={() => {
|
||||
(playerElement as HTMLVideoElement).requestPictureInPicture();
|
||||
}}
|
||||
>
|
||||
<i>pip</i>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { _ } from '$lib/i18n';
|
||||
import type shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import ISO6391 from 'iso-639-1';
|
||||
import { playerAlwaysLoopStore } from '$lib/store';
|
||||
import { playbackRates } from '$lib/player/index';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
player,
|
||||
playerElement
|
||||
}: { player: shaka.Player; playerElement: HTMLMediaElement | undefined } = $props();
|
||||
|
||||
let playerSettings: 'quality' | 'speed' | 'language' | 'root' = $state('root');
|
||||
let playerCurrentVideoTrack: shaka.extern.VideoTrack | undefined = $state(undefined);
|
||||
let playerCurrentAudioTrack: shaka.extern.AudioTrack | undefined = $state(undefined);
|
||||
let playerLoop = $state($playerAlwaysLoopStore);
|
||||
|
||||
onMount(() => {
|
||||
player.addEventListener('loaded', () => {
|
||||
setActiveVideoTrack();
|
||||
setActiveAudioTrack();
|
||||
});
|
||||
});
|
||||
|
||||
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 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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="surface-container-highest">
|
||||
<i>settings</i>
|
||||
<menu class="no-wrap mobile player-settings">
|
||||
{#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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { bookmarkletSaveToUrl } from '$lib/externalSettings/index';
|
||||
import { bookmarkletSaveToUrl, settingsToJson } from '$lib/externalSettings/index';
|
||||
import { letterCase, titleCases } from '$lib/letterCasing';
|
||||
import { setAmoledTheme } from '$lib/theme';
|
||||
import { Clipboard } from '@capacitor/clipboard';
|
||||
@@ -14,7 +14,8 @@
|
||||
setInvidiousInstance,
|
||||
goToInvidiousLogin,
|
||||
invidiousLogout,
|
||||
timeout
|
||||
timeout,
|
||||
shareURL
|
||||
} from '../../misc';
|
||||
import { getPages, type Pages } from '../../navPages';
|
||||
import ColorPicker from 'svelte-awesome-color-picker';
|
||||
@@ -464,14 +465,32 @@
|
||||
<button
|
||||
class="no-margin"
|
||||
onclick={async () => {
|
||||
await Clipboard.write({ string: bookmarkletSaveToUrl() });
|
||||
await shareURL(bookmarkletSaveToUrl());
|
||||
}}
|
||||
>
|
||||
<i>content_copy</i>
|
||||
<span>{$_('copyUrl')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space"></div>
|
||||
<div class="settings">
|
||||
<h6>{$_('layout.exportToJson')}</h6>
|
||||
<div class="space"></div>
|
||||
<button
|
||||
class="no-margin"
|
||||
onclick={async () => {
|
||||
await Clipboard.write({ string: settingsToJson() });
|
||||
|
||||
addToast({
|
||||
data: {
|
||||
text: $_('player.share.copiedSuccess')
|
||||
text: get(_)('player.share.copiedSuccess')
|
||||
}
|
||||
});
|
||||
}}>{$_('copyUrl')}</button
|
||||
}}
|
||||
>
|
||||
<i>content_copy</i>
|
||||
<span>{$_('copy')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Channel } from '../api/model';
|
||||
import { insecureRequestImageHandler, truncate } from '../misc';
|
||||
import { interfaceLowBandwidthMode } from '../store';
|
||||
import type { Channel } from '$lib/api/model';
|
||||
import { insecureRequestImageHandler, truncate } from '$lib/misc';
|
||||
import { interfaceLowBandwidthMode } from '$lib/store';
|
||||
|
||||
interface Props {
|
||||
channel: Channel;
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
import { _ } from '$lib/i18n';
|
||||
import type { HashTag } from '../api/model';
|
||||
import { truncate } from '../misc';
|
||||
import type { HashTag } from '$lib/api/model';
|
||||
import { truncate } from '$lib/misc';
|
||||
|
||||
interface Props {
|
||||
hashtag: HashTag;
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Playlist, PlaylistPage } from '../api/model';
|
||||
import { insecureRequestImageHandler, truncate } from '../misc';
|
||||
import { interfaceLowBandwidthMode } from '../store';
|
||||
import type { Playlist, PlaylistPage } from '$lib/api/model';
|
||||
import { insecureRequestImageHandler, truncate } from '$lib/misc';
|
||||
import { interfaceLowBandwidthMode } from '$lib/store';
|
||||
|
||||
interface Props {
|
||||
playlist: Playlist | PlaylistPage;
|
||||
+5
-5
@@ -6,10 +6,10 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { getDeArrow, getThumbnail } from '../api';
|
||||
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
|
||||
import { createVideoUrl, insecureRequestImageHandler, isYTBackend } from '../misc';
|
||||
import type { PlayerEvents } from '../player';
|
||||
import { getDeArrow, getThumbnail } from '$lib/api';
|
||||
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '$lib/api/model';
|
||||
import { createVideoUrl, insecureRequestImageHandler, isYTBackend } from '$lib/misc';
|
||||
import type { PlayerEvents } from '$lib/player';
|
||||
import {
|
||||
invidiousAuthStore,
|
||||
deArrowEnabledStore,
|
||||
@@ -21,7 +21,7 @@
|
||||
syncPartyPeerStore,
|
||||
synciousInstanceStore,
|
||||
synciousStore
|
||||
} from '../store';
|
||||
} from '$lib/store';
|
||||
import { queueGetWatchProgress } from '$lib/api/apiExtended';
|
||||
import { relativeTimestamp } from '$lib/time';
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PlaylistPage, PlaylistPageVideo, VideoPlay } from '$lib/api/model';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
import { goToNextVideo, goToPreviousVideo } from '$lib/player';
|
||||
import { playlistSettingsStore } from '$lib/store';
|
||||
import VideoThumbnail from '../thumbnail/VideoThumbnail.svelte';
|
||||
|
||||
let {
|
||||
playlist,
|
||||
video
|
||||
}: { playlist: { videos: PlaylistPageVideo[]; info: PlaylistPage }; video: VideoPlay } = $props();
|
||||
|
||||
let loopPlaylist: boolean = $state(false);
|
||||
let shufflePlaylist: boolean = $state(false);
|
||||
|
||||
playlistSettingsStore.subscribe((playlistSetting) => {
|
||||
if (playlist.info.playlistId in playlistSetting) {
|
||||
loopPlaylist = playlistSetting[playlist.info.playlistId].loop;
|
||||
shufflePlaylist = playlistSetting[playlist.info.playlistId].shuffle;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<article
|
||||
style="height: 85vh; position: relative;scrollbar-width: none;"
|
||||
id="playlist"
|
||||
class="scroll no-padding surface-container border"
|
||||
>
|
||||
<article class="no-elevate border" style="position: sticky; top: 0; z-index: 3;">
|
||||
<h6>{playlist.info.title}</h6>
|
||||
<p>
|
||||
{cleanNumber(playlist.info.viewCount)}
|
||||
{$_('views')} • {playlist.info.videoCount}
|
||||
{$_('videos')}
|
||||
</p>
|
||||
<p>
|
||||
{#if playlist.info.authorId}
|
||||
<a
|
||||
href={resolve(`/channel/[authorId]`, {
|
||||
authorId: playlist.info.authorId
|
||||
})}>{playlist.info.author}</a
|
||||
>
|
||||
{:else}
|
||||
{playlist.info.author}
|
||||
{/if}
|
||||
</p>
|
||||
<nav>
|
||||
<button
|
||||
class="circle surface-container-highest"
|
||||
onclick={() => goToPreviousVideo(playlist.info.playlistId)}
|
||||
>
|
||||
<i>skip_previous</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.previous')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
loopPlaylist = !loopPlaylist;
|
||||
playlistSettingsStore.set({
|
||||
[playlist.info.playlistId]: { loop: loopPlaylist, shuffle: shufflePlaylist }
|
||||
});
|
||||
}}
|
||||
class="circle"
|
||||
class:surface-container-highest={!loopPlaylist}
|
||||
>
|
||||
<i>loop</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.loopPlaylist')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
shufflePlaylist = !shufflePlaylist;
|
||||
playlistSettingsStore.set({
|
||||
[playlist.info.playlistId]: { loop: loopPlaylist, shuffle: shufflePlaylist }
|
||||
});
|
||||
}}
|
||||
class="circle"
|
||||
class:surface-container-highest={!shufflePlaylist}
|
||||
>
|
||||
<i>shuffle</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.shuffleVideos')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="circle surface-container-highest"
|
||||
onclick={async () => await goToNextVideo(video, playlist.info.playlistId)}
|
||||
>
|
||||
<i>skip_next</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.next')}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="space"></div>
|
||||
<div class="divider"></div>
|
||||
</article>
|
||||
|
||||
<div class="space"></div>
|
||||
|
||||
{#each playlist.videos as playlistVideo, index (index)}
|
||||
<article
|
||||
class="no-padding border"
|
||||
style="margin: .7em;"
|
||||
id={playlistVideo.videoId}
|
||||
class:primary-border={playlistVideo.videoId === video.videoId}
|
||||
>
|
||||
{#key playlistVideo.videoId}
|
||||
<VideoThumbnail
|
||||
video={playlistVideo}
|
||||
sideways={true}
|
||||
playlistId={playlist.info.playlistId || undefined}
|
||||
/>
|
||||
{/key}
|
||||
</article>
|
||||
{/each}
|
||||
</article>
|
||||
+11
-6
@@ -3,9 +3,9 @@
|
||||
import Fuse from 'fuse.js';
|
||||
import { type VTTCue, parseText, type ParsedCaptionsResult } from 'media-captions';
|
||||
import { _ } from '$lib/i18n';
|
||||
import type { VideoPlay } from '../api/model';
|
||||
import { decodeHtmlCharCodes } from '../misc';
|
||||
import { invidiousInstanceStore } from '../store';
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
import { decodeHtmlCharCodes } from '$lib/misc';
|
||||
import { invidiousInstanceStore } from '$lib/store';
|
||||
|
||||
interface Props {
|
||||
video: VideoPlay;
|
||||
@@ -57,8 +57,11 @@
|
||||
|
||||
isLoading = true;
|
||||
transcript = null;
|
||||
|
||||
const resp = await fetch(urlConstructed);
|
||||
if (!resp.ok) return;
|
||||
transcript = await parseText(await resp.text(), { strict: false });
|
||||
|
||||
transcriptCues = transcript.cues;
|
||||
|
||||
isLoading = false;
|
||||
@@ -83,7 +86,7 @@
|
||||
<article class="scroll border no-padding" style="height: 75vh;" id="transcript">
|
||||
<article class="no-elevate padding" style="position: sticky; top: 0; z-index: 3;">
|
||||
<h6>{$_('transcript')}</h6>
|
||||
<div class="field label suffix border">
|
||||
<div class="field label suffix surface-container-highest">
|
||||
<select bind:value={url} onchange={loadTranscript} name="captions">
|
||||
<option selected={true} value={null}>{$_('selectLang')}</option>
|
||||
{#each video.captions as caption (caption)}
|
||||
@@ -94,7 +97,8 @@
|
||||
<i>arrow_drop_down</i>
|
||||
</div>
|
||||
{#if transcriptCues.length > 0}
|
||||
<div class="max field round suffix prefix small no-margin surface-variant">
|
||||
<div class="space"></div>
|
||||
<div class="max field suffix prefix small no-margin surface-container-highest">
|
||||
<i class="front">search</i><input
|
||||
bind:value={search}
|
||||
oninput={searchTranscript}
|
||||
@@ -124,7 +128,8 @@
|
||||
class="transcript-line"
|
||||
role="presentation"
|
||||
onclick={() => (playerElement.currentTime = cue.startTime)}
|
||||
class:secondary-container={currentTime >= cue.startTime && currentTime <= cue.endTime}
|
||||
class:surface-container-highest={currentTime >= cue.startTime &&
|
||||
currentTime <= cue.endTime}
|
||||
>
|
||||
<p class="chip no-margin">{videoLength(cue.startTime)}</p>
|
||||
<p class="transcript-text">{decodeHtmlCharCodes(cue.text.replace(/<[^>]+>/g, ''))}</p>
|
||||
@@ -133,6 +133,55 @@ menu {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
menu.player-settings {
|
||||
transform: scale(1) translateY(-40%) translateX(0) !important;
|
||||
width: 300px !important;
|
||||
height: 200px !important;
|
||||
}
|
||||
|
||||
.player-slider {
|
||||
height: var(--player-timeline-height);
|
||||
margin: 0 auto;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.player-slider.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-slider.volume {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.player-slider .track {
|
||||
background: var(--secondary-container);
|
||||
height: 100%;
|
||||
position: relative;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.player-slider .range {
|
||||
position: absolute;
|
||||
background: var(--inverse-primary);
|
||||
inset: 0;
|
||||
right: var(--percentage-inv);
|
||||
border-radius: 0.25rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.player-slider [data-melt-slider-thumb] {
|
||||
position: absolute;
|
||||
border-radius: 1rem;
|
||||
background: var(--primary);
|
||||
left: var(--percentage);
|
||||
top: 50%;
|
||||
width: 5px;
|
||||
height: 35px;
|
||||
z-index: 3;
|
||||
cursor: grab;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
menu.mobile {
|
||||
position: fixed !important;
|
||||
|
||||
@@ -26,7 +26,6 @@ export async function syncSettingsToBackend() {
|
||||
let initalLoad = true;
|
||||
store.store.subscribe((value) => {
|
||||
if (!get(rawMasterKeyStore)) return;
|
||||
|
||||
if (initalLoad) {
|
||||
initalLoad = false;
|
||||
return;
|
||||
@@ -117,6 +116,19 @@ export function bookmarkletSaveToUrl(): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function settingsToJson(): string {
|
||||
const settings: Record<string, string> = {};
|
||||
|
||||
for (const { name, store, excludeFromBookmarklet } of persistedStores) {
|
||||
const value = get(store);
|
||||
if (!excludeFromBookmarklet) {
|
||||
settings[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(settings);
|
||||
}
|
||||
|
||||
export function bookmarkletLoadFromUrl() {
|
||||
const toSet: Record<string, string> = {};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"popularPageDisabled": "Popular page has been disabled by Admins",
|
||||
"premium": "Premium YouTube content can't be watched on Materialious.",
|
||||
"copyUrl": "Copy URL",
|
||||
"copy": "Copy JSON",
|
||||
"loadMore": "Load more",
|
||||
"views": "views",
|
||||
"login": "Login",
|
||||
@@ -126,9 +127,7 @@
|
||||
"chapters": "Chapters",
|
||||
"share": {
|
||||
"title": "Share",
|
||||
"materialiousLink": "Copy Materialious link",
|
||||
"invidiousRedirect": "Copy Invidious redirect link",
|
||||
"youtubeLink": "Copy Youtube link",
|
||||
"copyXLink": "Copy {{linkType}} link",
|
||||
"includeTimestamp": "Timestamped",
|
||||
"copiedSuccess": "Copied to clipboard"
|
||||
},
|
||||
@@ -239,6 +238,7 @@
|
||||
"playerStatistics": "Show player statistics by default"
|
||||
},
|
||||
"bookmarklet": "Bookmarklet",
|
||||
"exportToJson": "Export to JSON",
|
||||
"instanceUrl": "Instance URL",
|
||||
"sponsors": {
|
||||
"sponsor": "Sponsor",
|
||||
|
||||
@@ -33,6 +33,8 @@ import { Clipboard } from '@capacitor/clipboard';
|
||||
import { isOwnBackend } from './shared';
|
||||
import { Browser } from '@capacitor/browser';
|
||||
import { clearFeedYTjs } from './api/youtubejs/subscriptions';
|
||||
import { addToast } from './components/Toast.svelte';
|
||||
import { _ } from './i18n';
|
||||
|
||||
export function getPublicEnv(envName: string): string | undefined {
|
||||
return env[`PUBLIC_${envName}`] ?? import.meta.env[`VITE_${envName}`];
|
||||
@@ -114,6 +116,12 @@ export async function shareURL(url: string) {
|
||||
} else {
|
||||
await Clipboard.write({ string: url });
|
||||
}
|
||||
|
||||
addToast({
|
||||
data: {
|
||||
text: get(_)('player.share.copiedSuccess')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureNoTrailingSlash(url: any): string {
|
||||
@@ -306,7 +314,7 @@ export async function goToInvidiousLogin() {
|
||||
path.search = searchParams.toString();
|
||||
await Browser.open({ url: path.toString() });
|
||||
} else {
|
||||
searchParams.set('callback_url', `${location.origin}${resolve('/auth', {})}`);
|
||||
searchParams.set('callback_url', `${location.origin}${resolve('/invidious/auth', {})}`);
|
||||
path.search = searchParams.toString();
|
||||
document.location.href = path.toString();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { PlaylistPageVideo, VideoPlay } from '$lib/api/model';
|
||||
import {
|
||||
isAndroidTvStore,
|
||||
playerAutoplayNextByDefaultStore,
|
||||
playerDefaultLanguage,
|
||||
playerDefaultQualityStore,
|
||||
playerPlaylistHistory,
|
||||
playerState,
|
||||
playlistSettingsStore,
|
||||
syncPartyConnectionsStore
|
||||
} from '$lib/store';
|
||||
@@ -11,6 +14,8 @@ import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { loadEntirePlaylist } from '$lib/playlist';
|
||||
import { unsafeRandomItem } from '$lib/misc';
|
||||
import type shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
import ISO6391 from 'iso-639-1';
|
||||
|
||||
export interface PlayerEvent {
|
||||
type: 'pause' | 'seek' | 'change-video' | 'play' | 'playlist' | 'goto';
|
||||
@@ -30,6 +35,8 @@ export const playbackRates = [
|
||||
export const playerDoubleTapSeek = 10.0;
|
||||
|
||||
export function goToPreviousVideo(playlistId: string | null) {
|
||||
playerState.set(undefined);
|
||||
|
||||
const previousVideos = get(playerPlaylistHistory);
|
||||
if (previousVideos.length > 1) {
|
||||
goto(
|
||||
@@ -43,6 +50,8 @@ export function goToPreviousVideo(playlistId: string | null) {
|
||||
}
|
||||
|
||||
export async function goToNextVideo(video: VideoPlay, playlistId: string | null) {
|
||||
playerState.set(undefined);
|
||||
|
||||
const isAndroidTv = get(isAndroidTvStore);
|
||||
|
||||
if (!playlistId) {
|
||||
@@ -104,3 +113,90 @@ export async function goToNextVideo(video: VideoPlay, playlistId: string | null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreQualityPreference(player: shaka.Player) {
|
||||
const numericValue = parseInt(get(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 } });
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreDefaultLanguage(player: shaka.Player) {
|
||||
if (!get(playerDefaultLanguage) || get(playerDefaultLanguage) === 'original') {
|
||||
const languageAndRole = player.getAudioLanguagesAndRoles().find(({ role }) => role === 'main');
|
||||
if (languageAndRole !== undefined) {
|
||||
player.selectAudioLanguage(languageAndRole.language);
|
||||
return;
|
||||
}
|
||||
} else if (get(playerDefaultLanguage)) {
|
||||
const audioLanguages = player.getAudioLanguages();
|
||||
const langCode = ISO6391.getCode(get(playerDefaultLanguage));
|
||||
|
||||
for (const audioLanguage of audioLanguages) {
|
||||
if (audioLanguage.startsWith(langCode)) {
|
||||
player.selectAudioLanguage(audioLanguage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleSubtitles(player: shaka.Player) {
|
||||
const isVisible = player.isTextTrackVisible();
|
||||
if (isVisible) {
|
||||
player.setTextTrackVisibility(false);
|
||||
} else {
|
||||
let langCode: string;
|
||||
if (get(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -23,7 +23,7 @@ import {
|
||||
createRecoverableError,
|
||||
headersToGenericObject,
|
||||
makeResponse
|
||||
} from '$lib/sabr/helpers';
|
||||
} from '$lib/player/sabr/helpers';
|
||||
|
||||
interface ShakaResponseArgs {
|
||||
uri: string;
|
||||
@@ -317,7 +317,6 @@ export class ShakaPlayerAdapter implements SabrPlayerAdapter {
|
||||
}
|
||||
|
||||
if (result) {
|
||||
abortController.abort();
|
||||
return this.createShakaResponse({
|
||||
uri,
|
||||
request,
|
||||
@@ -34,8 +34,6 @@ export async function injectSabr(
|
||||
});
|
||||
|
||||
sabrAdapter.onReloadPlayerResponse(async (reloadContext) => {
|
||||
console.log('[SABR]', 'Reloading player response...');
|
||||
|
||||
if (!video.ytjs) return;
|
||||
|
||||
const reloadedInfo = await video.ytjs.innertube.actions.execute('/player', {
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import type { StoryBoard } from './api/model';
|
||||
import { ImageCache } from './images';
|
||||
import type { StoryBoard } from '$lib/api/model';
|
||||
import { ImageCache } from '$lib/images';
|
||||
import { parseText } from 'media-captions';
|
||||
import { findElementForTime } from './misc';
|
||||
import { findElementForTime } from '$lib/misc';
|
||||
|
||||
export interface TimelineThumbnail {
|
||||
url: string;
|
||||
@@ -10,8 +10,8 @@
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
import Settings from '$lib/components/settings/Settings.svelte';
|
||||
import SyncParty from '$lib/components/SyncParty.svelte';
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import Player from '$lib/components/Player.svelte';
|
||||
import Thumbnail from '$lib/components/thumbnail/VideoThumbnail.svelte';
|
||||
import Player from '$lib/components/player/Player.svelte';
|
||||
import '$lib/css/global.css';
|
||||
import { getPages } from '$lib/navPages';
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import ItemsList from '$lib/components/layout/ItemsList.svelte';
|
||||
import { _ } from '$lib/i18n/index';
|
||||
import { feedCacheStore } from '$lib/store';
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import PageLoading from '$lib/components/PageLoading.svelte';
|
||||
import { invidiousAuthStore } from '$lib/store';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Auth response handling for Desktop
|
||||
onMount(() => {
|
||||
const username = $page.url.searchParams.get('username');
|
||||
const token = $page.url.searchParams.get('token');
|
||||
|
||||
if (username && token) {
|
||||
invidiousAuthStore.set({
|
||||
username: username,
|
||||
token: token
|
||||
});
|
||||
}
|
||||
|
||||
goto(resolve('/', {}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageLoading />
|
||||
@@ -5,15 +5,15 @@
|
||||
import { proxyGoogleImage } from '$lib/images';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
import { channelCacheStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
|
||||
import { Clipboard } from '@capacitor/clipboard';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { _ } from '$lib/i18n';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import ItemsList from '$lib/components/layout/ItemsList.svelte';
|
||||
import Author from '$lib/components/Author.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import Share from '$lib/components/Share.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let tab: ChannelContentTypes = $state('videos');
|
||||
|
||||
@@ -108,37 +108,29 @@
|
||||
</p>
|
||||
</div>
|
||||
{#if !$isAndroidTvStore}
|
||||
<button class="border">
|
||||
<i>share</i>
|
||||
<span>{$_('player.share.title')}</span>
|
||||
<menu class="no-wrap mobile">
|
||||
{#if !Capacitor.isNativePlatform()}
|
||||
<li
|
||||
class="row"
|
||||
role="presentation"
|
||||
onclick={async () => {
|
||||
await Clipboard.write({ string: location.href });
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
>
|
||||
{$_('player.share.materialiousLink')}
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
<li
|
||||
class="row"
|
||||
role="presentation"
|
||||
onclick={async () => {
|
||||
await Clipboard.write({
|
||||
string: `https://www.youtube.com/channel/${page.params.slug}`
|
||||
});
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
>
|
||||
{$_('player.share.youtubeLink')}
|
||||
</li>
|
||||
</menu>
|
||||
</button>
|
||||
<Share
|
||||
iconOnly={false}
|
||||
shares={[
|
||||
{
|
||||
type: 'materialious',
|
||||
path: resolve('/channel/[channelId]', {
|
||||
channelId: page.params.slug
|
||||
})
|
||||
},
|
||||
{
|
||||
type: 'youtube',
|
||||
path: `/channel/${page.params.slug}`
|
||||
},
|
||||
{
|
||||
type: 'invidious',
|
||||
path: `/channel/${page.params.slug}`
|
||||
},
|
||||
{
|
||||
type: 'invidious redirect',
|
||||
path: `/channel/${page.params.slug}`
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getHashtag } from '$lib/api';
|
||||
import type { Video } from '$lib/api/model';
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import ItemsList from '$lib/components/layout/ItemsList.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { unsafeRandomItem } from '$lib/misc';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
import { isAndroidTvStore, playlistSettingsStore } from '$lib/store';
|
||||
import { Clipboard } from '@capacitor/clipboard';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { _ } from '$lib/i18n';
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import ItemsList from '$lib/components/layout/ItemsList.svelte';
|
||||
import Share from '$lib/components/Share.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@
|
||||
playlistSettingsStore.set({
|
||||
[data.playlist.info.playlistId]: { shuffle: true, loop: false }
|
||||
})}
|
||||
class="button circle extra no-margin border"
|
||||
class="button circle extra no-margin surface-container-highest"
|
||||
>
|
||||
<i>shuffle</i>
|
||||
<div class="tooltip bottom">
|
||||
@@ -68,36 +68,29 @@
|
||||
<div class="space"></div>
|
||||
|
||||
{#if !$isAndroidTvStore}
|
||||
<button class="border no-margin">
|
||||
<i>share</i>
|
||||
<span>{$_('player.share.title')}</span>
|
||||
<menu class="no-wrap mobile">
|
||||
{#if !Capacitor.isNativePlatform()}
|
||||
<li
|
||||
class="row"
|
||||
role="presentation"
|
||||
onclick={async () => {
|
||||
await Clipboard.write({ string: location.href });
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
>
|
||||
{$_('player.share.materialiousLink')}
|
||||
</li>
|
||||
{/if}
|
||||
<li
|
||||
class="row"
|
||||
role="presentation"
|
||||
onclick={async () => {
|
||||
await Clipboard.write({
|
||||
string: `https://www.youtube.com/playlist?list=${data.playlist.info.playlistId}`
|
||||
});
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
>
|
||||
{$_('player.share.youtubeLink')}
|
||||
</li>
|
||||
</menu>
|
||||
</button>
|
||||
<nav class="right-align">
|
||||
<Share
|
||||
iconOnly={false}
|
||||
shares={[
|
||||
{
|
||||
type: 'materialious',
|
||||
path: resolve('/playlist/[playlistId]', { playlistId: page.params.slug })
|
||||
},
|
||||
{
|
||||
type: 'invidious',
|
||||
path: `/playlist?list=${page.params.slug}`
|
||||
},
|
||||
{
|
||||
type: 'invidious redirect',
|
||||
path: `/playlist?list=${page.params.slug}`
|
||||
},
|
||||
{
|
||||
type: 'youtube',
|
||||
path: `/playlist?list=${page.params.slug}`
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</nav>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
|
||||
import { deletePersonalPlaylist, getPersonalPlaylists, postPersonalPlaylist } from '$lib/api';
|
||||
import ContentColumn from '$lib/components/ContentColumn.svelte';
|
||||
import PlaylistThumbnail from '$lib/components/PlaylistThumbnail.svelte';
|
||||
import ContentColumn from '$lib/components/layout/ContentColumn.svelte';
|
||||
import PlaylistThumbnail from '$lib/components/thumbnail/PlaylistThumbnail.svelte';
|
||||
import { ui } from 'beercss';
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { searchCacheStore } from '$lib/store';
|
||||
import { _ } from '$lib/i18n';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import ItemsList from '$lib/components/layout/ItemsList.svelte';
|
||||
import type { SearchOptions, SearchResults } from '$lib/api/model';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { PlaylistPageVideo, Video, VideoBase } from '$lib/api/model';
|
||||
import { feedCacheStore, feedLoadingStore } from '$lib/store';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import ItemsList from '$lib/components/layout/ItemsList.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { _ } from '$lib/i18n';
|
||||
import PageLoading from '$lib/components/PageLoading.svelte';
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
removePlaylistVideo
|
||||
} from '$lib/api/index';
|
||||
import type { Comments, PlaylistPage } from '$lib/api/model';
|
||||
import ShareVideo from '$lib/components/ShareVideo.svelte';
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import Transcript from '$lib/components/Transcript.svelte';
|
||||
import Thumbnail from '$lib/components/thumbnail/VideoThumbnail.svelte';
|
||||
import Transcript from '$lib/components/watch/Transcript.svelte';
|
||||
import { getBestThumbnail } from '$lib/images';
|
||||
import { letterCase } from '$lib/letterCasing';
|
||||
import { cleanNumber, numberWithCommas } from '$lib/numbers';
|
||||
import { goToNextVideo, goToPreviousVideo, type PlayerEvents } from '$lib/player/index';
|
||||
import { numberWithCommas } from '$lib/numbers';
|
||||
import {
|
||||
invidiousAuthStore,
|
||||
interfaceAutoExpandChapters,
|
||||
@@ -24,7 +22,6 @@
|
||||
playerTheatreModeByDefaultStore,
|
||||
playertheatreModeIsActive,
|
||||
playlistCacheStore,
|
||||
playlistSettingsStore,
|
||||
syncPartyConnectionsStore,
|
||||
type PlayerState
|
||||
} from '$lib/store';
|
||||
@@ -42,6 +39,9 @@
|
||||
import { humanizeSeconds, relativeTimestamp } from '$lib/time';
|
||||
import { getWatchDetails } from '$lib/watch';
|
||||
import { page } from '$app/state';
|
||||
import Share from '$lib/components/Share.svelte';
|
||||
import Playlist from '$lib/components/watch/Playlist.svelte';
|
||||
import type { PlayerEvents } from '$lib/player/index.js';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
@@ -55,9 +55,6 @@
|
||||
let personalPlaylists: PlaylistPage[] | null = $state(null);
|
||||
data.streamed.personalPlaylists?.then((streamPlaylists) => (personalPlaylists = streamPlaylists));
|
||||
|
||||
let loopPlaylist: boolean = $state(false);
|
||||
let shufflePlaylist: boolean = $state(false);
|
||||
|
||||
playertheatreModeIsActive.set(get(playerTheatreModeByDefaultStore));
|
||||
|
||||
let pauseTimerSeconds: number = $state(-1);
|
||||
@@ -84,14 +81,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
playlistSettingsStore.subscribe((playlistSetting) => {
|
||||
if (!data.playlistId) return;
|
||||
if (data.playlistId in playlistSetting) {
|
||||
loopPlaylist = playlistSetting[data.playlistId].loop;
|
||||
shufflePlaylist = playlistSetting[data.playlistId].shuffle;
|
||||
}
|
||||
});
|
||||
|
||||
function playerSyncEvents(conn: DataConnection) {
|
||||
if (playerElement) {
|
||||
conn.send({
|
||||
@@ -449,15 +438,44 @@
|
||||
{$_('transcript')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="surface-container-highest"
|
||||
onclick={(event: Event) => event.stopPropagation()}
|
||||
><i>share</i>
|
||||
<div class="tooltip">
|
||||
{$_('player.share.title')}
|
||||
</div>
|
||||
<ShareVideo bind:currentTime={playerCurrentTime} video={data.video} />
|
||||
</button>
|
||||
<Share
|
||||
includePromptText={$_('player.share.includeTimestamp')}
|
||||
shares={[
|
||||
{
|
||||
type: 'materialious',
|
||||
path: resolve('/watch/[videoId]', { videoId: data.video.videoId }),
|
||||
param: {
|
||||
key: 'time',
|
||||
value: () => Math.round(playerCurrentTime)
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'invidious',
|
||||
path: `/watch?=${data.video.videoId}`,
|
||||
param: {
|
||||
key: 'v',
|
||||
value: () => Math.round(playerCurrentTime)
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'invidious redirect',
|
||||
path: `/watch?=${data.video.videoId}`,
|
||||
param: {
|
||||
key: 'v',
|
||||
value: () => Math.round(playerCurrentTime)
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'youtube',
|
||||
path: `/watch?=${data.video.videoId}`,
|
||||
param: {
|
||||
key: 'v',
|
||||
value: () => Math.round(playerCurrentTime)
|
||||
}
|
||||
}
|
||||
]}
|
||||
iconOnly={true}
|
||||
/>
|
||||
{#if personalPlaylists && personalPlaylists.length > 0}
|
||||
<button class="surface-container-highest">
|
||||
<i>add</i>
|
||||
@@ -581,100 +599,7 @@
|
||||
<Transcript video={data.video} bind:playerElement />
|
||||
{/if}
|
||||
{#if data.playlistId && data.playlistId in $playlistCacheStore}
|
||||
<article
|
||||
style="height: 85vh; position: relative;scrollbar-width: none;"
|
||||
id="playlist"
|
||||
class="scroll no-padding surface-container border"
|
||||
>
|
||||
<article class="no-elevate border" style="position: sticky; top: 0; z-index: 3;">
|
||||
<h6>{$playlistCacheStore[data.playlistId].info.title}</h6>
|
||||
<p>
|
||||
{cleanNumber($playlistCacheStore[data.playlistId].info.viewCount)}
|
||||
{$_('views')} • {$playlistCacheStore[data.playlistId].info.videoCount}
|
||||
{$_('videos')}
|
||||
</p>
|
||||
<p>
|
||||
{#if $playlistCacheStore[data.playlistId].info.authorId}
|
||||
<a
|
||||
href={resolve(`/channel/[authorId]`, {
|
||||
authorId: $playlistCacheStore[data.playlistId].info.authorId
|
||||
})}>{$playlistCacheStore[data.playlistId].info.author}</a
|
||||
>
|
||||
{:else}
|
||||
{$playlistCacheStore[data.playlistId].info.author}
|
||||
{/if}
|
||||
</p>
|
||||
<nav>
|
||||
<button
|
||||
onclick={() => {
|
||||
loopPlaylist = !loopPlaylist;
|
||||
playlistSettingsStore.set({
|
||||
[data.playlistId as string]: { loop: loopPlaylist, shuffle: shufflePlaylist }
|
||||
});
|
||||
}}
|
||||
class="circle"
|
||||
class:fill={!loopPlaylist}
|
||||
>
|
||||
<i>loop</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.loopPlaylist')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
shufflePlaylist = !shufflePlaylist;
|
||||
playlistSettingsStore.set({
|
||||
[data.playlistId as string]: { loop: loopPlaylist, shuffle: shufflePlaylist }
|
||||
});
|
||||
}}
|
||||
class="circle"
|
||||
class:fill={!shufflePlaylist}
|
||||
>
|
||||
<i>shuffle</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.shuffleVideos')}
|
||||
</div>
|
||||
</button>
|
||||
<button class="circle fill" onclick={() => goToPreviousVideo(data.playlistId)}>
|
||||
<i>skip_previous</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.previous')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="circle fill"
|
||||
onclick={async () => await goToNextVideo(data.video, data.playlistId)}
|
||||
>
|
||||
<i>skip_next</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.next')}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="space"></div>
|
||||
<div class="divider"></div>
|
||||
</article>
|
||||
|
||||
<div class="space"></div>
|
||||
|
||||
{#each $playlistCacheStore[data.playlistId].videos as playlistVideo (playlistVideo.videoId)}
|
||||
<article
|
||||
class="no-padding border"
|
||||
style="margin: .7em;"
|
||||
id={playlistVideo.videoId}
|
||||
class:primary-border={playlistVideo.videoId === data.video.videoId}
|
||||
>
|
||||
{#key playlistVideo.videoId}
|
||||
<Thumbnail
|
||||
video={playlistVideo}
|
||||
sideways={true}
|
||||
playlistId={data.playlistId || undefined}
|
||||
/>
|
||||
{/key}
|
||||
</article>
|
||||
{/each}
|
||||
</article>
|
||||
<Playlist video={data.video} playlist={$playlistCacheStore[data.playlistId]} />
|
||||
{:else if data.video.recommendedVideos}
|
||||
{#each data.video.recommendedVideos as recommendedVideo (recommendedVideo.videoId)}
|
||||
<article class="no-padding border">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Player from '$lib/components/Player.svelte';
|
||||
import Player from '$lib/components/player/Player.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ContentColumn from '$lib/components/ContentColumn.svelte';
|
||||
import Player from '$lib/components/Player.svelte';
|
||||
import ContentColumn from '$lib/components/layout/ContentColumn.svelte';
|
||||
import Player from '$lib/components/player/Player.svelte';
|
||||
import Author from '$lib/components/Author.svelte';
|
||||
import Description from '$lib/components/watch/Description.svelte';
|
||||
import LikesDislikes from '$lib/components/watch/LikesDislikes.svelte';
|
||||
@@ -10,7 +10,7 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import { playlistCacheStore } from '$lib/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import ItemsList from '$lib/components/layout/ItemsList.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
|
||||
@@ -81,19 +81,20 @@
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
ui();
|
||||
|
||||
if (Capacitor.getPlatform() === 'android' && $themeColorStore) {
|
||||
try {
|
||||
const colorPalette = await colorTheme.getColorPalette();
|
||||
themeColorStore.set(convertToHexColorCode(colorPalette.primary));
|
||||
await ui('theme', $themeColorStore);
|
||||
} catch {
|
||||
// Continue regardless of error
|
||||
}
|
||||
}
|
||||
await ui();
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
if (!$themeColorStore) {
|
||||
try {
|
||||
const colorPalette = await colorTheme.getColorPalette();
|
||||
const colorAsHex = convertToHexColorCode(colorPalette.primary);
|
||||
themeColorStore.set(colorAsHex);
|
||||
await ui('theme', colorAsHex);
|
||||
} catch {
|
||||
// Continue regardless of error
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', async (event: MouseEvent) => {
|
||||
// Handles opening links in browser for android.
|
||||
const link = (event.target as HTMLElement).closest('a');
|
||||
|
||||
@@ -7,6 +7,27 @@ import { parse as tldParse } from 'tldts';
|
||||
import { USER_AGENT } from 'bgutils-js';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
const ALLOWED_HEADERS = [
|
||||
'Origin',
|
||||
'X-Requested-With',
|
||||
'Content-Type',
|
||||
'Accept',
|
||||
'Authorization',
|
||||
'x-goog-visitor-id',
|
||||
'x-goog-api-key',
|
||||
'x-origin',
|
||||
'x-youtube-client-version',
|
||||
'x-youtube-client-name',
|
||||
'x-goog-api-format-version',
|
||||
'x-goog-authuser',
|
||||
'x-user-agent',
|
||||
'Accept-Language',
|
||||
'X-Goog-FieldMask',
|
||||
'Range',
|
||||
'Referer',
|
||||
'Cookie'
|
||||
].join(', ');
|
||||
|
||||
const allowedBaseDomains: string[] = [
|
||||
'youtube.com',
|
||||
'ytimg.com',
|
||||
@@ -81,11 +102,15 @@ async function proxyRequest(
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers();
|
||||
|
||||
requestHeaders.set('host', urlToProxyObj.host);
|
||||
requestHeaders.set('origin', urlToProxyObj.origin);
|
||||
requestHeaders.set('user-agent', USER_AGENT);
|
||||
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (authHeader) {
|
||||
requestHeaders.set('Authorization', authHeader);
|
||||
}
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers: requestHeaders,
|
||||
@@ -113,17 +138,18 @@ async function proxyRequest(
|
||||
});
|
||||
} catch (err) {
|
||||
errorMsg = (err as any).toString();
|
||||
console.warn('Proxy failed with error: ', errorMsg);
|
||||
}
|
||||
|
||||
if (!response || errorMsg) {
|
||||
throw error(500, errorMsg);
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
const responseHeaders = new Headers(response.headers);
|
||||
responseHeaders.set('transfer-encoding', 'chunked');
|
||||
responseHeaders.delete('content-encoding');
|
||||
responseHeaders.set('access-control-allow-origin', request.headers.get('origin') ?? '');
|
||||
responseHeaders.set('timing-allow-origin', request.headers.get('origin') ?? '');
|
||||
responseHeaders.delete('access-control-allow-origin');
|
||||
responseHeaders.delete('timing-allow-origin');
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
@@ -149,3 +175,16 @@ export async function PUT({ request, params, locals }) {
|
||||
export async function POST({ request, params, locals }) {
|
||||
return await proxyRequest(request, params.urlToProxy, locals.userId);
|
||||
}
|
||||
|
||||
export async function OPTIONS({ request }) {
|
||||
return new Response('', {
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'Access-Control-Allow-Origin': request.headers.get('origin') || '',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PATCH, PUT, OPTIONS',
|
||||
'Access-Control-Allow-Headers': ALLOWED_HEADERS,
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'Access-Control-Allow-Credentials': 'true'
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { invidiousAuthStore } from '$lib/store';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ url }) {
|
||||
const username = url.searchParams.get('username');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (username && token) {
|
||||
invidiousAuthStore.set({
|
||||
username: username,
|
||||
token: token
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(302, resolve('/', {}));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.15.9"
|
||||
LATEST_VERSION = "1.16.0"
|
||||
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
|
||||
|
||||
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")
|
||||
|
||||
Reference in New Issue
Block a user