@@ -17,10 +17,11 @@
|
||||
|
||||
# Features
|
||||
- [Invidious companion support.](./docs/DOCKER.md#invidious-companion-support)
|
||||
- [Invidious API extended integration!](https://github.com/Materialious/api-extended)
|
||||
- [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.
|
||||
- Support for disabling certificate validation for homelab users.
|
||||
- Sync your watch progress between Invidious sessions.
|
||||
- Watch sync parties!
|
||||
- Watch sync parties.
|
||||
- Sponsorblock built-in.
|
||||
- Return YouTube dislikes built-in.
|
||||
- DeArrow built-in (With local processing fallback).
|
||||
@@ -191,7 +192,7 @@ brew install --cask spotube
|
||||
|
||||
# Translations
|
||||
|
||||
Help [translate Materialious via Weblate](https://toolate.othing.xyz/projects/materialious/)!
|
||||
Help [translate Materialious via Weblate](https://toolate.othing.xyz/projects/materialious/)!
|
||||
|
||||
<a href="https://toolate.othing.xyz/projects/materialious/">
|
||||
<img src="https://toolate.othing.xyz/widget/materialious/materialious/multi-auto.svg" alt="Translation status" />
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 117
|
||||
versionName "1.9.0"
|
||||
versionCode 118
|
||||
versionName "1.9.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -62,7 +62,11 @@
|
||||
|
||||
|
||||
|
||||
<release version="1.9.0" date="2025-6-04">
|
||||
|
||||
<release version="1.9.1" date="2025-6-05">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.9.1</url>
|
||||
</release>
|
||||
<release version="1.9.0" date="2025-6-04">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.9.0</url>
|
||||
</release>
|
||||
<release version="1.8.12" date="2025-6-04">
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.1",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
|
||||
Generated
+68
-743
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -14,9 +14,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@sveltejs/adapter-auto": "^3.2.5",
|
||||
"@sveltejs/adapter-static": "^3.0.5",
|
||||
"@sveltejs/kit": "^2.20.7",
|
||||
"@sveltejs/adapter-auto": "^6.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.21.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/eslint": "^8.56.7",
|
||||
"@types/event-source-polyfill": "^1.0.5",
|
||||
@@ -57,6 +57,7 @@
|
||||
"googlevideo": "^3.0.0",
|
||||
"he": "^1.2.0",
|
||||
"human-number": "^2.0.4",
|
||||
"i18next": "^25.2.1",
|
||||
"iso-3166": "^4.3.0",
|
||||
"iso-639-1": "^3.1.5",
|
||||
"material-dynamic-colors": "^1.1.1",
|
||||
@@ -65,10 +66,8 @@
|
||||
"peerjs": "^1.5.4",
|
||||
"shaka-player": "^4.11.7",
|
||||
"sponsorblock-api": "^0.2.4",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"svelte-infinite-loading": "^1.4.0",
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"terser": "^5.34.1",
|
||||
"youtubei.js": "^13.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,13 @@ export interface Captions {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Ytjs {
|
||||
innertube: Innertube;
|
||||
video: YT.VideoInfo;
|
||||
clientPlaybackNonce: string;
|
||||
rawApiResponse: ApiResponse;
|
||||
}
|
||||
|
||||
export interface VideoPlay extends Video {
|
||||
keywords: string[];
|
||||
likeCount: number;
|
||||
@@ -100,12 +107,7 @@ export interface VideoPlay extends Video {
|
||||
authorThumbnails: Image[];
|
||||
captions: Captions[];
|
||||
storyboards?: StoryBoard[];
|
||||
ytjs?: {
|
||||
innertube: Innertube;
|
||||
video: YT.VideoInfo;
|
||||
clientPlaybackNonce: string;
|
||||
rawApiResponse: ApiResponse;
|
||||
};
|
||||
ytjs?: Ytjs;
|
||||
fallbackPatch?: 'youtubejs' | 'piped';
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Channel } from '../api/model';
|
||||
import { insecureRequestImageHandler, truncate } from '../misc';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import type { HashTag } from '../api/model';
|
||||
import { truncate } from '../misc';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
import { getBestThumbnail } from '$lib/images';
|
||||
import { letterCase } from '$lib/letterCasing';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Playlist } from '../api/model';
|
||||
import { insecureRequestImageHandler, truncate } from '../misc';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { createEventDispatcher, onMount, tick } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { getSearchSuggestions } from '../api';
|
||||
import {
|
||||
interfaceSearchHistoryEnabled,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { ensureNoTrailingSlash } from '../../misc';
|
||||
import { synciousInstanceStore, synciousStore } from '../../store';
|
||||
@@ -11,7 +11,8 @@
|
||||
|
||||
<form
|
||||
onsubmit={preventDefault(() =>
|
||||
synciousInstanceStore.set(ensureNoTrailingSlash(synciousInstance)))}
|
||||
synciousInstanceStore.set(ensureNoTrailingSlash(synciousInstance))
|
||||
)}
|
||||
>
|
||||
<nav>
|
||||
<div class="field label border max">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { instanceStore } from '$lib/store';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { ensureNoTrailingSlash } from '../../misc';
|
||||
import {
|
||||
|
||||
@@ -7,13 +7,12 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import ui from 'beercss';
|
||||
import { iso31661 } from 'iso-3166';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { ensureNoTrailingSlash } from '../../misc';
|
||||
import { getPages } from '../../navPages';
|
||||
import {
|
||||
authStore,
|
||||
companionStore,
|
||||
darkModeStore,
|
||||
instanceStore,
|
||||
interfaceAllowInsecureRequests,
|
||||
@@ -31,7 +30,6 @@
|
||||
} from '../../store';
|
||||
|
||||
let invidiousInstance = $state(get(instanceStore));
|
||||
let companionInstance = $state(get(companionStore));
|
||||
let region = $state(get(interfaceRegionStore));
|
||||
let forceCase = $state(get(interfaceForceCase));
|
||||
let defaultPage = $state(get(interfaceDefaultPage));
|
||||
@@ -93,11 +91,6 @@
|
||||
ui('#dialog-settings');
|
||||
}
|
||||
|
||||
function setCompanion(event: Event) {
|
||||
event.preventDefault();
|
||||
companionStore.set(ensureNoTrailingSlash(companionInstance));
|
||||
}
|
||||
|
||||
function allowInsecureRequests() {
|
||||
if (!Capacitor.isNativePlatform()) return;
|
||||
interfaceAllowInsecureRequests.set(!$interfaceAllowInsecureRequests);
|
||||
@@ -153,21 +146,6 @@
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={setCompanion}>
|
||||
<nav>
|
||||
<div class="field label border max">
|
||||
<input bind:value={companionInstance} name="companion-instance" type="text" />
|
||||
<label for="companion-instance">{$_('layout.companionUrl')}</label>
|
||||
</div>
|
||||
<button class="square round">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
</form>
|
||||
{#if invalidInstance}
|
||||
<div style="margin-bottom: 6em;"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<button onclick={toggleDarkMode} class="no-margin">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { titleCase } from '$lib/letterCasing';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import ISO6391 from 'iso-639-1';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
playerAlwaysLoopStore,
|
||||
@@ -20,6 +20,13 @@
|
||||
|
||||
let defaultLanguage = $state(get(playerDefaultLanguage));
|
||||
|
||||
let localVideoFallback: 'enabled' | 'disabled' | 'always' = $state('enabled');
|
||||
if (!$playerYouTubeJsFallback) {
|
||||
localVideoFallback = 'disabled';
|
||||
} else if ($playerYouTubeJsAlways) {
|
||||
localVideoFallback = 'always';
|
||||
}
|
||||
|
||||
const languageNames = ISO6391.getAllCodes().map((code) =>
|
||||
ISO6391.getName(code).toLocaleLowerCase()
|
||||
);
|
||||
@@ -28,6 +35,21 @@
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
playerDefaultQualityStore.set(value);
|
||||
}
|
||||
|
||||
function onLocalVideoFallbackChange() {
|
||||
switch (localVideoFallback) {
|
||||
case 'enabled':
|
||||
playerYouTubeJsFallback.set(true);
|
||||
break;
|
||||
case 'disabled':
|
||||
playerYouTubeJsFallback.set(false);
|
||||
break;
|
||||
case 'always':
|
||||
playerYouTubeJsAlways.set(true);
|
||||
playerYouTubeJsFallback.set(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="margin"></div>
|
||||
@@ -55,8 +77,8 @@
|
||||
onchange={onQualityChange}
|
||||
>
|
||||
<option value="auto">Auto (Recommended)</option>
|
||||
<option value="144">144p (Low)</option>
|
||||
<option value="240">240p</option>
|
||||
<option value="144">144p (Ultra low)</option>
|
||||
<option value="240">240p (Low)</option>
|
||||
<option value="360">360p (SD)</option>
|
||||
<option value="480">480p (SD+)</option>
|
||||
<option value="720">720p (HD)</option>
|
||||
@@ -68,6 +90,18 @@
|
||||
<i>arrow_drop_down</i>
|
||||
</div>
|
||||
|
||||
{#if Capacitor.isNativePlatform()}
|
||||
<div class="field suffix border label">
|
||||
<select name="ytfallback" bind:value={localVideoFallback} onchange={onLocalVideoFallbackChange}>
|
||||
<option value="enabled">{$_('enabled')}</option>
|
||||
<option value="always">{$_('layout.player.youtubeJsAlways')}</option>
|
||||
<option value="disabled">{$_('disabled')}</option>
|
||||
</select>
|
||||
<label for="ytfallback">{$_('layout.player.localVideoFallback')}</label>
|
||||
<i>arrow_drop_down</i>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field no-margin">
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
@@ -183,37 +217,3 @@
|
||||
</label>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{#if Capacitor.isNativePlatform()}
|
||||
<div class="field no-margin">
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
<div>{$_('layout.player.youtubeJsAlways')}</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$playerYouTubeJsAlways}
|
||||
onclick={() => playerYouTubeJsAlways.set(!$playerYouTubeJsAlways)}
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="field no-margin">
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
<div>{$_('layout.player.youtubeJsFallback')}</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$playerYouTubeJsFallback}
|
||||
onclick={() => playerYouTubeJsFallback.set(!$playerYouTubeJsFallback)}
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { ensureNoTrailingSlash } from '../../misc';
|
||||
import { returnYTDislikesInstanceStore, returnYtDislikesStore } from '../../store';
|
||||
@@ -11,7 +11,8 @@
|
||||
|
||||
<form
|
||||
onsubmit={preventDefault(() =>
|
||||
returnYTDislikesInstanceStore.set(ensureNoTrailingSlash(returnYTInstance)))}
|
||||
returnYTDislikesInstanceStore.set(ensureNoTrailingSlash(returnYTInstance))
|
||||
)}
|
||||
>
|
||||
<nav>
|
||||
<div class="field label border max">
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ISO6391 from 'iso-639-1';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { _ } from '$lib/i18n';
|
||||
import ApiExtended from './ApiExtended.svelte';
|
||||
import DataPreferences from './DataPreferences.svelte';
|
||||
import DeArrow from './DeArrow.svelte';
|
||||
@@ -53,10 +51,6 @@
|
||||
<i>smart_display</i>
|
||||
<span>{$_('layout.player.title')}</span>
|
||||
</a>
|
||||
<a class:active={activeTab === 'data'} onclick={() => (activeTab = 'data')}>
|
||||
<i>save</i>
|
||||
<span>{$_('layout.dataPreferences.dataPreferences')}</span>
|
||||
</a>
|
||||
<a class:active={activeTab === 'ryd'} onclick={() => (activeTab = 'ryd')}>
|
||||
<i>thumb_down</i>
|
||||
<span>RYD</span>
|
||||
@@ -73,6 +67,10 @@
|
||||
<i>keyboard_double_arrow_down</i>
|
||||
<span>{$_('layout.deArrow.title')}</span>
|
||||
</a>
|
||||
<a class:active={activeTab === 'data'} onclick={() => (activeTab = 'data')}>
|
||||
<i>save</i>
|
||||
<span>{$_('layout.dataPreferences.dataPreferences')}</span>
|
||||
</a>
|
||||
<a href="https://github.com/sponsors/WardPearce" target="_blank" referrerpolicy="no-referrer">
|
||||
<i>favorite</i>
|
||||
<span>{$_('donate')}</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { ensureNoTrailingSlash } from '../../misc';
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { instanceStore } from '$lib/store';
|
||||
import { Clipboard } from '@capacitor/clipboard';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import ui from 'beercss';
|
||||
import type { DataConnection } from 'peerjs';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { syncPartyConnectionsStore, syncPartyPeerStore } from '../store';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { letterCase } from '$lib/letterCasing';
|
||||
import { cleanNumber, videoLength } from '$lib/numbers';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { getDeArrow, getThumbnail, getVideoProgress } from '../api';
|
||||
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { videoLength } from '$lib/numbers';
|
||||
import Fuse from 'fuse.js';
|
||||
import { VTTCue, parseText, type ParsedCaptionsResult } from 'media-captions';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { VideoPlay } from '../api/model';
|
||||
import { decodeHtmlCharCodes } from '../misc';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { removePlaylistVideo } from '../api';
|
||||
import type { PlaylistPageVideo, Video, VideoBase } from '../api/model';
|
||||
import { authStore, feedLastItemId } from '../store';
|
||||
|
||||
@@ -1,19 +1,52 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { init, register } from 'svelte-i18n';
|
||||
import i18next, { type InitOptions } from 'i18next';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
|
||||
const defaultLocale = 'en';
|
||||
|
||||
register('en', () => import('./locales/en.json'));
|
||||
register('ru', () => import('./locales/ru.json'));
|
||||
register('zh-CN', () => import('./locales/zh-CN.json'));
|
||||
register('tr', () => import('./locales/tr.json'));
|
||||
register('nl', () => import('./locales/nl.json'));
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('es', () => import('./locales/es.json'));
|
||||
register('sh', () => import('./locales/sh.json'));
|
||||
register('pt-BR', () => import('./locales/pt-BR.json'));
|
||||
export const locale: Writable<string> = writable(defaultLocale);
|
||||
export const _: Writable<(key: string, options?: any) => string> = writable(() => '');
|
||||
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: browser ? window.navigator.language : defaultLocale
|
||||
});
|
||||
const resources: Record<string, () => Promise<Record<string, any>>> = {
|
||||
en: () => import('./locales/en.json'),
|
||||
ru: () => import('./locales/ru.json'),
|
||||
'zh-CN': () => import('./locales/zh-CN.json'),
|
||||
tr: () => import('./locales/tr.json'),
|
||||
nl: () => import('./locales/nl.json'),
|
||||
de: () => import('./locales/de.json'),
|
||||
es: () => import('./locales/es.json'),
|
||||
sh: () => import('./locales/sh.json'),
|
||||
'pt-BR': () => import('./locales/pt-BR.json')
|
||||
};
|
||||
|
||||
function getUserLocale(): string {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const lang = navigator.language;
|
||||
return resources[lang] ? lang : defaultLocale;
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
export async function initI18n(selectedLocale: string = getUserLocale()): Promise<void> {
|
||||
const langToLoad = resources[selectedLocale] ? selectedLocale : defaultLocale;
|
||||
const translations = await resources[langToLoad]();
|
||||
|
||||
const options: InitOptions = {
|
||||
lng: langToLoad,
|
||||
fallbackLng: defaultLocale,
|
||||
resources: {
|
||||
[langToLoad]: {
|
||||
translation: translations.default || translations
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await i18next.init(options);
|
||||
|
||||
locale.set(langToLoad);
|
||||
_.set(i18next.t.bind(i18next));
|
||||
|
||||
window.addEventListener('languagechange', () => {
|
||||
const newLang = getUserLocale();
|
||||
initI18n(newLang);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"copyUrl": "Copy URL",
|
||||
"loadMore": "Load more",
|
||||
"views": "views",
|
||||
@@ -92,7 +93,11 @@
|
||||
"unableToLoadComments": "Unable to load comments",
|
||||
"addToPlaylist": "Add to playlist",
|
||||
"youtubeJsFallBack": "Local video fallback being used",
|
||||
"defaultLanguage": "Default Language"
|
||||
"defaultLanguage": "Default Language",
|
||||
"youtubeJsLoading": "Video failed to load, attempting to reload with local video fallback.",
|
||||
"retryText": "Video failed to load, how would you like to continue?",
|
||||
"enableYoutubejsTemp": "Use local video fallback once",
|
||||
"enableYoutubejsPerm": "Always allow local video fallback"
|
||||
},
|
||||
"layout": {
|
||||
"interface": "Interface",
|
||||
@@ -140,8 +145,9 @@
|
||||
"backgroundPlay": "Play audio in background",
|
||||
"dash": "Dash",
|
||||
"silenceSkipper": "Silence skipper (Experimental)",
|
||||
"localVideoFallback": "Local video fallback",
|
||||
"youtubeJsFallback": "Local video processing fallback",
|
||||
"youtubeJsAlways": "Always use local video processing",
|
||||
"youtubeJsAlways": "Always use",
|
||||
"lockOrientation": "Orientation locked to aspect ratio"
|
||||
},
|
||||
"bookmarklet": "Bookmarklet",
|
||||
|
||||
@@ -4,6 +4,7 @@ import he from 'he';
|
||||
import type Peer from 'peerjs';
|
||||
import { get } from 'svelte/store';
|
||||
import { instanceStore, interfaceAllowInsecureRequests } from './store';
|
||||
import type { Channel, HashTag, Playlist, PlaylistPageVideo, Video, VideoBase } from './api/model';
|
||||
|
||||
export function truncate(value: string, maxLength: number = 50): string {
|
||||
return value.length > maxLength ? `${value.substring(0, maxLength)}...` : value;
|
||||
@@ -71,3 +72,33 @@ export async function insecureRequestImageHandler(source: string): Promise<HTMLI
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
export type feedItem = VideoBase | Video | PlaylistPageVideo | Channel | Video | Playlist | HashTag;
|
||||
export type feedItems = feedItem[];
|
||||
|
||||
export function extractUniqueId(item: feedItem): string {
|
||||
if ('videoId' in item) {
|
||||
return item.videoId;
|
||||
} else if ('authorId' in item) {
|
||||
return item.authorId;
|
||||
} else {
|
||||
return item.title;
|
||||
}
|
||||
}
|
||||
|
||||
export function excludeDuplicateFeeds(currentItems: feedItems, newItems: feedItems): feedItems {
|
||||
const existingIds: string[] = [];
|
||||
|
||||
currentItems.forEach((item) => {
|
||||
existingIds.push(extractUniqueId(item));
|
||||
});
|
||||
|
||||
const nonDuplicatedNewItems: feedItems = [];
|
||||
newItems.forEach((item) => {
|
||||
if (!existingIds.includes(extractUniqueId(item))) {
|
||||
nonDuplicatedNewItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return [...nonDuplicatedNewItems, ...currentItems];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
// Must be a func do to how i18n is loaded
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
import { base64ToU8, Protos } from 'googlevideo';
|
||||
import { Constants, type Misc } from 'youtubei.js';
|
||||
import { HttpFetchPlugin, type SabrStreamingContext } from '$lib/sabr/shakaHttpPlugin';
|
||||
import {
|
||||
fromFormat,
|
||||
fromFormatInitializationMetadata,
|
||||
fromMediaHeader,
|
||||
getUniqueFormatId
|
||||
} from '$lib/sabr/formatKeyUtils';
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
import { poTokenCacheStore } from '$lib/store';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export function injectSABR(
|
||||
player: shaka.Player,
|
||||
playerElement: HTMLMediaElement,
|
||||
video: VideoPlay
|
||||
): ResizeObserver | undefined {
|
||||
if (!video.ytjs) return;
|
||||
|
||||
shaka.net.NetworkingEngine.registerScheme(
|
||||
'http',
|
||||
HttpFetchPlugin.parse,
|
||||
shaka.net.NetworkingEngine.PluginPriority.PREFERRED
|
||||
);
|
||||
shaka.net.NetworkingEngine.registerScheme(
|
||||
'https',
|
||||
HttpFetchPlugin.parse,
|
||||
shaka.net.NetworkingEngine.PluginPriority.PREFERRED
|
||||
);
|
||||
|
||||
const sessionId = Array.from(Array(16), () => Math.floor(Math.random() * 36).toString(36)).join(
|
||||
''
|
||||
);
|
||||
let videoPlaybackUstreamerConfig: string | undefined;
|
||||
let serverAbrStreamingUrl: URL | undefined = undefined;
|
||||
let drmParams: string | undefined;
|
||||
const lastRequestMs = 0;
|
||||
let lastSeekMs = 0;
|
||||
let lastManualFormatSelectionMs = 0;
|
||||
let lastActionMs = 0;
|
||||
let clientViewportHeight = playerElement?.clientHeight || 0;
|
||||
let clientViewportWidth = playerElement?.clientWidth || 0;
|
||||
let lastPlaybackCookie: Protos.PlaybackCookie | undefined;
|
||||
const initializedFormats = new Map<
|
||||
string,
|
||||
{
|
||||
lastSegmentMetadata?: {
|
||||
formatId: Protos.FormatId;
|
||||
startTimeMs: number;
|
||||
startSequenceNumber: number;
|
||||
endSequenceNumber: number;
|
||||
durationMs: number;
|
||||
};
|
||||
formatInitializationMetadata: Protos.FormatInitializationMetadata;
|
||||
}
|
||||
>();
|
||||
let formatList: Misc.Format[] = [];
|
||||
|
||||
const playerElementResizeObserver = new ResizeObserver(() => {
|
||||
if (playerElement) {
|
||||
clientViewportHeight = playerElement.clientHeight;
|
||||
clientViewportWidth = playerElement.clientWidth;
|
||||
}
|
||||
});
|
||||
|
||||
playerElementResizeObserver?.observe(playerElement);
|
||||
|
||||
if (
|
||||
video.ytjs.rawApiResponse.data.streamingData &&
|
||||
(video.ytjs.rawApiResponse.data.streamingData as any).drmParams
|
||||
) {
|
||||
drmParams = (video.ytjs.rawApiResponse.data.streamingData as any).drmParams;
|
||||
player.configure({
|
||||
drm: {
|
||||
servers: {
|
||||
'com.widevine.alpha':
|
||||
'https://www.youtube.com/youtubei/v1/player/get_drm_license?alt=json'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
videoPlaybackUstreamerConfig =
|
||||
video.ytjs.video.page[0].player_config?.media_common_config.media_ustreamer_request_config
|
||||
?.video_playback_ustreamer_config;
|
||||
|
||||
if (video.ytjs.video.streaming_data) {
|
||||
formatList = video.ytjs.video.streaming_data.adaptive_formats;
|
||||
}
|
||||
|
||||
if (video.ytjs.video.streaming_data?.server_abr_streaming_url)
|
||||
serverAbrStreamingUrl = new URL(
|
||||
video.ytjs.innertube.session.player!.decipher(
|
||||
video.ytjs.video.streaming_data.server_abr_streaming_url
|
||||
)
|
||||
);
|
||||
|
||||
playerElement.addEventListener('seeked', () => (lastSeekMs = Date.now()));
|
||||
|
||||
player.addEventListener('variantchanged', (event) => {
|
||||
// Technically, all variant changes here are manual, given we don't handle ABR updates from the server.
|
||||
if (event.type !== 'variant') {
|
||||
lastManualFormatSelectionMs = Date.now();
|
||||
}
|
||||
|
||||
lastActionMs = Date.now();
|
||||
});
|
||||
|
||||
const networkingEngine = player.getNetworkingEngine();
|
||||
|
||||
if (!networkingEngine) return;
|
||||
|
||||
// Based off the following
|
||||
// https://github.com/FreeTubeApp/FreeTube/blob/d270c9e251a433f1e4246a3f6a37acef707d22aa/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js#L1206
|
||||
// https://github.com/LuanRT/BgUtils/blob/6b121166be1ccb0b952dee1bdac488808365ae6b/examples/browser/web/src/main.ts#L293
|
||||
// https://github.com/LuanRT/yt-sabr-shaka-demo/blob/main/src/components/VideoPlayer.vue
|
||||
networkingEngine.registerRequestFilter(async (type, request, context) => {
|
||||
if (!player) return;
|
||||
|
||||
const originalUrl = new URL(request.uris[0]);
|
||||
const url = originalUrl.hostname === 'sabr' ? serverAbrStreamingUrl : originalUrl;
|
||||
const headers = request.headers;
|
||||
|
||||
if (!url) return;
|
||||
|
||||
if (
|
||||
type === shaka.net.NetworkingEngine.RequestType.SEGMENT &&
|
||||
url.pathname.includes('videoplayback')
|
||||
) {
|
||||
if (!video.liveNow) {
|
||||
const currentFormat = formatList.find(
|
||||
(format) =>
|
||||
fromFormat(format) === (new URL(request.uris[0]).searchParams.get('___key') || '')
|
||||
);
|
||||
|
||||
if (!videoPlaybackUstreamerConfig) throw new Error('Ustreamer config not found.');
|
||||
|
||||
if (!currentFormat) throw new Error('No format found for SABR request.');
|
||||
|
||||
if (!playerElement) throw new Error('No video element found.');
|
||||
|
||||
const activeVariant = player
|
||||
.getVariantTracks()
|
||||
.find(
|
||||
(track) =>
|
||||
getUniqueFormatId(currentFormat) ===
|
||||
(currentFormat.has_video ? track.originalVideoId : track.originalAudioId)
|
||||
);
|
||||
|
||||
let videoFormat: Misc.Format | undefined;
|
||||
let audioFormat: Misc.Format | undefined;
|
||||
let videoFormatId: Protos.FormatId | undefined;
|
||||
let audioFormatId: Protos.FormatId | undefined;
|
||||
|
||||
if (activeVariant) {
|
||||
for (const fmt of formatList) {
|
||||
const uniqueFormatId = getUniqueFormatId(fmt);
|
||||
if (uniqueFormatId === activeVariant.originalVideoId) {
|
||||
videoFormat = fmt;
|
||||
} else if (uniqueFormatId === activeVariant.originalAudioId) {
|
||||
audioFormat = fmt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (videoFormat) {
|
||||
videoFormatId = {
|
||||
itag: videoFormat.itag,
|
||||
lastModified: parseInt(videoFormat.last_modified_ms),
|
||||
xtags: videoFormat.xtags
|
||||
};
|
||||
}
|
||||
|
||||
if (audioFormat) {
|
||||
audioFormatId = {
|
||||
itag: audioFormat.itag,
|
||||
lastModified: parseInt(audioFormat.last_modified_ms),
|
||||
xtags: audioFormat.xtags
|
||||
};
|
||||
}
|
||||
|
||||
const isInit = context ? !context.segment : true;
|
||||
|
||||
const videoPlaybackAbrRequest: Protos.VideoPlaybackAbrRequest = {
|
||||
clientAbrState: {
|
||||
playbackRate: player.getPlaybackRate(),
|
||||
playerTimeMs: Math.round(
|
||||
(context?.segment?.getStartTime() ?? playerElement.currentTime) * 1000
|
||||
),
|
||||
elapsedWallTimeMs: Date.now() - lastRequestMs,
|
||||
timeSinceLastSeek: lastSeekMs === 0 ? 0 : Date.now() - lastSeekMs,
|
||||
timeSinceLastActionMs: lastActionMs === 0 ? 0 : Date.now() - lastActionMs,
|
||||
timeSinceLastManualFormatSelectionMs:
|
||||
lastManualFormatSelectionMs === 0 ? 0 : Date.now() - lastManualFormatSelectionMs,
|
||||
clientViewportIsFlexible: false,
|
||||
bandwidthEstimate: Math.round(player.getStats().estimatedBandwidth),
|
||||
drcEnabled: currentFormat.is_drc === true,
|
||||
enabledTrackTypesBitfield: currentFormat.has_audio ? 1 : 2,
|
||||
clientViewportHeight,
|
||||
clientViewportWidth
|
||||
},
|
||||
bufferedRanges: [],
|
||||
selectedFormatIds: [],
|
||||
selectedAudioFormatIds: [audioFormatId || {}],
|
||||
selectedVideoFormatIds: [videoFormatId || {}],
|
||||
videoPlaybackUstreamerConfig: base64ToU8(videoPlaybackUstreamerConfig),
|
||||
streamerContext: {
|
||||
poToken: base64ToU8(get(poTokenCacheStore) ?? ''),
|
||||
playbackCookie: lastPlaybackCookie
|
||||
? Protos.PlaybackCookie.encode(lastPlaybackCookie).finish()
|
||||
: undefined,
|
||||
clientInfo: {
|
||||
clientName: parseInt(Constants.CLIENT_NAME_IDS.WEB),
|
||||
clientVersion: video.ytjs?.innertube.session.context.client.clientVersion,
|
||||
osName: 'Windows',
|
||||
osVersion: '10.0'
|
||||
},
|
||||
field5: [],
|
||||
field6: []
|
||||
},
|
||||
field1000: []
|
||||
};
|
||||
|
||||
// Normalize the resolution.
|
||||
if (currentFormat.width && currentFormat.height) {
|
||||
let resolution = currentFormat.height;
|
||||
|
||||
const aspectRatio = currentFormat.height / currentFormat.width;
|
||||
|
||||
if (aspectRatio > 16 / 9) {
|
||||
resolution = Math.round((currentFormat.width * 9) / 16);
|
||||
}
|
||||
|
||||
if (resolution && videoPlaybackAbrRequest.clientAbrState) {
|
||||
videoPlaybackAbrRequest.clientAbrState.stickyResolution = resolution;
|
||||
videoPlaybackAbrRequest.clientAbrState.lastManualSelectedResolution = resolution;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInit) {
|
||||
// Add the currently/previously active formats to the list of buffered ranges and selected formats
|
||||
// so that the server doesn't send its init data again.
|
||||
const initializedFormatsArray = Array.from(initializedFormats.values());
|
||||
|
||||
for (const initializedFormat of initializedFormatsArray) {
|
||||
if (initializedFormat.lastSegmentMetadata) {
|
||||
videoPlaybackAbrRequest.bufferedRanges.push({
|
||||
formatId: initializedFormat.lastSegmentMetadata.formatId,
|
||||
startSegmentIndex: initializedFormat.lastSegmentMetadata.startSequenceNumber,
|
||||
durationMs: initializedFormat.lastSegmentMetadata.durationMs,
|
||||
startTimeMs: 0,
|
||||
endSegmentIndex: initializedFormat.lastSegmentMetadata.endSequenceNumber
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (audioFormatId) videoPlaybackAbrRequest.selectedFormatIds.push(audioFormatId);
|
||||
|
||||
if (videoFormatId) videoPlaybackAbrRequest.selectedFormatIds.push(videoFormatId);
|
||||
}
|
||||
|
||||
request.body = Protos.VideoPlaybackAbrRequest.encode(videoPlaybackAbrRequest).finish();
|
||||
|
||||
const byteRange = headers.Range
|
||||
? {
|
||||
start: Number(headers.Range.split('=')[1].split('-')[0]),
|
||||
end: Number(headers.Range.split('=')[1].split('-')[1])
|
||||
}
|
||||
: null;
|
||||
|
||||
const sabrStreamingContext = {
|
||||
byteRange,
|
||||
format: currentFormat,
|
||||
isInit,
|
||||
isUMP: true,
|
||||
isSABR: true,
|
||||
playerTimeMs: videoPlaybackAbrRequest.clientAbrState?.playerTimeMs
|
||||
};
|
||||
|
||||
// @NOTE: Not a real header. See the http plugin code for more info.
|
||||
request.headers['X-Streaming-Context'] = btoa(JSON.stringify(sabrStreamingContext));
|
||||
delete headers.Range;
|
||||
} else {
|
||||
url.pathname += `/ump/1/srfvp/1/pot/${get(poTokenCacheStore)}`;
|
||||
|
||||
request.headers['X-Streaming-Context'] = btoa(
|
||||
JSON.stringify({
|
||||
isUMP: true,
|
||||
isSABR: false
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
request.method = 'POST';
|
||||
} else if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) {
|
||||
const wrapped = {} as Record<string, any>;
|
||||
wrapped.context = video.ytjs?.innertube.session.context;
|
||||
wrapped.cpn = video.ytjs?.clientPlaybackNonce;
|
||||
wrapped.drmParams = decodeURIComponent(drmParams || '');
|
||||
wrapped.drmSystem = 'DRM_SYSTEM_WIDEVINE';
|
||||
wrapped.drmVideoFeature = 'DRM_VIDEO_FEATURE_SDR';
|
||||
wrapped.licenseRequest = shaka.util.Uint8ArrayUtils.toBase64(
|
||||
request.body as ArrayBuffer | ArrayBufferView
|
||||
);
|
||||
wrapped.sessionId = sessionId;
|
||||
wrapped.videoId = video.videoId;
|
||||
request.body = shaka.util.StringUtils.toUTF8(JSON.stringify(wrapped));
|
||||
}
|
||||
|
||||
request.uris[0] = url.toString();
|
||||
});
|
||||
|
||||
networkingEngine.registerResponseFilter(async (type, response, context) => {
|
||||
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
|
||||
const url = new URL(response.uri);
|
||||
if (
|
||||
url.hostname.endsWith('.youtube.com') &&
|
||||
url.pathname === '/api/timedtext' &&
|
||||
url.searchParams.get('caps') === 'asr' &&
|
||||
url.searchParams.get('kind') === 'asr' &&
|
||||
url.searchParams.get('fmt') === 'vtt'
|
||||
) {
|
||||
const stringBody = new TextDecoder().decode(response.data);
|
||||
// position:0% for LTR text and position:100% for RTL text
|
||||
const cleaned = stringBody.replaceAll(/ align:start position:(?:10)?0%$/gm, '');
|
||||
|
||||
/* @ts-ignore */
|
||||
response.data = new TextEncoder().encode(cleaned).buffer;
|
||||
} else {
|
||||
const sabrStreamingContext = response.headers['X-Streaming-Context'];
|
||||
|
||||
if (sabrStreamingContext) {
|
||||
const { streamInfo, format, byteRange } = JSON.parse(
|
||||
atob(sabrStreamingContext)
|
||||
) as SabrStreamingContext;
|
||||
|
||||
if (streamInfo) {
|
||||
const sabrRedirect = streamInfo.redirect;
|
||||
const playbackCookie = streamInfo.playbackCookie;
|
||||
const streamProtectionStatus = streamInfo.streamProtectionStatus;
|
||||
const formatInitMetadata = streamInfo.formatInitMetadata || [];
|
||||
const mainSegmentMediaHeader = streamInfo.mediaHeader;
|
||||
|
||||
// If we have a redirect, follow it.
|
||||
if (sabrRedirect?.url && !response.data.byteLength) {
|
||||
let redirectUrl = new URL(sabrRedirect.url);
|
||||
|
||||
serverAbrStreamingUrl = redirectUrl;
|
||||
redirectUrl = new URL(`https://sabr?___key=${fromFormat(format) || ''}`);
|
||||
|
||||
const retryParameters = player!.getConfiguration().streaming.retryParameters;
|
||||
|
||||
const redirectRequest = shaka.net.NetworkingEngine.makeRequest(
|
||||
[redirectUrl.toString()],
|
||||
retryParameters
|
||||
);
|
||||
|
||||
// Keep range so we can slice the response (only if it's the init segment).
|
||||
if (typeof byteRange !== 'undefined') {
|
||||
redirectRequest.headers['Range'] = `bytes=${byteRange.start}-${byteRange.end}`;
|
||||
}
|
||||
|
||||
const requestOperation = player!
|
||||
.getNetworkingEngine()
|
||||
?.request(type, redirectRequest, context);
|
||||
const redirectResponse = await requestOperation!.promise;
|
||||
|
||||
// Modify the original response to contain the results of the redirect
|
||||
// response.
|
||||
Object.assign(response, redirectResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (playbackCookie) lastPlaybackCookie = streamInfo.playbackCookie;
|
||||
|
||||
if (streamProtectionStatus && streamProtectionStatus.status === 3) {
|
||||
console.warn('[UMP] Attestation required.');
|
||||
}
|
||||
|
||||
for (const metadata of formatInitMetadata) {
|
||||
const key = fromFormatInitializationMetadata(metadata);
|
||||
if (!initializedFormats.has(key)) {
|
||||
initializedFormats.set(key, {
|
||||
formatInitializationMetadata: metadata
|
||||
});
|
||||
console.log(`[SABR] Initialized format ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mainSegmentMediaHeader) {
|
||||
const formatKey = fromMediaHeader(mainSegmentMediaHeader);
|
||||
const initializedFormat = initializedFormats.get(formatKey);
|
||||
|
||||
if (initializedFormat) {
|
||||
initializedFormat.lastSegmentMetadata = {
|
||||
formatId: mainSegmentMediaHeader.formatId!,
|
||||
startTimeMs: mainSegmentMediaHeader.startMs || 0,
|
||||
startSequenceNumber: mainSegmentMediaHeader.sequenceNumber || 1,
|
||||
endSequenceNumber: mainSegmentMediaHeader.sequenceNumber || 1,
|
||||
durationMs: mainSegmentMediaHeader.durationMs || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (type == shaka.net.NetworkingEngine.RequestType.LICENSE) {
|
||||
const wrappedString = shaka.util.StringUtils.fromUTF8(response.data);
|
||||
const wrapped = JSON.parse(wrappedString);
|
||||
const rawLicenseBase64 = wrapped.license;
|
||||
response.data = shaka.util.Uint8ArrayUtils.fromBase64(rawLicenseBase64);
|
||||
}
|
||||
});
|
||||
|
||||
return playerElementResizeObserver;
|
||||
}
|
||||
@@ -18,19 +18,13 @@ function platformDependentDefault(givenValue: any, defaultValue: any): any {
|
||||
export const instanceStore: Writable<string> = persisted(
|
||||
'invidiousInstance',
|
||||
platformDependentDefault(
|
||||
ensureNoTrailingSlash(import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE),
|
||||
!import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE
|
||||
? undefined
|
||||
: ensureNoTrailingSlash(import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE),
|
||||
'https://invidious.materialio.us'
|
||||
)
|
||||
);
|
||||
|
||||
export const companionStore: Writable<string | undefined> = persisted(
|
||||
'companionInstance',
|
||||
platformDependentDefault(
|
||||
ensureNoTrailingSlash(import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE),
|
||||
undefined
|
||||
)
|
||||
);
|
||||
|
||||
export const authStore: Writable<null | { username: string; token: string }> = persisted(
|
||||
'authToken',
|
||||
null
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
import ui from 'beercss';
|
||||
import 'material-dynamic-colors';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { Clipboard } from '@capacitor/clipboard';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { synciousStore } from '$lib/store';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { Clipboard } from '@capacitor/clipboard';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import ContentColumn from '$lib/components/ContentColumn.svelte';
|
||||
import PlaylistThumbnail from '$lib/components/PlaylistThumbnail.svelte';
|
||||
import { ui } from 'beercss';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
import PageLoading from '$lib/components/PageLoading.svelte';
|
||||
import PlaylistThumbnail from '$lib/components/PlaylistThumbnail.svelte';
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import { extractUniqueId } from '$lib/misc.js';
|
||||
import { feedLastItemId, searchCacheStore } from '$lib/store.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -27,6 +28,7 @@
|
||||
async function changeType(type: 'playlist' | 'all' | 'video' | 'channel') {
|
||||
currentType = type;
|
||||
currentPage = 1;
|
||||
data.searchStoreId = type + data.slug;
|
||||
searchCacheStore.set({ [data.searchStoreId]: await getSearch(data.slug, { type: type }) });
|
||||
}
|
||||
|
||||
@@ -91,15 +93,8 @@
|
||||
<article
|
||||
class="no-padding"
|
||||
style="height: 100%;"
|
||||
onclick={() =>
|
||||
feedLastItemId.set(
|
||||
'videoId' in item ? item.videoId : 'authorId' in item ? item.authorId : item.title
|
||||
)}
|
||||
id={'videoId' in item
|
||||
? item.videoId
|
||||
: 'authorId' in item
|
||||
? item.authorId
|
||||
: item.title}
|
||||
onclick={() => feedLastItemId.set(extractUniqueId(item))}
|
||||
id={extractUniqueId(item)}
|
||||
>
|
||||
{#if item.type === 'video'}
|
||||
<Thumbnail video={item} />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getSearch } from '$lib/api/index';
|
||||
import type { Channel, HashTag, Playlist, Video } from '$lib/api/model';
|
||||
import { excludeDuplicateFeeds } from '$lib/misc.js';
|
||||
import { searchCacheStore } from '$lib/store.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -25,7 +27,12 @@ export async function load({ params, url }) {
|
||||
} else {
|
||||
getSearch(params.slug, { type: type }).then((newSearch) => {
|
||||
searchCacheStore.set({
|
||||
[searchStoreId]: [...new Set([...newSearch, ...search])]
|
||||
[searchStoreId]: excludeDuplicateFeeds(search, newSearch) as (
|
||||
| Channel
|
||||
| Video
|
||||
| Playlist
|
||||
| HashTag
|
||||
)[]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { PlaylistPageVideo, Video, VideoBase } from '$lib/api/model.js';
|
||||
import VideoList from '$lib/components/VideoList.svelte';
|
||||
import { feedCacheStore } from '$lib/store';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
|
||||
let currentPage = 1;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getFeed } from '$lib/api/index';
|
||||
import type { PlaylistPageVideo, Video, VideoBase } from '$lib/api/model';
|
||||
import { excludeDuplicateFeeds } from '$lib/misc';
|
||||
import { feedCacheStore } from '$lib/store';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -21,7 +23,11 @@ export async function load() {
|
||||
await getFeed(100, 1).then((feeds) => {
|
||||
const newVideos = [...feeds.notifications, ...feeds.videos, ...videos];
|
||||
feedCacheStore.set({
|
||||
subscription: [...new Set(newVideos)]
|
||||
subscription: excludeDuplicateFeeds(videos, newVideos) as (
|
||||
| VideoBase
|
||||
| Video
|
||||
| PlaylistPageVideo
|
||||
)[]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { deleteUnsubscribe } from '$lib/api';
|
||||
import Fuse from 'fuse.js';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
import type { DataConnection } from 'peerjs';
|
||||
import { type Segment } from 'sponsorblock-api';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
@@ -2,14 +2,18 @@ import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getResolveUrl } from '$lib/api';
|
||||
import '$lib/i18n'; // Import to initialize. Important :)
|
||||
import { initI18n } from '$lib/i18n';
|
||||
import { getPages } from '$lib/navPages.js';
|
||||
import { authStore, interfaceDefaultPage } from '$lib/store.js';
|
||||
import { locale, waitLocale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export async function load({ url }) {
|
||||
if (browser) {
|
||||
await initI18n();
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/@')) {
|
||||
const username = url.pathname.split('/')[1];
|
||||
|
||||
@@ -21,11 +25,6 @@ export async function load({ url }) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
locale.set(window.navigator.language);
|
||||
}
|
||||
await waitLocale();
|
||||
|
||||
const defaultPage = get(interfaceDefaultPage);
|
||||
|
||||
if (
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.9.0"
|
||||
LATEST_VERSION = "1.9.1"
|
||||
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
|
||||
|
||||
WORKING_DIR = os.path.join(
|
||||
|
||||
Reference in New Issue
Block a user