@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 130
|
||||
versionName "1.9.13"
|
||||
versionCode 131
|
||||
versionName "1.9.14"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -65,7 +65,11 @@
|
||||
|
||||
|
||||
|
||||
<release version="1.9.13" date="2025-6-19">
|
||||
|
||||
<release version="1.9.14" date="2025-6-23">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.9.14</url>
|
||||
</release>
|
||||
<release version="1.9.13" date="2025-6-19">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.9.13</url>
|
||||
</release>
|
||||
<release version="1.9.12" date="2025-6-19">
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.9.12",
|
||||
"version": "1.9.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.9.12",
|
||||
"version": "1.9.14",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.9.13",
|
||||
"version": "1.9.14",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.9.13",
|
||||
"version": "1.9.14",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// src/lib/requestQueue.ts
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import type { SynciousProgressModel } from './model';
|
||||
import { getVideoProgress } from '.';
|
||||
|
||||
const synciousCacheStore: Writable<SynciousProgressModel[] | null> = writable(null);
|
||||
|
||||
const videoIds: string[] = [];
|
||||
const pendingResolves = new Map<string, (result: SynciousProgressModel | undefined) => void>();
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const DEBOUNCE_MS = 1000;
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
async function processBatches(): Promise<void> {
|
||||
const batches: string[][] = [];
|
||||
|
||||
while (videoIds.length > 0) {
|
||||
batches.push(videoIds.splice(0, BATCH_SIZE));
|
||||
}
|
||||
|
||||
const results: SynciousProgressModel[] = [];
|
||||
|
||||
for (const batch of batches) {
|
||||
const res: SynciousProgressModel[] = await getVideoProgress(batch.join(','));
|
||||
results.push(...res);
|
||||
|
||||
// Resolve pending promises for this batch
|
||||
for (const videoId of batch) {
|
||||
const match = res.find((item) => item.video_id === videoId);
|
||||
const resolve = pendingResolves.get(videoId);
|
||||
if (resolve) {
|
||||
resolve(match);
|
||||
pendingResolves.delete(videoId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synciousCacheStore.set(results);
|
||||
}
|
||||
|
||||
export function queueSyncious(videoId: string): Promise<SynciousProgressModel | undefined> {
|
||||
videoIds.push(videoId);
|
||||
|
||||
const promise = new Promise<SynciousProgressModel | undefined>((resolve) => {
|
||||
pendingResolves.set(videoId, resolve);
|
||||
});
|
||||
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
processBatches().catch((e) => {
|
||||
console.error('Failed to process batches:', e);
|
||||
});
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
export { synciousCacheStore };
|
||||
@@ -64,7 +64,7 @@ export function buildAuthHeaders(): { headers: Record<string, string> } {
|
||||
if (authToken.startsWith('SID=')) {
|
||||
return { headers: { __sid_auth: authToken } };
|
||||
} else {
|
||||
return { headers: { Authorization: `Bearer ${get(authStore)?.token}` } };
|
||||
return { headers: { Authorization: `Bearer ${authToken}` } };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { deleteVideoProgress, getVideoProgress, saveVideoProgress } from '../api';
|
||||
import type { VideoPlay } from '../api/model';
|
||||
import type { PlaylistPageVideo, VideoPlay } from '../api/model';
|
||||
import {
|
||||
authStore,
|
||||
darkModeStore,
|
||||
instanceStore,
|
||||
isAndroidTvStore,
|
||||
playerAndroidLockOrientation,
|
||||
playerAutoplayNextByDefaultStore,
|
||||
playerAutoPlayStore,
|
||||
playerDefaultLanguage,
|
||||
playerDefaultPlaybackSpeed,
|
||||
@@ -34,12 +36,14 @@
|
||||
playerSavePlaybackPositionStore,
|
||||
playerStatisticsByDefault,
|
||||
playerYouTubeJsFallback,
|
||||
playlistSettingsStore,
|
||||
sponsorBlockCategoriesStore,
|
||||
sponsorBlockDisplayToastStore,
|
||||
sponsorBlockStore,
|
||||
sponsorBlockUrlStore,
|
||||
synciousInstanceStore,
|
||||
synciousStore,
|
||||
syncPartyConnectionsStore,
|
||||
themeColorStore
|
||||
} from '../store';
|
||||
import { getDynamicTheme, setStatusBarColor } from '../theme';
|
||||
@@ -47,7 +51,10 @@
|
||||
import { patchYoutubeJs } from '$lib/patches/youtubejs';
|
||||
import { playbackRates } from '$lib/const';
|
||||
import { EndTimeElement } from '$lib/shaka-elements/endTime';
|
||||
import androidTv from '$lib/android/plugins/androidTv';
|
||||
import { loadEntirePlaylist } from '$lib/playlist';
|
||||
import { goto } from '$app/navigation';
|
||||
import { unsafeRandomItem } from '$lib/misc';
|
||||
import type { PlayerEvents } from '$lib/player';
|
||||
|
||||
interface Props {
|
||||
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
|
||||
@@ -65,12 +72,10 @@
|
||||
}: Props = $props();
|
||||
|
||||
let snackBarAlert = $state('');
|
||||
let playerPosSet = false;
|
||||
let originalOrigination: ScreenOrientationResult | undefined;
|
||||
let watchProgressTimeout: NodeJS.Timeout;
|
||||
let playerElementResizeObserver: ResizeObserver | undefined;
|
||||
let showVideoRetry = $state(false);
|
||||
let isAndroidTv = $state(false);
|
||||
|
||||
let player: shaka.Player;
|
||||
let shakaUi: shaka.ui.Overlay;
|
||||
@@ -78,13 +83,25 @@
|
||||
const STORAGE_KEY_VOLUME = 'shaka-preferred-volume';
|
||||
|
||||
async function updateSeekBarTheme() {
|
||||
if (!shakaUi) return;
|
||||
await tick();
|
||||
shakaUi.configure({
|
||||
seekBarColors: {
|
||||
played: (await getDynamicTheme())['--primary']
|
||||
}
|
||||
});
|
||||
|
||||
setChapterMarkers();
|
||||
|
||||
const overflowMenuButton = document.querySelector('.shaka-overflow-menu-button');
|
||||
if (overflowMenuButton) {
|
||||
overflowMenuButton.innerHTML = 'settings';
|
||||
}
|
||||
|
||||
const backToOverflowButton = document.querySelector('.shaka-back-to-overflow-button');
|
||||
if (backToOverflowButton) {
|
||||
backToOverflowButton.innerHTML = 'arrow_back_ios_new';
|
||||
}
|
||||
}
|
||||
|
||||
themeColorStore.subscribe(updateSeekBarTheme);
|
||||
@@ -192,7 +209,7 @@
|
||||
if (
|
||||
Capacitor.getPlatform() === 'android' &&
|
||||
data.video.adaptiveFormats.length > 0 &&
|
||||
!isAndroidTv
|
||||
!$isAndroidTvStore
|
||||
) {
|
||||
const videoFormats = data.video.adaptiveFormats.filter((format) =>
|
||||
format.type.startsWith('video/')
|
||||
@@ -405,8 +422,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
isAndroidTv = (await androidTv.isAndroidTv()).value;
|
||||
|
||||
HttpFetchPlugin.cacheManager.clearCache();
|
||||
|
||||
player = new shaka.Player();
|
||||
@@ -473,13 +488,12 @@
|
||||
'statistics'
|
||||
],
|
||||
playbackRates: playbackRates,
|
||||
enableTooltips: false,
|
||||
seekBarColors: {
|
||||
played: (await getDynamicTheme())['--primary']
|
||||
}
|
||||
enableTooltips: false
|
||||
});
|
||||
|
||||
player.addEventListener('error', async (event) => {
|
||||
updateSeekBarTheme();
|
||||
|
||||
player.addEventListener('error', (event) => {
|
||||
const error = (event as CustomEvent).detail as shaka.util.Error;
|
||||
console.error('Player error:', error);
|
||||
});
|
||||
@@ -500,16 +514,6 @@
|
||||
|
||||
await androidHandleRotate();
|
||||
|
||||
const overflowMenuButton = document.querySelector('.shaka-overflow-menu-button');
|
||||
if (overflowMenuButton) {
|
||||
overflowMenuButton.innerHTML = 'settings';
|
||||
}
|
||||
|
||||
const backToOverflowButton = document.querySelector('.shaka-back-to-overflow-button');
|
||||
if (backToOverflowButton) {
|
||||
backToOverflowButton.innerHTML = 'arrow_back_ios_new';
|
||||
}
|
||||
|
||||
Mousetrap.bind('space', () => {
|
||||
if (!playerElement) return;
|
||||
|
||||
@@ -521,18 +525,20 @@
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('right', () => {
|
||||
if (!playerElement) return;
|
||||
playerElement.currentTime = playerElement.currentTime + 10;
|
||||
return false;
|
||||
});
|
||||
if (!$isAndroidTvStore) {
|
||||
Mousetrap.bind('right', () => {
|
||||
if (!playerElement) return;
|
||||
playerElement.currentTime = playerElement.currentTime + 10;
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('left', () => {
|
||||
if (!playerElement) return;
|
||||
Mousetrap.bind('left', () => {
|
||||
if (!playerElement) return;
|
||||
|
||||
playerElement.currentTime = playerElement.currentTime - 10;
|
||||
return false;
|
||||
});
|
||||
playerElement.currentTime = playerElement.currentTime - 10;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
Mousetrap.bind('c', () => {
|
||||
const isVisible = player.isTextTrackVisible();
|
||||
@@ -576,18 +582,55 @@
|
||||
return false;
|
||||
});
|
||||
|
||||
setChapterMarkers();
|
||||
|
||||
if (isAndroidTv) {
|
||||
Mousetrap.bind('enter', () => {
|
||||
if (playerElement?.paused) {
|
||||
playerElement?.play();
|
||||
} else {
|
||||
playerElement?.pause();
|
||||
playerElement.addEventListener('ended', async () => {
|
||||
if (!data.playlistId) {
|
||||
if ($playerAutoplayNextByDefaultStore) {
|
||||
goto(`/watch/${data.video.recommendedVideos[0].videoId}`);
|
||||
}
|
||||
return false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = await loadEntirePlaylist(data.playlistId);
|
||||
const playlistVideoIds = playlist.videos.map((value) => {
|
||||
return value.videoId;
|
||||
});
|
||||
}
|
||||
|
||||
let goToVideo: PlaylistPageVideo | undefined;
|
||||
|
||||
const shufflePlaylist = $playlistSettingsStore[data.playlistId]?.shuffle ?? false;
|
||||
const loopPlaylist = $playlistSettingsStore[data.playlistId]?.loop ?? false;
|
||||
|
||||
if (shufflePlaylist) {
|
||||
goToVideo = unsafeRandomItem(playlist.videos);
|
||||
} else {
|
||||
const currentVideoIndex = playlistVideoIds.indexOf(data.video.videoId);
|
||||
const newIndex = currentVideoIndex + 1;
|
||||
if (currentVideoIndex !== -1 && newIndex < playlistVideoIds.length) {
|
||||
goToVideo = playlist.videos[newIndex];
|
||||
} else if (loopPlaylist) {
|
||||
// Loop playlist on end
|
||||
goToVideo = playlist.videos[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof goToVideo !== 'undefined') {
|
||||
if ($syncPartyConnectionsStore) {
|
||||
$syncPartyConnectionsStore.forEach((conn) => {
|
||||
if (typeof goToVideo === 'undefined') return;
|
||||
|
||||
conn.send({
|
||||
events: [
|
||||
{ type: 'change-video', videoId: goToVideo.videoId },
|
||||
{ type: 'playlist', playlistId: data.playlistId }
|
||||
]
|
||||
} as PlayerEvents);
|
||||
});
|
||||
}
|
||||
|
||||
goto(`/watch/${goToVideo.videoId}?playlist=${data.playlistId}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await loadVideo();
|
||||
@@ -602,9 +645,6 @@
|
||||
});
|
||||
|
||||
async function loadPlayerPos() {
|
||||
if (playerPosSet) return;
|
||||
playerPosSet = true;
|
||||
|
||||
if (loadTimeFromUrl($page)) return;
|
||||
|
||||
let toSetTime = 0;
|
||||
@@ -628,11 +668,11 @@
|
||||
}
|
||||
|
||||
function savePlayerPos() {
|
||||
if (data.video.hlsUrl) return;
|
||||
if (data.video.liveNow) return;
|
||||
|
||||
const synciousEnabled = $synciousStore && $synciousInstanceStore && $authStore;
|
||||
|
||||
if ($playerSavePlaybackPositionStore && player && playerElement && playerElement.currentTime) {
|
||||
if ($playerSavePlaybackPositionStore && playerElement) {
|
||||
if (
|
||||
playerElement.currentTime < playerElement.duration - 10 &&
|
||||
playerElement.currentTime > 10
|
||||
@@ -657,7 +697,7 @@
|
||||
}
|
||||
|
||||
onDestroy(async () => {
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
if (Capacitor.getPlatform() === 'android' && !$isAndroidTvStore) {
|
||||
if (originalOrigination) {
|
||||
await StatusBar.setOverlaysWebView({ overlay: false });
|
||||
await StatusBar.show();
|
||||
@@ -667,7 +707,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
Mousetrap.unbind(['enter', 'left', 'right']);
|
||||
Mousetrap.unbind(['left', 'right', 'space', 'c', 'f', 'shift+left', 'shift+right']);
|
||||
|
||||
if (watchProgressTimeout) {
|
||||
clearTimeout(watchProgressTimeout);
|
||||
@@ -677,7 +717,6 @@
|
||||
savePlayerPos();
|
||||
} catch (error) {}
|
||||
|
||||
playerPosSet = false;
|
||||
HttpFetchPlugin.cacheManager.clearCache();
|
||||
|
||||
if (playerElementResizeObserver) {
|
||||
@@ -702,8 +741,8 @@
|
||||
<div
|
||||
id="shaka-container"
|
||||
class="player-theme"
|
||||
class:contain-video={!isAndroidTv}
|
||||
class:tv-contain-video={isAndroidTv}
|
||||
class:contain-video={!$isAndroidTvStore}
|
||||
class:tv-contain-video={$isAndroidTvStore}
|
||||
data-shaka-player-container
|
||||
class:hide={showVideoRetry}
|
||||
>
|
||||
@@ -711,9 +750,9 @@
|
||||
controls={false}
|
||||
autoplay={$playerAutoPlayStore}
|
||||
id="player"
|
||||
poster={getBestThumbnail(data.video.videoThumbnails, 1251, 781)}
|
||||
poster={getBestThumbnail(data.video.videoThumbnails, 9999, 9999)}
|
||||
></video>
|
||||
{#if isEmbed}
|
||||
{#if isEmbed && !isAndroidTvStore}
|
||||
<div class="chip blur embed" style="position: absolute;top: 10px;left: 10px;font-size: 18px;">
|
||||
{data.video.title}
|
||||
</div>
|
||||
@@ -769,6 +808,11 @@
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
video[poster] {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
video {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { getDeArrow, getThumbnail, getVideoProgress } from '../api';
|
||||
import { getDeArrow, getThumbnail } from '../api';
|
||||
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
|
||||
import { insecureRequestImageHandler, truncate } from '../misc';
|
||||
import type { PlayerEvents } from '../player';
|
||||
@@ -21,6 +21,7 @@
|
||||
synciousStore
|
||||
} from '../store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { queueSyncious } from '$lib/api/apiExtended';
|
||||
|
||||
interface Props {
|
||||
video: VideoBase | Video | Notification | PlaylistPageVideo;
|
||||
@@ -32,7 +33,9 @@
|
||||
|
||||
let placeholderHeight: number = $state(0);
|
||||
|
||||
let watchUrl = new URL(`${location.origin}/watch/${video.videoId}`);
|
||||
let watchUrl = new URL(
|
||||
`${location.origin}/${$isAndroidTvStore ? 'tv' : 'watch'}/${video.videoId}`
|
||||
);
|
||||
|
||||
if (playlistId !== '') {
|
||||
watchUrl.searchParams.set('playlist', playlistId);
|
||||
@@ -109,7 +112,7 @@
|
||||
|
||||
if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) {
|
||||
try {
|
||||
progress = (await getVideoProgress(video.videoId, { priority: 'low' }))[0].time.toString();
|
||||
progress = (await queueSyncious(video.videoId))?.time?.toString() ?? undefined;
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
@@ -138,7 +141,7 @@
|
||||
|
||||
function calcThumbnailPlaceholderHeight() {
|
||||
if ($isAndroidTvStore) {
|
||||
placeholderHeight = innerWidth / 3;
|
||||
placeholderHeight = 100;
|
||||
return;
|
||||
}
|
||||
if (!sideways) {
|
||||
@@ -152,7 +155,7 @@
|
||||
placeholderHeight = innerWidth / 12;
|
||||
}
|
||||
} else {
|
||||
placeholderHeight = 115;
|
||||
placeholderHeight = 100;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -162,9 +165,7 @@
|
||||
tabindex="0"
|
||||
role="button"
|
||||
onclick={async () => {
|
||||
if ($isAndroidTvStore) {
|
||||
goto(`${location.origin}/embed/${video.videoId}`);
|
||||
}
|
||||
goto(watchUrl);
|
||||
}}
|
||||
>
|
||||
<div id="thumbnail-container">
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { deleteUnsubscribe, postSubscribe } from '$lib/api';
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
|
||||
import { truncate } from '$lib/misc';
|
||||
import { authStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
let { video, subscribed = $bindable(false) }: { video: VideoPlay; subscribed?: boolean } =
|
||||
$props();
|
||||
|
||||
async function toggleSubscribed() {
|
||||
if (subscribed) {
|
||||
await deleteUnsubscribe(video.authorId);
|
||||
} else {
|
||||
await postSubscribe(video.authorId);
|
||||
}
|
||||
|
||||
subscribed = !subscribed;
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<a href={`/channel/${video.authorId}`}>
|
||||
<nav style="gap: 0.5em;">
|
||||
{#if !$interfaceLowBandwidthMode}
|
||||
<img
|
||||
loading="lazy"
|
||||
class="circle large"
|
||||
src={proxyGoogleImage(getBestThumbnail(video.authorThumbnails))}
|
||||
alt="Channel profile"
|
||||
/>
|
||||
{/if}
|
||||
<div>
|
||||
<p style="margin: 0;" class="bold">
|
||||
{$isAndroidTvStore ? video.author : truncate(video.author, 16)}
|
||||
</p>
|
||||
<p style="margin: 0;">{video.subCountText}</p>
|
||||
</div>
|
||||
</nav>
|
||||
</a>
|
||||
{#if $authStore}
|
||||
<button
|
||||
onclick={toggleSubscribed}
|
||||
class:inverse-surface={!subscribed}
|
||||
class:border={subscribed}
|
||||
>
|
||||
{#if !subscribed}
|
||||
{$_('subscribe')}
|
||||
{:else}
|
||||
{$_('unsubscribe')}
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="inverse-surface" disabled>
|
||||
{$_('subscribe')}
|
||||
<div class="tooltip">
|
||||
{$_('loginRequired')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { numberWithCommas } from '$lib/numbers';
|
||||
import { _ } from '$lib/i18n';
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
import { onMount } from 'svelte';
|
||||
import { expandSummery } from '$lib/misc';
|
||||
import { interfaceAutoExpandDesc } from '$lib/store';
|
||||
|
||||
let { video, description }: { video: VideoPlay; description: string } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if ($interfaceAutoExpandDesc) {
|
||||
expandSummery('description');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<details>
|
||||
<summary id="description" class="bold none">
|
||||
<nav>
|
||||
<div class="max">
|
||||
{numberWithCommas(video.viewCount)}
|
||||
{$_('views')} • {video.publishedText}
|
||||
</div>
|
||||
<i>expand_more</i>
|
||||
</nav>
|
||||
</summary>
|
||||
<div class="space"></div>
|
||||
<div class="medium scroll">
|
||||
<div style="white-space: pre-line; overflow-wrap: break-word;">
|
||||
{@html description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="scroll">
|
||||
{#if video.keywords}
|
||||
{#each video.keywords as keyword}
|
||||
<a href={`/search/${encodeURIComponent(keyword)}`} class="chip">{keyword}</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</nav>
|
||||
</details>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { ReturnYTDislikes, VideoPlay } from '$lib/api/model';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
|
||||
let {
|
||||
video,
|
||||
returnYTDislikes
|
||||
}: { video: VideoPlay; returnYTDislikes?: Promise<ReturnYTDislikes> | null } = $props();
|
||||
</script>
|
||||
|
||||
{#await returnYTDislikes then returnYTDislikes}
|
||||
{#if returnYTDislikes}
|
||||
<nav class="no-space">
|
||||
<button style="cursor: default;" class="border left-round">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(returnYTDislikes.likes)}</span>
|
||||
</button>
|
||||
<button style="cursor: default;margin-right: 0.5em;" class="border right-round">
|
||||
<i class="small">thumb_down_alt</i>
|
||||
<span>{cleanNumber(returnYTDislikes.dislikes)}</span>
|
||||
</button>
|
||||
</nav>
|
||||
{:else}
|
||||
<button style="cursor: default;margin-right: 0.5em;" class="border">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(video.likeCount)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/await}
|
||||
@@ -5,6 +5,8 @@
|
||||
"loadMore": "Load more",
|
||||
"views": "views",
|
||||
"login": "Login",
|
||||
"recommendedVideos": "Recommended Videos",
|
||||
"playlistVideos": "Playlist Videos",
|
||||
"invidiousLogin": "Please log in with your Invidious account",
|
||||
"invalidInstance": "Please verify the URL. If it's correct, the instance may be down or may not support third-party clients.",
|
||||
"invidiousBlockWarning": "Invidious is currently being blocked by Google. If videos aren't loading for this instance, please use this instance on Materialious on {android} or {desktop} to get around this with local video fallback.",
|
||||
|
||||
@@ -106,3 +106,10 @@ export function excludeDuplicateFeeds(currentItems: feedItems, newItems: feedIte
|
||||
|
||||
return [...nonDuplicatedNewItems, ...currentItems];
|
||||
}
|
||||
|
||||
export function expandSummery(id: string) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
|
||||
let dashUri: string | undefined;
|
||||
|
||||
if (video.streaming_data) {
|
||||
video.streaming_data.adaptive_formats = video.streaming_data.adaptive_formats.map((format) => {
|
||||
video.streaming_data.adaptive_formats.forEach((format) => {
|
||||
const formatKey = fromFormat(format) || '';
|
||||
format.url = `https://sabr?___key=${formatKey}`;
|
||||
format.signature_cipher = undefined;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { getPlaylist } from './api';
|
||||
import type { PlaylistPage, PlaylistPageVideo } from './api/model';
|
||||
import { playlistCacheStore } from './store';
|
||||
|
||||
export async function loadEntirePlaylist(
|
||||
playlistId: string
|
||||
): Promise<{ videos: PlaylistPageVideo[]; info: PlaylistPage }> {
|
||||
const cachedPlaylists = get(playlistCacheStore);
|
||||
if (playlistId in cachedPlaylists) {
|
||||
console.log('Using cache');
|
||||
return cachedPlaylists[playlistId];
|
||||
}
|
||||
|
||||
let playlistVideos: PlaylistPageVideo[] = [];
|
||||
let playlist: PlaylistPage | undefined = undefined;
|
||||
|
||||
const ignoreVideos: string[] = [];
|
||||
|
||||
for (let page = 1; page < Infinity; page++) {
|
||||
const newPlaylist = await getPlaylist(playlistId, page);
|
||||
if (page === 1) {
|
||||
playlist = newPlaylist;
|
||||
}
|
||||
let newVideos = newPlaylist.videos;
|
||||
if (newVideos.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
newVideos = newVideos.filter((playlistVideo) => {
|
||||
return playlistVideo.lengthSeconds > 0 && !ignoreVideos.includes(playlistVideo.videoId);
|
||||
});
|
||||
|
||||
newVideos.forEach((playlistVideo) => {
|
||||
ignoreVideos.push(playlistVideo.videoId);
|
||||
});
|
||||
|
||||
playlistVideos = [...playlistVideos, ...newVideos].sort(
|
||||
(a: PlaylistPageVideo, b: PlaylistPageVideo) => {
|
||||
return a.index < b.index ? -1 : 1;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof playlist === 'undefined') {
|
||||
throw new Error('Unable to fetch playlist');
|
||||
}
|
||||
|
||||
const combined = { videos: playlistVideos, info: playlist };
|
||||
playlistCacheStore.set({ [playlistId]: combined });
|
||||
|
||||
return combined;
|
||||
}
|
||||
@@ -4,7 +4,15 @@ import type { DataConnection } from 'peerjs';
|
||||
import { persisted } from 'svelte-persisted-store';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import type { TitleCase } from './letterCasing';
|
||||
import type { Channel, HashTag, Playlist, PlaylistPageVideo, Video, VideoBase } from './api/model';
|
||||
import type {
|
||||
Channel,
|
||||
HashTag,
|
||||
Playlist,
|
||||
PlaylistPage,
|
||||
PlaylistPageVideo,
|
||||
Video,
|
||||
VideoBase
|
||||
} from './api/model';
|
||||
import { ensureNoTrailingSlash } from './misc';
|
||||
|
||||
function platformDependentDefault(givenValue: any, defaultValue: any): any {
|
||||
@@ -130,5 +138,8 @@ export const searchCacheStore: Writable<{
|
||||
[searchTypeAndQuery: string]: (Channel | Video | Playlist | HashTag)[];
|
||||
}> = writable({});
|
||||
export const feedLastItemId: Writable<string | undefined> = writable(undefined);
|
||||
export const playlistCacheStore: Writable<{
|
||||
[playlistId: string]: { videos: PlaylistPageVideo[]; info: PlaylistPage };
|
||||
}> = writable({});
|
||||
|
||||
export const isAndroidTvStore: Writable<boolean> = writable(false);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
amSubscribed,
|
||||
getComments,
|
||||
getDislikes,
|
||||
getPersonalPlaylists,
|
||||
getVideo,
|
||||
postHistory
|
||||
} from '$lib/api/index';
|
||||
import { loadEntirePlaylist } from '$lib/playlist';
|
||||
import {
|
||||
authStore,
|
||||
playerProxyVideosStore,
|
||||
returnYTDislikesInstanceStore,
|
||||
returnYtDislikesStore
|
||||
} from '$lib/store';
|
||||
import { phaseDescription } from '$lib/timestamps';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export async function getWatchDetails(videoId: string, url: URL) {
|
||||
let video;
|
||||
try {
|
||||
video = await getVideo(videoId, get(playerProxyVideosStore), { priority: 'high' });
|
||||
} catch (errorMessage: any) {
|
||||
error(500, errorMessage);
|
||||
}
|
||||
|
||||
let personalPlaylists;
|
||||
if (get(authStore)) {
|
||||
postHistory(video.videoId);
|
||||
personalPlaylists = getPersonalPlaylists({ priority: 'low' });
|
||||
} else {
|
||||
personalPlaylists = null;
|
||||
}
|
||||
|
||||
let comments;
|
||||
try {
|
||||
comments = video.liveNow
|
||||
? null
|
||||
: getComments(videoId, { sort_by: 'top', source: 'youtube' }, { priority: 'low' });
|
||||
} catch {
|
||||
comments = null;
|
||||
}
|
||||
|
||||
let returnYTDislikes;
|
||||
const returnYTDislikesInstance = get(returnYTDislikesInstanceStore);
|
||||
if (returnYTDislikesInstance && returnYTDislikesInstance !== '') {
|
||||
try {
|
||||
returnYTDislikes = get(returnYtDislikesStore)
|
||||
? getDislikes(videoId, { priority: 'low' })
|
||||
: null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const playlistId = url.searchParams.get('playlist');
|
||||
if (playlistId) {
|
||||
await loadEntirePlaylist(playlistId);
|
||||
}
|
||||
|
||||
return {
|
||||
video: video,
|
||||
content: phaseDescription(video.videoId, video.descriptionHtml, video.fallbackPatch),
|
||||
playlistId: playlistId,
|
||||
streamed: {
|
||||
personalPlaylists: personalPlaylists,
|
||||
returnYTDislikes: returnYTDislikes,
|
||||
comments: comments,
|
||||
subscribed: amSubscribed(video.authorId)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -173,6 +173,19 @@
|
||||
onMount(async () => {
|
||||
ui();
|
||||
|
||||
let themeHex = get(themeColorStore);
|
||||
if (themeHex) {
|
||||
await ui('theme', themeHex);
|
||||
} else if (Capacitor.getPlatform() === 'android') {
|
||||
if (!themeHex) {
|
||||
try {
|
||||
const colorPalette = await colorTheme.getColorPalette();
|
||||
themeHex = convertToHexColorCode(colorPalette.primary);
|
||||
await ui('theme', themeHex);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
$isAndroidTvStore = (await androidTv.isAndroidTv()).value;
|
||||
|
||||
if ($isAndroidTvStore) {
|
||||
@@ -192,19 +205,6 @@
|
||||
// So user preferences overwrite instance preferences.
|
||||
bookmarkletLoadFromUrl();
|
||||
|
||||
let themeHex = get(themeColorStore);
|
||||
if (themeHex) {
|
||||
await ui('theme', themeHex);
|
||||
} else if (Capacitor.getPlatform() === 'android') {
|
||||
if (!themeHex) {
|
||||
try {
|
||||
const colorPalette = await colorTheme.getColorPalette();
|
||||
themeHex = convertToHexColorCode(colorPalette.primary);
|
||||
await ui('theme', themeHex);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
await setStatusBarColor();
|
||||
|
||||
setTheme();
|
||||
|
||||
@@ -1,56 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { getPlaylist } from '$lib/api/index';
|
||||
import type { PlaylistPageVideo } from '$lib/api/model';
|
||||
import VideoList from '$lib/components/VideoList.svelte';
|
||||
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 { onMount } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let videos: PlaylistPageVideo[] | undefined = $state();
|
||||
|
||||
if (data.playlist.videos.length > 0) {
|
||||
videos = data.playlist.videos
|
||||
.sort((a: PlaylistPageVideo, b: PlaylistPageVideo) => {
|
||||
return a.index < b.index ? -1 : 1;
|
||||
})
|
||||
.filter((playlistVideo) => {
|
||||
return playlistVideo.lengthSeconds > 0;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
for (let page = 1; page++; ) {
|
||||
const newVideos = (await getPlaylist(data.playlist.playlistId, page)).videos;
|
||||
if (newVideos.length === 0) {
|
||||
break;
|
||||
}
|
||||
videos = [...(videos as PlaylistPageVideo[]), ...newVideos].sort(
|
||||
(a: PlaylistPageVideo, b: PlaylistPageVideo) => {
|
||||
return a.index < b.index ? -1 : 1;
|
||||
}
|
||||
);
|
||||
videos = videos.filter((playlistVideo) => {
|
||||
return playlistVideo.lengthSeconds > 0;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space"></div>
|
||||
|
||||
<article>
|
||||
{#if videos}
|
||||
{#if data.playlist.videos}
|
||||
<nav>
|
||||
<a
|
||||
href={!$isAndroidTvStore
|
||||
? `/watch/${videos[0].videoId}?playlist=${data.playlist.playlistId}`
|
||||
: `/embed/${videos[0].videoId}?playlist=${data.playlist.playlistId}`}
|
||||
? `/watch/${data.playlist.videos[0].videoId}?playlist=${data.playlist.info.playlistId}`
|
||||
: `/tv/${data.playlist.videos[0].videoId}?playlist=${data.playlist.info.playlistId}`}
|
||||
class="button circle extra no-margin"
|
||||
>
|
||||
<i>play_arrow</i>
|
||||
@@ -61,10 +29,12 @@
|
||||
|
||||
<a
|
||||
href={!$isAndroidTvStore
|
||||
? `/watch/${unsafeRandomItem(videos).videoId}?playlist=${data.playlist.playlistId}`
|
||||
: `/embed/${unsafeRandomItem(videos).videoId}?playlist=${data.playlist.playlistId}`}
|
||||
? `/watch/${unsafeRandomItem(data.playlist.videos).videoId}?playlist=${data.playlist.info.playlistId}`
|
||||
: `/tv/${unsafeRandomItem(data.playlist.videos).videoId}?playlist=${data.playlist.info.playlistId}`}
|
||||
onclick={() =>
|
||||
playlistSettingsStore.set({ [data.playlist.playlistId]: { shuffle: true, loop: false } })}
|
||||
playlistSettingsStore.set({
|
||||
[data.playlist.info.playlistId]: { shuffle: true, loop: false }
|
||||
})}
|
||||
class="button circle extra no-margin border"
|
||||
>
|
||||
<i>shuffle</i>
|
||||
@@ -74,16 +44,16 @@
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
<h3>{data.playlist.title}</h3>
|
||||
<h3>{data.playlist.info.title}</h3>
|
||||
<p>
|
||||
{cleanNumber(data.playlist.viewCount)}
|
||||
{$_('views')} • {data.playlist.videoCount}
|
||||
{cleanNumber(data.playlist.info.viewCount)}
|
||||
{$_('views')} • {data.playlist.info.videoCount}
|
||||
{$_('videos')}
|
||||
</p>
|
||||
<div class="divider" style="margin-bottom: 1em;"></div>
|
||||
|
||||
<article style="max-height: 200px;" class="scroll no-padding no-elevate no-round">
|
||||
<p style="white-space: pre-line;word-wrap: break-word;">{data.playlist.description}</p>
|
||||
<p style="white-space: pre-line;word-wrap: break-word;">{data.playlist.info.description}</p>
|
||||
</article>
|
||||
|
||||
<div class="space"></div>
|
||||
@@ -109,7 +79,7 @@
|
||||
role="presentation"
|
||||
onclick={async () => {
|
||||
await Clipboard.write({
|
||||
string: `https://www.youtube.com/playlist?list=${data.playlist.playlistId}`
|
||||
string: `https://www.youtube.com/playlist?list=${data.playlist.info.playlistId}`
|
||||
});
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
@@ -120,6 +90,10 @@
|
||||
</button>
|
||||
</article>
|
||||
|
||||
{#if videos}
|
||||
<VideoList {videos} playlistAuthor={data.playlist.author} playlistId={data.playlist.playlistId} />
|
||||
{#if data.playlist.videos}
|
||||
<VideoList
|
||||
videos={data.playlist.videos}
|
||||
playlistAuthor={data.playlist.info.author}
|
||||
playlistId={data.playlist.info.playlistId}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getPlaylist } from '$lib/api/index';
|
||||
import { loadEntirePlaylist } from '$lib/playlist.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export async function load({ params }) {
|
||||
let playlist;
|
||||
|
||||
try {
|
||||
playlist = await getPlaylist(params.slug);
|
||||
playlist = await loadEntirePlaylist(params.slug);
|
||||
} catch (errorMessage: any) {
|
||||
error(500, errorMessage);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import {
|
||||
addPlaylistVideo,
|
||||
deleteUnsubscribe,
|
||||
getComments,
|
||||
getPersonalPlaylists,
|
||||
getPlaylist,
|
||||
postSubscribe,
|
||||
removePlaylistVideo
|
||||
} from '$lib/api/index';
|
||||
import type { Comments, PlaylistPage, PlaylistPageVideo } from '$lib/api/model';
|
||||
import type { Comments, PlaylistPage } from '$lib/api/model';
|
||||
import Comment from '$lib/components/Comment.svelte';
|
||||
import Player from '$lib/components/Player.svelte';
|
||||
import ShareVideo from '$lib/components/ShareVideo.svelte';
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import Transcript from '$lib/components/Transcript.svelte';
|
||||
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
|
||||
import { getBestThumbnail } from '$lib/images';
|
||||
import { letterCase } from '$lib/letterCasing';
|
||||
import { truncate, unsafeRandomItem } from '$lib/misc';
|
||||
import { cleanNumber, humanizeSeconds, numberWithCommas } from '$lib/numbers';
|
||||
import type { PlayerEvents } from '$lib/player.js';
|
||||
import {
|
||||
authStore,
|
||||
interfaceAutoExpandChapters,
|
||||
interfaceAutoExpandComments,
|
||||
interfaceAutoExpandDesc,
|
||||
interfaceLowBandwidthMode,
|
||||
playerAutoplayNextByDefaultStore,
|
||||
playerTheatreModeByDefaultStore,
|
||||
playlistCacheStore,
|
||||
playlistSettingsStore,
|
||||
syncPartyConnectionsStore,
|
||||
syncPartyPeerStore
|
||||
@@ -38,6 +31,11 @@
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { loadEntirePlaylist } from '$lib/playlist.js';
|
||||
import Author from '$lib/components/Watch/Author.svelte';
|
||||
import Description from '$lib/components/Watch/Description.svelte';
|
||||
import { expandSummery } from '$lib/misc.js';
|
||||
import LikesDislikes from '$lib/components/Watch/LikesDislikes.svelte';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
@@ -49,16 +47,11 @@
|
||||
});
|
||||
|
||||
let subscribed: boolean = $state(false);
|
||||
data.streamed.subscribed.then((streamedIsSubbed) => {
|
||||
subscribed = streamedIsSubbed;
|
||||
});
|
||||
data.streamed.subscribed.then((isSubbed) => (subscribed = isSubbed));
|
||||
|
||||
let personalPlaylists: PlaylistPage[] | null = $state(null);
|
||||
data.streamed.personalPlaylists?.then((streamPlaylists) => (personalPlaylists = streamPlaylists));
|
||||
|
||||
let playlistVideos: PlaylistPageVideo[] = $state([]);
|
||||
let playlist: PlaylistPage | null = $state(null);
|
||||
|
||||
let loopPlaylist: boolean = $state(false);
|
||||
let shufflePlaylist: boolean = $state(false);
|
||||
|
||||
@@ -72,15 +65,6 @@
|
||||
|
||||
let playerCurrentTime: number = $state(0);
|
||||
|
||||
let currentChapterStartTime: number = $state(0);
|
||||
|
||||
function expandSummery(id: string) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.click();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($interfaceAutoExpandComments && comments) {
|
||||
expandSummery('comment-section');
|
||||
@@ -135,7 +119,7 @@
|
||||
event.playlistId !== data.playlistId
|
||||
) {
|
||||
data.playlistId = event.playlistId;
|
||||
await loadPlaylist(event.playlistId);
|
||||
await loadEntirePlaylist(event.playlistId);
|
||||
goToCurrentPlaylistItem();
|
||||
}
|
||||
});
|
||||
@@ -227,8 +211,8 @@
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if ($interfaceAutoExpandDesc) {
|
||||
expandSummery('description');
|
||||
if (data.playlistId) {
|
||||
await goToCurrentPlaylistItem();
|
||||
}
|
||||
|
||||
if ($interfaceAutoExpandChapters) {
|
||||
@@ -245,70 +229,8 @@
|
||||
playerElement.addEventListener('timeupdate', () => {
|
||||
if (!playerElement) return;
|
||||
playerCurrentTime = playerElement.currentTime;
|
||||
|
||||
if (data.content.timestamps) {
|
||||
for (const timestamp of data.content.timestamps) {
|
||||
if (timestamp.time >= playerCurrentTime && timestamp.endTime <= playerCurrentTime) {
|
||||
currentChapterStartTime = timestamp.time;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
playerElement.addEventListener('ended', async () => {
|
||||
if (playlistVideos.length === 0) {
|
||||
if ($playerAutoplayNextByDefaultStore) {
|
||||
goto(`/watch/${data.video.recommendedVideos[0].videoId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await goToCurrentPlaylistItem();
|
||||
|
||||
const playlistVideoIds = playlistVideos.map((value) => {
|
||||
return value.videoId;
|
||||
});
|
||||
|
||||
let goToVideo: PlaylistPageVideo | undefined;
|
||||
|
||||
if (shufflePlaylist) {
|
||||
goToVideo = unsafeRandomItem(playlistVideos);
|
||||
} else {
|
||||
const currentVideoIndex = playlistVideoIds.indexOf(data.video.videoId);
|
||||
const newIndex = currentVideoIndex + 1;
|
||||
if (currentVideoIndex !== -1 && newIndex < playlistVideoIds.length) {
|
||||
goToVideo = playlistVideos[newIndex];
|
||||
} else if (loopPlaylist) {
|
||||
// Loop playlist on end
|
||||
goToVideo = playlistVideos[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof goToVideo !== 'undefined') {
|
||||
if ($syncPartyConnectionsStore) {
|
||||
$syncPartyConnectionsStore.forEach((conn) => {
|
||||
if (typeof goToVideo === 'undefined') return;
|
||||
|
||||
conn.send({
|
||||
events: [
|
||||
{ type: 'change-video', videoId: goToVideo.videoId },
|
||||
{ type: 'playlist', playlistId: data.playlistId }
|
||||
]
|
||||
} as PlayerEvents);
|
||||
});
|
||||
}
|
||||
|
||||
goto(`/watch/${goToVideo.videoId}?playlist=${data.playlistId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.playlistId) return;
|
||||
|
||||
await loadPlaylist(data.playlistId);
|
||||
|
||||
await goToCurrentPlaylistItem();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -320,28 +242,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function loadPlaylist(playlistId: string) {
|
||||
for (let page = 1; page < Infinity; page++) {
|
||||
const newPlaylist = await getPlaylist(playlistId, page);
|
||||
if (page === 1) {
|
||||
playlist = newPlaylist;
|
||||
}
|
||||
const newVideos = newPlaylist.videos;
|
||||
if (newVideos.length === 0) {
|
||||
break;
|
||||
}
|
||||
playlistVideos = [...playlistVideos, ...newVideos].sort(
|
||||
(a: PlaylistPageVideo, b: PlaylistPageVideo) => {
|
||||
return a.index < b.index ? -1 : 1;
|
||||
}
|
||||
);
|
||||
|
||||
playlistVideos = playlistVideos.filter((playlistVideo) => {
|
||||
return playlistVideo.lengthSeconds > 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function goToCurrentPlaylistItem() {
|
||||
await tick();
|
||||
const playlistCurrentVideo = document.getElementById(data.video.videoId);
|
||||
@@ -393,16 +293,6 @@
|
||||
comments.comments = [...comments.comments, ...loadedComments.comments];
|
||||
}
|
||||
|
||||
async function toggleSubscribed() {
|
||||
if (subscribed) {
|
||||
await deleteUnsubscribe(data.video.authorId);
|
||||
} else {
|
||||
await postSubscribe(data.video.authorId);
|
||||
}
|
||||
|
||||
subscribed = !subscribed;
|
||||
}
|
||||
|
||||
function toggleTheatreMode() {
|
||||
theatreMode = !theatreMode;
|
||||
}
|
||||
@@ -440,65 +330,10 @@
|
||||
|
||||
<div class="grid no-padding">
|
||||
<div class="s12 m12 l5">
|
||||
<nav>
|
||||
<a href={`/channel/${data.video.authorId}`}>
|
||||
<nav style="gap: 0.5em;">
|
||||
{#if !$interfaceLowBandwidthMode}
|
||||
<img
|
||||
loading="lazy"
|
||||
class="circle large"
|
||||
src={proxyGoogleImage(getBestThumbnail(data.video.authorThumbnails))}
|
||||
alt="Channel profile"
|
||||
/>
|
||||
{/if}
|
||||
<div>
|
||||
<p style="margin: 0;" class="bold">{truncate(data.video.author, 16)}</p>
|
||||
<p style="margin: 0;">{data.video.subCountText}</p>
|
||||
</div>
|
||||
</nav>
|
||||
</a>
|
||||
{#if $authStore}
|
||||
<button
|
||||
onclick={toggleSubscribed}
|
||||
class:inverse-surface={!subscribed}
|
||||
class:border={subscribed}
|
||||
>
|
||||
{#if !subscribed}
|
||||
{$_('subscribe')}
|
||||
{:else}
|
||||
{$_('unsubscribe')}
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="inverse-surface" disabled>
|
||||
{$_('subscribe')}
|
||||
<div class="tooltip">
|
||||
{$_('loginRequired')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
<Author video={data.video} bind:subscribed />
|
||||
</div>
|
||||
<div class="s12 m12 l7 video-actions">
|
||||
{#await data.streamed.returnYTDislikes then returnYTDislikes}
|
||||
{#if returnYTDislikes}
|
||||
<nav class="no-space">
|
||||
<button style="cursor: default;" class="border left-round">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(returnYTDislikes.likes)}</span>
|
||||
</button>
|
||||
<button style="cursor: default;margin-right: 0.5em;" class="border right-round">
|
||||
<i class="small">thumb_down_alt</i>
|
||||
<span>{cleanNumber(returnYTDislikes.dislikes)}</span>
|
||||
</button>
|
||||
</nav>
|
||||
{:else}
|
||||
<button style="cursor: default;margin-right: 0.5em;" class="border">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(data.video.likeCount)}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/await}
|
||||
<LikesDislikes video={data.video} returnYTDislikes={data.streamed.returnYTDislikes} />
|
||||
|
||||
<div>
|
||||
<button onclick={toggleTheatreMode} class="m l" class:border={!theatreMode}>
|
||||
@@ -583,31 +418,7 @@
|
||||
</div>
|
||||
|
||||
<article>
|
||||
<details>
|
||||
<summary id="description" class="bold none">
|
||||
<nav>
|
||||
<div class="max">
|
||||
{numberWithCommas(data.video.viewCount)}
|
||||
{$_('views')} • {data.video.publishedText}
|
||||
</div>
|
||||
<i>expand_more</i>
|
||||
</nav>
|
||||
</summary>
|
||||
<div class="space"></div>
|
||||
<div class="medium scroll">
|
||||
<div style="white-space: pre-line; overflow-wrap: break-word;">
|
||||
{@html data.content.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="scroll">
|
||||
{#if data.video.keywords}
|
||||
{#each data.video.keywords as keyword}
|
||||
<a href={`/search/${encodeURIComponent(keyword)}`} class="chip">{keyword}</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</nav>
|
||||
</details>
|
||||
<Description video={data.video} description={data.content.description} />
|
||||
</article>
|
||||
|
||||
{#if data.content.timestamps.length > 0}
|
||||
@@ -682,28 +493,30 @@
|
||||
{#if showTranscript && playerElement}
|
||||
<Transcript video={data.video} bind:playerElement />
|
||||
{/if}
|
||||
{#if playlist}
|
||||
{#if data.playlistId && data.playlistId in $playlistCacheStore}
|
||||
<article
|
||||
style="height: 85vh; position: relative;"
|
||||
style="height: 85vh; position: relative;scrollbar-width: none;"
|
||||
id="playlist"
|
||||
class="scroll no-padding surface-container-high"
|
||||
>
|
||||
<article class="no-elevate" style="position: sticky; top: 0; z-index: 3;">
|
||||
<h6>{playlist.title}</h6>
|
||||
<h6>{$playlistCacheStore[data.playlistId].info.title}</h6>
|
||||
<p>
|
||||
{cleanNumber(playlist.viewCount)}
|
||||
{$_('views')} • {playlist.videoCount}
|
||||
{cleanNumber($playlistCacheStore[data.playlistId].info.viewCount)}
|
||||
{$_('views')} • {$playlistCacheStore[data.playlistId].info.videoCount}
|
||||
{$_('videos')}
|
||||
</p>
|
||||
<p><a href={`/channel/${playlist.authorId}`}>{playlist.author}</a></p>
|
||||
<p>
|
||||
<a href={`/channel/${$playlistCacheStore[data.playlistId].info.authorId}`}
|
||||
>{$playlistCacheStore[data.playlistId].info.author}</a
|
||||
>
|
||||
</p>
|
||||
<nav>
|
||||
<button
|
||||
onclick={() => {
|
||||
if (!playlist) return;
|
||||
|
||||
loopPlaylist = !loopPlaylist;
|
||||
playlistSettingsStore.set({
|
||||
[playlist.playlistId]: { loop: loopPlaylist, shuffle: shufflePlaylist }
|
||||
[data.playlistId as string]: { loop: loopPlaylist, shuffle: shufflePlaylist }
|
||||
});
|
||||
}}
|
||||
class="circle"
|
||||
@@ -716,11 +529,9 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
if (!playlist) return;
|
||||
|
||||
shufflePlaylist = !shufflePlaylist;
|
||||
playlistSettingsStore.set({
|
||||
[playlist.playlistId]: { loop: loopPlaylist, shuffle: shufflePlaylist }
|
||||
[data.playlistId as string]: { loop: loopPlaylist, shuffle: shufflePlaylist }
|
||||
});
|
||||
}}
|
||||
class="circle"
|
||||
@@ -739,7 +550,7 @@
|
||||
|
||||
<div class="space"></div>
|
||||
|
||||
{#each playlistVideos as playlistVideo}
|
||||
{#each $playlistCacheStore[data.playlistId].videos as playlistVideo}
|
||||
<article
|
||||
class="no-padding primary-border"
|
||||
style="margin: .7em;"
|
||||
|
||||
@@ -1,64 +1,5 @@
|
||||
import {
|
||||
amSubscribed,
|
||||
getComments,
|
||||
getDislikes,
|
||||
getPersonalPlaylists,
|
||||
getVideo,
|
||||
postHistory
|
||||
} from '$lib/api/index';
|
||||
import {
|
||||
authStore,
|
||||
playerProxyVideosStore,
|
||||
returnYTDislikesInstanceStore,
|
||||
returnYtDislikesStore
|
||||
} from '$lib/store';
|
||||
import { phaseDescription } from '$lib/timestamps';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
import { getWatchDetails } from '$lib/watch.js';
|
||||
|
||||
export async function load({ params, url }) {
|
||||
let video;
|
||||
try {
|
||||
video = await getVideo(params.slug, get(playerProxyVideosStore), { priority: "high" });
|
||||
} catch (errorMessage: any) {
|
||||
error(500, errorMessage);
|
||||
}
|
||||
|
||||
let personalPlaylists;
|
||||
if (get(authStore)) {
|
||||
postHistory(video.videoId);
|
||||
personalPlaylists = getPersonalPlaylists({ priority: 'low' });
|
||||
} else {
|
||||
personalPlaylists = null;
|
||||
}
|
||||
|
||||
let comments;
|
||||
try {
|
||||
comments = video.liveNow
|
||||
? null
|
||||
: getComments(params.slug, { sort_by: 'top', source: 'youtube' }, { priority: "low" });
|
||||
} catch {
|
||||
comments = null;
|
||||
}
|
||||
|
||||
let returnYTDislikes;
|
||||
const returnYTDislikesInstance = get(returnYTDislikesInstanceStore);
|
||||
if (returnYTDislikesInstance && returnYTDislikesInstance !== '') {
|
||||
try {
|
||||
returnYTDislikes = get(returnYtDislikesStore) ? getDislikes(params.slug, { priority: "low" }) : null;
|
||||
} catch { }
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
video: video,
|
||||
content: phaseDescription(video.videoId, video.descriptionHtml, video.fallbackPatch),
|
||||
playlistId: url.searchParams.get('playlist'),
|
||||
streamed: {
|
||||
personalPlaylists: personalPlaylists,
|
||||
returnYTDislikes: returnYTDislikes,
|
||||
comments: comments,
|
||||
subscribed: amSubscribed(video.authorId),
|
||||
}
|
||||
};
|
||||
return getWatchDetails(params.slug, url);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getWatchDetails } from '$lib/watch.js';
|
||||
|
||||
export async function load({ params, url }) {
|
||||
return await getWatchDetails(params.slug, url);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import ContentColumn from '$lib/components/ContentColumn.svelte';
|
||||
import Player from '$lib/components/Player.svelte';
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import Author from '$lib/components/Watch/Author.svelte';
|
||||
import Description from '$lib/components/Watch/Description.svelte';
|
||||
import LikesDislikes from '$lib/components/Watch/LikesDislikes.svelte';
|
||||
import { letterCase } from '$lib/letterCasing.js';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { playlistCacheStore } from '$lib/store.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let playerElement: HTMLMediaElement | undefined = $state();
|
||||
let showInfo = $state(false);
|
||||
|
||||
let subscribed: boolean = $state(false);
|
||||
data.streamed.subscribed.then((isSubbed) => (subscribed = isSubbed));
|
||||
|
||||
onMount(() => {
|
||||
Mousetrap.bind('down', () => {
|
||||
if (showInfo) return true;
|
||||
|
||||
showInfo = true;
|
||||
tick().then(() => {
|
||||
document.getElementById('shown-info')?.focus();
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('up', () => {
|
||||
const infoElement = document.getElementById('shown-info');
|
||||
|
||||
if (showInfo && infoElement) {
|
||||
if (infoElement.scrollTop === 0) {
|
||||
showInfo = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!showInfo) {
|
||||
showInfo = true;
|
||||
tick().then(() => {
|
||||
document.getElementById('shown-info')?.focus();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
Mousetrap.bind('right', () => {
|
||||
if (!playerElement || showInfo) return true;
|
||||
playerElement.currentTime = playerElement.currentTime + 10;
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('left', () => {
|
||||
if (!playerElement || showInfo) return true;
|
||||
|
||||
playerElement.currentTime = playerElement.currentTime - 10;
|
||||
return false;
|
||||
});
|
||||
|
||||
Mousetrap.bind('enter', () => {
|
||||
if (!showInfo) {
|
||||
if (playerElement?.paused) {
|
||||
playerElement?.play();
|
||||
} else {
|
||||
playerElement?.pause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
Mousetrap.unbind(['up', 'down', 'left', 'right', 'enter']);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#key data.video.videoId}
|
||||
<Player bind:playerElement isEmbed={true} {data} />
|
||||
{/key}
|
||||
|
||||
{#if showInfo}
|
||||
<article id="shown-info">
|
||||
<h5>{letterCase(data.video.title)}</h5>
|
||||
<Author bind:subscribed video={data.video} />
|
||||
<div class="space"></div>
|
||||
<LikesDislikes video={data.video} returnYTDislikes={data.streamed.returnYTDislikes} />
|
||||
<article class="border">
|
||||
<Description video={data.video} description={data.content.description} />
|
||||
</article>
|
||||
|
||||
{#if data.playlistId && data.playlistId in $playlistCacheStore}
|
||||
<h5 style="margin-bottom: 0;">{$_('playlistVideos')}</h5>
|
||||
|
||||
<div class="grid">
|
||||
{#each $playlistCacheStore[data.playlistId].videos as playlistVideo}
|
||||
<ContentColumn>
|
||||
<article
|
||||
class="no-padding primary-border"
|
||||
style="height: 100%;"
|
||||
onclick={() => {
|
||||
showInfo = false;
|
||||
}}
|
||||
role="presentation"
|
||||
id={playlistVideo.videoId}
|
||||
class:border={playlistVideo.videoId === data.video.videoId}
|
||||
>
|
||||
{#key playlistVideo.videoId}
|
||||
<Thumbnail
|
||||
video={playlistVideo}
|
||||
sideways={true}
|
||||
playlistId={data.playlistId || undefined}
|
||||
/>
|
||||
{/key}
|
||||
</article>
|
||||
</ContentColumn>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<h5 style="margin-bottom: 0;">{$_('recommendedVideos')}</h5>
|
||||
<div class="grid">
|
||||
{#each data.video.recommendedVideos as recommendedVideo}
|
||||
<ContentColumn>
|
||||
<article
|
||||
onclick={() => {
|
||||
showInfo = false;
|
||||
}}
|
||||
role="presentation"
|
||||
style="height: 100%;"
|
||||
class="no-padding"
|
||||
>
|
||||
{#key recommendedVideo.videoId}
|
||||
<Thumbnail video={recommendedVideo} sideways={false} />
|
||||
{/key}
|
||||
</article>
|
||||
</ContentColumn>
|
||||
{/each}
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
#shown-info {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
z-index: 101;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.9.13"
|
||||
LATEST_VERSION = "1.9.14"
|
||||
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