@@ -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"
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Generated
+2948
-26
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
+2
-2
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -14,7 +14,6 @@ export type FeedItem =
|
||||
| Video
|
||||
| PlaylistPageVideo
|
||||
| Channel
|
||||
| Video
|
||||
| Playlist
|
||||
| HashTag
|
||||
| PlaylistPage
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"exportingToInvidious": "Invidiousへのエクスポートを開始しました",
|
||||
"exportingToInvidiousFinished": "Invidiousへのエクスポートが終了しました"
|
||||
},
|
||||
"exportToJson": "JSON にエクスポート"
|
||||
"exportToJson": "JSON にエクスポート",
|
||||
"historyEnabled": "視聴履歴を保存"
|
||||
},
|
||||
"subscribe": "登録",
|
||||
"invidiousBlockWarning": "Invidious は、現在Googleにブロックされています。このインスタンスで動画が読み込めない場合、{android} 版または {desktop} 版の Materialious でこのインスタンスを使用し、動画取得を直接行う代替接続を使ってください。",
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user