Merge pull request #554 from Materialious/update/1.4.0

Update/1.4.0
This commit is contained in:
Ward
2024-09-17 22:01:48 +12:00
committed by GitHub
20 changed files with 2866 additions and 58 deletions
+1
View File
@@ -24,6 +24,7 @@
- Mini player.
- Silence skipper (Experimental.)
- ffmpeg integration for downloading videos with audio at any quality ([Configuration required](./docs/DOCKER.md#step-7-optional-enabling-downloads)).
- [YouTube.js](https://github.com/LuanRT/YouTube.js) fallback if Invidious fails loading videos for Desktop.
- Preview video on hover.
- Sponsorblock built-in.
- Return YouTube dislikes built-in.
+37 -3
View File
@@ -276,24 +276,32 @@ VITE_DEFAULT_PEERJS_PORT: 443
## Step 7 (Optional): Enabling downloads
### Step 1: Add the following to your reverse proxy
### Step 1: Add the following to your reverse proxies
#### Caddy
```caddy
# Materialious
materialious.example.com {
reverse_proxy localhost:3001
header {
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Embedder-Policy "require-corp"
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Embedder-Policy "require-corp"
}
}
# Invidious
invidious.example.com {
# Other configurations
header /ggpht/* Cross-Origin-Resource-Policy cross-origin
}
```
#### Nginx
```nginx
# Materialious
server {
listen 80;
server_name materialious.example.com;
@@ -305,12 +313,23 @@ server {
proxy_pass http://localhost:3001;
}
}
# Invidious
server {
listen 80;
server_name invidious.example.com;
location /ggpht/ {
add_header Cross-Origin-Resource-Policy cross-origin;
}
}
```
#### Traefik
Add the following to Materialious
```yaml
# Materialious
http:
middlewares:
materialious-downloads:
@@ -318,6 +337,21 @@ http:
customResponseHeaders:
Cross-Origin-Opener-Policy: "same-origin"
Cross-Origin-Embedder-Policy: "require-corp"
# Invidious
http:
routers:
ggpht-router:
rule: "PathPrefix(`/ggpht/`)"
service: ggpht-service
middlewares:
- headers-ggpht
middlewares:
headers-ggpht:
headers:
customResponseHeaders:
Cross-Origin-Resource-Policy: cross-origin
```
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 36
versionName "1.3.7"
versionCode 37
versionName "1.4.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.3.7",
"version": "1.4.0",
"description": "Modern material design for Invidious.",
"author": {
"name": "Ward Pearce",
@@ -27,7 +27,8 @@
"electron-unhandled": "^4.0.1",
"electron-updater": "^6.3.4",
"electron-window-state": "^5.0.3",
"jsonfile": "^6.1.0"
"jsonfile": "^6.1.0",
"youtube-po-token-generator": "^0.2.0"
},
"devDependencies": {
"electron": "^32.1.0",
+5 -3
View File
@@ -1,4 +1,6 @@
require('./rt/electron-rt');
//////////////////////////////
// User Defined Preload scripts below
console.log('User Preload!');
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electron', {
generatePoToken: () => ipcRenderer.invoke('generate-po-token')
});
+11 -1
View File
@@ -6,11 +6,12 @@ import {
} from '@capacitor-community/electron';
import chokidar from 'chokidar';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, Menu, MenuItem, nativeImage, session, Tray } from 'electron';
import { app, BrowserWindow, ipcMain, Menu, MenuItem, nativeImage, session, Tray } from 'electron';
import electronIsDev from 'electron-is-dev';
import electronServe from 'electron-serve';
import windowStateKeeper from 'electron-window-state';
import { join } from 'path';
import { generate } from 'youtube-po-token-generator';
// Define components for a watcher to detect when the webapp is changed so we can reload in Dev mode.
const reloadWatcher = {
@@ -214,6 +215,15 @@ export class ElectronCapacitorApp {
CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
}, 400);
});
ipcMain.handle('generate-po-token', async () => {
try {
return await generate();
} catch (error) {
console.error('Error generating token:', error);
return { error: error.message }; // Return error to renderer
}
});
}
}
+2632 -7
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.3.7",
"version": "1.4.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -26,16 +26,20 @@
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.18.0",
"@vite-pwa/sveltekit": "^0.6.5",
"buffer": "^6.0.3",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.44.0",
"i": "^0.3.7",
"npm": "^10.8.3",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^4.2.19",
"svelte-check": "^3.8.6",
"tslib": "^2.7.0",
"typescript": "^5.5.4",
"vite": "^5.4.5"
"vite": "^5.4.5",
"youtubei.js": "^10.4.0"
},
"type": "module",
"dependencies": {
@@ -62,4 +66,4 @@
"svelte-persisted-store": "^0.11.0",
"vidstack": "^1.12.9"
}
}
}
+9 -1
View File
@@ -1,3 +1,5 @@
import { patchYoutubeJs } from '$lib/patches/youtubejs';
import { Capacitor } from '@capacitor/core';
import { get } from 'svelte/store';
import {
authStore,
@@ -5,6 +7,7 @@ import {
deArrowThumbnailInstanceStore,
instanceStore,
interfaceRegionStore,
playerYouTubeJsFallback,
returnYTDislikesInstanceStore,
synciousInstanceStore
} from '../store';
@@ -68,7 +71,12 @@ export async function getPopular(): Promise<Video[]> {
}
export async function getVideo(videoId: string, local: boolean = false): Promise<VideoPlay> {
const resp = await fetchErrorHandle(await fetch(setRegion(buildPath(`videos/${videoId}?local=${local}`))));
const resp = await fetch(setRegion(buildPath(`videos/${videoId}?local=${local}`)));
if (!resp.ok && Capacitor.getPlatform() === 'electron' && get(playerYouTubeJsFallback)) {
return await patchYoutubeJs(videoId);
} else {
await fetchErrorHandle(resp);
}
return await resp.json();
}
+8 -4
View File
@@ -6,11 +6,15 @@
export let channel: Channel;
let channelPfp: string;
let channelPfp: HTMLImageElement | undefined;
onMount(async () => {
const pfpResp = await fetch(proxyGoogleImage(getBestThumbnail(channel.authorThumbnails)));
channelPfp = URL.createObjectURL(await pfpResp.blob());
const img = new Image();
img.src = proxyGoogleImage(getBestThumbnail(channel.authorThumbnails));
img.onload = () => {
channelPfp = img;
};
});
</script>
@@ -23,7 +27,7 @@
<img
class="circle"
style="width: 90px;height: 90px;"
src={channelPfp}
src={channelPfp.src}
alt={channel.author}
/>
{/if}
+13 -7
View File
@@ -2,7 +2,7 @@
import { _ } from 'svelte-i18n';
import { getComments } from './Api';
import { type Comment, type Comments } from './Api/model';
import { getBestThumbnail, loadPfp, numberWithCommas, proxyGoogleImage } from './misc';
import { getBestThumbnail, numberWithCommas, proxyGoogleImage } from './misc';
export let comment: Comment;
export let videoId: string;
@@ -31,9 +31,12 @@
</script>
<div class="comment">
{#await loadPfp(proxyGoogleImage(getBestThumbnail(comment.authorThumbnails))) then pfp}
<img class="circle small" src={pfp} alt="comment profile" />
{/await}
<img
class="circle small"
src={proxyGoogleImage(getBestThumbnail(comment.authorThumbnails))}
alt="comment profile"
/>
<div>
<div class="row">
<a href={`/channel/${comment.authorId}`}>
@@ -58,9 +61,12 @@
<p><i>thumb_up</i> {numberWithCommas(comment.likeCount)}</p>
{#if comment.creatorHeart}
<div>
{#await loadPfp(proxyGoogleImage(comment.creatorHeart.creatorThumbnail)) then pfp}
<img class="circle" style="width: 25px; height: 25px" src={pfp} alt="Creator profile" />
{/await}
<img
class="circle"
style="width: 25px; height: 25px"
src={proxyGoogleImage(comment.creatorHeart.creatorThumbnail)}
alt="Creator profile"
/>
<i style="font-size: 20px;margin-left: 5px;" class="absolute left red-text bottom fill"
>favorite</i
>
+4 -1
View File
@@ -193,7 +193,10 @@
label: caption.label,
kind: 'captions',
language: caption.language_code,
src: `${get(instanceStore)}${caption.url}`
// Need if captions are generated when youtube.js is being used.
src: caption.url.startsWith('blob:')
? caption.url
: `${get(instanceStore)}${caption.url}`
});
});
}
+21 -2
View File
@@ -30,6 +30,7 @@
playerProxyVideosStore,
playerSavePlaybackPositionStore,
playerTheatreModeByDefaultStore,
playerYouTubeJsFallback,
returnYTDislikesInstanceStore,
returnYtDislikesStore,
silenceSkipperStore,
@@ -130,8 +131,8 @@
>
<nav>
<div class="field label border max">
<input bind:value={invidiousInstance} name="returnyt-instance" type="text" />
<label for="returnyt-instance">{$_('layout.instanceUrl')}</label>
<input bind:value={invidiousInstance} name="invidious-instance" type="text" />
<label for="invidious-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
@@ -439,6 +440,24 @@
</label>
</nav>
</div>
{#if Capacitor.getPlatform() === 'electron'}
<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}
on:click={() => playerYouTubeJsFallback.set(!$playerYouTubeJsFallback)}
/>
<span></span>
</label>
</nav>
</div>
{/if}
</div>
<div class="settings">
+13 -7
View File
@@ -39,7 +39,7 @@
let videoPreviewVolume: number = 0.4;
let imgHeight: number;
let authorImg: string = '';
let authorImg: HTMLImageElement | undefined;
let proxyVideos = get(playerProxyVideosStore);
@@ -85,10 +85,12 @@
async function loadAuthor() {
const channel = await getChannel(video.authorId);
const imgResp = await fetch(
proxyGoogleImage(getBestThumbnail(channel.authorThumbnails, 75, 75))
);
authorImg = URL.createObjectURL(await imgResp.blob());
const img = new Image();
img.src = proxyGoogleImage(getBestThumbnail(channel.authorThumbnails, 75, 75));
img.onload = () => {
authorImg = img;
};
}
onMount(async () => {
@@ -228,6 +230,10 @@
showVideoPreview = true;
try {
videoPreview = await getVideo(video.videoId);
if (videoPreview.formatStreams.length === 0) {
showVideoPreview = false;
}
if (videoPreview.hlsUrl) {
showVideoPreview = false;
videoPreview = null;
@@ -353,8 +359,8 @@
<div class="thumbnail-details video-title">
{#if !sideways}
<div style="margin-right: 1em;">
{#if authorImg !== ''}
<img class="circle small" src={authorImg} alt="Author" />
{#if authorImg}
<img src={authorImg.src} alt="Author" class="circle small" />
{:else}
<progress style="padding: 15px;" class="circle small"></progress>
{/if}
+2 -1
View File
@@ -123,7 +123,8 @@
"proxyVideos": "Proxy videos",
"backgroundPlay": "Play audio in background",
"dash": "Dash",
"silenceSkipper": "Silence skipper (Experimental)"
"silenceSkipper": "Silence skipper (Experimental)",
"youtubeJsFallback": "Locally video processing fallback"
},
"bookmarklet": "Bookmarklet",
"instanceUrl": "Instance URL",
+4 -9
View File
@@ -170,11 +170,6 @@ export function proxyVideoUrl(source: string): string {
return rawSrc.toString();
}
export async function loadPfp(url: string): Promise<string> {
const resp = await fetch(url);
return URL.createObjectURL(await resp.blob());
}
export function pullBitratePreference(): number {
const vidstack = localStorage.getItem('video-player');
@@ -239,15 +234,15 @@ export function getBestThumbnail(
maxHeightDimension = 360
): string {
if (images && images.length > 0) {
images = images.filter(
const imagesFiltered = images.filter(
(image) => image.width < maxWidthDimension && image.height < maxHeightDimension
);
if (images.length === 0) {
return '';
if (imagesFiltered.length === 0) {
return images[0].url;
}
images.sort((a, b) => {
imagesFiltered.sort((a, b) => {
return b.width * b.height - a.width * a.height;
});
+87
View File
@@ -0,0 +1,87 @@
import type { Image, Thumbnail, VideoBase, VideoPlay } from '$lib/Api/model';
import { numberWithCommas } from '$lib/misc';
export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
const innertube = (await import('youtubei.js')).Innertube;
const tokens = await (window as any).electron.generatePoToken();
const youtube = await innertube.create({
visitor_data: tokens.visitorData,
po_token: tokens.poToken
});
const video = await youtube.getInfo(videoId);
if (!video.primary_info || !video.secondary_info) {
throw new Error('Unable to pull video info from youtube.js');
}
let manifest = await video.toDash();
// Hack to fix video not displaying.
// Thanks to absidue & Andrews54757
manifest = manifest.replaceAll('<EssentialProperty', '<SupplementalProperty');
const dashUri = URL.createObjectURL(new Blob([manifest], { type: 'application/dash+xml;charset=utf8' }));
const descString = video.secondary_info.description.toString();
let authorThumbnails: Image[];
if (video.basic_info.channel_id) {
authorThumbnails = (await youtube.getChannel(video.basic_info.channel_id)).metadata.avatar as Image[];
} else {
authorThumbnails = [];
}
let recommendedVideos: VideoBase[] = [];
video.watch_next_feed?.forEach((recommended: Record<string, any>) => {
recommendedVideos.push({
videoThumbnails: recommended?.thumbnails as Thumbnail[] || [],
videoId: recommended?.id || '',
title: recommended?.title.toString() || '',
viewCountText: numberWithCommas(Number(recommended?.view_count.toString().replace(/\D/g, '') || 0)) || '',
lengthSeconds: recommended?.duration?.seconds || 0,
author: recommended?.author.name || '',
authorId: recommended?.author.id || ''
});
});
return {
type: 'video',
title: video.primary_info.title.toString(),
viewCount: Number(video.primary_info.view_count.toString().replace(/\D/g, '')),
viewCountText: video.primary_info.view_count.toString(),
likeCount: video.basic_info.like_count || 0,
dislikeCount: 0,
allowRatings: false,
rating: 0,
isListed: 0,
isFamilyFriendly: video.basic_info.is_family_safe || true,
genre: video.basic_info.category || '',
genreUrl: '',
dashUrl: dashUri,
adaptiveFormats: [],
formatStreams: [],
recommendedVideos: recommendedVideos,
authorThumbnails: authorThumbnails,
captions: [],
authorId: video.basic_info.channel_id || '',
authorUrl: `/channel/${video.basic_info.channel_id}`,
authorVerified: false,
description: descString,
descriptionHtml: video.secondary_info.description.toHTML() || descString,
published: 0,
publishedText: video.primary_info.published.toString(),
premiereTimestamp: 0,
liveNow: false,
premium: false,
isUpcoming: false,
videoId: videoId,
videoThumbnails: video.basic_info.thumbnail as Thumbnail[],
author: video.basic_info.author || 'Unknown',
lengthSeconds: video.basic_info.duration || 0,
subCountText: '',
keywords: video.basic_info.keywords || [],
allowedRegions: []
};
}
+1
View File
@@ -39,6 +39,7 @@ export const playerTheatreModeByDefaultStore = persisted('theatreModeByDefault',
export const playerAutoplayNextByDefaultStore = persisted('autoplayNextByDefault', false);
export const playerMiniPlayerStore = persisted('miniPlayer', true);
export const playerAndroidBackgroundPlayStore = persisted('androidBackgroundPlayer', true);
export const playerYouTubeJsFallback = persisted('youTubeJsFallback', true);
export const returnYtDislikesStore = persisted('returnYtDislikes', false);
export const returnYTDislikesInstanceStore: Writable<string | null | undefined> = persisted(
@@ -45,7 +45,6 @@
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { MediaPlayerElement } from 'vidstack/elements';
import { loadPfp } from '../../../../lib/misc.js';
export let data;
@@ -487,9 +486,11 @@
<nav>
<a href={`/channel/${data.video.authorId}`}>
<nav>
{#await loadPfp(proxyGoogleImage(getBestThumbnail(data.video.authorThumbnails))) then pfp}
<img class="circle large" src={pfp} alt="Channel profile" />
{/await}
<img
class="circle large"
src={proxyGoogleImage(getBestThumbnail(data.video.authorThumbnails))}
alt="Channel profile"
/>
<div>
<p style="margin: 0;" class="bold">{data.video.author}</p>
<p style="margin: 0;">{data.video.subCountText}</p>
+1 -1
View File
@@ -5,7 +5,7 @@ import json
import os
import re
LATEST_VERSION = "1.3.7"
LATEST_VERSION = "1.4.0"
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")
ROOT_PACKAGE = os.path.join(WORKING_DIR, "package.json")