Merge pull request #949 from Materialious/update/1.9.1

Update/1.9.1
This commit is contained in:
Ward
2025-06-05 15:32:35 +12:00
committed by GitHub
45 changed files with 1011 additions and 1531 deletions
+4 -3
View File
@@ -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" />
+2 -2
View File
@@ -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">
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.9.0",
"version": "1.9.1",
"description": "Modern material design for Invidious.",
"author": {
"name": "Ward Pearce",
+68 -743
View File
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -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"
}
}
}
+8 -6
View File
@@ -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';
+48 -15
View File
@@ -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);
});
}
+8 -2
View File
@@ -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",
+31
View File
@@ -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 -1
View File
@@ -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
+418
View File
@@ -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;
}
+3 -9
View File
@@ -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
+1 -1
View File
@@ -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();
+5 -6
View File
@@ -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
View File
@@ -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(