Merge pull request #1512 from Materialious/update/1.16.8

Update/1.16.8
This commit is contained in:
Ward
2026-03-05 13:49:15 +13:00
committed by GitHub
66 changed files with 4553 additions and 666 deletions
+5
View File
@@ -47,6 +47,11 @@ services:
# guide here for URL structure https://docs.preset.io/docs/uri-connection-strings
DATABASE_CONNECTION_URI: "sqlite:///materialious-data/materialious.db"
# YouTube player id to use, can be left blank but setting to a older player id may fix
# video playback with local video processing.
# https://youtube-player-ids.nadeko.net
PUBLIC_PLAYER_ID: ""
# Use Materialious account system.
PUBLIC_INTERNAL_AUTH: "true"
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 225
versionName "1.16.7"
versionCode 226
versionName "1.16.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -90,7 +90,11 @@
<release version="1.16.7" date="2026-3-02">
<release version="1.16.8" date="2026-3-03">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.8</url>
</release>
<release version="1.16.7" date="2026-3-02">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.7</url>
</release>
<release version="1.16.6" date="2026-2-23">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.16.7",
"version": "1.16.8",
"description": "Modern material design for YouTube and Invidious.",
"author": {
"name": "Ward Pearce",
+2948 -26
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.16.7",
"version": "1.16.8",
"private": true,
"scripts": {
"dev": "npm run patch:github && vite dev",
@@ -87,6 +87,7 @@
"mysql2": "^3.18.2",
"pg": "^8.19.0",
"pg-hstore": "^2.3.4",
"safe-regex2": "^5.0.0",
"sequelize": "^6.37.7",
"shaka-player": "^4.16.14",
"sponsorblock-api": "^0.2.4",
@@ -94,6 +95,7 @@
"svelte-awesome-color-picker": "^4.1.1",
"svelte-infinite-loading": "^1.4.0",
"tldts": "^7.0.24",
"trystero": "^0.22.0",
"youtubei.js": "^16.0.1",
"zod": "^4.3.6"
}
+5 -9
View File
@@ -2,10 +2,8 @@ import sodium from 'libsodium-wrappers-sumo';
import { decryptWithMasterKey, encryptWithMasterKey, getRawKey, getSecureHash } from './encryption';
import type { VideoPlay, VideoWatchHistory } from '../model';
import { getBestThumbnail } from '$lib/images';
import { get } from 'svelte/store';
import { watchHistoryEnabledStore } from '$lib/store';
export async function updateWatchHistory(videoId: string, progress: number) {
export async function updateWatchHistoryBackend(videoId: string, progress: number) {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
@@ -55,7 +53,7 @@ async function decryptWatchHistory(
};
}
export async function getVideoWatchHistory(
export async function getVideoWatchHistoryBackend(
videoId: string
): Promise<VideoWatchHistory | undefined> {
await sodium.ready;
@@ -74,7 +72,7 @@ export async function getVideoWatchHistory(
return await decryptWatchHistory(await resp.json());
}
export async function getWatchHistory(
export async function getWatchHistoryBackend(
options: { page?: number; videoIds?: string[]; fetchOptions?: RequestInit } = {
page: undefined,
videoIds: undefined,
@@ -116,13 +114,11 @@ export async function getWatchHistory(
return history;
}
export async function deleteWatchHistory() {
export async function deleteWatchHistoryBackend() {
await fetch('/api/user/history', { method: 'DELETE' });
}
export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
if (!get(watchHistoryEnabledStore)) return;
export async function saveWatchHistoryBackend(video: VideoPlay, progress: number = 0) {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
+25
View File
@@ -0,0 +1,25 @@
import { get } from 'svelte/store';
import { fetchErrorHandle } from './invidious/request';
import { deArrowInstanceStore, deArrowThumbnailInstanceStore } from '$lib/store';
import type { DeArrow } from './model';
export async function getDeArrow(videoId: string, fetchOptions?: RequestInit): Promise<DeArrow> {
const resp = await fetchErrorHandle(
await fetch(`${get(deArrowInstanceStore)}/api/branding?videoID=${videoId}`, fetchOptions)
);
return await resp.json();
}
export async function getThumbnailDeArrow(
videoId: string,
time: number,
fetchOptions?: RequestInit
): Promise<string> {
const resp = await fetchErrorHandle(
await fetch(
`${get(deArrowThumbnailInstanceStore)}/api/v1/getThumbnail?videoID=${videoId}&time=${time}`,
fetchOptions
)
);
return URL.createObjectURL(await resp.blob());
}
@@ -1,5 +1,5 @@
import type { VideoWatchHistory } from '../model';
import { getWatchHistory } from './history';
import type { VideoWatchHistory } from './model';
import { getWatchHistory } from './index';
const videoIds: string[] = [];
const pendingResolves = new Map<string, (result: VideoWatchHistory | undefined) => void>();
+152 -310
View File
@@ -2,23 +2,16 @@ import { getVideoYTjs } from '$lib/api/youtubejs/video';
import { get } from 'svelte/store';
import {
invidiousAuthStore,
deArrowInstanceStore,
deArrowThumbnailInstanceStore,
invidiousInstanceStore,
interfaceRegionStore,
playerYouTubeJsAlways,
playerYouTubeJsFallback,
rawMasterKeyStore,
returnYTDislikesInstanceStore
watchHistoryEnabledStore
} from '../store';
import type {
ChannelPage,
Comments,
DeArrow,
Feed,
PlaylistPage,
ResolvedUrl,
ReturnYTDislikes,
SearchSuggestion,
Subscription,
Video,
@@ -27,7 +20,8 @@ import type {
SearchResults,
CommentsOptions,
ChannelOptions,
ChannelContent
ChannelContent,
VideoWatchHistory
} from './model';
import { commentsSetDefaults, searchSetDefaults, useEngineFallback } from './misc';
import { getSearchYTjs } from './youtubejs/search';
@@ -51,69 +45,42 @@ import {
getSubscriptionsBackend,
postSubscribeBackend
} from './backend/subscriptions';
import { getUserLocale } from '$lib/i18n';
export function buildPath(path: string): URL {
return setLocale(new URL(`${get(invidiousInstanceStore)}/api/v1/${path}`));
}
export function setLocale(url: URL): URL {
const region = get(interfaceRegionStore);
if (region) {
url.searchParams.set('region', region);
}
const locale = getUserLocale();
url.searchParams.set('hl', locale);
return url;
}
export class HTTPError {
msg: string;
response: Response;
constructor(msg: string, response: Response) {
this.msg = msg;
this.response = response;
}
toString() {
return this.msg;
}
}
export async function fetchErrorHandle(response: Response): Promise<Response> {
if (!response.ok) {
let message = 'Internal error';
// Attempt to parse error.
try {
const json = await response.json();
message = json.errorBacktrace || json.error || json.message;
} catch {
// Continue regardless of error
}
throw new HTTPError(
`${response.status} - ${response.statusText}\n${decodeURIComponent(response.url)}\n${message}`,
response
);
}
return response;
}
export function buildAuthHeaders(): { headers: Record<string, string> } {
const authToken = get(invidiousAuthStore)?.token ?? '';
if (authToken.startsWith('SID=')) {
return { headers: { __sid_auth: authToken } };
} else {
return { headers: { Authorization: `Bearer ${authToken}` } };
}
}
import { getFeedInvidious, getPopularInvidious, getSubscriptionsInvidious } from './invidious/feed';
import { getResolveUrlInvidious } from './invidious/misc';
import { getVideoInvidious } from './invidious/video';
import { getCommentsInvidious } from './invidious/comments';
import {
getChannelContentInvidious,
getChannelInvidious,
searchChannelContentInvidious
} from './invidious/channel';
import { getSearchSuggestionsInvidious } from './invidious/searchSuggestions';
import { getHashtagInvidious } from './invidious/hashtag';
import { getSearchInvidious } from './invidious/search';
import { notificationsMarkAsReadInvidious } from './invidious/notifcations';
import {
amSubscribedInvidious,
deleteUnsubscribeInvidious,
postSubscribeInvidious
} from './invidious/subscribe';
import { postHistoryInvidious } from './invidious/history';
import {
addPlaylistVideoInvidious,
deletePersonalPlaylistInvidious,
getPersonalPlaylistsInvidious,
getPlaylistInvidious,
postPersonalPlaylistInvidious,
removePlaylistVideoInvidious
} from './invidious/playlist';
import {
deleteWatchHistoryBackend,
getVideoWatchHistoryBackend,
getWatchHistoryBackend,
saveWatchHistoryBackend,
updateWatchHistoryBackend
} from './backend/history';
import { localDb } from '$lib/dexie';
import { getBestThumbnail } from '$lib/images';
export async function getPopular(fetchOptions?: RequestInit): Promise<Video[]> {
// Doesn't exist in YTjs.
@@ -121,20 +88,15 @@ export async function getPopular(fetchOptions?: RequestInit): Promise<Video[]> {
return [];
}
const resp = await fetchErrorHandle(await fetch(buildPath('popular'), fetchOptions));
return await resp.json();
return getPopularInvidious(fetchOptions);
}
export async function getResolveUrl(url: string): Promise<ResolvedUrl> {
if (isYTBackend() || useEngineFallback('ResolveUrl')) {
return await getResolveUrlYTjs(url);
return getResolveUrlYTjs(url);
}
const path = buildPath('resolveurl');
path.searchParams.set('url', url);
const resp = await fetchErrorHandle(await fetch(path));
return await resp.json();
return getResolveUrlInvidious(url);
}
export async function getVideo(
@@ -147,30 +109,10 @@ export async function getVideo(
isYTBackend() ||
useEngineFallback('Video')
) {
return await getVideoYTjs(videoId);
return getVideoYTjs(videoId);
}
const path = buildPath(`videos/${videoId}`);
path.searchParams.set('local', local.toString());
const resp = await fetch(path, fetchOptions);
if (!resp.ok && get(playerYouTubeJsFallback) && isUnrestrictedPlatform()) {
return await getVideoYTjs(videoId);
} else {
await fetchErrorHandle(resp);
}
return await resp.json();
}
export async function getDislikes(
videoId: string,
fetchOptions?: RequestInit
): Promise<ReturnYTDislikes> {
const resp = await fetchErrorHandle(
await fetch(`${get(returnYTDislikesInstanceStore)}/votes?videoId=${videoId}`, fetchOptions)
);
return await resp.json();
return getVideoInvidious(videoId, local, fetchOptions);
}
export async function getComments(
@@ -181,15 +123,10 @@ export async function getComments(
commentsSetDefaults(options);
if (isYTBackend() || useEngineFallback('Comments')) {
return await getCommentsYTjs(videoId, options);
return getCommentsYTjs(videoId, options);
}
const path = buildPath(`comments/${videoId}`);
if (options.continuation) path.searchParams.set('continuation', options.continuation);
if (options.sort_by) path.searchParams.set('sort_by', options.sort_by);
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
return await resp.json();
return getCommentsInvidious(videoId, options, fetchOptions);
}
export async function getChannel(
@@ -199,10 +136,8 @@ export async function getChannel(
if (isYTBackend() || useEngineFallback('Channel')) {
return getChannelYTjs(channelId);
}
const resp = await fetchErrorHandle(
await fetch(buildPath(`channels/${channelId}`), fetchOptions)
);
return await resp.json();
return getChannelInvidious(channelId, fetchOptions);
}
export async function getChannelContent(
@@ -213,18 +148,10 @@ export async function getChannelContent(
if (typeof options.type === 'undefined') options.type = 'videos';
if (isYTBackend() || useEngineFallback('ChannelContent')) {
return await getChannelContentYTjs(channelId, options);
return getChannelContentYTjs(channelId, options);
}
const url = buildPath(`channels/${channelId}/${options.type}`);
if (typeof options.continuation !== 'undefined')
url.searchParams.set('continuation', options.continuation);
if (typeof options.sortBy !== 'undefined') url.searchParams.set('sort_by', options.sortBy);
const resp = await fetchErrorHandle(await fetch(url.toString(), fetchOptions));
return await resp.json();
return getChannelContentInvidious(channelId, options, fetchOptions);
}
export async function searchChannelContent(
@@ -239,11 +166,7 @@ export async function searchChannelContent(
};
}
const path = buildPath(`channel/${channelId}/search`);
path.searchParams.set('q', search);
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
return await resp.json();
return searchChannelContentInvidious(channelId, search, fetchOptions);
}
export async function getSearchSuggestions(
@@ -254,22 +177,14 @@ export async function getSearchSuggestions(
return getSearchSuggestionsYTjs(search);
}
const path = buildPath('search/suggestions');
path.searchParams.set('q', search);
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
return await resp.json();
return getSearchSuggestionsInvidious(search, fetchOptions);
}
export async function getHashtag(tag: string, page: number = 0): Promise<{ results: Video[] }> {
// TODO: Implement in YTjs
if (isYTBackend()) return { results: [] };
const path = buildPath(`hashtag/${tag}`);
path.searchParams.set('page', page.toString());
const resp = await fetchErrorHandle(await fetch(path));
return await resp.json();
return getHashtagInvidious(tag, page);
}
export async function getSearch(
@@ -280,21 +195,10 @@ export async function getSearch(
searchSetDefaults(options);
if (isYTBackend() || useEngineFallback('Search')) {
return await getSearchYTjs(search, options);
return getSearchYTjs(search, options);
}
const path = buildPath('search');
path.searchParams.set('q', search);
if (options.date) path.searchParams.set('date', options.date);
if (options.duration) path.searchParams.set('duration', options.duration);
if (options.features) path.searchParams.set('features', options.features);
if (options.page) path.searchParams.set('page', options.page);
if (options.sort_by) path.searchParams.set('sort_by', options.sort_by);
if (options.type) path.searchParams.set('type', options.type);
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
return await resp.json();
return getSearchInvidious(search, options, fetchOptions);
}
export async function getFeed(
@@ -306,21 +210,14 @@ export async function getFeed(
return getFeedYTjs(maxResults, page);
}
const path = buildPath('auth/feed');
path.searchParams.set('max_results', maxResults.toString());
path.searchParams.set('page', page.toString());
const resp = await fetchErrorHandle(
await fetch(path, { ...buildAuthHeaders(), ...fetchOptions })
);
return await resp.json();
return getFeedInvidious(maxResults, page, fetchOptions);
}
export async function notificationsMarkAsRead(fetchOptions: RequestInit = {}) {
// Not support functionality of YTjs
if (isYTBackend()) return;
const path = buildPath('auth/notifications');
await fetchErrorHandle(await fetch(path, { ...buildAuthHeaders(), ...fetchOptions }));
return notificationsMarkAsReadInvidious(fetchOptions);
}
export async function getSubscriptions(
@@ -339,10 +236,8 @@ export async function getSubscriptions(
return getSubscriptionsYTjs();
}
const resp = await fetchErrorHandle(
await fetch(buildPath('auth/subscriptions'), { ...buildAuthHeaders(), ...fetchOptions })
);
return await resp.json();
return getSubscriptionsInvidious(fetchOptions);
}
export async function amSubscribed(
@@ -357,16 +252,7 @@ export async function amSubscribed(
return amSubscribedYTjs(authorId);
}
if (!get(invidiousAuthStore)) return false;
try {
const subscriptions = (await getSubscriptions(fetchOptions)).filter(
(sub) => sub.authorId === authorId
);
return subscriptions.length === 1;
} catch {
return false;
}
return amSubscribedInvidious(authorId, fetchOptions);
}
export async function postSubscribe(
@@ -382,13 +268,7 @@ export async function postSubscribe(
return postSubscribeYTjs(authorId);
}
await fetchErrorHandle(
await fetch(buildPath(`auth/subscriptions/${authorId}`), {
method: 'POST',
...buildAuthHeaders(),
...fetchOptions
})
);
return postSubscribeInvidious(authorId, fetchOptions);
}
export async function deleteUnsubscribe(authorId: string, fetchOptions: RequestInit = {}) {
@@ -402,68 +282,106 @@ export async function deleteUnsubscribe(authorId: string, fetchOptions: RequestI
return deleteUnsubscribeYTjs(authorId);
}
await fetchErrorHandle(
await fetch(buildPath(`auth/subscriptions/${authorId}`), {
method: 'DELETE',
...buildAuthHeaders(),
...fetchOptions
})
);
return deleteUnsubscribeInvidious(authorId, fetchOptions);
}
export async function getHistory(
page: number = 1,
maxResults: number = 20,
fetchOptions: RequestInit = {}
): Promise<string[]> {
// Not supported functionality of YTjs.
if (isYTBackend()) {
return [];
export async function getWatchHistory(
options: { page?: number; videoIds?: string[]; fetchOptions?: RequestInit } = {
page: undefined,
videoIds: undefined,
fetchOptions: undefined
}
): Promise<VideoWatchHistory[]> {
if (!get(watchHistoryEnabledStore)) return [];
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return getWatchHistoryBackend(options);
}
const path = buildPath(`auth/history`);
path.searchParams.set('page', page.toString());
path.searchParams.set('max_results', maxResults.toString());
let watchHistory = await localDb.watchHistory.toArray();
watchHistory.sort((a, b) => b.watched.getTime() - a.watched.getTime());
const resp = await fetchErrorHandle(
await fetch(path, {
...buildAuthHeaders(),
...fetchOptions
})
);
return await resp.json();
if (options.videoIds) {
watchHistory = watchHistory.filter((item) => options.videoIds?.includes(item.videoId));
}
const cullAfter = 1000;
if (watchHistory.length > cullAfter) {
const videosToDelete = watchHistory.slice(cullAfter);
const videoIdsToDelete = videosToDelete.map((video) => video.videoId);
await localDb.watchHistory.where('videoId').anyOf(videoIdsToDelete).delete();
// Don't display culled videos.
watchHistory = watchHistory.slice(0, cullAfter);
}
const maxResults = 100;
const page = options.page ?? 1; // default to page 1
const start = (page - 1) * maxResults;
const end = start + maxResults;
watchHistory = watchHistory.slice(start, end);
return watchHistory;
}
export async function deleteHistory(
videoId: string | undefined = undefined,
export async function getVideoWatchHistory(
videoId: string
): Promise<VideoWatchHistory | undefined> {
if (!get(watchHistoryEnabledStore)) return;
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return getVideoWatchHistoryBackend(videoId);
}
return await localDb.watchHistory.get({ videoId });
}
export async function deleteWatchHistory() {
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return deleteWatchHistoryBackend();
}
await localDb.watchHistory.clear();
}
export async function updateWatchHistory(
videoId: string,
progress: number,
fetchOptions: RequestInit = {}
) {
if (isYTBackend()) return;
if (!get(watchHistoryEnabledStore)) return;
let url = '/api/v1/auth/history';
if (typeof videoId !== 'undefined') {
url += `/${videoId}`;
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return updateWatchHistoryBackend(videoId, progress);
}
await fetchErrorHandle(
await fetch(buildPath(url), {
method: 'DELETE',
...buildAuthHeaders(),
...fetchOptions
})
);
if (get(invidiousAuthStore)) postHistoryInvidious(videoId, fetchOptions);
await localDb.watchHistory.update({ videoId }, { progress, watched: new Date() });
}
export async function postHistory(videoId: string, fetchOptions: RequestInit = {}) {
if (isYTBackend()) return;
export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
if (!get(watchHistoryEnabledStore)) return;
await fetchErrorHandle(
await fetch(buildPath(`auth/history/${videoId}`), {
method: 'POST',
...buildAuthHeaders(),
...fetchOptions
})
);
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return saveWatchHistoryBackend(video, progress);
}
if (await localDb.watchHistory.get({ videoId: video.videoId })) return;
await localDb.watchHistory.add({
author: video.author,
watched: new Date(),
lengthSeconds: video.lengthSeconds,
progress,
id: video.videoId,
title: video.title,
thumbnail: getBestThumbnail(video.videoThumbnails),
videoId: video.videoId,
type: 'historyVideo'
});
}
export async function getPlaylist(
@@ -475,21 +393,7 @@ export async function getPlaylist(
return await getPlaylistYTjs(playlistId);
}
let resp;
const path = buildPath(`${get(invidiousAuthStore) ? 'auth/' : ''}playlists/${playlistId}`);
path.searchParams.set('page', page.toString());
if (get(invidiousAuthStore)) {
resp = await fetch(path, {
...buildAuthHeaders(),
...fetchOptions
});
} else {
resp = await fetch(path, fetchOptions);
}
await fetchErrorHandle(resp);
return await resp.json();
return getPlaylistInvidious(playlistId, page, fetchOptions);
}
export async function getPersonalPlaylists(
@@ -497,21 +401,13 @@ export async function getPersonalPlaylists(
): Promise<PlaylistPage[]> {
if (isYTBackend()) return [];
const resp = await fetchErrorHandle(
await fetch(buildPath('auth/playlists'), { ...buildAuthHeaders(), ...fetchOptions })
);
return await resp.json();
return getPersonalPlaylistsInvidious(fetchOptions);
}
export async function deletePersonalPlaylist(playlistId: string) {
if (isYTBackend()) return;
await fetchErrorHandle(
await fetch(buildPath(`auth/playlists/${playlistId}`), {
method: 'DELETE',
...buildAuthHeaders()
})
);
return deletePersonalPlaylistInvidious(playlistId);
}
export async function postPersonalPlaylist(
@@ -520,21 +416,7 @@ export async function postPersonalPlaylist(
fetchOptions: RequestInit = {}
) {
if (isYTBackend()) return;
const headers: Record<string, Record<string, string>> = buildAuthHeaders();
headers['headers']['Content-type'] = 'application/json';
await fetchErrorHandle(
await fetch(buildPath('auth/playlists'), {
method: 'POST',
body: JSON.stringify({
title: title,
privacy: privacy
}),
...headers,
...fetchOptions
})
);
return postPersonalPlaylistInvidious(title, privacy, fetchOptions);
}
export async function addPlaylistVideo(
@@ -543,20 +425,7 @@ export async function addPlaylistVideo(
fetchOptions: RequestInit = {}
) {
if (isYTBackend()) return;
const headers: Record<string, Record<string, string>> = buildAuthHeaders();
headers['headers']['Content-type'] = 'application/json';
await fetchErrorHandle(
await fetch(buildPath(`auth/playlists/${playlistId}/videos`), {
method: 'POST',
body: JSON.stringify({
videoId: videoId
}),
...headers,
...fetchOptions
})
);
return addPlaylistVideoInvidious(playlistId, videoId, fetchOptions);
}
export async function removePlaylistVideo(
@@ -566,32 +435,5 @@ export async function removePlaylistVideo(
) {
if (isYTBackend()) return;
await fetchErrorHandle(
await fetch(buildPath(`auth/playlists/${playlistId}/videos/${indexId}`), {
method: 'DELETE',
...buildAuthHeaders(),
...fetchOptions
})
);
}
export async function getDeArrow(videoId: string, fetchOptions?: RequestInit): Promise<DeArrow> {
const resp = await fetchErrorHandle(
await fetch(`${get(deArrowInstanceStore)}/api/branding?videoID=${videoId}`, fetchOptions)
);
return await resp.json();
}
export async function getThumbnail(
videoId: string,
time: number,
fetchOptions?: RequestInit
): Promise<string> {
const resp = await fetchErrorHandle(
await fetch(
`${get(deArrowThumbnailInstanceStore)}/api/v1/getThumbnail?videoID=${videoId}&time=${time}`,
fetchOptions
)
);
return URL.createObjectURL(await resp.blob());
return removePlaylistVideoInvidious(playlistId, indexId, fetchOptions);
}
@@ -0,0 +1,40 @@
import type { ChannelContent, ChannelOptions, ChannelPage } from '../model';
import { buildPath, fetchErrorHandle } from './request';
export async function getChannelInvidious(
channelId: string,
fetchOptions?: RequestInit
): Promise<ChannelPage> {
const resp = await fetchErrorHandle(
await fetch(buildPath(`channels/${channelId}`), fetchOptions)
);
return await resp.json();
}
export async function getChannelContentInvidious(
channelId: string,
options: ChannelOptions,
fetchOptions?: RequestInit
): Promise<ChannelContent> {
const url = buildPath(`channels/${channelId}/${options.type}`);
if (typeof options.continuation !== 'undefined')
url.searchParams.set('continuation', options.continuation);
if (typeof options.sortBy !== 'undefined') url.searchParams.set('sort_by', options.sortBy);
const resp = await fetchErrorHandle(await fetch(url.toString(), fetchOptions));
return await resp.json();
}
export async function searchChannelContentInvidious(
channelId: string,
search: string,
fetchOptions?: RequestInit
): Promise<ChannelContent> {
const path = buildPath(`channel/${channelId}/search`);
path.searchParams.set('q', search);
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
return await resp.json();
}
@@ -0,0 +1,15 @@
import type { Comments, CommentsOptions } from '../model';
import { buildPath, fetchErrorHandle } from './request';
export async function getCommentsInvidious(
videoId: string,
options: CommentsOptions,
fetchOptions?: RequestInit
): Promise<Comments> {
const path = buildPath(`comments/${videoId}`);
if (options.continuation) path.searchParams.set('continuation', options.continuation);
if (options.sort_by) path.searchParams.set('sort_by', options.sort_by);
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
return await resp.json();
}
@@ -0,0 +1,30 @@
import type { Feed, Subscription, Video } from '../model';
import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request';
export async function getPopularInvidious(fetchOptions?: RequestInit): Promise<Video[]> {
const resp = await fetchErrorHandle(await fetch(buildPath('popular'), fetchOptions));
return await resp.json();
}
export async function getFeedInvidious(
maxResults: number,
page: number,
fetchOptions: RequestInit = {}
): Promise<Feed> {
const path = buildPath('auth/feed');
path.searchParams.set('max_results', maxResults.toString());
path.searchParams.set('page', page.toString());
const resp = await fetchErrorHandle(
await fetch(path, { ...buildAuthHeaders(), ...fetchOptions })
);
return await resp.json();
}
export async function getSubscriptionsInvidious(
fetchOptions: RequestInit = {}
): Promise<Subscription[]> {
const resp = await fetchErrorHandle(
await fetch(buildPath('auth/subscriptions'), { ...buildAuthHeaders(), ...fetchOptions })
);
return await resp.json();
}
@@ -0,0 +1,13 @@
import type { Video } from '../model';
import { buildPath, fetchErrorHandle } from './request';
export async function getHashtagInvidious(
tag: string,
page: number = 0
): Promise<{ results: Video[] }> {
const path = buildPath(`hashtag/${tag}`);
path.searchParams.set('page', page.toString());
const resp = await fetchErrorHandle(await fetch(path));
return await resp.json();
}
@@ -0,0 +1,11 @@
import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request';
export async function postHistoryInvidious(videoId: string, fetchOptions: RequestInit = {}) {
await fetchErrorHandle(
await fetch(buildPath(`auth/history/${videoId}`), {
method: 'POST',
...buildAuthHeaders(),
...fetchOptions
})
);
}
@@ -0,0 +1,10 @@
import type { ResolvedUrl } from '../model';
import { buildPath, fetchErrorHandle } from './request';
export async function getResolveUrlInvidious(url: string): Promise<ResolvedUrl> {
const path = buildPath('resolveurl');
path.searchParams.set('url', url);
const resp = await fetchErrorHandle(await fetch(path));
return await resp.json();
}
@@ -0,0 +1,6 @@
import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request';
export async function notificationsMarkAsReadInvidious(fetchOptions: RequestInit = {}) {
const path = buildPath('auth/notifications');
await fetchErrorHandle(await fetch(path, { ...buildAuthHeaders(), ...fetchOptions }));
}
@@ -0,0 +1,99 @@
import { get } from 'svelte/store';
import type { PlaylistPage } from '../model';
import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request';
import { invidiousAuthStore } from '$lib/store';
export async function getPlaylistInvidious(
playlistId: string,
page: number = 1,
fetchOptions: RequestInit = {}
): Promise<PlaylistPage> {
let resp;
const path = buildPath(`${get(invidiousAuthStore) ? 'auth/' : ''}playlists/${playlistId}`);
path.searchParams.set('page', page.toString());
if (get(invidiousAuthStore)) {
resp = await fetch(path, {
...buildAuthHeaders(),
...fetchOptions
});
} else {
resp = await fetch(path, fetchOptions);
}
await fetchErrorHandle(resp);
return await resp.json();
}
export async function getPersonalPlaylistsInvidious(
fetchOptions: RequestInit = {}
): Promise<PlaylistPage[]> {
const resp = await fetchErrorHandle(
await fetch(buildPath('auth/playlists'), { ...buildAuthHeaders(), ...fetchOptions })
);
return await resp.json();
}
export async function deletePersonalPlaylistInvidious(playlistId: string) {
await fetchErrorHandle(
await fetch(buildPath(`auth/playlists/${playlistId}`), {
method: 'DELETE',
...buildAuthHeaders()
})
);
}
export async function postPersonalPlaylistInvidious(
title: string,
privacy: 'public' | 'private' | 'unlisted',
fetchOptions: RequestInit = {}
) {
const headers: Record<string, Record<string, string>> = buildAuthHeaders();
headers['headers']['Content-type'] = 'application/json';
await fetchErrorHandle(
await fetch(buildPath('auth/playlists'), {
method: 'POST',
body: JSON.stringify({
title: title,
privacy: privacy
}),
...headers,
...fetchOptions
})
);
}
export async function addPlaylistVideoInvidious(
playlistId: string,
videoId: string,
fetchOptions: RequestInit = {}
) {
const headers: Record<string, Record<string, string>> = buildAuthHeaders();
headers['headers']['Content-type'] = 'application/json';
await fetchErrorHandle(
await fetch(buildPath(`auth/playlists/${playlistId}/videos`), {
method: 'POST',
body: JSON.stringify({
videoId: videoId
}),
...headers,
...fetchOptions
})
);
}
export async function removePlaylistVideoInvidious(
playlistId: string,
indexId: string,
fetchOptions: RequestInit = {}
) {
await fetchErrorHandle(
await fetch(buildPath(`auth/playlists/${playlistId}/videos/${indexId}`), {
method: 'DELETE',
...buildAuthHeaders(),
...fetchOptions
})
);
}
@@ -0,0 +1,65 @@
import { getUserLocale } from '$lib/i18n';
import { interfaceRegionStore, invidiousAuthStore, invidiousInstanceStore } from '$lib/store';
import { get } from 'svelte/store';
export function buildPath(path: string): URL {
return setLocale(new URL(`${get(invidiousInstanceStore)}/api/v1/${path}`));
}
export function setLocale(url: URL): URL {
const region = get(interfaceRegionStore);
if (region) {
url.searchParams.set('region', region);
}
const locale = getUserLocale();
url.searchParams.set('hl', locale);
return url;
}
export class HTTPError {
msg: string;
response: Response;
constructor(msg: string, response: Response) {
this.msg = msg;
this.response = response;
}
toString() {
return this.msg;
}
}
export async function fetchErrorHandle(response: Response): Promise<Response> {
if (!response.ok) {
let message = 'Internal error';
// Attempt to parse error.
try {
const json = await response.json();
message = json.errorBacktrace || json.error || json.message;
} catch {
// Continue regardless of error
}
throw new HTTPError(
`${response.status} - ${response.statusText}\n${decodeURIComponent(response.url)}\n${message}`,
response
);
}
return response;
}
export function buildAuthHeaders(): { headers: Record<string, string> } {
const authToken = get(invidiousAuthStore)?.token ?? '';
if (authToken.startsWith('SID=')) {
return { headers: { __sid_auth: authToken } };
} else {
return { headers: { Authorization: `Bearer ${authToken}` } };
}
}
@@ -0,0 +1,21 @@
import type { SearchOptions, SearchResults } from '../model';
import { buildPath, fetchErrorHandle } from './request';
export async function getSearchInvidious(
search: string,
options: SearchOptions,
fetchOptions?: RequestInit
): Promise<SearchResults> {
const path = buildPath('search');
path.searchParams.set('q', search);
if (options.date) path.searchParams.set('date', options.date);
if (options.duration) path.searchParams.set('duration', options.duration);
if (options.features) path.searchParams.set('features', options.features);
if (options.page) path.searchParams.set('page', options.page);
if (options.sort_by) path.searchParams.set('sort_by', options.sort_by);
if (options.type) path.searchParams.set('type', options.type);
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
return await resp.json();
}
@@ -0,0 +1,13 @@
import type { SearchSuggestion } from '../model';
import { buildPath, fetchErrorHandle } from './request';
export async function getSearchSuggestionsInvidious(
search: string,
fetchOptions?: RequestInit
): Promise<SearchSuggestion> {
const path = buildPath('search/suggestions');
path.searchParams.set('q', search);
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
return await resp.json();
}
@@ -0,0 +1,40 @@
import { invidiousAuthStore } from '$lib/store';
import { get } from 'svelte/store';
import { getSubscriptionsInvidious } from './feed';
import { buildAuthHeaders, buildPath, fetchErrorHandle } from './request';
export async function amSubscribedInvidious(
authorId: string,
fetchOptions: RequestInit = {}
): Promise<boolean> {
if (!get(invidiousAuthStore)) return false;
try {
const subscriptions = (await getSubscriptionsInvidious(fetchOptions)).filter(
(sub) => sub.authorId === authorId
);
return subscriptions.length === 1;
} catch {
return false;
}
}
export async function postSubscribeInvidious(authorId: string, fetchOptions: RequestInit = {}) {
await fetchErrorHandle(
await fetch(buildPath(`auth/subscriptions/${authorId}`), {
method: 'POST',
...buildAuthHeaders(),
...fetchOptions
})
);
}
export async function deleteUnsubscribeInvidious(authorId: string, fetchOptions: RequestInit = {}) {
await fetchErrorHandle(
await fetch(buildPath(`auth/subscriptions/${authorId}`), {
method: 'DELETE',
...buildAuthHeaders(),
...fetchOptions
})
);
}
@@ -0,0 +1,24 @@
import { get } from 'svelte/store';
import type { VideoPlay } from '../model';
import { buildPath, fetchErrorHandle } from './request';
import { getVideoYTjs } from '../youtubejs/video';
import { playerYouTubeJsFallback } from '$lib/store';
import { isUnrestrictedPlatform } from '$lib/misc';
export async function getVideoInvidious(
videoId: string,
local: boolean = false,
fetchOptions?: RequestInit
): Promise<VideoPlay> {
const path = buildPath(`videos/${videoId}`);
path.searchParams.set('local', local.toString());
const resp = await fetch(path, fetchOptions);
if (!resp.ok && get(playerYouTubeJsFallback) && isUnrestrictedPlatform()) {
return await getVideoYTjs(videoId);
} else {
await fetchErrorHandle(resp);
}
return await resp.json();
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { getPublicEnv } from '$lib/misc';
import { interfaceRegionStore } from '$lib/store';
import { USER_AGENT } from 'bgutils-js';
import { get } from 'svelte/store';
@@ -12,7 +13,8 @@ export async function getInnertube(): Promise<Innertube> {
fetch: fetch,
cache: new UniversalCache(true),
location: get(interfaceRegionStore),
user_agent: USER_AGENT
user_agent: USER_AGENT,
player_id: getPublicEnv('PLAYER_ID') ?? '9f4cc5e4'
});
return innertube;
+14
View File
@@ -0,0 +1,14 @@
import { get } from 'svelte/store';
import type { ReturnYTDislikes } from './model';
import { fetchErrorHandle } from './invidious/request';
import { returnYTDislikesInstanceStore } from '$lib/store';
export async function getDislikesRYD(
videoId: string,
fetchOptions?: RequestInit
): Promise<ReturnYTDislikes> {
const resp = await fetchErrorHandle(
await fetch(`${get(returnYTDislikesInstanceStore)}/votes?videoId=${videoId}`, fetchOptions)
);
return await resp.json();
}
+8 -1
View File
@@ -31,6 +31,7 @@
'invidious redirect': 'https://redirect.invidious.io'
};
let shareButtonElement: HTMLElement | undefined = $state();
let includePrompt = $state(false);
async function onShare(share: ShareLink) {
@@ -40,10 +41,16 @@
url.searchParams.append(share.param.key, share.param.value().toString());
await shareURL(url.toString());
shareButtonElement?.click();
}
</script>
<button class="surface-container-highest" onclick={(event: Event) => event.stopPropagation()}>
<button
bind:this={shareButtonElement}
class="surface-container-highest"
onclick={(event: Event) => event.stopPropagation()}
>
<i>share</i>
{#if !iconOnly}
{$_('player.share.title')}
@@ -0,0 +1,200 @@
<script lang="ts">
import { goto, pushState } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { _ } from '$lib/i18n';
import { playerState } from '$lib/store';
import sodium from 'libsodium-wrappers-sumo';
import { onDestroy, onMount } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { joinRoom, type ActionReceiver, type DataPayload, type Room } from 'trystero/mqtt';
import z from 'zod';
import { addToast } from './Toast.svelte';
const zWatchPartyEvent = z.object({
event: z.union([
z.literal('pause'),
z.literal('play'),
z.literal('seek'),
z.literal('goToVideo')
]),
videoId: z.string().regex(/^[a-zA-Z0-9_-]{11}$/),
sent: z.date(),
currentTime: z.number().min(0)
});
type WatchPartyEvent = z.infer<typeof zWatchPartyEvent>;
let room: Room | undefined = $state();
let roomId: string | undefined = $state();
const appId = 'materialious_';
type SendEvent = (message: WatchPartyEvent, peerToSendTo?: string) => void;
// If room not in use message just get sent nowhere.
let sendEvent: SendEvent = () => {};
onMount(() => initalRoom());
onDestroy(() => room?.leave());
function actionReceiver(receiver: ActionReceiver<DataPayload>) {
receiver((data) => {
const dataParsed = zWatchPartyEvent.safeParse(data);
if (!dataParsed.success) return;
if (dataParsed.data.event === 'goToVideo') {
goto(resolve('/watch/[videoId]', { videoId: dataParsed.data.videoId }));
return;
}
const player = $playerState;
if (!player?.playerElement) return;
const playerElement = player.playerElement;
const currentTime = playerElement.currentTime;
const sentTime = dataParsed.data.sent.getTime();
const timeDifference = Math.abs(currentTime - sentTime);
if (timeDifference > 5000) return;
const currentTimeWithDifference = Math.max(
0,
Math.min(dataParsed.data.currentTime + timeDifference, playerElement.duration)
);
switch (dataParsed.data.event) {
case 'play':
playerElement.play();
playerElement.currentTime = currentTimeWithDifference;
break;
case 'pause':
playerElement.pause();
playerElement.currentTime = currentTimeWithDifference;
break;
case 'seek':
playerElement.currentTime = currentTimeWithDifference;
break;
}
});
}
function setupRoom(room: Room) {
const [sendAction, getAction] = room.makeAction('watchParty');
sendEvent = sendAction as unknown as SendEvent;
actionReceiver(getAction);
}
async function createRoom() {
await sodium.ready;
roomId = sodium.to_base64(sodium.randombytes_buf(24));
room = joinRoom({ appId }, roomId);
setupRoom(room);
const currentSearchParams = new SvelteURLSearchParams(window.location.search);
currentSearchParams.set('room', roomId);
room.onPeerJoin((peerId) => {
const player = get(playerState);
if (!player || !player.data || !player.playerElement) return;
addToast({
data: {
text: $_('watchParty.userJoin')
}
});
sendEvent(
{
event: 'goToVideo',
videoId: player.data.video.videoId,
sent: new Date(),
currentTime: player.playerElement.currentTime
},
peerId
);
});
room.onPeerLeave(() => {
addToast({
data: {
text: $_('watchParty.userLeft')
}
});
});
pushState(`?${currentSearchParams.toString()}`, { replaceState: false }); // eslint-disable-line svelte/no-navigation-without-resolve
}
function initalRoom() {
const givenRoomId = page.url.searchParams.get('room');
if (!givenRoomId) return;
room = joinRoom({ appId }, givenRoomId);
setupRoom(room);
}
playerState.subscribe((player) => {
if (!player?.playerElement) return;
const playerElement = player.playerElement;
function sendPlayerEvent(event: 'play' | 'pause' | 'seek') {
if (!room || !player) return;
sendEvent({
event,
videoId: player.data.video.videoId,
sent: new Date(),
currentTime: playerElement.currentTime
});
}
let initalPlaying = true;
playerElement.addEventListener('play', () => sendPlayerEvent('play'));
playerElement.addEventListener('playing', () => {
if (!initalPlaying) return;
initalPlaying = true;
sendPlayerEvent('play');
});
playerElement.addEventListener('seeked', () => sendPlayerEvent('seek'));
playerElement.addEventListener('pause', () => sendPlayerEvent('pause'));
playerElement.addEventListener('waiting', () => sendPlayerEvent('pause'));
playerElement.addEventListener('error', () => sendPlayerEvent('pause'));
});
</script>
<article>
<h4>{$_('watchParty.header')}</h4>
{#if !room}
<div class="space"></div>
<button onclick={createRoom} class="surface-container-highest">
<span>{$_('watchParty.createRoom')}</span>
</button>
{:else}
<div class="space"></div>
<button
onclick={() => {
room?.leave();
room = undefined;
}}
class="surface-container-highest"
>
{$_('watchParty.leaveRoom')}
</button>
{/if}
</article>
@@ -14,6 +14,7 @@
import { SpatialMenu } from 'melt/builders';
import { mergeAttrs } from 'melt';
import { Capacitor } from '@capacitor/core';
import { isItemFiltered } from '$lib/filtering/index';
interface Props {
items?: FeedItems;
@@ -78,49 +79,51 @@
<div
class={classes}
class:item-container={Capacitor.getPlatform() !== 'android' || $isAndroidTvStore}
class:item-container={Capacitor.getPlatform() !== 'android' && !$isAndroidTvStore}
>
{#if items.length === 0}
<NoResults />
{/if}
<div class="grid" {...spatialMenu.root}>
{#each items as item, index (index)}
{@const uniqueItemId = extractUniqueId(item)}
{@const spatialItem = spatialMenu.getItem(item, { onSelect: () => goToItem(uniqueItemId) })}
<ContentColumn>
<article
{...mergeAttrs(spatialItem.attrs, {
onclick: () => goToItem(uniqueItemId),
id: uniqueItemId
})}
class="no-padding item-select border"
class:item-select-focused={spatialItem.highlighted}
style="height: 100%;"
>
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream' || item.type === 'historyVideo'}
{#key item.videoId}
<Thumbnail video={item} {playlistId} />
{/key}
{#if $invidiousAuthStore && decodeURIComponent($invidiousAuthStore.username) === playlistAuthor && 'indexId' in item}
<div class="right-align" style="margin: 1em .5em;">
<button
onclick={async () => removePlaylistItem(item.indexId)}
class="tertiary circle small"
>
<i>delete</i>
<div class="tooltip">{$_('delete')}</div>
</button>
</div>
{#if !isItemFiltered(item)}
{@const uniqueItemId = extractUniqueId(item)}
{@const spatialItem = spatialMenu.getItem(item, { onSelect: () => goToItem(uniqueItemId) })}
<ContentColumn>
<article
{...mergeAttrs(spatialItem.attrs, {
onclick: () => goToItem(uniqueItemId),
id: uniqueItemId
})}
class="no-padding item-select border"
class:item-select-focused={spatialItem.highlighted}
style="height: 100%;"
>
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream' || item.type === 'historyVideo'}
{#key item.videoId}
<Thumbnail video={item} {playlistId} />
{/key}
{#if $invidiousAuthStore && decodeURIComponent($invidiousAuthStore.username) === playlistAuthor && 'indexId' in item}
<div class="right-align" style="margin: 1em .5em;">
<button
onclick={async () => removePlaylistItem(item.indexId)}
class="tertiary circle small"
>
<i>delete</i>
<div class="tooltip">{$_('delete')}</div>
</button>
</div>
{/if}
{:else if item.type === 'channel'}
<ChannelThumbnail channel={item} />
{:else if item.type === 'playlist'}
<PlaylistThumbnail playlist={item} />
{:else if item.type === 'hashtag'}
<HashtagThumbnail hashtag={item} />
{/if}
{:else if item.type === 'channel'}
<ChannelThumbnail channel={item} />
{:else if item.type === 'playlist'}
<PlaylistThumbnail playlist={item} />
{:else if item.type === 'hashtag'}
<HashtagThumbnail hashtag={item} />
{/if}
</article>
</ContentColumn>
</article>
</ContentColumn>
{/if}
{/each}
</div>
</div>
@@ -1,6 +1,6 @@
<script lang="ts" module>
let trackVisible: boolean = $state(false);
let captionsCues: VTTCue[] = $state([]);
let renderer: CaptionsRenderer | undefined;
let captionTracks: Record<string, string> = {};
@@ -19,7 +19,7 @@
});
}
captionsCues = (await parseText(await resp.text(), { strict: true, type: 'vtt' })).cues;
renderer?.changeTrack(await parseResponse(resp));
}
</script>
@@ -27,8 +27,9 @@
import type { VideoPlay } from '$lib/api/model';
import { onDestroy, onMount } from 'svelte';
import { addToast } from '../Toast.svelte';
import { parseText, renderVTTCueString, type VTTCue } from 'media-captions';
import { parseResponse, CaptionsRenderer } from 'media-captions';
import { getCaptionUrl } from '$lib/player/captions';
import 'media-captions/styles/captions.css';
let {
video,
@@ -41,21 +42,8 @@
} = $props();
let captionElement: HTMLElement | undefined = $state();
let captionContainerHeight: number = $state(0);
function updateCaptionHeight() {
if (captionElement) {
captionContainerHeight = captionElement.offsetHeight;
}
}
$effect(() => {
updateCaptionHeight();
});
onMount(async () => {
window.addEventListener('resize', updateCaptionHeight);
if (video.captions) {
for (const caption of video.captions) {
const captionUrl = getCaptionUrl(caption, video.fallbackPatch);
@@ -65,66 +53,58 @@
captionTracks[caption.language_code] = captionUrl;
}
}
if (captionElement) {
renderer = new CaptionsRenderer(captionElement);
}
});
onDestroy(() => {
window.removeEventListener('resize', updateCaptionHeight);
captionTracks = {};
captionsCues = [];
trackVisible = false;
});
$effect(() => {
if (!renderer) return;
renderer.currentTime = currentTime;
});
</script>
{#if trackVisible && captionsCues.length > 0}
<div
class="caption-container"
bind:this={captionElement}
style:top={`calc(${showControls ? 'var(--video-player-height) * var(--top-percentage-controls-shown)' : 'var(--video-player-height) * var(--top-percentage-controls-hidden)'} - ${captionContainerHeight}px)`}
>
{#each captionsCues as cue (cue)}
<p class:hide={currentTime <= cue.startTime || currentTime >= cue.endTime}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderVTTCueString(cue, currentTime)}
</p>
{/each}
</div>
{/if}
<div
id="captions"
class:controls-shown={showControls}
bind:this={captionElement}
class:hide={!trackVisible}
></div>
<style>
:root {
--top-percentage-controls-shown: 0.85;
--top-percentage-controls-hidden: 0.98;
#captions {
--overlay-padding: 1%;
--cue-color: white;
--cue-bg-color: rgba(0, 0, 0, 0.8);
--cue-font-size: calc(var(--overlay-height) / 100 * 3);
--cue-line-height: calc(var(--cue-font-size) * 1.2);
--cue-padding-x: calc(var(--cue-font-size) * 0.6);
--cue-padding-y: calc(var(--cue-font-size) * 0.4);
bottom: 30px;
left: 55%;
transform: translateX(-50%); /* true horizontal centering */
}
.caption-container {
position: absolute;
z-index: 5;
left: 50%;
transform: translateX(-50%);
width: fit-content;
}
p {
padding: 5px;
border-radius: 0.25rem;
user-select: none;
font-size: 1.5rem;
color: #fff !important;
background-color: rgb(0, 0, 0, 0.7);
#captions.controls-shown {
bottom: 80px;
}
@media screen and (max-width: 1000px) {
:root {
--top-percentage-controls-shown: 0;
}
.caption-container {
#captions {
--cue-font-size: calc(var(--overlay-height) / 100 * 8);
bottom: 0px;
width: 100%;
}
p {
font-size: 1rem;
#captions.controls-shown {
display: none;
}
}
</style>
@@ -1,10 +1,10 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import { getBestThumbnail } from '$lib/images';
import { videoLength } from '$lib/numbers';
import { generateChapterWebVTT, type ParsedDescription } from '$lib/description';
import { Capacitor, SystemBars, SystemBarsStyle, SystemBarType } from '@capacitor/core';
import { error, type Page } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import Mousetrap from 'mousetrap';
import { CapacitorMusicControls } from 'capacitor-music-controls-plugin';
import shaka from 'shaka-player/dist/shaka-player.ui';
@@ -27,9 +27,8 @@
playerProxyVideosStore,
playerSavePlaybackPositionStore,
playerState,
playertheatreModeIsActive,
playerTheatreModeIsActive,
playerYouTubeJsFallback,
rawMasterKeyStore,
sponsorBlockCategoriesStore,
sponsorBlockDisplayToastStore,
sponsorBlockStore,
@@ -61,7 +60,7 @@
import { Network, type ConnectionStatus } from '@capacitor/network';
import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation';
import ClosedCaptions from './ClosedCaptions.svelte';
import { getVideoWatchHistory, updateWatchHistory } from '$lib/api/backend/history';
import { getVideoWatchHistory, updateWatchHistory } from '$lib/api';
interface Props {
data: { video: VideoPlay; content: ParsedDescription; playlistId: string | null };
@@ -113,7 +112,7 @@
step: 0.01
});
playertheatreModeIsActive.subscribe(async () => {
playerTheatreModeIsActive.subscribe(async () => {
await tick();
updateVideoPlayerHeight();
});
@@ -198,7 +197,7 @@
});
}
function loadTimeFromUrl(page: Page): boolean {
function loadTimeFromUrl(): boolean {
if (player) {
const timeGivenUrl = page.url.searchParams.get('time');
if (timeGivenUrl && !isNaN(parseFloat(timeGivenUrl))) {
@@ -211,7 +210,9 @@
return false;
}
page.subscribe((pageUpdate) => loadTimeFromUrl(pageUpdate));
$effect(() => {
loadTimeFromUrl();
});
function toggleFullscreen() {
if (document.fullscreenElement) {
@@ -447,8 +448,6 @@
// Change instantly to stop video from being loud for a second
restoreVolumePreference();
playerContainer = document.getElementById('player-container') as HTMLElement;
window.addEventListener('resize', updateVideoPlayerHeight);
updateVideoPlayerHeight();
@@ -724,22 +723,13 @@
});
async function getPlaybackHistory(): Promise<number> {
if (loadTimeFromUrl($page) || !$playerSavePlaybackPositionStore) return 0;
if (loadTimeFromUrl() || !$playerSavePlaybackPositionStore) return 0;
let toSetTime = 0;
try {
const playerPos = localStorage.getItem(`v_${data.video.videoId}`);
if (playerPos && Number(playerPos) > toSetTime) {
toSetTime = Number(playerPos);
}
} catch {
// Continue regardless of error
}
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
const watchHistory = await getVideoWatchHistory(data.video.videoId);
if (watchHistory) toSetTime = watchHistory.progress;
const watchHistory = await getVideoWatchHistory(data.video.videoId);
if (watchHistory && watchHistory.progress < playerMaxKnownTime - 10) {
toSetTime = watchHistory.progress;
}
return toSetTime;
@@ -748,23 +738,7 @@
function savePlayerbackHistory() {
if (data.video.liveNow || !$playerSavePlaybackPositionStore || !playerElement) return;
if (playerElement.currentTime < playerElement.duration - 10 && playerElement.currentTime > 10) {
try {
localStorage.setItem(`v_${data.video.videoId}`, playerElement.currentTime.toString());
} catch {
// Continue regardless of error
}
} else {
try {
localStorage.removeItem(`v_${data.video.videoId}`);
} catch {
// Continue regardless of error
}
}
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
updateWatchHistory(data.video.videoId, playerElement.currentTime);
}
updateWatchHistory(data.video.videoId, playerElement.currentTime);
}
onDestroy(async () => {
@@ -1,6 +1,4 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash } from '../../misc';
@@ -15,7 +13,12 @@
let deArrowThumbnailUrl = $state(get(deArrowThumbnailInstanceStore));
</script>
<form onsubmit={preventDefault(() => deArrowInstanceStore.set(ensureNoTrailingSlash(deArrowUrl)))}>
<form
onsubmit={(event: Event) => {
event.preventDefault();
deArrowInstanceStore.set(ensureNoTrailingSlash(deArrowUrl));
}}
>
<nav>
<div class="field prefix label surface-container-highest max">
<i>link</i>
@@ -28,7 +31,12 @@
</nav>
</form>
<form onsubmit={preventDefault(() => deArrowThumbnailInstanceStore.set(deArrowThumbnailUrl))}>
<form
onsubmit={(event: Event) => {
event.preventDefault();
deArrowThumbnailInstanceStore.set(deArrowThumbnailUrl);
}}
>
<nav>
<div class="field prefix label surface-container-highest max">
<i>link</i>
@@ -0,0 +1,306 @@
<script lang="ts">
import { loadContentFilterFromURL, zFilterGroup, zFilterOperatorEnum } from '$lib/filtering';
import { ChannelSchema, VideoSchema, type SchemaStructure } from '$lib/filtering/schemas';
import { _ } from '$lib/i18n';
import { titleCase, camelCaseToHuman } from '$lib/letterCasing';
import {
filterContentListStore,
filterContentUrlAutoUpdateStore,
filterContentUrlStore
} from '$lib/store';
import type z from 'zod';
import { addToast } from '../Toast.svelte';
import { Clipboard } from '@capacitor/clipboard';
let remoteFilterListUrl: string = $state($filterContentUrlStore ?? '');
let remoteError: string = $state('');
type FilterType = 'channel' | 'video';
const filterTypes: FilterType[] = ['channel', 'video'];
const schema: Record<FilterType, SchemaStructure> = {
channel: ChannelSchema,
video: VideoSchema
};
let contentFilters = $state($filterContentListStore);
async function loadFilterList(event: Event) {
event.preventDefault();
remoteError = '';
if (!remoteFilterListUrl) {
remoteError = 'No URL specified';
return;
}
try {
contentFilters = await loadContentFilterFromURL(remoteFilterListUrl);
filterContentListStore.set(contentFilters);
filterContentUrlStore.set(remoteFilterListUrl);
filterContentUrlAutoUpdateStore.set(false);
} catch (errorMsg) {
remoteError = (errorMsg as Error).message;
}
}
async function exportAsJSON() {
await Clipboard.write({
string: JSON.stringify(
{
version: 'v1',
createdFor: 'materialious',
filterBy: contentFilters
},
null,
2
)
});
addToast({
data: {
text: $_('player.share.copiedSuccess')
}
});
}
function addFilter(type: FilterType) {
if (!contentFilters) contentFilters = [];
contentFilters.push({
conditions: [],
type
});
filterContentListStore.set(contentFilters);
}
function removeFilter(filter: z.infer<typeof zFilterGroup>) {
if (!contentFilters) contentFilters = [];
contentFilters = contentFilters.filter((item) => item !== filter);
filterContentListStore.set(contentFilters);
}
</script>
<article class="error-container">
<p>{$_('layout.backendEngine.warning')}</p>
</article>
<form onsubmit={loadFilterList}>
<nav>
<div
class="field prefix label surface-container-highest max"
class:invalid={remoteError !== ''}
>
<i>link</i>
<input tabindex="0" bind:value={remoteFilterListUrl} name="remote-url" type="text" />
<label tabindex="-1" for="remote-url">{$_('layout.filter.url')}</label>
{#if remoteError !== ''}
<span class="error">{remoteError}</span>
{/if}
</div>
<button class="circle">
<i>done</i>
</button>
</nav>
</form>
{#if $filterContentUrlStore}
<nav class="no-padding">
<div class="max">
<p>{$_('layout.filter.autoUpdate')}</p>
</div>
<label class="switch" tabindex="0">
<input
bind:checked={$filterContentUrlAutoUpdateStore}
onclick={() => filterContentUrlAutoUpdateStore.set(!$filterContentUrlAutoUpdateStore)}
type="checkbox"
role="switch"
/>
<span></span>
</label>
</nav>
<div class="space"></div>
{/if}
{#if contentFilters}
{#if contentFilters.length > 0}
<button class="surface-container-highest" onclick={exportAsJSON}>
<i>content_copy</i>
<span>{$_('copy')}</span>
</button>
<div class="space"></div>
{/if}
{#each contentFilters as filter (filter)}
<article class="no-margin surface-container-high">
<div class="grid">
<div class="s12 m6 l6">
<div class="label field suffix surface-container-highest">
<select name="content-type">
{#each filterTypes as filterType (filterType)}
<option selected={filterType === filter.type} value={filterType}
>{titleCase(filterType)}</option
>
{/each}
</select>
<label for="content-type">{$_('layout.filter.contentType')}</label>
<i>arrow_drop_down</i>
</div>
</div>
<div class="s12 m6 l6 right-align">
<button onclick={() => removeFilter(filter)} class="surface-container-highest">
<i>close</i>
</button>
</div>
</div>
<div class="space"></div>
<hr />
{#if filter.conditions}
<ul class="list">
{#each filter.conditions as condition (condition)}
<li style="display: block;">
<nav class="right-align no-margin">
<button
onclick={() => {
filter.conditions = filter.conditions.filter((item) => condition !== item);
filterContentListStore.set(contentFilters);
}}
class="surface-container-highest"
>
<i>close</i>
</button>
</nav>
<div class="label field suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.field = event.currentTarget.value;
filterContentListStore.set(contentFilters);
}}
name="field"
>
{#each Object.keys(schema[filter.type]) as key (key)}
<option value={key} selected={condition.field === key}
>{camelCaseToHuman(key)}</option
>
{/each}
</select>
<label for="field">{$_('layout.filter.field')}</label>
<i>arrow_drop_down</i>
</div>
<div class="field label suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.operator = event.currentTarget.value as z.infer<
typeof zFilterOperatorEnum
>;
filterContentListStore.set(contentFilters);
}}
name="operator"
>
{#each zFilterOperatorEnum.options as operator (operator)}
<option selected={operator === condition.operator} value={operator}
>{operator}</option
>
{/each}
</select>
<label for="operator">{$_('layout.filter.operator')}</label>
<i>arrow_drop_down</i>
</div>
{#if schema[filter.type][condition.field] === 'boolean'}
<div class="field label suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.value = event.currentTarget.value;
filterContentListStore.set(contentFilters);
}}
name="boolean-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
<option selected={condition.value === 'true'} value="true">true</option>
<option selected={condition.value === 'false'} value="false">false</option>
</select>
<label for="boolean-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else if Array.isArray(schema[filter.type][condition.field])}
<div class="field label suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.value = event.currentTarget.value;
filterContentListStore.set(contentFilters);
}}
name="array-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
{#each schema[filter.type][condition.field] as value (value)}
<option selected={condition.value === value} {value}
>{camelCaseToHuman(value)}</option
>
{/each}
</select>
<label for="array-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else}
<div class="field label border">
<input
oninput={(event: Event & { currentTarget: HTMLInputElement }) => {
condition.value =
schema[filter.type][condition.field] === 'number'
? Number(event.currentTarget.value)
: event.currentTarget.value;
filterContentListStore.set(contentFilters);
}}
name="value"
type={schema[filter.type][condition.field] === 'string' ? 'text' : 'number'}
value={condition.value}
/>
<label for="value">{$_('layout.filter.value')}</label>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="small-space"></div>
<button
onclick={() => {
filter.conditions.push({
operator: 'equals',
field: 'author',
value: ''
});
}}
class="surface-container-highest"
>
<i>add</i>
<span>{$_('layout.filter.addConditional')}</span>
</button>
</article>
<div class="small-space"></div>
{/each}
{/if}
<div>
<button class="surface-container-highest">
<i>add</i>
<span>{$_('layout.filter.addFilter')}</span>
</button>
<menu>
{#each filterTypes as filterType (filterType)}
<li role="presentation" onclick={() => addFilter(filterType)}>{titleCase(filterType)}</li>
{/each}
</menu>
</div>
@@ -31,7 +31,8 @@
interfaceSearchHistoryEnabled,
interfaceSearchSuggestionsStore,
searchHistoryStore,
themeColorStore
themeColorStore,
watchHistoryEnabledStore
} from '../../store';
import { addToast } from '../Toast.svelte';
import { tick } from 'svelte';
@@ -274,6 +275,23 @@
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.historyEnabled')}</div>
</div>
<label class="switch" tabindex="0">
<input
type="checkbox"
bind:checked={$watchHistoryEnabledStore}
onclick={() => watchHistoryEnabledStore.set(!$watchHistoryEnabledStore)}
role="switch"
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
@@ -1,7 +1,6 @@
<script lang="ts">
import { _ } from '$lib/i18n';
import { materialiousLogout } from '$lib/auth';
import { watchHistoryEnabledStore } from '$lib/store';
let clickCount = $state(0);
const clicksToDelte = 3;
@@ -22,27 +21,6 @@
}
</script>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.historyEnabled')}</div>
</div>
<label class="switch" tabindex="0">
<input
type="checkbox"
bind:checked={$watchHistoryEnabledStore}
onclick={() => watchHistoryEnabledStore.set(!$watchHistoryEnabledStore)}
role="switch"
/>
<span></span>
</label>
</nav>
</div>
<div class="space"></div>
<div class="divider"></div>
<div class="space"></div>
<button class="tertiary" onclick={deleteAccount}>
<i>warning</i>
<span>{$_('layout.deleteAccount')}</span>
@@ -1,6 +1,4 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash } from '../../misc';
@@ -10,9 +8,10 @@
</script>
<form
onsubmit={preventDefault(() =>
returnYTDislikesInstanceStore.set(ensureNoTrailingSlash(returnYTInstance))
)}
onsubmit={(event: Event) => {
event.preventDefault();
returnYTDislikesInstanceStore.set(ensureNoTrailingSlash(returnYTInstance));
}}
>
<nav>
<div class="field prefix label surface-container-highest max">
@@ -14,6 +14,7 @@
import InternalAccount from './InternalAccount.svelte';
import { Tabs } from 'melt/builders';
import { mergeAttrs } from 'melt';
import Filters from './Filters.svelte';
type TabCategories =
| 'interface'
@@ -23,7 +24,8 @@
| 'dearrow'
| 'about'
| 'engine'
| 'account';
| 'account'
| 'filters';
const tabCategories: Tabs<TabCategories> = new Tabs({
value: 'interface',
@@ -39,6 +41,7 @@
let tabs: { id: TabCategories; label: string; icon: string; component: Component }[] = $state([
{ id: 'interface', label: $_('layout.interface'), icon: 'grid_view', component: Interface },
{ id: 'player', label: $_('layout.player.title'), icon: 'smart_display', component: Player },
{ id: 'filters', label: $_('layout.filter.title'), icon: 'filter_alt', component: Filters },
{ id: 'ryd', label: 'Return YT Dislike', icon: 'thumb_down', component: Ryd },
{ id: 'sponsorblock', label: 'Sponsorblock', icon: 'block', component: SponsorBlock },
{
@@ -1,6 +1,4 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash } from '../../misc';
@@ -43,9 +41,10 @@
</script>
<form
onsubmit={preventDefault(() =>
sponsorBlockUrlStore.set(ensureNoTrailingSlash(sponsorBlockInstance))
)}
onsubmit={(event: Event) => {
event.preventDefault();
sponsorBlockUrlStore.set(ensureNoTrailingSlash(sponsorBlockInstance));
}}
>
<nav>
<div class="field prefix label surface-container-highest max">
@@ -6,7 +6,6 @@
import { onDestroy, onMount } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { getDeArrow, getThumbnail } from '$lib/api';
import { Avatar } from 'melt/builders';
import type {
Notification,
@@ -15,17 +14,11 @@
VideoBase,
VideoWatchHistory
} from '$lib/api/model';
import {
deArrowEnabledStore,
isAndroidTvStore,
playerSavePlaybackPositionStore,
playerState,
rawMasterKeyStore
} from '$lib/store';
import { deArrowEnabledStore, isAndroidTvStore, playerState } from '$lib/store';
import { relativeTimestamp } from '$lib/time';
import { queueGetWatchHistory } from '$lib/api/backend/historyPool';
import { queueGetWatchHistory } from '$lib/api/historyPool';
import { page } from '$app/state';
import { isOwnBackend } from '$lib/shared';
import { getDeArrow, getThumbnailDeArrow } from '$lib/api/dearrow';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo | VideoWatchHistory;
@@ -44,18 +37,7 @@
watchUrl.searchParams.set('playlist', playlistId);
}
let beenWatched: boolean = $state(false);
let progress: string | undefined = $state();
if (get(playerSavePlaybackPositionStore)) {
try {
progress = localStorage.getItem(`v_${video.videoId}`) ?? undefined;
} catch {
progress = undefined;
}
} else {
progress = undefined;
}
let thumbnailSrc = $state(
'thumbnail' in video ? video.thumbnail : (getBestThumbnail(video.videoThumbnails) as string)
@@ -74,7 +56,7 @@
for (const thumbnail of deArrow.thumbnails) {
if (thumbnail.locked || thumbnail.original || thumbnail.votes > 0) {
if (thumbnail.timestamp !== null) {
thumbnailSrc = await getThumbnail(video.videoId, thumbnail.timestamp, {
thumbnailSrc = await getThumbnailDeArrow(video.videoId, thumbnail.timestamp, {
priority: 'low'
});
}
@@ -99,24 +81,14 @@
} else sideways = true;
}
function checkIfWatched() {
beenWatched = !!(progress && !page.url.pathname.endsWith('/history'));
}
onMount(async () => {
// Check if sideways should be enabled or disabled.
disableSideways();
checkIfWatched();
if (
!page.url.pathname.endsWith('/history') &&
isOwnBackend()?.internalAuth &&
get(rawMasterKeyStore)
)
if (!page.url.pathname.endsWith('/history'))
queueGetWatchHistory(video.videoId).then((watchHistory) => {
if (watchHistory) {
progress = watchHistory.progress.toString();
checkIfWatched();
}
});
});
@@ -144,7 +116,7 @@
<div class:crop={thumbnailHTMLElement ? thumbnailHTMLElement.height > 300 : false}>
<img
class="responsive"
class:watched={beenWatched}
class:watched={progress !== undefined}
{...thumbnail.image}
bind:this={thumbnailHTMLElement}
alt="Thumbnail for video"
@@ -156,7 +128,7 @@
style="height: 200px;"
></div>
{#if beenWatched}
{#if progress !== undefined}
<div class="chip surface-container-highest">
<i>check</i>
</div>
@@ -76,7 +76,7 @@
<article class="comment" class:border={!isSubComp}>
<div class="comment-header">
<img {...avatar.image} loading="lazy" class="circle small" alt="comment profile" />
<img {...avatar.image} class="circle small" alt="comment profile" />
<button
class="secondary-container"
{...mergeAttrs(avatar.fallback, {
+5 -3
View File
@@ -1,6 +1,6 @@
import type { Table } from 'dexie';
import Dexie from 'dexie';
import type { Video } from './api/model';
import type { Video, VideoWatchHistory } from './api/model';
export interface FavouriteChannels {
channelId: string;
@@ -17,13 +17,15 @@ export class MaterialiousDb extends Dexie {
favouriteChannels!: Table<FavouriteChannels>;
channelSubscriptions!: Table<ChannelSubscriptions>;
subscriptionFeed!: Table<Video>;
watchHistory!: Table<VideoWatchHistory>;
constructor() {
super('materialious');
this.version(2).stores({
this.version(3).stores({
favouriteChannels: 'channelId',
channelSubscriptions: 'channelId',
subscriptionFeed: 'videoId, authorId, published'
subscriptionFeed: 'videoId, authorId, published',
watchHistory: 'videoId'
});
}
}
@@ -51,7 +51,9 @@ import {
playerYouTubeJsAlways,
interfaceSearchHistoryEnabled,
playerPreferredVolumeStore,
watchHistoryEnabledStore
watchHistoryEnabledStore,
filterContentUrlStore,
filterContentUrlAutoUpdateStore
} from '$lib/store';
import { isOwnBackend } from '$lib/shared';
@@ -60,8 +62,8 @@ type PersistedStore<T> = {
store: Writable<T>;
schema: z.ZodType<T>;
serialize?: (value: T) => string;
excludeFromBookmarklet?: boolean; // Won't be include in bookmarklet
excludeFromBackendSync?: boolean;
excludeFromBookmarklet?: boolean; // Won't be included in bookmarklet
excludeFromBackendSync?: boolean; // Won't be sync'd to account cloud
};
const zBoolean = z.coerce.boolean();
@@ -253,6 +255,21 @@ export const persistedStores: PersistedStore<any>[] = [
store: sponsorBlockCategoriesStore,
schema: zChapterModeRecord,
serialize: JSON.stringify
},
{
name: 'watchHistoryEnabled',
store: watchHistoryEnabledStore,
schema: zBoolean
},
{
name: 'filterContentUrl',
store: filterContentUrlStore,
schema: zString
},
{
name: 'filterContentUrlAutoUpdate',
store: filterContentUrlAutoUpdateStore,
schema: zBoolean
}
];
@@ -335,11 +352,6 @@ if (isOwnBackend()) {
store: playerYouTubeJsAlways,
schema: zBoolean
});
persistedStores.push({
name: 'watchHistoryEnabled',
store: watchHistoryEnabledStore,
schema: zBoolean
});
}
export const persistedStoreKeys = persistedStores.map((store) => store.name);
-1
View File
@@ -14,7 +14,6 @@ export type FeedItem =
| Video
| PlaylistPageVideo
| Channel
| Video
| Playlist
| HashTag
| PlaylistPage
+131
View File
@@ -0,0 +1,131 @@
import { z } from 'zod';
import type { FeedItem } from '$lib/feed';
import isSafeRegex from 'safe-regex2';
import { filterContentListStore } from '$lib/store';
import { get } from 'svelte/store';
import { originalFetch } from '$lib/fetchProxy';
import { ChannelSchema, VideoSchema } from './schemas';
export const zFilterOperatorEnum = z.enum([
'equals', // equal to
'in', // in a set of values
'like', // contains (string matching)
'gt', // greater than
'lt', // less than
'regex' // regular expression matching
]);
const allowedFields = new Set([...Object.keys(VideoSchema), ...Object.keys(ChannelSchema)]);
// Filter condition schema
const zFilterCondition = z.object({
field: z.string().refine((val) => allowedFields.has(val), {
message: 'Invalid field'
}), // Field to filter
operator: zFilterOperatorEnum, // Operator
value: z.union([
// Value to compare against
z.string(),
z.number(),
z.array(z.string()),
z.array(z.number()),
z.string().regex(/.*/)
])
});
// Logical grouping operator
export const zFilterGroup = z.object({
conditions: z.array(zFilterCondition), // A list of conditions to apply
type: z.union([z.literal('video'), z.literal('channel')]) // Type of content
});
export const zFilterSchema = z.array(zFilterGroup);
const zFilterRootSchema = z.object({
version: z.literal('v1'),
createdFor: z.literal('materialious'),
filterBy: zFilterSchema
});
export function isItemFiltered(item: FeedItem): boolean {
const filteredContent = get(filterContentListStore);
if (!filteredContent) return false;
return filteredContent.some((filterGroup) => {
if (filterGroup.type !== item.type) {
// Video is an alias for shortVideo & stream
if (filterGroup.type !== 'video' || (item.type !== 'shortVideo' && item.type !== 'stream')) {
return false;
}
}
if (filterGroup.conditions.length === 0) return false;
const evaluateCondition = (condition: (typeof filterGroup.conditions)[number]): boolean => {
if (!(condition.field in item)) return false;
const fieldValue = item[condition.field as keyof FeedItem];
switch (condition.operator) {
case 'equals':
return fieldValue.toString() === condition.value.toString();
case 'in':
return Array.isArray(condition.value) && (condition.value as any[]).includes(fieldValue);
case 'like':
return (
typeof fieldValue === 'string' &&
typeof condition.value === 'string' &&
fieldValue.toLowerCase().includes(condition.value.toLowerCase())
);
case 'gt':
return (
typeof fieldValue === 'number' &&
typeof condition.value === 'number' &&
fieldValue > condition.value
);
case 'lt':
return (
typeof fieldValue === 'number' &&
typeof condition.value === 'number' &&
fieldValue < condition.value
);
case 'regex':
if (typeof condition.value !== 'string' || !isSafeRegex(condition.value)) return false;
return typeof fieldValue === 'string' && new RegExp(condition.value).test(fieldValue);
default:
return false;
}
};
return filterGroup.conditions.every(evaluateCondition);
});
}
export async function loadContentFilterFromURL(
url: string
): Promise<z.infer<typeof zFilterSchema>> {
const resp = await originalFetch(url, { method: 'GET', credentials: 'omit' });
if (!resp.ok) throw new Error('Response status code');
let respJson;
try {
respJson = await resp.json();
} catch {
// Handled outside of catch
}
if (!respJson) throw new Error('Invalid JSON');
const parsedFilterList = zFilterRootSchema.safeParse(respJson);
if (!parsedFilterList.success) throw new Error(parsedFilterList.error.message);
return parsedFilterList.data.filterBy;
}
+28
View File
@@ -0,0 +1,28 @@
// Not possible to exact from typescript interfaces at runtime.
export type SchemaStructure = Record<string, 'string' | 'number' | 'boolean' | string[]>;
export const VideoSchema: SchemaStructure = {
author: 'string',
authorId: 'string',
authorUrl: 'string',
authorVerified: 'boolean',
published: 'number',
publishedText: 'string',
title: 'string',
type: ['video', 'shortVideo', 'stream'],
videoId: 'string',
viewCount: 'number',
viewCountText: 'string'
};
export const ChannelSchema: SchemaStructure = {
author: 'string',
authorId: 'string',
authorUrl: 'string',
authorVerified: 'boolean',
autoGenerated: 'boolean',
channelHandle: 'string',
description: 'string',
subCount: 'number'
};
+2 -1
View File
@@ -205,7 +205,8 @@
"clickXmoreTimesToDelete": "Klicke {{clicksTillDelete}} weitere(s) Mal(e) zum Löschen",
"backendEngine": {
"warning": "Erweiterte Einstellungen! Nur für erfahrene Nutzer. Ändere sie nicht, ausser du weisst, was du tust."
}
},
"historyEnabled": "Wiedergabeverlauf speichern"
},
"subscribe": "Abonnieren",
"recommendedVideos": "Vorgeschlagene Videos",
+23 -10
View File
@@ -77,11 +77,6 @@
"newest": "Newest",
"oldest": "Oldest",
"popular": "Popular",
"syncParty": {
"userJoined": "User joined your watch party.",
"userLeft": "User left your watch party.",
"userOnPrivatePage": "User currently viewing a private page, syncing will resume shorty."
},
"videoTabs": {
"all": "All",
"videos": "Videos",
@@ -90,6 +85,17 @@
"shorts": "Shorts",
"streams": "Streams"
},
"watchParty": {
"start": "Start a watch party",
"header": "Watch party",
"createRoom": "Create room",
"joinRoom": "Join room",
"roomID": "Room ID",
"shareRoom": "Share room",
"leaveRoom": "Leave room",
"userJoin": "User joined room",
"userLeft": "User left room"
},
"subscriptions": {
"manageSubscriptions": "Manage subscriptions"
},
@@ -167,13 +173,8 @@
"interface": "Interface",
"star": "Star us on Github!",
"engine": "Backend engine",
"syncParty": "Sync party",
"about": "About",
"materialiousAccount": "Account",
"syncPartyWarning": "Please note your IP will be visible to users you invite.",
"startSyncParty": "Start sync party",
"endSyncParty": "End sync party",
"joinSyncParty": "Enter sync party URL",
"shareURL": "Share URL",
"notifications": "Notifications",
"noNewNotifications": "No new notifications here",
@@ -258,6 +259,18 @@
"manual": "Manual skip",
"timeline": "Timeline only"
},
"filter": {
"title": "Content Filters",
"url": "Import Filters from URL",
"autoUpdate": "Automatically update filter from URL",
"contentType": "Content type",
"field": "Field",
"operator": "Operator",
"optionPlaceholder": "Select your option",
"value": "Value",
"addConditional": "Add conditional",
"addFilter": "Add filter"
},
"deArrow": {
"title": "DeArrow",
"thumbnailInstanceUrl": "Thumbnail instance URL",
+2 -1
View File
@@ -102,7 +102,8 @@
"clickXmoreTimesToDelete": "Klick {{clicksTillDelete}} wiiteri Mal zum Lösche",
"backendEngine": {
"warning": "Erwiterti Iistellige! Nur für erfahreni Nutzer. Ändere sie nöd, usser du weisch, was du machsch."
}
},
"historyEnabled": "Wiedergabeverlauf speichere"
},
"videos": "Videos",
"cancel": "Abbreche",
+2 -1
View File
@@ -221,7 +221,8 @@
"exportingToInvidious": "Invidiousへのエクスポートを開始しました",
"exportingToInvidiousFinished": "Invidiousへのエクスポートが終了しました"
},
"exportToJson": "JSON にエクスポート"
"exportToJson": "JSON にエクスポート",
"historyEnabled": "視聴履歴を保存"
},
"subscribe": "登録",
"invidiousBlockWarning": "Invidious は、現在Googleにブロックされています。このインスタンスで動画が読み込めない場合、{android} 版または {desktop} 版の Materialious でこのインスタンスを使用し、動画取得を直接行う代替接続を使ってください。",
+9
View File
@@ -53,3 +53,12 @@ export function titleCase(text: string): string {
return titleCasedWords.join(' ');
}
export function camelCaseToHuman(input: string): string {
return sentenceCase(
input
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
.trim()
);
}
+3 -1
View File
@@ -10,7 +10,9 @@ import { addToast } from './components/Toast.svelte';
import { _ } from './i18n';
export function getPublicEnv(envName: string): string | undefined {
return env[`PUBLIC_${envName}`] ?? import.meta.env[`VITE_${envName}`];
const envValue = env[`PUBLIC_${envName}`] ?? import.meta.env[`VITE_${envName}`];
if (envValue === '') return;
return envValue;
}
export function isMobile(): boolean {
+5 -8
View File
@@ -1,8 +1,7 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { isYTBackend } from './misc';
import { invidiousAuthStore, rawMasterKeyStore } from './store';
import { isOwnBackend } from './shared';
import { invidiousAuthStore } from './store';
export type Pages = { icon: string; href: string; name: string; requiresAuth: boolean }[];
@@ -26,16 +25,14 @@ export function getPages(): Pages {
href: '/playlists',
name: get(_)('pages.playlists'),
requiresAuth: true
}
];
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore))
pages.push({
},
{
icon: 'history',
href: '/history',
name: get(_)('pages.history'),
requiresAuth: false
});
}
];
pages = pages.filter((page) => {
return !page.requiresAuth || (get(invidiousAuthStore) && !isYTBackend());
+21 -1
View File
@@ -22,6 +22,8 @@ import type {
import type { ParsedDescription } from './description';
import { ensureNoTrailingSlash, getPublicEnv } from './misc';
import type { EngineFallback } from './api/misc';
import type z from 'zod';
import type { zFilterSchema } from './filtering/index';
function createListenerFunctions(): {
callListeners: (eventKey: string, newValue: any) => void;
@@ -187,7 +189,7 @@ export interface PlayerState {
}
export const playerState: Writable<PlayerState | undefined> = writable(undefined);
export const playertheatreModeIsActive = writable(false);
export const playerTheatreModeIsActive = writable(false);
export const returnYtDislikesStore = persist(writable(false), createStorage(), 'returnYtDislikes');
export const returnYTDislikesInstanceStore: Writable<string | null | undefined> = persist(
@@ -339,6 +341,24 @@ export const searchHistoryStore: Writable<string[]> = persist(
'searchHistory'
);
export const filterContentListStore: Writable<z.infer<typeof zFilterSchema> | undefined> = persist(
writable(),
createStorage(),
'filterContentList'
);
export const filterContentUrlStore: Writable<string | undefined> = persist(
writable(),
createStorage(),
'filterContentUrl'
);
export const filterContentUrlAutoUpdateStore: Writable<boolean> = persist(
writable(false),
createStorage(),
'filterContentUrlAutoUpdate'
);
export const feedLoadingStore: Writable<boolean> = writable(false);
export const feedCacheStore: Writable<{
[key: string]: (VideoBase | Video | PlaylistPageVideo)[];
+4 -15
View File
@@ -1,16 +1,9 @@
import {
getComments,
getDislikes,
getPersonalPlaylists,
getVideo,
postHistory
} from '$lib/api/index';
import { getComments, getPersonalPlaylists, getVideo, saveWatchHistory } from '$lib/api/index';
import { loadEntirePlaylist } from '$lib/playlist';
import {
invidiousAuthStore,
playerProxyVideosStore,
playerState,
rawMasterKeyStore,
returnYTDislikesInstanceStore,
returnYtDislikesStore
} from '$lib/store';
@@ -18,8 +11,7 @@ import { parseDescription } from '$lib/description';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
import { _ } from './i18n';
import { isOwnBackend } from './shared';
import { saveWatchHistory } from './api/backend/history';
import { getDislikesRYD } from './api/ytd';
export async function getWatchDetails(videoId: string, url: URL) {
const playerStateRetrieved = get(playerState);
@@ -42,15 +34,12 @@ export async function getWatchDetails(videoId: string, url: URL) {
let personalPlaylists;
if (get(invidiousAuthStore)) {
postHistory(video.videoId);
personalPlaylists = getPersonalPlaylists({ priority: 'low' });
} else {
personalPlaylists = null;
}
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
saveWatchHistory(video);
}
saveWatchHistory(video);
let comments;
try {
@@ -64,7 +53,7 @@ export async function getWatchDetails(videoId: string, url: URL) {
if (returnYTDislikesInstance && returnYTDislikesInstance !== '') {
try {
returnYTDislikes = get(returnYtDislikesStore)
? getDislikes(videoId, { priority: 'low' })
? getDislikesRYD(videoId, { priority: 'low' })
: null;
} catch {
// Continue regardless of error
+33 -15
View File
@@ -2,7 +2,7 @@
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { navigating, page } from '$app/stores';
import { navigating, page } from '$app/state';
import { getFeed, notificationsMarkAsRead } from '$lib/api/index';
import type { Notification } from '$lib/api/model';
import Logo from '$lib/components/Logo.svelte';
@@ -18,7 +18,7 @@
interfaceDefaultPage,
isAndroidTvStore,
playerState,
playertheatreModeIsActive,
playerTheatreModeIsActive,
rawMasterKeyStore,
themeColorStore,
backendInUseStore,
@@ -33,6 +33,7 @@
import Author from '$lib/components/Author.svelte';
import Toast from '$lib/components/Toast.svelte';
import { isOwnBackend } from '$lib/shared';
import WatchParty from '$lib/components/WatchParty.svelte';
let { children } = $props();
@@ -40,7 +41,8 @@
let mobileSearchShow = $state(false);
let notifications: Notification[] = $state([]);
let playerIsPip: boolean = $state(false);
let playerIsPip = $state(false);
let showWatchParty = $state(page.url.searchParams.get('room') !== null);
let pages = $state(getPages());
invidiousAuthStore.subscribe(() => {
@@ -53,8 +55,8 @@
pages = getPages();
});
page.subscribe((pageData) => {
playerIsPip = !pageData.url.pathname.includes('/watch');
$effect(() => {
playerIsPip = !page.url.pathname.includes('/watch');
requestAnimationFrame(() => resetScroll());
});
@@ -160,7 +162,7 @@
id="left-nav"
class="left m l surface-container"
class:tv-nav={$isAndroidTvStore}
class:hide={$playertheatreModeIsActive}
class:hide={$playerTheatreModeIsActive}
>
<header class="small-padding">
<a href={resolve($interfaceDefaultPage, {})} tabindex="-1" data-sveltekit-preload-data="off">
@@ -168,13 +170,13 @@
</a>
</header>
{#if $isAndroidTvStore}
<a href={resolve('/search', {})} class:active={$page.url.href.endsWith('/search')}>
<a href={resolve('/search', {})} class:active={page.url.href.endsWith('/search')}>
<i>search</i>
<div>{$_('searchPlaceholder')}</div>
</a>
{/if}
{#each pages as navPage (navPage)}
<a href={resolve(navPage.href, {})} class:active={$page.url.href.endsWith(navPage.href)}
<a href={resolve(navPage.href, {})} class:active={page.url.href.endsWith(navPage.href)}
><i>{navPage.icon}</i>
<div>{navPage.name}</div>
</a>
@@ -202,7 +204,7 @@
</nav>
{#if !$isAndroidTvStore}
<nav class="top" id="top-content" class:tv-nav={$isAndroidTvStore}>
{#if $playertheatreModeIsActive}
{#if $playerTheatreModeIsActive}
<header role="presentation" style="cursor: pointer;" tabindex="-1" class="small-padding">
<a href={resolve($interfaceDefaultPage, {})}>
<Logo />
@@ -245,6 +247,17 @@
<Search on:searchCancelled={() => (mobileSearchShow = false)} />
</div>
{:else}
<!-- Watch parties only work in HTTPS environments -->
{#if page.url.protocol === 'https:'}
<button
onclick={() => (showWatchParty = !showWatchParty)}
class="circle large transparent"
class:active={showWatchParty}
>
<i>groups</i>
<div class="tooltip bottom">{$_('watchParty.start')}</div>
</button>
{/if}
{#if $invidiousAuthStore && !isYTBackend()}
<button
class="circle large transparent"
@@ -290,7 +303,7 @@
<a
class="round"
href={resolve(navPage.href, {})}
class:active={$page.url.href.endsWith(navPage.href)}
class:active={page.url.href.endsWith(navPage.href)}
data-sveltekit-preload-data="off"
><i>{navPage.icon}</i>
<span style="font-size: .8em;">{navPage.name}</span>
@@ -317,14 +330,18 @@
</dialog>
<main id="main-content" tabindex="0" class="responsive max root">
{#if showWatchParty}
<WatchParty />
{/if}
{#if $playerState}
<div class="grid">
<div
class:pip={playerIsPip}
class:s12={!playerIsPip}
class:m12={!playerIsPip}
class:l12={$playertheatreModeIsActive && !playerIsPip}
class:l9={!$playertheatreModeIsActive && !playerIsPip}
class:l12={$playerTheatreModeIsActive && !playerIsPip}
class:l9={!$playerTheatreModeIsActive && !playerIsPip}
>
<div class="pip-info">
{#if playerIsPip}
@@ -369,11 +386,12 @@
</div>
{/if}
{#if $navigating}
{#await navigating.complete}
<PageLoading />
{:else}
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{:then _}
{@render children?.()}
{/if}
{/await}
<Toast />
</main>
+2 -1
View File
@@ -1,5 +1,6 @@
import { resolve } from '$app/paths';
import { getPopular, HTTPError } from '$lib/api/index';
import { getPopular } from '$lib/api/index';
import { HTTPError } from '$lib/api/invidious/request';
import { isYTBackend } from '$lib/misc';
import { feedCacheStore, invidiousInstanceStore } from '$lib/store';
import { error, redirect } from '@sveltejs/kit';
@@ -1,8 +1,8 @@
<script lang="ts">
import { deleteWatchHistory, getWatchHistory } from '$lib/api/backend/history';
import ItemsList from '$lib/components/layout/ItemsList.svelte';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
import { _ } from '$lib/i18n';
import { deleteWatchHistory, getWatchHistory } from '$lib/api/index.js';
let { data } = $props();
@@ -1,13 +1,5 @@
import { resolve } from '$app/paths';
import { getWatchHistory } from '$lib/api/backend/history';
import { isOwnBackend } from '$lib/shared';
import { rawMasterKeyStore } from '$lib/store';
import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store';
import { getWatchHistory } from '$lib/api';
export async function load() {
if (!isOwnBackend()?.internalAuth || !get(rawMasterKeyStore))
throw redirect(302, resolve('/', {}));
return { videos: await getWatchHistory() };
}
@@ -1,6 +1,4 @@
<script lang="ts">
import { preventDefault } from 'svelte/legacy';
import { deletePersonalPlaylist, getPersonalPlaylists, postPersonalPlaylist } from '$lib/api';
import ContentColumn from '$lib/components/layout/ContentColumn.svelte';
import PlaylistThumbnail from '$lib/components/thumbnail/PlaylistThumbnail.svelte';
@@ -71,7 +69,12 @@
</div>
<dialog id="create-playlist">
<form onsubmit={preventDefault(createPlaylist)}>
<form
onsubmit={async (event: Event) => {
event.preventDefault();
await createPlaylist();
}}
>
<div class="field label border">
<input bind:value={playlistTitle} required name="title" type="text" />
<label for="title">{$_('title')}</label>
@@ -20,7 +20,7 @@
playerPlaylistHistory,
playerState,
playerTheatreModeByDefaultStore,
playertheatreModeIsActive,
playerTheatreModeIsActive,
playlistCacheStore,
type PlayerState
} from '$lib/store';
@@ -38,6 +38,7 @@
import { page } from '$app/state';
import Share from '$lib/components/Share.svelte';
import Playlist from '$lib/components/watch/Playlist.svelte';
import { isItemFiltered } from '$lib/filtering/index.js';
let { data = $bindable() } = $props();
@@ -51,7 +52,7 @@
let personalPlaylists: PlaylistPage[] | null = $state(null);
data.streamed.personalPlaylists?.then((streamPlaylists) => (personalPlaylists = streamPlaylists));
playertheatreModeIsActive.set(get(playerTheatreModeByDefaultStore));
playerTheatreModeIsActive.set(get(playerTheatreModeByDefaultStore));
let pauseTimerSeconds: number = $state(-1);
@@ -151,7 +152,7 @@
playerState.set(undefined);
}
playertheatreModeIsActive.set(false);
playerTheatreModeIsActive.set(false);
});
async function goToCurrentPlaylistItem() {
@@ -219,7 +220,7 @@
}
function toggleTheatreMode() {
playertheatreModeIsActive.set(!$playertheatreModeIsActive);
playerTheatreModeIsActive.set(!$playerTheatreModeIsActive);
}
let pauseTimeout: ReturnType<typeof setTimeout> | undefined = $state();
@@ -240,7 +241,7 @@
</svelte:head>
<div class="grid no-padding">
<div class={`s12 m12 l${$playertheatreModeIsActive ? '12' : '9'}`}>
<div class={`s12 m12 l${$playerTheatreModeIsActive ? '12' : '9'}`}>
<div style="display: flex;justify-content: center;">
{#if data.video.premiereTimestamp}
<article class="video-placeholder">
@@ -266,7 +267,7 @@
<button
onclick={toggleTheatreMode}
class="m l"
class:surface-container-highest={!$playertheatreModeIsActive}
class:surface-container-highest={!$playerTheatreModeIsActive}
>
<i>width_wide</i>
<div class="tooltip">{$_('player.theatreMode')}</div>
@@ -288,7 +289,7 @@
<button
onclick={() => (
(showTranscript = !showTranscript),
playertheatreModeIsActive.set(false)
playerTheatreModeIsActive.set(false)
)}
class:surface-container-highest={!showTranscript}
>
@@ -452,7 +453,7 @@
</article>
{/if}
</div>
{#if !$playertheatreModeIsActive}
{#if !$playerTheatreModeIsActive}
<div class="s12 m12 l3 recommended">
{#if showTranscript}
<Transcript video={data.video} bind:currentTime={playerCurrentTime} />
@@ -461,11 +462,13 @@
<Playlist video={data.video} playlist={$playlistCacheStore[data.playlistId]} />
{:else if data.video.recommendedVideos}
{#each data.video.recommendedVideos as recommendedVideo (recommendedVideo.videoId)}
<article class="no-padding border">
{#key recommendedVideo.videoId}
<Thumbnail video={recommendedVideo} sideways={true} />
{/key}
</article>
{#if !isItemFiltered(recommendedVideo)}
<article class="no-padding border">
{#key recommendedVideo.videoId}
<Thumbnail video={recommendedVideo} sideways={true} />
{/key}
</article>
{/if}
{/each}
{/if}
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { navigating } from '$app/stores';
import { navigating } from '$app/state';
import PageLoading from '$lib/components/PageLoading.svelte';
let { children } = $props();
@@ -14,8 +14,9 @@
</style>
</svelte:head>
{#if $navigating}
{#await navigating.complete}
<PageLoading />
{:else}
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{:then _}
{@render children?.()}
{/if}
{/await}
+20 -2
View File
@@ -12,7 +12,10 @@ import {
invidiousInstanceStore,
interfaceDefaultPage,
isAndroidTvStore,
rawMasterKeyStore
rawMasterKeyStore,
filterContentListStore,
filterContentUrlStore,
filterContentUrlAutoUpdateStore
} from '$lib/store';
import { get, type Writable } from 'svelte/store';
import { Capacitor } from '@capacitor/core';
@@ -21,6 +24,7 @@ import { deserialize } from '@macfja/serializer';
import { isYTBackend } from '$lib/misc';
import { isOwnBackend } from '$lib/shared/index';
import '$lib/fetchProxy';
import { loadContentFilterFromURL } from '$lib/filtering/index.js';
export const ssr = false;
export const prerender = false;
@@ -37,7 +41,10 @@ export async function load({ url }) {
invidiousInstance: invidiousInstanceStore,
authToken: invidiousAuthStore,
backendInUse: backendInUseStore,
rawMasterKey: rawMasterKeyStore
rawMasterKey: rawMasterKeyStore,
filterContentList: filterContentListStore,
filterContentUrl: filterContentUrlStore,
filterContentUrlAutoUpdate: filterContentUrlAutoUpdateStore
};
for (const [key, store] of Object.entries(preferenceKey)) {
@@ -48,6 +55,17 @@ export async function load({ url }) {
}
}
if (get(filterContentUrlAutoUpdateStore)) {
const filterUrl = get(filterContentUrlStore);
if (filterUrl) {
try {
loadContentFilterFromURL(filterUrl);
} catch {
// Continue regardless of error
}
}
}
const resolvedRoot = resolve('/', {});
if (url.pathname.startsWith(resolvedRoot + '@')) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -18,19 +18,19 @@ export default defineConfig({
{
purpose: 'maskable',
sizes: '512x512',
src: 'icon512_maskable.png',
src: 'icon512-maskable.png',
type: 'image/png'
},
{
purpose: 'any',
sizes: '512x512',
src: 'icon512_rounded.png',
src: 'icon512-any.png',
type: 'image/png'
}
],
orientation: 'any',
display: 'standalone',
name: 'Materialious'
name: 'Materialious',
short_name: 'Materialious'
}
}),
sveltekit()
+1 -1
View File
@@ -3,7 +3,7 @@ import os
import re
from datetime import datetime
LATEST_VERSION = "1.16.7"
LATEST_VERSION = "1.16.8"
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")