Merge pull request #992 from Materialious/update/1.9.14

Update/1.9.14
This commit is contained in:
Ward
2025-06-23 14:58:27 +12:00
committed by GitHub
26 changed files with 689 additions and 411 deletions
+2 -2
View File
@@ -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">
+2 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.9.13",
"version": "1.9.14",
"private": true,
"scripts": {
"dev": "vite dev",
+59
View File
@@ -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 };
+1 -1
View File
@@ -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}` } };
}
}
+98 -54
View File
@@ -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.",
+7
View File
@@ -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();
}
}
+1 -1
View File
@@ -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;
+53
View File
@@ -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;
}
+12 -1
View File
@@ -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);
+71
View File
@@ -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)
}
};
}
+13 -13
View File
@@ -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
View File
@@ -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")