@@ -3,7 +3,7 @@
|
||||
<br />
|
||||
<h1>Materialious</h1>
|
||||
<quote>
|
||||
Modern material design for Invidious.
|
||||
Modern material design for YouTube and Invidious.
|
||||
</quote>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
|
||||
# Features
|
||||
- Support for using without a Invidious instance (With experimental Youtubejs backend).
|
||||
- [Invidious companion support.](./docs/DOCKER.md#invidious-companion-support)
|
||||
- [Invidious API extended integration.](https://github.com/Materialious/api-extended)
|
||||
- [YouTube.js](https://github.com/LuanRT/YouTube.js) fallback if Invidious fails loading videos for Desktop & Android.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Modern material design for Invidious
|
||||
Modern material design for YouTube and Invidious.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 203
|
||||
versionName "1.13.19"
|
||||
versionCode 204
|
||||
versionName "1.14.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Name=Materialious
|
||||
Comment=Modern material design for Invidious.
|
||||
Comment=Modern material design for YouTube and Invidious.
|
||||
GenericName=Materialious
|
||||
Exec=run.sh %u
|
||||
Icon=us.materialio.Materialious
|
||||
|
||||
@@ -102,7 +102,11 @@
|
||||
|
||||
|
||||
|
||||
<release version="1.13.19" date="2026-2-11">
|
||||
|
||||
<release version="1.14.0" date="2026-2-11">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.14.0</url>
|
||||
</release>
|
||||
<release version="1.13.19" date="2026-2-11">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.13.19</url>
|
||||
</release>
|
||||
<release version="1.13.18" date="2026-2-11">
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.13.19",
|
||||
"version": "1.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.13.19",
|
||||
"version": "1.14.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.13.19",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"version": "1.14.0",
|
||||
"description": "Modern material design for YouTube and Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
"email": "wardpearce@pm.me"
|
||||
@@ -45,4 +45,4 @@
|
||||
"capacitor",
|
||||
"electron"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.13.19",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run patch:github && vite dev",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/logo.svg" />
|
||||
<meta name="description" content="Modern material design for Invidious." />
|
||||
<meta name="description" content="Modern material design for YouTube and Invidious." />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="invidious,materialious,proxy,youtube,yt,theme,interface,modern"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getVideoTYjs } from '$lib/api/youtubejs';
|
||||
import { getVideoYTjs } from '$lib/api/youtubejs/video';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
@@ -13,15 +13,10 @@ import {
|
||||
synciousInstanceStore
|
||||
} from '../store';
|
||||
import type {
|
||||
Channel,
|
||||
ChannelContentPlaylists,
|
||||
ChannelContentVideos,
|
||||
ChannelPage,
|
||||
Comments,
|
||||
DeArrow,
|
||||
Feed,
|
||||
HashTag,
|
||||
Playlist,
|
||||
PlaylistPage,
|
||||
ResolvedUrl,
|
||||
ReturnYTDislikes,
|
||||
@@ -29,8 +24,28 @@ import type {
|
||||
Subscription,
|
||||
ApiExntendedProgressModel,
|
||||
Video,
|
||||
VideoPlay
|
||||
VideoPlay,
|
||||
SearchOptions,
|
||||
SearchResults,
|
||||
CommentsOptions,
|
||||
ChannelOptions,
|
||||
ChannelContent
|
||||
} from './model';
|
||||
import { commentsSetDefaults, searchSetDefaults, useEngineFallback } from './misc';
|
||||
import { getSearchYTjs } from './youtubejs/search';
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import { getSearchSuggestionsYTjs } from './youtubejs/searchSuggestions';
|
||||
import { getResolveUrlYTjs } from './youtubejs/misc';
|
||||
import { getCommentsYTjs } from './youtubejs/comments';
|
||||
import { getChannelContentYTjs, getChannelYTjs } from './youtubejs/channel';
|
||||
import {
|
||||
amSubscribedYTjs,
|
||||
deleteUnsubscribeYTjs,
|
||||
getFeedYTjs,
|
||||
getSubscriptionsYTjs,
|
||||
postSubscribeYTjs
|
||||
} from './youtubejs/subscriptions';
|
||||
import { getPlaylistYTjs } from './youtubejs/playlist';
|
||||
|
||||
export function buildPath(path: string): URL {
|
||||
return new URL(`${get(instanceStore)}/api/v1/${path}`);
|
||||
@@ -71,11 +86,20 @@ export function buildAuthHeaders(): { headers: Record<string, string> } {
|
||||
}
|
||||
|
||||
export async function getPopular(fetchOptions?: RequestInit): Promise<Video[]> {
|
||||
// Doesn't exist in YTjs.
|
||||
if (isYTBackend()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resp = await fetchErrorHandle(await fetch(buildPath('popular'), fetchOptions));
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export async function getResolveUrl(url: string): Promise<ResolvedUrl> {
|
||||
if (isYTBackend() || useEngineFallback('ResolveUrl')) {
|
||||
return await getResolveUrlYTjs(url);
|
||||
}
|
||||
|
||||
const resp = await fetchErrorHandle(await fetch(`${buildPath('resolveurl')}?url=${url}`));
|
||||
return await resp.json();
|
||||
}
|
||||
@@ -85,14 +109,18 @@ export async function getVideo(
|
||||
local: boolean = false,
|
||||
fetchOptions?: RequestInit
|
||||
): Promise<VideoPlay> {
|
||||
if (get(playerYouTubeJsAlways) && Capacitor.isNativePlatform()) {
|
||||
return await getVideoTYjs(videoId);
|
||||
if (
|
||||
(get(playerYouTubeJsAlways) && Capacitor.isNativePlatform()) ||
|
||||
isYTBackend() ||
|
||||
useEngineFallback('Video')
|
||||
) {
|
||||
return await getVideoYTjs(videoId);
|
||||
}
|
||||
|
||||
const resp = await fetch(setRegion(buildPath(`videos/${videoId}?local=${local}`)), fetchOptions);
|
||||
|
||||
if (!resp.ok && get(playerYouTubeJsFallback) && Capacitor.isNativePlatform()) {
|
||||
return await getVideoTYjs(videoId);
|
||||
return await getVideoYTjs(videoId);
|
||||
} else {
|
||||
await fetchErrorHandle(resp);
|
||||
}
|
||||
@@ -111,23 +139,17 @@ export async function getDislikes(
|
||||
|
||||
export async function getComments(
|
||||
videoId: string,
|
||||
parameters: {
|
||||
sort_by?: 'top' | 'new';
|
||||
source?: 'youtube' | 'reddit';
|
||||
continuation?: string;
|
||||
},
|
||||
options: CommentsOptions,
|
||||
fetchOptions?: RequestInit
|
||||
): Promise<Comments> {
|
||||
if (typeof parameters.sort_by === 'undefined') {
|
||||
parameters.sort_by = 'top';
|
||||
}
|
||||
commentsSetDefaults(options);
|
||||
|
||||
if (typeof parameters.source === 'undefined') {
|
||||
parameters.source = 'youtube';
|
||||
if (isYTBackend() || useEngineFallback('Comments')) {
|
||||
return await getCommentsYTjs(videoId, options);
|
||||
}
|
||||
|
||||
const path = buildPath(`comments/${videoId}`);
|
||||
path.search = new URLSearchParams(parameters).toString();
|
||||
path.search = new URLSearchParams(options).toString();
|
||||
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
|
||||
return await resp.json();
|
||||
}
|
||||
@@ -136,32 +158,32 @@ export async function getChannel(
|
||||
channelId: string,
|
||||
fetchOptions?: RequestInit
|
||||
): Promise<ChannelPage> {
|
||||
if (isYTBackend() || useEngineFallback('Channel')) {
|
||||
return getChannelYTjs(channelId);
|
||||
}
|
||||
const resp = await fetchErrorHandle(
|
||||
await fetch(buildPath(`channels/${channelId}`), fetchOptions)
|
||||
);
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export type channelSortBy = 'oldest' | 'newest' | 'popular';
|
||||
export type channelContentTypes = 'videos' | 'playlists' | 'streams' | 'shorts';
|
||||
|
||||
export async function getChannelContent(
|
||||
channelId: string,
|
||||
parameters: {
|
||||
type?: channelContentTypes;
|
||||
continuation?: string;
|
||||
sortBy?: channelSortBy;
|
||||
},
|
||||
options: ChannelOptions,
|
||||
fetchOptions?: RequestInit
|
||||
): Promise<ChannelContentVideos | ChannelContentPlaylists> {
|
||||
if (typeof parameters.type === 'undefined') parameters.type = 'videos';
|
||||
): Promise<ChannelContent> {
|
||||
if (typeof options.type === 'undefined') options.type = 'videos';
|
||||
|
||||
const url = buildPath(`channels/${channelId}/${parameters.type}`);
|
||||
const url = buildPath(`channels/${channelId}/${options.type}`);
|
||||
|
||||
if (typeof parameters.continuation !== 'undefined')
|
||||
url.searchParams.set('continuation', parameters.continuation);
|
||||
if (typeof options.continuation !== 'undefined')
|
||||
url.searchParams.set('continuation', options.continuation);
|
||||
|
||||
if (typeof parameters.sortBy !== 'undefined') url.searchParams.set('sort_by', parameters.sortBy);
|
||||
if (typeof options.sortBy !== 'undefined') url.searchParams.set('sort_by', options.sortBy);
|
||||
|
||||
if (isYTBackend() || useEngineFallback('ChannelContent')) {
|
||||
return await getChannelContentYTjs(channelId, options);
|
||||
}
|
||||
|
||||
const resp = await fetchErrorHandle(await fetch(url.toString(), fetchOptions));
|
||||
return await resp.json();
|
||||
@@ -171,7 +193,14 @@ export async function searchChannelContent(
|
||||
channelId: string,
|
||||
search: string,
|
||||
fetchOptions?: RequestInit
|
||||
) {
|
||||
): Promise<ChannelContent> {
|
||||
// Not Implemented in YTjs
|
||||
if (isYTBackend()) {
|
||||
return {
|
||||
videos: []
|
||||
};
|
||||
}
|
||||
|
||||
const path = buildPath(`channel/${channelId}/search`);
|
||||
path.search = new URLSearchParams({ q: search }).toString();
|
||||
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
|
||||
@@ -182,6 +211,10 @@ export async function getSearchSuggestions(
|
||||
search: string,
|
||||
fetchOptions?: RequestInit
|
||||
): Promise<SearchSuggestion> {
|
||||
if (isYTBackend() || useEngineFallback('SearchSuggestions')) {
|
||||
return getSearchSuggestionsYTjs(search);
|
||||
}
|
||||
|
||||
const path = buildPath('search/suggestions');
|
||||
path.search = new URLSearchParams({ q: search }).toString();
|
||||
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
|
||||
@@ -189,34 +222,22 @@ export async function getSearchSuggestions(
|
||||
}
|
||||
|
||||
export async function getHashtag(tag: string, page: number = 0): Promise<{ results: Video[] }> {
|
||||
// TODO: Implement in YTjs
|
||||
if (isYTBackend()) return { results: [] };
|
||||
|
||||
const resp = await fetchErrorHandle(await fetch(buildPath(`hashtag/${tag}?page=${page}`)));
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
|
||||
type?: 'video' | 'playlist' | 'channel' | 'all';
|
||||
duration?: 'short' | 'medium' | 'long';
|
||||
date?: 'hour' | 'today' | 'week' | 'month' | 'year';
|
||||
features?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export async function getSearch(
|
||||
search: string,
|
||||
options: SearchOptions,
|
||||
fetchOptions?: RequestInit
|
||||
): Promise<(Channel | Video | Playlist | HashTag)[]> {
|
||||
if (typeof options.sort_by === 'undefined') {
|
||||
options.sort_by = 'relevance';
|
||||
}
|
||||
): Promise<SearchResults> {
|
||||
searchSetDefaults(options);
|
||||
|
||||
if (typeof options.type === 'undefined') {
|
||||
options.type = 'all';
|
||||
}
|
||||
|
||||
if (typeof options.page === 'undefined') {
|
||||
options.page = '1';
|
||||
if (isYTBackend() || useEngineFallback('Search')) {
|
||||
return await getSearchYTjs(search, options);
|
||||
}
|
||||
|
||||
const path = buildPath('search');
|
||||
@@ -230,6 +251,10 @@ export async function getFeed(
|
||||
page: number,
|
||||
fetchOptions: RequestInit = {}
|
||||
): Promise<Feed> {
|
||||
if (isYTBackend()) {
|
||||
return getFeedYTjs(maxResults, page);
|
||||
}
|
||||
|
||||
const path = buildPath('auth/feed');
|
||||
path.search = new URLSearchParams({
|
||||
max_results: maxResults.toString(),
|
||||
@@ -241,7 +266,18 @@ export async function getFeed(
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
export async function getSubscriptions(fetchOptions: RequestInit = {}): Promise<Subscription[]> {
|
||||
if (isYTBackend()) {
|
||||
return getSubscriptionsYTjs();
|
||||
}
|
||||
const resp = await fetchErrorHandle(
|
||||
await fetch(buildPath('auth/subscriptions'), { ...buildAuthHeaders(), ...fetchOptions })
|
||||
);
|
||||
@@ -252,6 +288,10 @@ export async function amSubscribed(
|
||||
authorId: string,
|
||||
fetchOptions: RequestInit = {}
|
||||
): Promise<boolean> {
|
||||
if (isYTBackend()) {
|
||||
return amSubscribedYTjs(authorId);
|
||||
}
|
||||
|
||||
if (!get(authStore)) return false;
|
||||
|
||||
try {
|
||||
@@ -265,6 +305,10 @@ export async function amSubscribed(
|
||||
}
|
||||
|
||||
export async function postSubscribe(authorId: string, fetchOptions: RequestInit = {}) {
|
||||
if (isYTBackend()) {
|
||||
return postSubscribeYTjs(authorId);
|
||||
}
|
||||
|
||||
await fetchErrorHandle(
|
||||
await fetch(buildPath(`auth/subscriptions/${authorId}`), {
|
||||
method: 'POST',
|
||||
@@ -275,6 +319,10 @@ export async function postSubscribe(authorId: string, fetchOptions: RequestInit
|
||||
}
|
||||
|
||||
export async function deleteUnsubscribe(authorId: string, fetchOptions: RequestInit = {}) {
|
||||
if (isYTBackend()) {
|
||||
return deleteUnsubscribeYTjs(authorId);
|
||||
}
|
||||
|
||||
await fetchErrorHandle(
|
||||
await fetch(buildPath(`auth/subscriptions/${authorId}`), {
|
||||
method: 'DELETE',
|
||||
@@ -289,6 +337,11 @@ export async function getHistory(
|
||||
maxResults: number = 20,
|
||||
fetchOptions: RequestInit = {}
|
||||
): Promise<string[]> {
|
||||
// Not supported functionality of YTjs.
|
||||
if (isYTBackend()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resp = await fetchErrorHandle(
|
||||
await fetch(buildPath(`auth/history?page=${page}&max_results=${maxResults}`), {
|
||||
...buildAuthHeaders(),
|
||||
@@ -302,6 +355,8 @@ export async function deleteHistory(
|
||||
videoId: string | undefined = undefined,
|
||||
fetchOptions: RequestInit = {}
|
||||
) {
|
||||
if (isYTBackend()) return;
|
||||
|
||||
let url = '/api/v1/auth/history';
|
||||
if (typeof videoId !== 'undefined') {
|
||||
url += `/${videoId}`;
|
||||
@@ -317,6 +372,8 @@ export async function deleteHistory(
|
||||
}
|
||||
|
||||
export async function postHistory(videoId: string, fetchOptions: RequestInit = {}) {
|
||||
if (isYTBackend()) return;
|
||||
|
||||
await fetchErrorHandle(
|
||||
await fetch(buildPath(`auth/history/${videoId}`), {
|
||||
method: 'POST',
|
||||
@@ -331,6 +388,10 @@ export async function getPlaylist(
|
||||
page: number = 1,
|
||||
fetchOptions: RequestInit = {}
|
||||
): Promise<PlaylistPage> {
|
||||
if (isYTBackend() || useEngineFallback('Playlist')) {
|
||||
return await getPlaylistYTjs(playlistId);
|
||||
}
|
||||
|
||||
let resp;
|
||||
|
||||
if (get(authStore)) {
|
||||
@@ -348,6 +409,8 @@ export async function getPlaylist(
|
||||
export async function getPersonalPlaylists(
|
||||
fetchOptions: RequestInit = {}
|
||||
): Promise<PlaylistPage[]> {
|
||||
if (isYTBackend()) return [];
|
||||
|
||||
const resp = await fetchErrorHandle(
|
||||
await fetch(buildPath('auth/playlists'), { ...buildAuthHeaders(), ...fetchOptions })
|
||||
);
|
||||
@@ -355,6 +418,8 @@ export async function getPersonalPlaylists(
|
||||
}
|
||||
|
||||
export async function deletePersonalPlaylist(playlistId: string) {
|
||||
if (isYTBackend()) return;
|
||||
|
||||
await fetchErrorHandle(
|
||||
await fetch(buildPath(`auth/playlists/${playlistId}`), {
|
||||
method: 'DELETE',
|
||||
@@ -368,6 +433,8 @@ export async function postPersonalPlaylist(
|
||||
privacy: 'public' | 'private' | 'unlisted',
|
||||
fetchOptions: RequestInit = {}
|
||||
) {
|
||||
if (isYTBackend()) return;
|
||||
|
||||
const headers: Record<string, Record<string, string>> = buildAuthHeaders();
|
||||
headers['headers']['Content-type'] = 'application/json';
|
||||
|
||||
@@ -389,6 +456,8 @@ export async function addPlaylistVideo(
|
||||
videoId: string,
|
||||
fetchOptions: RequestInit = {}
|
||||
) {
|
||||
if (isYTBackend()) return;
|
||||
|
||||
const headers: Record<string, Record<string, string>> = buildAuthHeaders();
|
||||
headers['headers']['Content-type'] = 'application/json';
|
||||
|
||||
@@ -409,6 +478,8 @@ export async function removePlaylistVideo(
|
||||
indexId: string,
|
||||
fetchOptions: RequestInit = {}
|
||||
) {
|
||||
if (isYTBackend()) return;
|
||||
|
||||
await fetchErrorHandle(
|
||||
await fetch(buildPath(`auth/playlists/${playlistId}/videos/${indexId}`), {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { CommentsOptions, SearchOptions } from './model';
|
||||
import { engineFallbacksStore } from '$lib/store';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
export function searchSetDefaults(options: SearchOptions) {
|
||||
if (typeof options.sort_by === 'undefined') {
|
||||
options.sort_by = 'relevance';
|
||||
}
|
||||
|
||||
if (typeof options.type === 'undefined') {
|
||||
options.type = 'all';
|
||||
}
|
||||
|
||||
if (typeof options.page === 'undefined') {
|
||||
options.page = '1';
|
||||
}
|
||||
}
|
||||
|
||||
export function commentsSetDefaults(options: CommentsOptions) {
|
||||
if (typeof options.sort_by === 'undefined') {
|
||||
options.sort_by = 'top';
|
||||
}
|
||||
}
|
||||
|
||||
export type EngineFallback =
|
||||
| 'ResolveUrl'
|
||||
| 'Video'
|
||||
| 'Comments'
|
||||
| 'Channel'
|
||||
| 'ChannelContent'
|
||||
| 'SearchSuggestions'
|
||||
| 'Search'
|
||||
| 'Playlist';
|
||||
|
||||
export function useEngineFallback(fallback: EngineFallback): boolean {
|
||||
return get(engineFallbacksStore).includes(fallback) && Capacitor.isNativePlatform();
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { ApiResponse, Innertube, YT } from 'youtubei.js';
|
||||
|
||||
export interface SearchOptions {
|
||||
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
|
||||
type?: 'video' | 'playlist' | 'channel' | 'all';
|
||||
duration?: 'short' | 'medium' | 'long';
|
||||
date?: 'hour' | 'today' | 'week' | 'month' | 'year';
|
||||
features?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
url: string;
|
||||
width: number;
|
||||
@@ -7,7 +16,6 @@ export interface Image {
|
||||
}
|
||||
|
||||
export interface Thumbnail {
|
||||
quality: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -160,6 +168,7 @@ export interface Comment {
|
||||
replyCount: number;
|
||||
continuation: string;
|
||||
};
|
||||
getReplies?: () => Promise<Comments>;
|
||||
}
|
||||
|
||||
export interface Comments {
|
||||
@@ -167,6 +176,7 @@ export interface Comments {
|
||||
videoId: string;
|
||||
continuation?: string;
|
||||
comments: Comment[];
|
||||
getContinuation?: () => Promise<Comments>;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
@@ -217,12 +227,14 @@ export interface PlaylistPageVideo extends Omit<PlaylistVideo, 'type'> {
|
||||
|
||||
export interface ChannelContentVideos {
|
||||
videos: Video[];
|
||||
continuation: string;
|
||||
continuation?: string;
|
||||
getContinuation?: () => Promise<ChannelContentVideos>;
|
||||
}
|
||||
|
||||
export interface ChannelContentPlaylists {
|
||||
playlists: PlaylistPage[];
|
||||
continuation: string;
|
||||
continuation?: string;
|
||||
getContinuation?: () => Promise<ChannelContentPlaylists>;
|
||||
}
|
||||
|
||||
export interface PlaylistPage extends Omit<Playlist, 'videos'> {
|
||||
@@ -232,6 +244,7 @@ export interface PlaylistPage extends Omit<Playlist, 'videos'> {
|
||||
updated: number;
|
||||
isListed: boolean;
|
||||
videos: PlaylistPageVideo[];
|
||||
getContinuation?: () => Promise<PlaylistPage>;
|
||||
}
|
||||
|
||||
export interface ChannelPage extends Channel {
|
||||
@@ -295,3 +308,23 @@ export interface ApiExntendedProgressModel {
|
||||
export interface SynciousSaveProgressModel {
|
||||
time: number;
|
||||
}
|
||||
|
||||
export type SearchResults = (Channel | Video | Playlist | HashTag)[] & {
|
||||
getContinuation?: () => Promise<SearchResults>;
|
||||
};
|
||||
|
||||
export type ChannelContent = ChannelContentVideos | ChannelContentPlaylists;
|
||||
|
||||
export type CommentsOptions = {
|
||||
sort_by?: 'top' | 'new';
|
||||
continuation?: string;
|
||||
};
|
||||
|
||||
export type ChannelSortBy = 'oldest' | 'newest' | 'popular';
|
||||
export type ChannelContentTypes = 'videos' | 'playlists' | 'streams' | 'shorts';
|
||||
|
||||
export type ChannelOptions = {
|
||||
type?: ChannelContentTypes;
|
||||
continuation?: string;
|
||||
sortBy?: ChannelSortBy;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { YT, YTNodes } from 'youtubei.js';
|
||||
import { getInnertube } from '.';
|
||||
import type {
|
||||
ChannelContent,
|
||||
ChannelContentVideos,
|
||||
ChannelOptions,
|
||||
ChannelPage,
|
||||
Image,
|
||||
Video
|
||||
} from '../model';
|
||||
import { invidiousItemSchema } from './schema';
|
||||
|
||||
export async function getChannelYTjs(channelId: string): Promise<ChannelPage> {
|
||||
const innertube = await getInnertube();
|
||||
|
||||
const innerResults = await innertube.getChannel(channelId);
|
||||
|
||||
const authorId = innerResults.metadata.url?.split('/')[4] ?? '';
|
||||
|
||||
let authorBanners: Image[] = [];
|
||||
if (innerResults.header?.is(YTNodes.PageHeaderView)) {
|
||||
authorBanners = innerResults.header.banner?.image ?? [];
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'channel',
|
||||
allowedRegions: innerResults.metadata.available_countries ?? [],
|
||||
tabs: innerResults.tabs.map((tab) => tab.toLowerCase()),
|
||||
latestVideos: [],
|
||||
isFamilyFriendly: true,
|
||||
author: innerResults.metadata.title ?? '',
|
||||
authorThumbnails: innerResults.metadata.avatar ?? [],
|
||||
authorId: authorId,
|
||||
authorBanners,
|
||||
joined: 0,
|
||||
authorVerified: true,
|
||||
totalViews: 0,
|
||||
authorUrl: `/channel/${authorId}`,
|
||||
subCount: 0,
|
||||
autoGenerated: false,
|
||||
description: '',
|
||||
descriptionHml: ''
|
||||
};
|
||||
}
|
||||
|
||||
export function invidiousChannelContentSchema(
|
||||
innerResults: YT.Channel | YT.ChannelListContinuation,
|
||||
author: string
|
||||
) {
|
||||
const videos: Video[] = [];
|
||||
|
||||
let contents;
|
||||
if (
|
||||
innerResults instanceof YT.Channel &&
|
||||
innerResults.current_tab?.content?.is(YTNodes.RichGrid)
|
||||
) {
|
||||
contents = innerResults.current_tab.content.contents;
|
||||
} else if (
|
||||
innerResults instanceof YT.ChannelListContinuation &&
|
||||
innerResults.contents?.is(YTNodes.AppendContinuationItemsAction)
|
||||
) {
|
||||
contents = innerResults.contents.contents;
|
||||
}
|
||||
|
||||
if (!contents) return videos;
|
||||
|
||||
contents.forEach((item) => {
|
||||
if (item.is(YTNodes.RichItem)) {
|
||||
const invidiousSchema = invidiousItemSchema(item.content);
|
||||
if (invidiousSchema?.type === 'video') {
|
||||
invidiousSchema.author = author;
|
||||
videos.push(invidiousSchema);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return videos;
|
||||
}
|
||||
|
||||
function fetchChannelContentVideosWithContinuation(
|
||||
innerResults: YT.ChannelListContinuation,
|
||||
author: string
|
||||
): () => Promise<ChannelContentVideos> {
|
||||
return async () => {
|
||||
const continuation = await innerResults.getContinuation();
|
||||
return {
|
||||
videos: invidiousChannelContentSchema(continuation, author),
|
||||
getContinuation: fetchChannelContentVideosWithContinuation(continuation, author)
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchChannelContentWithContinuation(
|
||||
channelId: string,
|
||||
innerResults: YT.Channel | YT.ChannelListContinuation,
|
||||
author: string
|
||||
): Promise<ChannelContent> {
|
||||
const channelContent: ChannelContent = {
|
||||
continuation: innerResults.has_continuation ? 'logicalPlaceholder' : undefined,
|
||||
videos: invidiousChannelContentSchema(innerResults, author)
|
||||
};
|
||||
|
||||
if (channelContent.continuation) {
|
||||
channelContent.getContinuation = fetchChannelContentVideosWithContinuation(
|
||||
innerResults,
|
||||
author
|
||||
);
|
||||
}
|
||||
|
||||
return channelContent;
|
||||
}
|
||||
|
||||
export async function getChannelContentYTjs(
|
||||
channelId: string,
|
||||
options: ChannelOptions
|
||||
): Promise<ChannelContent> {
|
||||
const innertube = await getInnertube();
|
||||
|
||||
const channel = await innertube.getChannel(channelId);
|
||||
|
||||
const author = channel.metadata.title ?? '';
|
||||
|
||||
let innerResults: YT.Channel | YT.ChannelListContinuation;
|
||||
if (options.type === 'videos') {
|
||||
innerResults = await channel.getVideos();
|
||||
} else if (options.type === 'playlists') {
|
||||
innerResults = await channel.getPlaylists();
|
||||
} else if (options.type === 'shorts') {
|
||||
innerResults = await channel.getShorts();
|
||||
} else {
|
||||
innerResults = await channel.getLiveStreams();
|
||||
}
|
||||
|
||||
return fetchChannelContentWithContinuation(channelId, innerResults, author);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { extractNumber } from '$lib/numbers';
|
||||
import { YT, YTNodes } from 'youtubei.js';
|
||||
import { getInnertube } from '.';
|
||||
import type { Comment, Comments, CommentsOptions, Thumbnail } from '../model';
|
||||
|
||||
function invidiousCommentContentSchema(result: YTNodes.CommentView): Comment | undefined {
|
||||
if (!result.author) return;
|
||||
|
||||
return {
|
||||
author: result.author.name ?? '',
|
||||
authorId: result.author.id ?? '',
|
||||
authorUrl: `/channel/${result.author.id}`,
|
||||
authorThumbnails: result.author.thumbnails as Thumbnail[],
|
||||
isEdited: false,
|
||||
isPinned: result.is_pinned === true,
|
||||
content: result.content?.text ?? '',
|
||||
contentHtml: result.content?.toHTML() ?? '',
|
||||
published: 0,
|
||||
publishedText: result.published_time ?? '',
|
||||
likeCount: extractNumber(result.like_count ?? '0'),
|
||||
authorIsChannelOwner: result.author_is_channel_owner === true,
|
||||
creatorHeart: {
|
||||
creatorName: result.heart_active_tooltip?.split('@')[1] ?? '',
|
||||
creatorThumbnail: result.creator_thumbnail_url ?? ''
|
||||
},
|
||||
replies: {
|
||||
replyCount: extractNumber(result.reply_count ?? '0'),
|
||||
continuation: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function invidiousCommentSchema(innerResults: YT.Comments, videoId: string) {
|
||||
const comment: Comment[] = [];
|
||||
|
||||
innerResults.contents.forEach(async (result) => {
|
||||
if (result.comment) {
|
||||
const invidiousResult = invidiousCommentContentSchema(result.comment);
|
||||
if (invidiousResult) {
|
||||
if (result.has_replies)
|
||||
invidiousResult.getReplies = async (): Promise<Comments> => {
|
||||
const replies = await result.getReplies();
|
||||
if (!replies.replies) return { videoId: '', comments: [], commentCount: 0 };
|
||||
|
||||
const comments: Comment[] = replies.replies
|
||||
.map((reply) => invidiousCommentContentSchema(reply))
|
||||
.filter((comment): comment is Comment => !!comment);
|
||||
|
||||
return {
|
||||
videoId: videoId,
|
||||
comments: comments,
|
||||
commentCount: 0
|
||||
};
|
||||
};
|
||||
|
||||
comment.push(invidiousResult);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
async function fetchCommentsWithContinuation(
|
||||
videoId: string,
|
||||
innerResults: YT.Comments,
|
||||
options: CommentsOptions
|
||||
): Promise<Comments> {
|
||||
const comments: Comments = {
|
||||
videoId: videoId,
|
||||
comments: invidiousCommentSchema(innerResults, videoId),
|
||||
commentCount: extractNumber(innerResults.header?.comments_count.text ?? '0'),
|
||||
continuation: innerResults.has_continuation ? 'logicalPlaceholder' : undefined
|
||||
};
|
||||
|
||||
if (comments.continuation) {
|
||||
comments.getContinuation = async () => {
|
||||
const continuation = await innerResults.getContinuation();
|
||||
return fetchCommentsWithContinuation(videoId, continuation, options);
|
||||
};
|
||||
}
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
export async function getCommentsYTjs(
|
||||
videoId: string,
|
||||
options: CommentsOptions
|
||||
): Promise<Comments> {
|
||||
const innertube = await getInnertube();
|
||||
|
||||
const innerResults = await innertube.getComments(
|
||||
videoId,
|
||||
options.sort_by === 'top' ? 'TOP_COMMENTS' : 'NEWEST_FIRST'
|
||||
);
|
||||
|
||||
return fetchCommentsWithContinuation(videoId, innerResults, options);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { interfaceRegionStore } from '$lib/store';
|
||||
import { USER_AGENT } from 'bgutils-js';
|
||||
import { get } from 'svelte/store';
|
||||
import Innertube, { UniversalCache } from 'youtubei.js';
|
||||
|
||||
let innertube: Innertube | undefined;
|
||||
|
||||
export async function getInnertube(): Promise<Innertube> {
|
||||
if (innertube) return innertube;
|
||||
|
||||
innertube = await Innertube.create({
|
||||
fetch: fetch,
|
||||
cache: new UniversalCache(true),
|
||||
location: get(interfaceRegionStore),
|
||||
user_agent: USER_AGENT
|
||||
});
|
||||
|
||||
return innertube;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getInnertube } from '.';
|
||||
import type { ResolvedUrl } from '../model';
|
||||
|
||||
export async function getResolveUrlYTjs(url: string): Promise<ResolvedUrl> {
|
||||
const innertube = await getInnertube();
|
||||
|
||||
const resloved = await innertube.resolveURL(url);
|
||||
|
||||
return {
|
||||
pageType: resloved.metadata.page_type ?? '',
|
||||
params: '',
|
||||
ucid: resloved.metadata.url ? resloved.metadata.url?.split('/')[2] : ''
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { extractNumber } from '$lib/numbers';
|
||||
import { YT, YTNodes } from 'youtubei.js';
|
||||
import { getInnertube } from '.';
|
||||
import type { PlaylistPage, PlaylistPageVideo } from '../model';
|
||||
|
||||
async function fetchPlaylistWithContinuation(
|
||||
playlist: YT.Playlist,
|
||||
playlistId: string
|
||||
): Promise<PlaylistPage> {
|
||||
const videos: PlaylistPageVideo[] = [];
|
||||
|
||||
playlist.videos.forEach((video) => {
|
||||
if (video.is(YTNodes.PlaylistVideo)) {
|
||||
const videoIndex = video.index.text ?? '0';
|
||||
videos.push({
|
||||
type: 'video',
|
||||
author: video.author.name,
|
||||
authorId: video.author.id,
|
||||
index: extractNumber(videoIndex),
|
||||
indexId: videoIndex,
|
||||
viewCount: 0,
|
||||
title: video.title.text ?? '',
|
||||
videoId: video.id ?? '',
|
||||
lengthSeconds: video.duration.seconds,
|
||||
videoThumbnails: video.thumbnails
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const playlistPage: PlaylistPage = {
|
||||
type: 'playlist',
|
||||
title: playlist.info.title ?? '',
|
||||
description: playlist.info.description ?? '',
|
||||
descriptionHtml: playlist.info.description ?? '',
|
||||
viewCount: extractNumber(playlist.info.views ?? '0'),
|
||||
updated: 0,
|
||||
isListed: true,
|
||||
videos: videos,
|
||||
playlistId,
|
||||
videoCount: playlist.videos.length,
|
||||
author: playlist.info.author.name,
|
||||
authorId: playlist.info.author.id,
|
||||
authorVerified: true,
|
||||
playlistThumbnail: playlist.info.thumbnails[0].url ?? ''
|
||||
};
|
||||
|
||||
if (playlist) {
|
||||
playlistPage.getContinuation = async () => {
|
||||
const continuation = await playlist.getContinuation();
|
||||
return fetchPlaylistWithContinuation(continuation, playlistId);
|
||||
};
|
||||
}
|
||||
|
||||
return playlistPage;
|
||||
}
|
||||
|
||||
export async function getPlaylistYTjs(playlistId: string): Promise<PlaylistPage> {
|
||||
const innertube = await getInnertube();
|
||||
const playlist = await innertube.getPlaylist(playlistId);
|
||||
|
||||
return fetchPlaylistWithContinuation(playlist, playlistId);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { cleanNumber, extractNumber } from '$lib/numbers';
|
||||
import { convertToSeconds } from '$lib/time';
|
||||
import type { Channel, Playlist, Thumbnail, Video } from '../model';
|
||||
import { Helpers, YTNodes } from 'youtubei.js';
|
||||
|
||||
export function invidiousItemSchema(item: Helpers.YTNode): Video | Channel | Playlist | undefined {
|
||||
if (item.is(YTNodes.Video)) {
|
||||
const views = extractNumber(item.view_count?.toString() || '');
|
||||
return {
|
||||
type: 'video',
|
||||
title: item.title.toString(),
|
||||
videoId: item.video_id,
|
||||
viewCountText: cleanNumber(views),
|
||||
viewCount: views,
|
||||
videoThumbnails: item.thumbnails as Thumbnail[],
|
||||
published: 0,
|
||||
publishedText: item.published?.toString() || '',
|
||||
description: '',
|
||||
descriptionHtml: '',
|
||||
authorUrl: `/channel/${item.author.id}`,
|
||||
authorId: item.author.id,
|
||||
authorVerified: false,
|
||||
liveNow: false,
|
||||
isUpcoming: false,
|
||||
premium: false,
|
||||
author: item.author.name,
|
||||
lengthSeconds: item.length_text?.text ? convertToSeconds(item.length_text.text) : 0
|
||||
};
|
||||
} else if (item.is(YTNodes.Channel)) {
|
||||
return {
|
||||
type: 'channel',
|
||||
authorId: item.id,
|
||||
author: item.author.name,
|
||||
authorUrl: `/channel/${item.id}`,
|
||||
authorVerified: item.author.is_verified === true,
|
||||
subCount: item.video_count.text ? extractNumber(item.video_count.text) : 0,
|
||||
totalViews: 0,
|
||||
autoGenerated: false,
|
||||
description: item.description_snippet.text ?? '',
|
||||
descriptionHml: item.description_snippet.toHTML() ?? '',
|
||||
authorThumbnails: item.author.thumbnails as Thumbnail[]
|
||||
};
|
||||
} else if (item.is(YTNodes.LockupView) && item.content_type === 'PLAYLIST') {
|
||||
let author = '';
|
||||
const metadataRows = item.metadata?.metadata?.metadata_rows[0];
|
||||
if (metadataRows && metadataRows.metadata_parts) {
|
||||
author = metadataRows.metadata_parts[0].text?.text ?? '';
|
||||
}
|
||||
return {
|
||||
type: 'playlist',
|
||||
title: item.metadata?.title.text ?? '',
|
||||
playlistId: item.content_id,
|
||||
playlistThumbnail: item.content_image?.is(YTNodes.CollectionThumbnailView)
|
||||
? item.content_image.primary_thumbnail?.is(YTNodes.ThumbnailView)
|
||||
? item.content_image.primary_thumbnail.image[0].url
|
||||
: ''
|
||||
: '',
|
||||
authorVerified: false,
|
||||
author,
|
||||
authorId: '',
|
||||
videoCount: 0,
|
||||
videos: []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getInnertube } from '.';
|
||||
import type { SearchOptions, SearchResults } from '../model';
|
||||
import { type Types, YT } from 'youtubei.js';
|
||||
import { invidiousItemSchema } from './schema';
|
||||
|
||||
function invidiousSchema(innerResults: YT.Search): SearchResults {
|
||||
const searchResults: SearchResults = [];
|
||||
|
||||
innerResults.results.forEach((result) => {
|
||||
const item = invidiousItemSchema(result);
|
||||
if (item) searchResults.push(item);
|
||||
});
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
async function fetchSearchWithContinuation(innerResults: YT.Search): Promise<SearchResults> {
|
||||
const searchResults = invidiousSchema(innerResults);
|
||||
|
||||
if (innerResults.has_continuation) {
|
||||
searchResults.getContinuation = async () => {
|
||||
const continuation = await innerResults.getContinuation();
|
||||
return fetchSearchWithContinuation(continuation);
|
||||
};
|
||||
}
|
||||
|
||||
return searchResults;
|
||||
}
|
||||
|
||||
export async function getSearchYTjs(
|
||||
search: string,
|
||||
options: SearchOptions
|
||||
): Promise<SearchResults> {
|
||||
const innertube = await getInnertube();
|
||||
|
||||
const innerResults = await innertube.search(search, {
|
||||
sort_by: options.sort_by,
|
||||
duration: options.duration,
|
||||
features: [options.features] as Types.Feature[],
|
||||
upload_date: options.date
|
||||
});
|
||||
|
||||
return fetchSearchWithContinuation(innerResults);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getInnertube } from '.';
|
||||
import type { SearchSuggestion } from '../model';
|
||||
|
||||
export async function getSearchSuggestionsYTjs(search: string): Promise<SearchSuggestion> {
|
||||
const innertube = await getInnertube();
|
||||
|
||||
const searchSuggestions = await innertube.getSearchSuggestions(search);
|
||||
|
||||
return {
|
||||
query: search,
|
||||
suggestions: searchSuggestions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { localDb } from '$lib/dexie';
|
||||
import { logoutStores } from '$lib/misc';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
import { relativeTimestamp } from '$lib/time';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Feed, Subscription, Thumbnail } from '../model';
|
||||
import { getChannelYTjs } from './channel';
|
||||
import { engineCooldownYTStore, engineCullYTStore } from '$lib/store';
|
||||
|
||||
export async function getSubscriptionsYTjs(): Promise<Subscription[]> {
|
||||
const subscriptions: Subscription[] = [];
|
||||
|
||||
await localDb.channelSubscriptions.each((subscription) => {
|
||||
subscriptions.push({
|
||||
author: subscription.channelName,
|
||||
authorId: subscription.channelId
|
||||
});
|
||||
});
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
export async function amSubscribedYTjs(authorId: string): Promise<boolean> {
|
||||
return (await localDb.channelSubscriptions.where('channelId').equals(authorId).count()) > 0;
|
||||
}
|
||||
|
||||
export async function postSubscribeYTjs(authorId: string) {
|
||||
const channel = await getChannelYTjs(authorId);
|
||||
|
||||
try {
|
||||
await localDb.channelSubscriptions.add({
|
||||
channelId: authorId,
|
||||
channelName: channel.author,
|
||||
lastRSSFetch: new Date(0)
|
||||
});
|
||||
} catch {
|
||||
// Continue regardless
|
||||
}
|
||||
|
||||
// Store channel into db in background
|
||||
parseChannelRSS(authorId);
|
||||
}
|
||||
|
||||
export async function deleteUnsubscribeYTjs(authorId: string) {
|
||||
await localDb.channelSubscriptions.where('channelId').equals(authorId).delete();
|
||||
await localDb.subscriptionFeed.where('authorId').equals(authorId).delete();
|
||||
logoutStores();
|
||||
}
|
||||
|
||||
export async function parseChannelRSS(channelId: string): Promise<void> {
|
||||
const feedUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`;
|
||||
|
||||
const response = await fetch(feedUrl);
|
||||
const text = await response.text();
|
||||
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(text, 'text/xml');
|
||||
const entries = xmlDoc.getElementsByTagName('entry');
|
||||
|
||||
for (const entry of entries) {
|
||||
const videoId = entry.getElementsByTagName('yt:videoId')[0]?.textContent || '';
|
||||
const title = entry.getElementsByTagName('title')[0]?.textContent || 'Untitled';
|
||||
const description =
|
||||
entry.getElementsByTagName('media:description')[0]?.textContent || 'No description available';
|
||||
const descriptionHtml = description;
|
||||
const publishedAt = new Date(
|
||||
entry.getElementsByTagName('published')[0]?.textContent || new Date()
|
||||
);
|
||||
const published = publishedAt.getTime();
|
||||
const publishedText = relativeTimestamp(published, false);
|
||||
const author =
|
||||
entry.getElementsByTagName('author')[0]?.getElementsByTagName('name')[0]?.textContent ||
|
||||
'Unknown Author';
|
||||
const authorId =
|
||||
entry
|
||||
.getElementsByTagName('author')[0]
|
||||
?.getElementsByTagName('uri')[0]
|
||||
?.textContent.split('/')[4] || '';
|
||||
|
||||
const authorUrl = authorId ? `/channel/${authorId}` : '';
|
||||
const authorVerified = false;
|
||||
|
||||
// Extracting video thumbnails
|
||||
const videoThumbnails: Thumbnail[] = Array.from(
|
||||
entry.getElementsByTagName('media:group')[0]?.getElementsByTagName('media:thumbnail') || []
|
||||
).map((thumbElement) => ({
|
||||
url: thumbElement.getAttribute('url') || '',
|
||||
width: parseInt(thumbElement.getAttribute('width') || '0'),
|
||||
height: parseInt(thumbElement.getAttribute('height') || '0')
|
||||
}));
|
||||
|
||||
const liveNow = false;
|
||||
const isUpcoming = false;
|
||||
const premium = false;
|
||||
const lengthSeconds = 0;
|
||||
const viewCountText =
|
||||
entry.getElementsByTagName('media:statistics')[0]?.getAttribute('views') || '0';
|
||||
const viewCount = parseInt(viewCountText) || 0;
|
||||
|
||||
try {
|
||||
await localDb.subscriptionFeed.add({
|
||||
type: 'video',
|
||||
videoId,
|
||||
title,
|
||||
videoThumbnails,
|
||||
author,
|
||||
authorId,
|
||||
description,
|
||||
descriptionHtml,
|
||||
published,
|
||||
publishedText,
|
||||
liveNow,
|
||||
premium,
|
||||
isUpcoming,
|
||||
lengthSeconds,
|
||||
viewCountText: cleanNumber(viewCount),
|
||||
viewCount,
|
||||
authorUrl,
|
||||
authorVerified
|
||||
});
|
||||
} catch {
|
||||
// Continue regardless of error
|
||||
}
|
||||
|
||||
await localDb.channelSubscriptions.where('channelId').equals(channelId).modify({
|
||||
lastRSSFetch: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ignored to match non ytjs version of getfeed.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function getFeedYTjs(maxResults: number, page: number): Promise<Feed> {
|
||||
const channelSubscriptions = await localDb.channelSubscriptions.toArray();
|
||||
|
||||
const toUpdatePromises: Promise<void>[] = [];
|
||||
|
||||
const now = new Date();
|
||||
for (const channel of channelSubscriptions) {
|
||||
const lastRSSFetch = new Date(channel.lastRSSFetch);
|
||||
const timeDifference = now.getTime() - lastRSSFetch.getTime();
|
||||
const cooldownTime = get(engineCooldownYTStore) * 60 * 60 * 1000;
|
||||
if (timeDifference > cooldownTime) {
|
||||
toUpdatePromises.push(parseChannelRSS(channel.channelId));
|
||||
}
|
||||
}
|
||||
|
||||
if (toUpdatePromises) {
|
||||
await Promise.all(toUpdatePromises);
|
||||
}
|
||||
|
||||
const videos = await localDb.subscriptionFeed.toArray();
|
||||
videos.sort((a, b) => b.published - a.published);
|
||||
|
||||
const cullAfter = get(engineCullYTStore);
|
||||
|
||||
if (videos.length > cullAfter) {
|
||||
const videosToDelete = videos.slice(cullAfter);
|
||||
const videoIdsToDelete = videosToDelete.map((video) => video.videoId);
|
||||
|
||||
await localDb.subscriptionFeed.where('id').anyOf(videoIdsToDelete).delete();
|
||||
}
|
||||
|
||||
return {
|
||||
notifications: [],
|
||||
videos: videos
|
||||
};
|
||||
}
|
||||
+21
-22
@@ -8,13 +8,14 @@ import type {
|
||||
VideoBase,
|
||||
VideoPlay
|
||||
} from '$lib/api/model';
|
||||
import { interfaceRegionStore, poTokenCacheStore } from '$lib/store';
|
||||
import { poTokenCacheStore } from '$lib/store';
|
||||
import { convertToSeconds } from '$lib/time';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { USER_AGENT } from 'bgutils-js';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Types } from 'youtubei.js';
|
||||
import { Innertube, UniversalCache, Utils, YT, YTNodes, Platform } from 'youtubei.js';
|
||||
import { Utils, YT, YTNodes, Platform } from 'youtubei.js';
|
||||
import { getInnertube } from '.';
|
||||
import { cleanNumber, extractNumber } from '$lib/numbers';
|
||||
|
||||
Platform.shim.eval = async (
|
||||
data: Types.BuildScriptResult,
|
||||
@@ -35,17 +36,12 @@ Platform.shim.eval = async (
|
||||
return new Function(code)();
|
||||
};
|
||||
|
||||
export async function getVideoTYjs(videoId: string): Promise<VideoPlay> {
|
||||
export async function getVideoYTjs(videoId: string): Promise<VideoPlay> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
throw new Error('Platform not supported');
|
||||
}
|
||||
|
||||
const youtube = await Innertube.create({
|
||||
fetch: fetch,
|
||||
cache: new UniversalCache(false),
|
||||
location: get(interfaceRegionStore),
|
||||
user_agent: USER_AGENT
|
||||
});
|
||||
const innertube = await getInnertube();
|
||||
|
||||
const requestKey = 'O43z0dpjhgX20SCx4KAo';
|
||||
|
||||
@@ -57,7 +53,7 @@ export async function getVideoTYjs(videoId: string): Promise<VideoPlay> {
|
||||
const clientPlaybackNonce = Utils.generateRandomString(16);
|
||||
|
||||
const watchEndpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } });
|
||||
const rawPlayerResponse = await watchEndpoint.call(youtube.actions, {
|
||||
const rawPlayerResponse = await watchEndpoint.call(innertube.actions, {
|
||||
contentCheckOk: true,
|
||||
racyCheckOk: true,
|
||||
playbackContext: {
|
||||
@@ -65,16 +61,16 @@ export async function getVideoTYjs(videoId: string): Promise<VideoPlay> {
|
||||
pyv: true
|
||||
},
|
||||
contentPlaybackContext: {
|
||||
signatureTimestamp: youtube.session.player?.signature_timestamp
|
||||
signatureTimestamp: innertube.session.player?.signature_timestamp
|
||||
}
|
||||
}
|
||||
});
|
||||
const rawNextResponse = await watchEndpoint.call(youtube.actions, {
|
||||
const rawNextResponse = await watchEndpoint.call(innertube.actions, {
|
||||
override_endpoint: '/next'
|
||||
});
|
||||
const video = new YT.VideoInfo(
|
||||
[rawPlayerResponse, rawNextResponse],
|
||||
youtube.actions,
|
||||
innertube.actions,
|
||||
clientPlaybackNonce
|
||||
);
|
||||
|
||||
@@ -82,7 +78,7 @@ export async function getVideoTYjs(videoId: string): Promise<VideoPlay> {
|
||||
throw new Error('Unable to pull video info from youtube.js');
|
||||
}
|
||||
|
||||
const challengeResponse = await youtube.getAttestationChallenge('ENGAGEMENT_TYPE_UNBOUND');
|
||||
const challengeResponse = await innertube.getAttestationChallenge('ENGAGEMENT_TYPE_UNBOUND');
|
||||
poTokenCacheStore.set(await platformMinter(requestKey, videoId, challengeResponse));
|
||||
|
||||
let dashUri: string | undefined;
|
||||
@@ -139,7 +135,7 @@ export async function getVideoTYjs(videoId: string): Promise<VideoPlay> {
|
||||
|
||||
let authorThumbnails: Image[];
|
||||
if (video.basic_info.channel_id) {
|
||||
const channel = await youtube.getChannel(video.basic_info.channel_id);
|
||||
const channel = await innertube.getChannel(video.basic_info.channel_id);
|
||||
authorThumbnails = channel.metadata.avatar as Image[];
|
||||
} else {
|
||||
authorThumbnails = [];
|
||||
@@ -151,7 +147,7 @@ export async function getVideoTYjs(videoId: string): Promise<VideoPlay> {
|
||||
|
||||
url.searchParams.set('potc', '1');
|
||||
url.searchParams.set('pot', get(poTokenCacheStore) ?? '');
|
||||
url.searchParams.set('c', youtube.session.context.client.clientName);
|
||||
url.searchParams.set('c', innertube.session.context.client.clientName);
|
||||
url.searchParams.set('fmt', 'vtt');
|
||||
|
||||
// Remove &xosf=1 as it adds `position:63% line:0%` to the subtitle lines
|
||||
@@ -190,10 +186,13 @@ export async function getVideoTYjs(videoId: string): Promise<VideoPlay> {
|
||||
videoThumbnails: (recommended?.content_image.image as Thumbnail[]) || [],
|
||||
videoId: recommended.content_id,
|
||||
title: recommended.metadata.title.toString(),
|
||||
viewCountText:
|
||||
(recommended.metadata.metadata.metadata_rows[1]?.metadata_parts?.[0]?.text ?? '')
|
||||
.toString()
|
||||
.replace('views', '') || '',
|
||||
viewCountText: cleanNumber(
|
||||
extractNumber(
|
||||
(
|
||||
recommended.metadata.metadata.metadata_rows[1]?.metadata_parts?.[0]?.text ?? ''
|
||||
).toString()
|
||||
)
|
||||
),
|
||||
author:
|
||||
(
|
||||
recommended.metadata.metadata.metadata_rows[0]?.metadata_parts?.[0]?.text ?? ''
|
||||
@@ -271,7 +270,7 @@ export async function getVideoTYjs(videoId: string): Promise<VideoPlay> {
|
||||
keywords: video.basic_info.keywords || [],
|
||||
allowedRegions: [],
|
||||
ytjs: {
|
||||
innertube: youtube,
|
||||
innertube: innertube,
|
||||
video: video,
|
||||
clientPlaybackNonce: clientPlaybackNonce,
|
||||
rawApiResponse: rawPlayerResponse
|
||||
@@ -3,7 +3,7 @@
|
||||
import { amSubscribed, deleteUnsubscribe, postSubscribe } from '$lib/api';
|
||||
import type { Image } from '$lib/api/model';
|
||||
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
|
||||
import { truncate } from '$lib/misc';
|
||||
import { isYTBackend, truncate } from '$lib/misc';
|
||||
import { authStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { localDb } from '$lib/dexie';
|
||||
@@ -71,12 +71,12 @@
|
||||
</nav>
|
||||
</a>
|
||||
{#if !hideSubscribe}
|
||||
{#if $authStore}
|
||||
{#if $authStore || isYTBackend()}
|
||||
<nav class="group split">
|
||||
<button
|
||||
onclick={toggleSubscribed}
|
||||
class:inverse-surface={!subscribed}
|
||||
class:border={subscribed}
|
||||
class:primary={!subscribed}
|
||||
class:surface-container-highest={subscribed}
|
||||
>
|
||||
{#if !subscribed}
|
||||
{$_('subscribe')}
|
||||
@@ -86,8 +86,8 @@
|
||||
</button>
|
||||
{#if window.indexedDB && subscribed}
|
||||
<button
|
||||
class:inverse-surface={!favoritedChannel}
|
||||
class:border={favoritedChannel}
|
||||
class:primary={!favoritedChannel}
|
||||
class:surface-container-highest={favoritedChannel}
|
||||
onclick={toggleFavourited}
|
||||
class="square"
|
||||
>
|
||||
@@ -99,7 +99,7 @@
|
||||
{/if}
|
||||
</nav>
|
||||
{:else}
|
||||
<button class="inverse-surface" disabled>
|
||||
<button class="surface-container-highest" disabled>
|
||||
{$_('subscribe')}
|
||||
<div class="tooltip">
|
||||
{$_('loginRequired')}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
synciousStore
|
||||
} from '../store';
|
||||
import { setStatusBarColor } from '../theme';
|
||||
import { getVideoTYjs } from '$lib/api/youtubejs';
|
||||
import { getVideoYTjs } from '$lib/api/youtubejs/video';
|
||||
import {
|
||||
goToNextVideo,
|
||||
goToPreviousVideo,
|
||||
@@ -58,7 +58,7 @@
|
||||
import { Network, type ConnectionStatus } from '@capacitor/network';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { addToast } from './Toast.svelte';
|
||||
import { isMobile, truncate } from '$lib/misc';
|
||||
import { isMobile, isYTBackend, truncate } from '$lib/misc';
|
||||
import {
|
||||
generateThumbnailWebVTT,
|
||||
drawTimelineThumbnail,
|
||||
@@ -485,7 +485,7 @@
|
||||
|
||||
sabrAdapter = await injectSabr(data.video, player);
|
||||
|
||||
if (data.video.fallbackPatch === 'youtubejs') {
|
||||
if (data.video.fallbackPatch === 'youtubejs' && !isYTBackend()) {
|
||||
addToast({ data: { text: $_('player.youtubeJsFallBack') } });
|
||||
}
|
||||
|
||||
@@ -624,7 +624,7 @@
|
||||
|
||||
async function reloadVideo() {
|
||||
showVideoRetry = false;
|
||||
data.video = await getVideoTYjs(data.video.videoId);
|
||||
data.video = await getVideoYTjs(data.video.videoId);
|
||||
await loadVideo();
|
||||
}
|
||||
|
||||
@@ -1332,14 +1332,14 @@
|
||||
{/if}
|
||||
{#if showControls}
|
||||
<div id="mobile-time" transition:fade>
|
||||
<p class="chip inverse-primary s">
|
||||
<p class="chip surface-container-highest s">
|
||||
{#if data.video.liveNow}
|
||||
{$_('thumbnail.live')}
|
||||
{:else}
|
||||
{videoLength(currentTime)} / {videoLength(playerMaxKnownTime)}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="chip inverse-primary">
|
||||
<p class="chip surface-container-highest">
|
||||
{playerVideoEndTimePretty}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1400,7 +1400,7 @@
|
||||
</p>
|
||||
{:else if playerCloestTimestamp}
|
||||
<p class="no-margin" style="padding: 0 0.5rem;">
|
||||
{truncate(playerCloestTimestamp.title, 22)}
|
||||
{truncate(playerCloestTimestamp.title, 20)}
|
||||
</p>
|
||||
{/if}
|
||||
{videoLength(timeInSeconds)}
|
||||
@@ -1445,7 +1445,7 @@
|
||||
<nav>
|
||||
{#if !$isAndroidTvStore}
|
||||
<nav class="no-wrap">
|
||||
<button class="inverse-primary" onclick={toggleVideoPlaybackStatus}>
|
||||
<button class="surface-container-highest" onclick={toggleVideoPlaybackStatus}>
|
||||
<i>
|
||||
{#if playerCurrentPlaybackState}
|
||||
pause
|
||||
@@ -1481,7 +1481,7 @@
|
||||
</p>
|
||||
{#if !$isAndroidTvStore}
|
||||
{#if playerTextTracks && playerTextTracks.length > 0 && !data.video.liveNow}
|
||||
<button class="inverse-primary">
|
||||
<button class="surface-container-highest">
|
||||
<i>closed_caption</i>
|
||||
<menu class="no-wrap mobile" id="cc-menu" data-ui="#cc-menu">
|
||||
<li
|
||||
@@ -1506,7 +1506,7 @@
|
||||
</menu>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="inverse-primary">
|
||||
<button class="surface-container-highest">
|
||||
<i>settings</i>
|
||||
<menu class="no-wrap mobile" id="settings-menu">
|
||||
{#if playerSettings !== 'root'}
|
||||
@@ -1641,13 +1641,17 @@
|
||||
</menu>
|
||||
</button>
|
||||
{#if playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)}
|
||||
<button class="inverse-primary" onclick={handleAirPlayClick} title="AirPlay">
|
||||
<button
|
||||
class="surface-container-highest"
|
||||
onclick={handleAirPlayClick}
|
||||
title="AirPlay"
|
||||
>
|
||||
<i>airplay</i>
|
||||
</button>
|
||||
{/if}
|
||||
{#if document.pictureInPictureEnabled}
|
||||
<button
|
||||
class="inverse-primary"
|
||||
class="surface-container-highest"
|
||||
onclick={() => {
|
||||
(playerElement as HTMLVideoElement).requestPictureInPicture();
|
||||
}}
|
||||
@@ -1655,7 +1659,7 @@
|
||||
<i>pip</i>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="inverse-primary" onclick={toggleFullscreen}>
|
||||
<button class="surface-container-highest" onclick={toggleFullscreen}>
|
||||
<i>
|
||||
{#if playerIsFullscreen}
|
||||
fullscreen_exit
|
||||
@@ -1775,7 +1779,7 @@
|
||||
.player-slider [data-melt-slider-thumb] {
|
||||
position: absolute;
|
||||
border-radius: 1rem;
|
||||
background: var(--inverse-primary);
|
||||
background: var(--primary);
|
||||
left: var(--percentage);
|
||||
top: 50%;
|
||||
width: 5px;
|
||||
@@ -1889,8 +1893,8 @@
|
||||
}
|
||||
|
||||
.chip {
|
||||
background-color: var(--inverse-primary);
|
||||
color: var(--primary);
|
||||
background-color: var(--surface-container-highest);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
|
||||
let { playlist, disabled = false }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
let img: HTMLImageElement | undefined = $state();
|
||||
|
||||
const playlistLink = resolve('/playlist/[playlistId]', { playlistId: playlist.playlistId });
|
||||
@@ -25,25 +23,17 @@
|
||||
onMount(async () => {
|
||||
if (get(interfaceLowBandwidthMode)) return;
|
||||
|
||||
let imgSrc = '';
|
||||
let imgSrc;
|
||||
if (playlist.videos && playlist.videos.length > 0) {
|
||||
imgSrc = getBestThumbnail(playlist.videos[0].videoThumbnails) || '';
|
||||
} else if (playlist.playlistThumbnail && playlist.playlistThumbnail.length > 0) {
|
||||
} else if (playlist.playlistThumbnail) {
|
||||
imgSrc = playlist.playlistThumbnail;
|
||||
} else {
|
||||
imgSrc = '';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
img = await insecureRequestImageHandler(imgSrc);
|
||||
|
||||
img.onload = () => {
|
||||
loading = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
loading = false;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -53,18 +43,14 @@
|
||||
style="width: 100%; overflow: hidden;min-height:100px;"
|
||||
class="wave"
|
||||
>
|
||||
{#if playlist.videoCount > 0 && !$interfaceLowBandwidthMode}
|
||||
{#if loading}
|
||||
<progress class="circle"></progress>
|
||||
{:else if img && img.src !== ''}
|
||||
<img
|
||||
loading="lazy"
|
||||
class="responsive"
|
||||
style="max-width: 100%;height: 100%;"
|
||||
src={img.src}
|
||||
alt="Thumbnail for playlist"
|
||||
/>
|
||||
{/if}
|
||||
{#if !$interfaceLowBandwidthMode && img}
|
||||
<img
|
||||
loading="lazy"
|
||||
class="responsive"
|
||||
style="max-width: 100%;height: 100%;"
|
||||
src={img.src}
|
||||
alt="Thumbnail for playlist"
|
||||
/>
|
||||
{:else}
|
||||
<h6 style="margin: 3em 0;">No image</h6>
|
||||
{/if}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
synciousStore
|
||||
} from '../store';
|
||||
import { queueGetWatchProgress } from '$lib/api/apiExtended';
|
||||
import { relativeTimestamp } from '$lib/time';
|
||||
|
||||
interface Props {
|
||||
video: VideoBase | Video | Notification | PlaylistPageVideo;
|
||||
@@ -175,7 +176,9 @@
|
||||
{#if !thumbnail}
|
||||
<div class="secondary-container" style="width: 100%;height: {placeholderHeight}px;"></div>
|
||||
{:else}
|
||||
<img class="responsive" loading="lazy" src={thumbnail.src} alt="Thumbnail for video" />
|
||||
<div class:crop={thumbnail.height > 300}>
|
||||
<img class="responsive" loading="lazy" src={thumbnail.src} alt="Thumbnail for video" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if progress}
|
||||
@@ -243,9 +246,12 @@
|
||||
{$_('views')}
|
||||
{/if}
|
||||
|
||||
{#if 'publishedText' in video}
|
||||
{#if 'published' in video}
|
||||
<div class="max">
|
||||
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)} • {video.publishedText}
|
||||
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)} • {relativeTimestamp(
|
||||
video.published,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -254,6 +260,22 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.crop {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.crop img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
clip-path: inset(30px 0 30px 0);
|
||||
display: block;
|
||||
transform: translateY(-30px);
|
||||
margin-bottom: -60px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { engineCooldownYTStore, engineCullYTStore, engineFallbacksStore } from '$lib/store';
|
||||
import { useEngineFallback, type EngineFallback } from '$lib/api/misc';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const engineFallbacks: EngineFallback[] = [
|
||||
'Channel',
|
||||
'ChannelContent',
|
||||
'Comments',
|
||||
'ResolveUrl',
|
||||
'Search',
|
||||
'SearchSuggestions',
|
||||
'Video',
|
||||
'Playlist'
|
||||
];
|
||||
|
||||
function enableFallback(event: Event) {
|
||||
if (!event.target) return;
|
||||
|
||||
const fallback = (event.target as HTMLInputElement).value as EngineFallback;
|
||||
|
||||
let enabledFallbacks = get(engineFallbacksStore);
|
||||
|
||||
if (useEngineFallback(fallback)) {
|
||||
enabledFallbacks = enabledFallbacks.filter((item) => {
|
||||
return item !== fallback;
|
||||
});
|
||||
} else {
|
||||
enabledFallbacks.push(fallback);
|
||||
}
|
||||
|
||||
engineFallbacksStore.set(enabledFallbacks);
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="error-container">
|
||||
<p>{$_('layout.backendEngine.warning')}</p>
|
||||
</article>
|
||||
|
||||
{#if isYTBackend()}
|
||||
<h4>Feed</h4>
|
||||
<div class="field label prefix border">
|
||||
<i>view_stream</i>
|
||||
<input
|
||||
oninput={(event: Event) => {
|
||||
engineCullYTStore.set(Number((event.target as HTMLInputElement).value));
|
||||
}}
|
||||
value={$engineCullYTStore}
|
||||
name="cull"
|
||||
type="number"
|
||||
/>
|
||||
<label for="cull">{$_('layout.backendEngine.cull')}</label>
|
||||
</div>
|
||||
<div class="field label prefix border">
|
||||
<i>schedule</i>
|
||||
<input
|
||||
oninput={(event: Event) => {
|
||||
engineCooldownYTStore.set(Number((event.target as HTMLInputElement).value));
|
||||
}}
|
||||
value={$engineCooldownYTStore}
|
||||
name="cull"
|
||||
type="number"
|
||||
/>
|
||||
<label for="cull">{$_('layout.backendEngine.cooldown')}</label>
|
||||
</div>
|
||||
{:else}
|
||||
<h4>{$_('layout.backendEngine.fallbacks')}</h4>
|
||||
{#each engineFallbacks as fallback (fallback)}
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
<p>{fallback}</p>
|
||||
</div>
|
||||
<label class="switch" tabindex="0">
|
||||
<input
|
||||
checked={useEngineFallback(fallback)}
|
||||
value={fallback}
|
||||
onclick={enableFallback}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</nav>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -11,13 +11,13 @@
|
||||
import type { RgbaColor, HsvaColor, Colord } from 'colord';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { ensureNoTrailingSlash, isMobile } from '../../misc';
|
||||
import { ensureNoTrailingSlash, isMobile, logoutStores } from '../../misc';
|
||||
import { getPages, type Pages } from '../../navPages';
|
||||
import ColorPicker from 'svelte-awesome-color-picker';
|
||||
import {
|
||||
authStore,
|
||||
backendInUseStore,
|
||||
darkModeStore,
|
||||
feedCacheStore,
|
||||
instanceStore,
|
||||
interfaceAllowInsecureRequests,
|
||||
interfaceAmoledTheme,
|
||||
@@ -32,8 +32,6 @@
|
||||
interfaceRegionStore,
|
||||
interfaceSearchHistoryEnabled,
|
||||
interfaceSearchSuggestionsStore,
|
||||
playlistCacheStore,
|
||||
searchCacheStore,
|
||||
searchHistoryStore,
|
||||
themeColorStore
|
||||
} from '../../store';
|
||||
@@ -77,6 +75,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clearPreviousInstance() {
|
||||
logoutStores();
|
||||
ui('#dialog-settings');
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function setInstance(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -107,13 +112,14 @@
|
||||
}
|
||||
|
||||
instanceStore.set(instance);
|
||||
authStore.set(null);
|
||||
feedCacheStore.set({});
|
||||
searchCacheStore.set({});
|
||||
playlistCacheStore.set({});
|
||||
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
ui('#dialog-settings');
|
||||
clearPreviousInstance();
|
||||
}
|
||||
|
||||
async function setBackend(event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
backendInUseStore.set(select.value as 'ivg' | 'yt');
|
||||
clearPreviousInstance();
|
||||
}
|
||||
|
||||
function allowInsecureRequests() {
|
||||
@@ -147,20 +153,38 @@
|
||||
</script>
|
||||
|
||||
{#if Capacitor.isNativePlatform()}
|
||||
<form onsubmit={setInstance}>
|
||||
<nav>
|
||||
<div class="field label border max" class:invalid={invalidInstance}>
|
||||
<input tabindex="0" bind:value={invidiousInstance} name="invidious-instance" type="text" />
|
||||
<label tabindex="-1" for="invidious-instance">{$_('layout.instanceUrl')}</label>
|
||||
{#if invalidInstance}
|
||||
<span class="error">{$_('invalidInstance')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="square round">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
</form>
|
||||
<div class="field label suffix border">
|
||||
<select name="backend-in-use" onchange={setBackend}>
|
||||
<option selected={$backendInUseStore === 'ivg'} value="ivg">Invidious</option>
|
||||
<option selected={$backendInUseStore === 'yt'} value="yt">YouTube (Experimental)</option>
|
||||
</select>
|
||||
<label for="backend-in-use">{$_('backend')}</label>
|
||||
<i>arrow_drop_down</i>
|
||||
</div>
|
||||
|
||||
{#if $backendInUseStore === 'ivg'}
|
||||
<form onsubmit={setInstance}>
|
||||
<nav>
|
||||
<div class="field label border max" class:invalid={invalidInstance}>
|
||||
<input
|
||||
tabindex="0"
|
||||
bind:value={invidiousInstance}
|
||||
name="invidious-instance"
|
||||
type="text"
|
||||
/>
|
||||
<label tabindex="-1" for="invidious-instance">{$_('layout.instanceUrl')}</label>
|
||||
{#if invalidInstance}
|
||||
<span class="error">{$_('invalidInstance')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="square round">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="space"></div>
|
||||
{/if}
|
||||
|
||||
{#if isMobile()}
|
||||
{#if invalidInstance}
|
||||
@@ -212,6 +236,7 @@
|
||||
onInput={setColor}
|
||||
position="responsive"
|
||||
isAlpha={false}
|
||||
hex={$themeColorStore}
|
||||
sliderDirection="horizontal"
|
||||
/>
|
||||
</div>
|
||||
@@ -460,7 +485,7 @@
|
||||
--cp-input-color: var(--surface);
|
||||
--cp-button-hover-color: var(--surface-variant);
|
||||
--slider-width: 50px;
|
||||
--picker-width: 45vw;
|
||||
--picker-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, type Component } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import ApiExtended from './ApiExtended.svelte';
|
||||
import DeArrow from './DeArrow.svelte';
|
||||
@@ -7,13 +7,15 @@
|
||||
import Player from './Player.svelte';
|
||||
import Ryd from './RYD.svelte';
|
||||
import SponsorBlock from './SponsorBlock.svelte';
|
||||
import { instanceStore, isAndroidTvStore } from '$lib/store';
|
||||
import { isAndroidTvStore } from '$lib/store';
|
||||
import About from './About.svelte';
|
||||
import Engine from './Engine.svelte';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
let activeTab = $state('interface');
|
||||
const isActive = (id: string) => activeTab === id;
|
||||
|
||||
const tabs = [
|
||||
const tabs: { id: string; label: string; icon: string; component: Component }[] = [
|
||||
{ id: 'interface', label: $_('layout.interface'), icon: 'grid_view', component: Interface },
|
||||
{ id: 'player', label: $_('layout.player.title'), icon: 'smart_display', component: Player },
|
||||
{ id: 'ryd', label: 'RYD', icon: 'thumb_down', component: Ryd },
|
||||
@@ -33,6 +35,15 @@
|
||||
}
|
||||
];
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
tabs.splice(1, 0, {
|
||||
id: 'engine',
|
||||
label: $_('layout.engine'),
|
||||
icon: 'build',
|
||||
component: Engine
|
||||
});
|
||||
}
|
||||
|
||||
const tabIds = tabs.map((tab) => tab.id);
|
||||
|
||||
let dialogType = $state('');
|
||||
@@ -89,9 +100,9 @@
|
||||
<button class="circle transparent" data-ui="#dialog-settings"><i>close</i></button>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<div style="height: 100%;">
|
||||
<nav class="wrap s">
|
||||
<button class="large small-round secondary max" data-ui="#tab-menu">
|
||||
<button class="large small-round surface-container-highest max" data-ui="#tab-menu">
|
||||
<i>{tabs[tabIds.indexOf(activeTab)].icon}</i>
|
||||
<span>{tabs[tabIds.indexOf(activeTab)].label}</span>
|
||||
<menu style="width: 100%;" data-ui="#tab-menu" id="tab-menu">
|
||||
@@ -111,76 +122,90 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<nav class="tabbed small m l" style="outline: none" role="tablist" tabindex="0">
|
||||
{#each tabs as tab, index (tab)}
|
||||
<a
|
||||
role="tab"
|
||||
class:active={isActive(tab.id)}
|
||||
aria-selected={isActive(tab.id)}
|
||||
id={`tab-${tab.id}`}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
tabindex={isActive(tab.id) ? 0 : -1}
|
||||
onclick={() => (activeTab = tab.id)}
|
||||
onkeydown={(event) => onKeydown(event, index)}
|
||||
>
|
||||
<i>{tab.icon}</i>
|
||||
<span>{tab.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{#if !$isAndroidTvStore}
|
||||
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||
<a href={`${$instanceStore}/preferences`} target="_blank" referrerpolicy="no-referrer">
|
||||
<i>
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"
|
||||
><path
|
||||
d="M244.2 502.3c-25.1-1-49.2-5.4-72.6-13.3-77.4-26.1-138-87.1-161.3-162.4C.2 294.1-2.5 258 2.3 222.3c4.7-34.9 17.3-68 37.3-98.2 15.3-23.2 37-46.1 60-63.3 46.9-35.1 109.2-54 168.2-51 32.9 1.7 63.4 8.6 92.9 21.1 10.4 4.4 25.4 12.3 35.2 18.5 18 11.3 28.4 19.7 43.8 35 16.8 16.8 28.1 30.8 38.6 48.1 9.2 15.1 17.9 34.5 23.2 51.3 2 6.5 5.6 20.9 7.2 28.8 3 15.1 3.4 20 3.4 42.3 0 23.5-.6 31-3.7 46.7-11.8 59.6-45.2 112-95.1 149.4-29 21.7-63.5 37.5-99.8 45.5-21.3 4.8-45.1 6.7-69.3 5.8m-44.6-100.1c0-.7-.1-2.6-.2-4.3l-.2-3.1-6-.3c-5.1-.3-6.1-.4-6.8-1-2.6-1.9-1.9-7.7 2.9-23.4 1.1-3.7 4.2-14.4 7-23.8 2.7-9.5 6.6-22.7 8.6-29.3 2-6.7 5.3-18.1 7.4-25.5 2.1-7.3 4.6-15.8 5.5-18.8s4.1-14 7.2-24.5c6.3-21.6 10.4-35.2 11.4-37.9.7-1.8.7-1.8 1.3-1.2.3.3 4.9 9.4 10.1 20.1 5.3 10.7 16.6 33.8 25.3 51.2 21.8 44.1 48.1 97.3 55 111.3l5.8 11.8 14.4-.1c7.9-.1 14.8-.2 15.2-.4.7-.3.7-.4-1.7-5.5-1.4-2.9-10.3-21.1-20-40.5-62.1-125-78-157.4-86.5-176-6-13.1-8.8-18.8-9.8-20-1.1-1.2-2.1-1.4-3-.3-1.5 1.7-5.2 12.8-11.9 35.9-2.6 9-8.3 28.5-12.6 43.3-8.4 28.7-10.8 36.9-18.2 62.1-2.6 8.8-6.1 20.8-7.8 26.6s-5.1 17.5-7.6 26.1c-5.5 18.9-7.8 26.5-9.4 30.8-2.7 7.3-3.3 8-6.3 8.6-1.1.2-3.1.4-4.3.4-2.8 0-4.5.5-5.2 1.4-.5.8-.7 6.2-.2 7 .3.4 3.1.5 29.6.7l10.9.1zm46.7-261.1c5.5-1.2 10.7-6.3 12-11.7.5-2 .4-6.3-.1-8.2-1.1-4-4.2-8-7.7-9.9-4.8-2.6-12-2.6-16.9 0-7.6 4.1-10.4 14.1-6.1 22.1 3.5 6.4 10.7 9.4 18.8 7.7"
|
||||
style="fill:#f0f0f0"
|
||||
/><path
|
||||
d="M2712.8 5196.2c-157.5-82.6-156.2-323.9 3.8-405.2 109.2-55.9 249-20.3 313.7 78.8 73.7 110.5 33 268-85.1 327.7-64.7 33-167.6 33-232.4-1.3z"
|
||||
style="fill:#00b6f0;stroke:#00b6f0;stroke-width:.297;stroke-miterlimit:10"
|
||||
transform="matrix(.0699 0 0 -.0699 44.236 474.48)"
|
||||
/><path
|
||||
d="M2833.5 4483.6c-15.2-20.3-80-210.9-129.6-384.9-38.1-132.1-128.3-444.6-209.6-717.7-25.4-87.6-73.7-252.8-108-368.4-33-115.6-81.3-280.7-108-368.4-25.4-87.6-73.7-252.8-108-368.4-33-115.6-81.3-280.7-108-368.4-25.4-87.6-72.4-247.7-102.9-355.7-74.9-262.9-118.1-379.8-146.1-393.8-12.7-7.6-44.5-12.7-71.1-12.7-92.7 0-106.7-10.2-102.9-68.6l3.8-52.1 289.6-3.8 289.6-2.5-3.8 59.7-3.8 61-92.7 3.8c-83.8 3.8-94 6.4-106.7 34.3-16.5 35.6 0 132.1 47 285.8 16.5 52.1 59.7 198.2 95.3 323.9 35.6 125.8 83.8 292.2 108 368.4 22.9 77.5 71.1 242.6 108 368.4 35.6 125.8 83.8 292.2 108 368.4 22.9 77.5 71.1 242.6 108 368.4 108 377.3 156.2 532.2 166.4 544.9 6.4 6.4 21.6-14 35.6-44.5 12.7-30.5 243.9-499.2 511.9-1040.3s567.8-1145.7 664.3-1343.9l176.6-358.2h213.4c116.9 0 213.4 5.1 213.4 10.2s-95.3 200.7-210.9 434.4C3613.4 2965.6 3129.4 3950 3048.2 4130.4c-36.8 80-87.6 191.8-113.1 247.7-55.9 120.8-73.7 139.8-101.6 105.5"
|
||||
style="fill:#575757"
|
||||
transform="matrix(.0699 0 0 -.0699 44.236 474.48)"
|
||||
/></svg
|
||||
>
|
||||
</svg>
|
||||
</i>
|
||||
<span>{$_('layout.settings')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="space"></div>
|
||||
<div class="divider"></div>
|
||||
{#each tabs as tab (tab)}
|
||||
<div
|
||||
class="page padding"
|
||||
id={`panel-${tab.id}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${tab.id}`}
|
||||
hidden={!isActive(tab.id)}
|
||||
inert={!$isAndroidTvStore && !isActive(tab.id)}
|
||||
aria-hidden={!isActive(tab.id)}
|
||||
class:active={isActive(tab.id)}
|
||||
>
|
||||
<tab.component />
|
||||
<div class="s">
|
||||
<div class="space"></div>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="s12 m4 l4 m l">
|
||||
<div class="padding">
|
||||
{#each tabs as tab, index (tab)}
|
||||
<a
|
||||
class:active={isActive(tab.id)}
|
||||
aria-selected={isActive(tab.id)}
|
||||
class="button surface-container-highest"
|
||||
id={`tab-${tab.id}`}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
tabindex={isActive(tab.id) ? 0 : -1}
|
||||
onclick={() => (activeTab = tab.id)}
|
||||
onkeydown={(event) => onKeydown(event, index)}
|
||||
>
|
||||
<i>{tab.icon}</i>
|
||||
<span>{tab.label}</span>
|
||||
</a>
|
||||
<div class="space"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="s12 m8 l8">
|
||||
{#each tabs as tab (tab)}
|
||||
<div
|
||||
class="page padding"
|
||||
id={`panel-${tab.id}`}
|
||||
aria-labelledby={`tab-${tab.id}`}
|
||||
hidden={!isActive(tab.id)}
|
||||
inert={!$isAndroidTvStore && !isActive(tab.id)}
|
||||
aria-hidden={!isActive(tab.id)}
|
||||
class:active={isActive(tab.id)}
|
||||
>
|
||||
<tab.component />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.tabbed {
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
.tabbed > a {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
#dialog-settings {
|
||||
width: fit-content;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-height: 800px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 0 !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.grid .m,
|
||||
.grid .l {
|
||||
border-right: solid 1px var(--secondary);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
dialog {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a.button {
|
||||
width: 100%;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
#dialog-settings {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,14 +23,17 @@
|
||||
const replyText: string = comment.replies?.replyCount > 1 ? $_('replies') : $_('reply');
|
||||
|
||||
async function loadReplies(continuation: string) {
|
||||
try {
|
||||
replies = await getComments(videoId, {
|
||||
continuation: continuation,
|
||||
sort_by: 'top',
|
||||
source: 'youtube'
|
||||
});
|
||||
} catch {
|
||||
// Continue regardless of error
|
||||
if (comment?.getReplies) {
|
||||
replies = await comment.getReplies();
|
||||
} else {
|
||||
try {
|
||||
replies = await getComments(videoId, {
|
||||
continuation: continuation,
|
||||
sort_by: 'top'
|
||||
});
|
||||
} catch {
|
||||
// Continue regardless of error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
|
||||
{#await returnYTDislikes then returnYTDislikes}
|
||||
{#if returnYTDislikes}
|
||||
<button style="cursor: default;margin-left: 0;" class="border">
|
||||
<button style="cursor: default;margin-left: 0;" class="surface-container-highest">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(returnYTDislikes.likes)}</span>
|
||||
<i class="small" style="margin-left: 0.5em;">thumb_down_alt</i>
|
||||
<span>{cleanNumber(returnYTDislikes.dislikes)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button style="cursor: default;margin-left: 0;" class="border">
|
||||
<button style="cursor: default;margin-left: 0;" class="surface-container-highest">
|
||||
<i class="small">thumb_up</i>
|
||||
<span>{cleanNumber(video.likeCount)}</span>
|
||||
</button>
|
||||
|
||||
@@ -68,7 +68,7 @@ nav.bottom {
|
||||
}
|
||||
|
||||
dialog {
|
||||
padding-top: calc(var(--safe-area-inset-top) + 1rem) !important;
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
main.root {
|
||||
@@ -156,4 +156,8 @@ menu {
|
||||
width: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
dialog {
|
||||
padding-top: calc(var(--safe-area-inset-top) + 1rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import type { Table } from 'dexie';
|
||||
import Dexie from 'dexie';
|
||||
import type { Video } from './api/model';
|
||||
|
||||
export interface FavouriteChannels {
|
||||
channelId: string;
|
||||
created: Date;
|
||||
}
|
||||
|
||||
export interface ChannelSubscriptions {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
lastRSSFetch: Date;
|
||||
}
|
||||
|
||||
export class MaterialiousDb extends Dexie {
|
||||
favouriteChannels!: Table<FavouriteChannels>;
|
||||
channelSubscriptions!: Table<ChannelSubscriptions>;
|
||||
subscriptionFeed!: Table<Video>;
|
||||
|
||||
constructor() {
|
||||
super('materialious');
|
||||
this.version(1).stores({
|
||||
favouriteChannels: 'channelId, created'
|
||||
this.version(2).stores({
|
||||
favouriteChannels: 'channelId',
|
||||
channelSubscriptions: 'channelId',
|
||||
subscriptionFeed: 'videoId, authorId, published'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"videos": "videos",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"backend": "Backend",
|
||||
"filters": {
|
||||
"filters": "Filters",
|
||||
"type": "Type",
|
||||
@@ -141,6 +142,7 @@
|
||||
"layout": {
|
||||
"interface": "Interface",
|
||||
"star": "Star us on Github!",
|
||||
"engine": "Backend engine",
|
||||
"syncParty": "Sync party",
|
||||
"about": "About",
|
||||
"syncPartyWarning": "Please note your IP will be visible to users you invite.",
|
||||
@@ -173,6 +175,12 @@
|
||||
"allowInsecureRequests": "Allow insecure requests",
|
||||
"disableAutoUpdate": "Disable automatic updates",
|
||||
"androidNativeShare": "Use native share",
|
||||
"backendEngine": {
|
||||
"warning": "Advanced settings! For experienced users only. Do not change unless you know what you’re doing.",
|
||||
"cull": "Max feed items",
|
||||
"cooldown": "Re-fetch channel hour cooldown",
|
||||
"fallbacks": "Fallbacks"
|
||||
},
|
||||
"player": {
|
||||
"title": "Player",
|
||||
"autoPlay": "Autoplay video",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { Image } from './api/model';
|
||||
import { instanceStore } from './store';
|
||||
import { isYTBackend } from './misc';
|
||||
|
||||
export class ImageCache {
|
||||
private cache = new Map<string, HTMLImageElement>();
|
||||
@@ -54,6 +55,8 @@ export function getBestThumbnail(
|
||||
export function proxyGoogleImage(source: string): string {
|
||||
if (source.startsWith('//')) source = `https:${source}`;
|
||||
|
||||
if (isYTBackend()) return source;
|
||||
|
||||
let path: string | undefined;
|
||||
try {
|
||||
path = new URL(source).pathname;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { pushState } from '$app/navigation';
|
||||
import { goto, pushState } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import he from 'he';
|
||||
import type Peer from 'peerjs';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
authStore,
|
||||
backendInUseStore,
|
||||
channelCacheStore,
|
||||
feedCacheStore,
|
||||
interfaceAllowInsecureRequests,
|
||||
interfaceAndroidUseNativeShare,
|
||||
isAndroidTvStore
|
||||
isAndroidTvStore,
|
||||
playlistCacheStore,
|
||||
searchCacheStore
|
||||
} from './store';
|
||||
import type {
|
||||
Channel,
|
||||
@@ -222,3 +228,21 @@ export function findElementForTime<T>(
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isYTBackend(): boolean {
|
||||
return get(backendInUseStore) === 'yt' && Capacitor.isNativePlatform();
|
||||
}
|
||||
|
||||
export function logoutStores() {
|
||||
authStore.set(null);
|
||||
feedCacheStore.set({});
|
||||
searchCacheStore.set({});
|
||||
playlistCacheStore.set({});
|
||||
channelCacheStore.set({});
|
||||
}
|
||||
|
||||
export function authProtected() {
|
||||
if (!get(authStore) && !isYTBackend()) {
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { isYTBackend } from './misc';
|
||||
|
||||
export type Pages = { icon: string; href: string; name: string; requiresAuth: boolean }[];
|
||||
|
||||
@@ -8,7 +9,7 @@ export function getPages(): Pages {
|
||||
return [
|
||||
{
|
||||
icon: 'home',
|
||||
href: '/',
|
||||
href: !isYTBackend() ? '/' : '/subscriptions',
|
||||
name: get(_)('pages.home'),
|
||||
requiresAuth: false
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import humanNumber from 'human-number';
|
||||
|
||||
export function extractNumber(input: string): number {
|
||||
const digits = input.replace(/\D+/g, '');
|
||||
return digits === '' ? NaN : Number(digits);
|
||||
}
|
||||
|
||||
export function numberWithCommas(number: number) {
|
||||
if (typeof number === 'undefined') return;
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
@@ -11,43 +11,58 @@ export async function loadEntirePlaylist(
|
||||
return cachedPlaylists[playlistId];
|
||||
}
|
||||
|
||||
let playlistVideos: PlaylistPageVideo[] = [];
|
||||
let playlist: PlaylistPage | undefined = undefined;
|
||||
const playlistVideos: PlaylistPageVideo[] = [];
|
||||
const ignoreVideos = new Set<string>();
|
||||
|
||||
const ignoreVideos: string[] = [];
|
||||
let newPlaylist = await getPlaylist(playlistId, 1);
|
||||
if (newPlaylist.getContinuation) {
|
||||
let firstVideoId: string = '';
|
||||
|
||||
for (let page = 1; page < Infinity; page++) {
|
||||
const newPlaylist = await getPlaylist(playlistId, page);
|
||||
if (page === 1) {
|
||||
playlist = newPlaylist;
|
||||
}
|
||||
let newVideos = newPlaylist.videos;
|
||||
if (newVideos.length === 0) {
|
||||
break;
|
||||
processVideos(newPlaylist.videos, ignoreVideos, playlistVideos);
|
||||
|
||||
while (newPlaylist.getContinuation) {
|
||||
const continuationResult = await newPlaylist.getContinuation();
|
||||
processVideos(continuationResult.videos, ignoreVideos, playlistVideos);
|
||||
|
||||
if (firstVideoId === continuationResult.videos[0].videoId) break;
|
||||
|
||||
firstVideoId = continuationResult.videos[0].videoId;
|
||||
}
|
||||
} else {
|
||||
let page = 1;
|
||||
while (true) {
|
||||
newPlaylist = await getPlaylist(playlistId, page);
|
||||
|
||||
newVideos = newVideos.filter((playlistVideo) => {
|
||||
playlistVideo.type = 'video';
|
||||
return playlistVideo.lengthSeconds > 0 && !ignoreVideos.includes(playlistVideo.videoId);
|
||||
});
|
||||
|
||||
newVideos.forEach((playlistVideo) => {
|
||||
ignoreVideos.push(playlistVideo.videoId);
|
||||
});
|
||||
|
||||
playlistVideos = [...playlistVideos, ...newVideos].sort(
|
||||
(a: PlaylistPageVideo, b: PlaylistPageVideo) => {
|
||||
return a.index < b.index ? -1 : 1;
|
||||
if (newPlaylist.videos.length === 0) {
|
||||
break;
|
||||
}
|
||||
);
|
||||
|
||||
processVideos(newPlaylist.videos, ignoreVideos, playlistVideos);
|
||||
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof playlist === 'undefined') {
|
||||
throw new Error('Unable to fetch playlist');
|
||||
}
|
||||
|
||||
const combined = { videos: playlistVideos, info: playlist };
|
||||
const combined = { videos: playlistVideos, info: newPlaylist };
|
||||
playlistCacheStore.set({ [playlistId]: combined });
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
function processVideos(
|
||||
videos: PlaylistPageVideo[],
|
||||
ignoreVideos: Set<string>,
|
||||
playlistVideos: PlaylistPageVideo[]
|
||||
) {
|
||||
const newVideos = videos.filter((playlistVideo) => {
|
||||
playlistVideo.type = 'video';
|
||||
return playlistVideo.lengthSeconds > 0 && !ignoreVideos.has(playlistVideo.videoId);
|
||||
});
|
||||
|
||||
newVideos.forEach((playlistVideo) => {
|
||||
ignoreVideos.add(playlistVideo.videoId);
|
||||
});
|
||||
|
||||
playlistVideos.push(...newVideos);
|
||||
playlistVideos.sort((a: PlaylistPageVideo, b: PlaylistPageVideo) => a.index - b.index);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { get } from 'svelte/store';
|
||||
import { isVideoID } from './misc';
|
||||
import { interfaceSearchHistoryEnabled, searchHistoryStore } from './store';
|
||||
import { isVideoID } from './misc';
|
||||
|
||||
function extractVideoId(url: string): string | null {
|
||||
const urlObj = new URL(url, 'http://example.com'); // Using a base URL in case searchValue is just a query parameter
|
||||
const videoId = urlObj.searchParams.get('v');
|
||||
return videoId;
|
||||
}
|
||||
|
||||
export function goToSearch(searchValue: string) {
|
||||
const searchTrimed = searchValue.trim();
|
||||
const searchTrimmed = searchValue.trim();
|
||||
|
||||
if (!searchTrimed) return;
|
||||
if (!searchTrimmed) return;
|
||||
|
||||
if (isVideoID(searchTrimed)) {
|
||||
// Go directly to video if Video ID provided
|
||||
goto(resolve('/watch/[videoId]', { videoId: searchTrimed }));
|
||||
const videoIdFromUrl = extractVideoId(searchTrimmed);
|
||||
if (videoIdFromUrl && isVideoID(videoIdFromUrl)) {
|
||||
goto(resolve('/watch/[videoId]', { videoId: videoIdFromUrl }));
|
||||
return;
|
||||
}
|
||||
|
||||
goto(resolve(`/search/[search]`, { search: encodeURIComponent(searchTrimed) }));
|
||||
goto(resolve(`/search/[search]`, { search: encodeURIComponent(searchTrimmed) }));
|
||||
|
||||
if (get(interfaceSearchHistoryEnabled) && !get(searchHistoryStore).includes(searchTrimed)) {
|
||||
if (get(interfaceSearchHistoryEnabled) && !get(searchHistoryStore).includes(searchTrimmed)) {
|
||||
const pastHistory = get(searchHistoryStore);
|
||||
if (pastHistory.length > 15) {
|
||||
pastHistory.pop();
|
||||
}
|
||||
searchHistoryStore.set([searchTrimed, ...pastHistory]);
|
||||
searchHistoryStore.set([searchTrimmed, ...pastHistory]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,18 @@ import {
|
||||
import type { TitleCase } from './letterCasing';
|
||||
import { serialize, deserialize } from '@macfja/serializer';
|
||||
import type {
|
||||
Channel,
|
||||
ChannelContentPlaylists,
|
||||
ChannelContentVideos,
|
||||
ChannelContent,
|
||||
ChannelPage,
|
||||
HashTag,
|
||||
Playlist,
|
||||
PlaylistPage,
|
||||
PlaylistPageVideo,
|
||||
SearchResults,
|
||||
Video,
|
||||
VideoBase,
|
||||
VideoPlay
|
||||
} from './api/model';
|
||||
import { ensureNoTrailingSlash } from './misc';
|
||||
import type { PhasedDescription } from './description';
|
||||
import { ensureNoTrailingSlash } from './misc';
|
||||
import type { EngineFallback } from './api/misc';
|
||||
|
||||
function createListenerFunctions(): {
|
||||
callListeners: (eventKey: string, newValue: any) => void;
|
||||
@@ -101,6 +99,12 @@ export const instanceStore: Writable<string> = persist(
|
||||
'invidiousInstance'
|
||||
);
|
||||
|
||||
export const backendInUseStore: Writable<'ivg' | 'yt'> = persist(
|
||||
writable('ivg'),
|
||||
createStorage(),
|
||||
'backendInUse'
|
||||
);
|
||||
|
||||
export const authStore: Writable<null | { username: string; token: string }> = persist(
|
||||
writable(null),
|
||||
createStorage(),
|
||||
@@ -310,6 +314,23 @@ export const deArrowThumbnailInstanceStore = persist(
|
||||
'deArrowThumbnailInstance'
|
||||
);
|
||||
|
||||
export const engineCullYTStore: Writable<number> = persist(
|
||||
writable(1000),
|
||||
createStorage(),
|
||||
'engineCullYT'
|
||||
);
|
||||
export const engineCooldownYTStore: Writable<number> = persist(
|
||||
writable(6),
|
||||
createStorage(),
|
||||
'engineCooldownYT'
|
||||
);
|
||||
|
||||
export const engineFallbacksStore: Writable<EngineFallback[]> = persist(
|
||||
writable([]),
|
||||
createStorage(),
|
||||
'engineFallbacks'
|
||||
);
|
||||
|
||||
export const syncPartyPeerStore: Writable<Peer | null> = writable(null);
|
||||
export const syncPartyConnectionsStore: Writable<DataConnection[] | null> = writable();
|
||||
|
||||
@@ -328,7 +349,7 @@ export const feedCacheStore: Writable<{
|
||||
[key: string]: (VideoBase | Video | PlaylistPageVideo)[];
|
||||
}> = writable({});
|
||||
export const searchCacheStore: Writable<{
|
||||
[searchTypeAndQuery: string]: (Channel | Video | Playlist | HashTag)[];
|
||||
[searchTypeAndQuery: string]: SearchResults;
|
||||
}> = writable({});
|
||||
export const feedLastItemId: Writable<string | undefined> = writable(undefined);
|
||||
export const playlistCacheStore: Writable<{
|
||||
@@ -340,6 +361,6 @@ export const isAndroidTvStore: Writable<boolean> = writable(false);
|
||||
export const channelCacheStore: Writable<{
|
||||
[key: string]: {
|
||||
channel: ChannelPage;
|
||||
displayContent: { [key: string]: ChannelContentVideos | ChannelContentPlaylists };
|
||||
displayContent: { [key: string]: ChannelContent };
|
||||
};
|
||||
}> = writable({});
|
||||
|
||||
@@ -13,25 +13,28 @@ export function humanizeTimestamp(epochTime: number): string {
|
||||
return dayjs.utc(epochTime).local().format('hh:mm A DD/MM/YYYY');
|
||||
}
|
||||
|
||||
export function relativeTimestamp(epochTime: number): string {
|
||||
export function relativeTimestamp(epochTime: number, includeTime: boolean = true): string {
|
||||
const now = dayjs();
|
||||
const timestamp = dayjs.utc(epochTime).local();
|
||||
|
||||
const isSameDay = now.isSame(timestamp, 'day');
|
||||
const isSameMonth = now.isSame(timestamp, 'month');
|
||||
const isSameWeek = now.isSame(timestamp, 'week');
|
||||
const isThisYear = now.isSame(timestamp, 'year');
|
||||
|
||||
const diffMilliseconds = timestamp.diff(now);
|
||||
const diffDuration = dayjs.duration(diffMilliseconds);
|
||||
|
||||
if (isSameDay) {
|
||||
let time = '';
|
||||
if (includeTime) {
|
||||
time += 'h:mm A';
|
||||
}
|
||||
|
||||
if (isSameDay || isSameWeek) {
|
||||
return diffDuration.humanize(true);
|
||||
} else if (isSameMonth) {
|
||||
return timestamp.format('Do @ h:mm A');
|
||||
} else if (isThisYear) {
|
||||
return timestamp.format('MMMM Do @ h:mm A');
|
||||
return timestamp.format(`MMMM Do ${time}`);
|
||||
} else {
|
||||
return timestamp.format('MMMM Do YYYY h:mm A');
|
||||
return timestamp.format(`MMMM Do YYYY ${time}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,8 +84,7 @@ export async function storyboardThumbnails(WebVTT: string): Promise<TimelineThum
|
||||
type: 'vtt'
|
||||
});
|
||||
|
||||
let index = 0;
|
||||
thumbnailsSheets.cues.forEach((cue) => {
|
||||
thumbnailsSheets.cues.forEach((cue, index) => {
|
||||
const urlParts = cue.text.split('#xywh=');
|
||||
const xywh = urlParts[1];
|
||||
const xywhValues = xywh.split(',');
|
||||
@@ -106,8 +105,6 @@ export async function storyboardThumbnails(WebVTT: string): Promise<TimelineThum
|
||||
xCoord,
|
||||
yCoord
|
||||
});
|
||||
|
||||
index++;
|
||||
});
|
||||
|
||||
return thumbnails;
|
||||
|
||||
@@ -47,9 +47,7 @@ export async function getWatchDetails(videoId: string, url: URL) {
|
||||
|
||||
let comments;
|
||||
try {
|
||||
comments = video.liveNow
|
||||
? null
|
||||
: getComments(videoId, { sort_by: 'top', source: 'youtube' }, { priority: 'low' });
|
||||
comments = video.liveNow ? null : getComments(videoId, { sort_by: 'top' }, { priority: 'low' });
|
||||
} catch {
|
||||
comments = null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { navigating, page } from '$app/stores';
|
||||
import colorTheme, { convertToHexColorCode } from '$lib/android/plugins/colorTheme';
|
||||
import { getFeed } from '$lib/api/index';
|
||||
import { getFeed, notificationsMarkAsRead } from '$lib/api/index';
|
||||
import type { Notification } from '$lib/api/model';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import PageLoading from '$lib/components/PageLoading.svelte';
|
||||
@@ -19,15 +19,12 @@
|
||||
import {
|
||||
authStore,
|
||||
darkModeStore,
|
||||
feedCacheStore,
|
||||
instanceStore,
|
||||
interfaceAmoledTheme,
|
||||
interfaceDefaultPage,
|
||||
isAndroidTvStore,
|
||||
playerState,
|
||||
playertheatreModeIsActive,
|
||||
playlistCacheStore,
|
||||
searchCacheStore,
|
||||
syncPartyPeerStore,
|
||||
themeColorStore
|
||||
} from '$lib/store';
|
||||
@@ -42,7 +39,7 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
import { truncate } from '$lib/misc';
|
||||
import { isYTBackend, logoutStores, truncate } from '$lib/misc';
|
||||
import Author from '$lib/components/Author.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
|
||||
@@ -182,15 +179,12 @@
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authStore.set(null);
|
||||
feedCacheStore.set({});
|
||||
searchCacheStore.set({});
|
||||
playlistCacheStore.set({});
|
||||
logoutStores();
|
||||
goto(resolve('/', {}));
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
const feed = await getFeed(15, 1);
|
||||
const feed = await getFeed(100, 1);
|
||||
notifications = feed.notifications;
|
||||
}
|
||||
|
||||
@@ -288,16 +282,18 @@
|
||||
<i>settings</i>
|
||||
<div>{$_('layout.settings')}</div>
|
||||
</a>
|
||||
{#if !isLoggedIn}
|
||||
<a onclick={login} href="#login">
|
||||
<i>login</i>
|
||||
<div>{$_('layout.login')}</div>
|
||||
</a>
|
||||
{:else}
|
||||
<a onclick={logout} href="#logout">
|
||||
<i>logout</i>
|
||||
<div>{$_('layout.logout')}</div>
|
||||
</a>
|
||||
{#if !isYTBackend()}
|
||||
{#if !isLoggedIn}
|
||||
<a onclick={login} href="#login">
|
||||
<i>login</i>
|
||||
<div>{$_('layout.login')}</div>
|
||||
</a>
|
||||
{:else}
|
||||
<a onclick={logout} href="#logout">
|
||||
<i>logout</i>
|
||||
<div>{$_('layout.logout')}</div>
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -350,7 +346,12 @@
|
||||
<div class="tooltip bottom">{$_('layout.syncParty')}</div>
|
||||
</button>
|
||||
{#if isLoggedIn}
|
||||
<button class="circle large transparent" onclick={() => ui('#dialog-notifications')}
|
||||
<button
|
||||
class="circle large transparent"
|
||||
onclick={() => {
|
||||
ui('#dialog-notifications');
|
||||
notificationsMarkAsRead();
|
||||
}}
|
||||
><i>notifications</i>
|
||||
<div class="badge secondary">
|
||||
{#if notifications.length > 99}
|
||||
@@ -367,16 +368,18 @@
|
||||
<div class="tooltip bottom">{$_('layout.settings')}</div>
|
||||
</button>
|
||||
|
||||
{#if !isLoggedIn}
|
||||
<button onclick={login} class="circle large transparent">
|
||||
<i>login</i>
|
||||
<div class="tooltip bottom">{$_('layout.login')}</div>
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={logout} class="circle large transparent">
|
||||
<i>logout</i>
|
||||
<div class="tooltip bottom">{$_('layout.logout')}</div>
|
||||
</button>
|
||||
{#if !isYTBackend()}
|
||||
{#if !isLoggedIn}
|
||||
<button onclick={login} class="circle large transparent">
|
||||
<i>login</i>
|
||||
<div class="tooltip bottom">{$_('layout.login')}</div>
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={logout} class="circle large transparent">
|
||||
<i>logout</i>
|
||||
<div class="tooltip bottom">{$_('layout.logout')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { getPopular } from '$lib/api/index';
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import { feedCacheStore } from '$lib/store';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export async function load() {
|
||||
if (isYTBackend()) {
|
||||
goto(resolve('/subscriptions', {}), { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
let popular = get(feedCacheStore).popular;
|
||||
let popularDisabled: boolean = false;
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
getChannelContent,
|
||||
searchChannelContent,
|
||||
type channelContentTypes,
|
||||
type channelSortBy
|
||||
} from '$lib/api';
|
||||
import type { ChannelContentPlaylists, ChannelContentVideos } from '$lib/api/model';
|
||||
import { getChannelContent, searchChannelContent } from '$lib/api';
|
||||
import type { ChannelContent, ChannelContentTypes, ChannelSortBy } from '$lib/api/model';
|
||||
import PageLoading from '$lib/components/PageLoading.svelte';
|
||||
import { proxyGoogleImage } from '$lib/images';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
@@ -18,17 +13,17 @@
|
||||
import Author from '$lib/components/Author.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
|
||||
let tab: channelContentTypes = $state('videos');
|
||||
let tab: ChannelContentTypes = $state('videos');
|
||||
|
||||
let sortBy: channelSortBy = $state('newest');
|
||||
const sortByOptions: channelSortBy[] = ['newest', 'oldest', 'popular'];
|
||||
let sortBy: ChannelSortBy = $state('newest');
|
||||
const sortByOptions: ChannelSortBy[] = ['newest', 'oldest', 'popular'];
|
||||
|
||||
let showSearch: boolean = $state(false);
|
||||
let channelSearch: string = $state('');
|
||||
|
||||
let displayContent: ChannelContentPlaylists | ChannelContentVideos | undefined =
|
||||
$state(undefined);
|
||||
let displayContent: ChannelContent | undefined = $state(undefined);
|
||||
|
||||
onMount(() => {
|
||||
displayContent = $channelCacheStore[page.params.slug].displayContent.videos;
|
||||
@@ -179,23 +174,25 @@
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
<div class="s12 m6 l6">
|
||||
{#if showSearch}
|
||||
<div class="max field suffix prefix small no-margin surface-variant">
|
||||
<i class="front">search</i><input
|
||||
bind:value={channelSearch}
|
||||
oninput={searchChannel}
|
||||
type="text"
|
||||
placeholder={$_('searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<nav class="right-align m l">
|
||||
<button onclick={() => (showSearch = true)}><i>search</i></button>
|
||||
</nav>
|
||||
<button class="s" onclick={() => (showSearch = true)}><i>search</i></button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !isYTBackend()}
|
||||
<div class="s12 m6 l6">
|
||||
{#if showSearch}
|
||||
<div class="max field suffix prefix small no-margin surface-variant">
|
||||
<i class="front">search</i><input
|
||||
bind:value={channelSearch}
|
||||
oninput={searchChannel}
|
||||
type="text"
|
||||
placeholder={$_('searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<nav class="right-align m l">
|
||||
<button onclick={() => (showSearch = true)}><i>search</i></button>
|
||||
</nav>
|
||||
<button class="s" onclick={() => (showSearch = true)}><i>search</i></button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
let playlistPrivacy: 'public' | 'private' | 'unlisted' = 'public';
|
||||
let playlistTitle: string = $state('');
|
||||
|
||||
function onPrivacyChange(event: any) {
|
||||
playlistPrivacy = event.currentTarget.value;
|
||||
function onPrivacyChange(event: Event) {
|
||||
if (!event.currentTarget) return;
|
||||
playlistPrivacy = (event.currentTarget as HTMLInputElement).value as
|
||||
| 'public'
|
||||
| 'private'
|
||||
| 'unlisted';
|
||||
}
|
||||
|
||||
async function removePlaylistItem(playlistId: string) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { getSearch, type SearchOptions } from '$lib/api';
|
||||
import { getSearch } from '$lib/api';
|
||||
import PageLoading from '$lib/components/PageLoading.svelte';
|
||||
import { searchCacheStore } from '$lib/store';
|
||||
import { _ } from '$lib/i18n';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import type { SearchOptions, SearchResults } from '$lib/api/model.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -68,20 +69,37 @@
|
||||
}
|
||||
|
||||
async function loadMore(event: InfiniteEvent) {
|
||||
currentPage++;
|
||||
searchOptions = {
|
||||
...searchOptions,
|
||||
page: currentPage.toString()
|
||||
};
|
||||
const newSearch = await getSearch(data.slug, searchOptions);
|
||||
let newSearch: SearchResults;
|
||||
|
||||
const searchCacheItem = $searchCacheStore[data.searchStoreId];
|
||||
|
||||
if (searchCacheItem?.getContinuation) {
|
||||
newSearch = await searchCacheItem.getContinuation();
|
||||
|
||||
if (newSearch.getContinuation) {
|
||||
searchCacheItem.getContinuation = newSearch.getContinuation;
|
||||
}
|
||||
} else {
|
||||
currentPage++;
|
||||
searchOptions = {
|
||||
...searchOptions,
|
||||
page: currentPage.toString()
|
||||
};
|
||||
|
||||
newSearch = await getSearch(data.slug, searchOptions);
|
||||
|
||||
// Set the continuation method if it exists
|
||||
if (newSearch.getContinuation) {
|
||||
searchCacheItem.getContinuation = newSearch.getContinuation;
|
||||
}
|
||||
}
|
||||
|
||||
if (newSearch.length === 0) {
|
||||
event.detail.complete();
|
||||
} else {
|
||||
searchCacheStore.set({
|
||||
[data.searchStoreId]: [...($searchCacheStore[data.searchStoreId] ?? []), ...newSearch]
|
||||
[data.searchStoreId]: [...(searchCacheItem ?? []), ...newSearch]
|
||||
});
|
||||
event.detail.loaded();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
|
||||
let currentPage = 1;
|
||||
let videos: (VideoBase | Video | PlaylistPageVideo)[] = $state($feedCacheStore.subscription);
|
||||
|
||||
async function loadMore(event: InfiniteEvent) {
|
||||
// Not supported or needed on YT backend
|
||||
if (isYTBackend()) return;
|
||||
|
||||
currentPage++;
|
||||
const feed = await getFeed(100, currentPage);
|
||||
if (feed.videos.length === 0) {
|
||||
@@ -24,7 +28,7 @@
|
||||
</script>
|
||||
|
||||
<nav class="right-align">
|
||||
<a class="button outline" href={resolve('/subscriptions/manage', {})}>
|
||||
<a class="button surface-container-highest" href={resolve('/subscriptions/manage', {})}>
|
||||
{$_('subscriptions.manageSubscriptions')}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getFeed } from '$lib/api/index';
|
||||
import type { PlaylistPageVideo, Video, VideoBase } from '$lib/api/model';
|
||||
import { localDb } from '$lib/dexie';
|
||||
import { excludeDuplicateFeeds } from '$lib/misc';
|
||||
import { authProtected, excludeDuplicateFeeds } from '$lib/misc';
|
||||
import { feedCacheStore } from '$lib/store';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -35,6 +35,8 @@ async function sortVideosByFavourites(videos: supportedVideos): Promise<supporte
|
||||
}
|
||||
|
||||
export async function load() {
|
||||
authProtected();
|
||||
|
||||
let videos = get(feedCacheStore).subscription;
|
||||
|
||||
if (!videos) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getSubscriptions } from '$lib/api';
|
||||
import { authProtected } from '$lib/misc';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export async function load() {
|
||||
authProtected();
|
||||
|
||||
let subscriptions;
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
import Description from '$lib/components/watch/Description.svelte';
|
||||
import LikesDislikes from '$lib/components/watch/LikesDislikes.svelte';
|
||||
import Comment from '$lib/components/watch/Comment.svelte';
|
||||
import { expandSummery } from '$lib/misc';
|
||||
import { expandSummery, isYTBackend } from '$lib/misc';
|
||||
import { humanizeSeconds, relativeTimestamp } from '$lib/time.js';
|
||||
import { getWatchDetails } from '$lib/watch.js';
|
||||
import { page } from '$app/state';
|
||||
@@ -348,13 +348,26 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedComments = await getComments(data.video.videoId, {
|
||||
continuation: comments?.continuation
|
||||
});
|
||||
let loadedComments: Comments;
|
||||
|
||||
comments.continuation = loadedComments.continuation;
|
||||
try {
|
||||
if (comments.getContinuation) {
|
||||
loadedComments = await comments.getContinuation();
|
||||
} else if (comments.continuation) {
|
||||
loadedComments = await getComments(data.video.videoId, {
|
||||
continuation: comments.continuation
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
comments.comments = [...comments.comments, ...loadedComments.comments];
|
||||
if (loadedComments.comments.length > 0) {
|
||||
comments.continuation = loadedComments.continuation;
|
||||
comments.comments = [...comments.comments, ...loadedComments.comments];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more comments:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheatreMode() {
|
||||
@@ -371,8 +384,6 @@
|
||||
pauseTimerSeconds = 0;
|
||||
clearTimeout(pauseTimeout);
|
||||
}, pauseTimerSeconds * 1000);
|
||||
|
||||
ui('#pause-timer');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -407,7 +418,7 @@
|
||||
<button
|
||||
onclick={toggleTheatreMode}
|
||||
class="m l"
|
||||
class:border={!$playertheatreModeIsActive}
|
||||
class:surface-container-highest={!$playertheatreModeIsActive}
|
||||
>
|
||||
<i>width_wide</i>
|
||||
<div class="tooltip">{$_('player.theatreMode')}</div>
|
||||
@@ -420,7 +431,7 @@
|
||||
}
|
||||
ui('#pause-timer');
|
||||
}}
|
||||
class:border={pauseTimerSeconds < 1}
|
||||
class:surface-container-highest={pauseTimerSeconds < 1}
|
||||
>
|
||||
<i>snooze</i>
|
||||
<div class="tooltip">{$_('player.pauseTimer')}</div>
|
||||
@@ -431,14 +442,14 @@
|
||||
(showTranscript = !showTranscript),
|
||||
playertheatreModeIsActive.set(false)
|
||||
)}
|
||||
class:border={!showTranscript}
|
||||
class:surface-container-highest={!showTranscript}
|
||||
>
|
||||
<i>description</i>
|
||||
<div class="tooltip">
|
||||
{$_('transcript')}
|
||||
</div>
|
||||
</button>
|
||||
<button class="border"
|
||||
<button class="surface-container-highest"
|
||||
><i>share</i>
|
||||
<div class="tooltip">
|
||||
{$_('player.share.title')}
|
||||
@@ -446,7 +457,7 @@
|
||||
<ShareVideo bind:currentTime={playerCurrentTime} video={data.video} />
|
||||
</button>
|
||||
{#if personalPlaylists && personalPlaylists.length > 0}
|
||||
<button class="border">
|
||||
<button class="surface-container-highest">
|
||||
<i>add</i>
|
||||
<div class="tooltip">{$_('player.addToPlaylist')}</div>
|
||||
<menu class="no-wrap mobile">
|
||||
@@ -474,10 +485,10 @@
|
||||
</menu>
|
||||
</button>
|
||||
{:else}
|
||||
<button disabled class="border">
|
||||
<button disabled class="surface-container-highest">
|
||||
<i>add</i>
|
||||
<div class="tooltip">
|
||||
{#if $authStore}
|
||||
{#if $authStore || isYTBackend()}
|
||||
{$_('player.noPlaylists')}
|
||||
{:else}
|
||||
{$_('loginRequired')}
|
||||
@@ -675,21 +686,39 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
id="pause-timer"
|
||||
onclose={(event: Event) => {
|
||||
if (pauseTimerSeconds > 0) setPauseTimer();
|
||||
(event.target as HTMLDialogElement).close();
|
||||
}}
|
||||
>
|
||||
<dialog id="pause-timer">
|
||||
<div>
|
||||
<h6>{$_('player.pauseVideoIn')} {humanizeSeconds(pauseTimerSeconds)}</h6>
|
||||
|
||||
<nav class="group">
|
||||
<button onclick={() => (pauseTimerSeconds += 300)} class="left-round">+5 mins</button>
|
||||
<button onclick={() => (pauseTimerSeconds += 1800)} class="no-round">+30 mins</button>
|
||||
<button onclick={() => (pauseTimerSeconds += 3600)} class="no-round">+1 hr</button>
|
||||
<button onclick={() => (pauseTimerSeconds += 7200)} class="right-round">+2 hrs</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
pauseTimerSeconds += 300;
|
||||
setPauseTimer();
|
||||
}}
|
||||
class="left-round">+5 mins</button
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
pauseTimerSeconds += 1800;
|
||||
setPauseTimer();
|
||||
}}
|
||||
class="no-round">+30 mins</button
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
pauseTimerSeconds += 3600;
|
||||
setPauseTimer();
|
||||
}}
|
||||
class="no-round">+1 hr</button
|
||||
>
|
||||
<button
|
||||
onclick={() => {
|
||||
pauseTimerSeconds += 7200;
|
||||
setPauseTimer();
|
||||
}}
|
||||
class="right-round">+2 hrs</button
|
||||
>
|
||||
</nav>
|
||||
|
||||
<div class="space"></div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
maximumFileSizeToCacheInBytes: 4000000
|
||||
},
|
||||
manifest: {
|
||||
description: 'Modern material design for Invidious.',
|
||||
description: 'Modern material design for YouTube and Invidious.',
|
||||
theme_color: '#8936FF',
|
||||
background_color: '#1e1b1e',
|
||||
icons: [
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.4 MiB |
+1
-1
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.13.19"
|
||||
LATEST_VERSION = "1.14.0"
|
||||
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
|
||||
|
||||
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")
|
||||
|
||||
Reference in New Issue
Block a user