Merge pull request #527 from Materialious/feature/transcoding

Dash video downloading
This commit is contained in:
Ward
2024-09-10 17:13:01 +12:00
committed by GitHub
21 changed files with 636 additions and 94 deletions
+2 -1
View File
@@ -22,7 +22,8 @@
- Sync your watch progress between Invidious sessions.
- Watch sync parties!
- Mini player.
- Silence skipper (Experimental)
- Silence skipper (Experimental.)
- ffmpeg integration for downloading videos with audio at any quality ([Configuration required](./docs/DOCKER.md#step-7-optional-enabling-downloads)).
- Preview video on hover.
- Sponsorblock built-in.
- Return YouTube dislikes built-in.
+59
View File
@@ -148,6 +148,9 @@ services:
# Look at "Overwriting Materialious defaults" for all the accepted values.
VITE_DEFAULT_SETTINGS: '{"themeColor": "#2596be","region": "US"}'
# Please look at step 7 about enabling this.
VITE_DEFAULT_DOWNLOAD_ENABLED: false
```
### Overwriting Materialious defaults
@@ -269,3 +272,59 @@ VITE_DEFAULT_PEERJS_HOST: "peerjs.example.com"
VITE_DEFAULT_PEERJS_PATH: "/"
VITE_DEFAULT_PEERJS_PORT: 443
```
## Step 7 (Optional): Enabling downloads
### Step 1: Add the following to your reverse proxy
#### Caddy
```caddy
materialious.example.com {
reverse_proxy localhost:3001
header {
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Embedder-Policy "require-corp"
}
}
```
#### Nginx
```nginx
server {
listen 80;
server_name materialious.example.com;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
location / {
proxy_pass http://localhost:3001;
}
}
```
#### Traefik
Add the following to Materialious
```yaml
http:
middlewares:
materialious-downloads:
headers:
customResponseHeaders:
Cross-Origin-Opener-Policy: "same-origin"
Cross-Origin-Embedder-Policy: "require-corp"
```
### Step 2: Modify docker compose.
Add this environment variable to Materialious.
```yaml
VITE_DEFAULT_DOWNLOAD_ENABLED: true
```
+1
View File
@@ -17,6 +17,7 @@ RUN echo "VITE_DEFAULT_INVIDIOUS_INSTANCE=VITE_DEFAULT_INVIDIOUS_INSTANCE_PLACEH
echo "VITE_DEFAULT_PEERJS_HOST=VITE_DEFAULT_PEERJS_HOST_PLACEHOLDER" >> .env && \
echo "VITE_DEFAULT_PEERJS_PATH=VITE_DEFAULT_PEERJS_PATH_PLACEHOLDER" >> .env && \
echo "VITE_DEFAULT_PEERJS_PORT=VITE_DEFAULT_PEERJS_PORT_PLACEHOLDER" >> .env && \
echo "VITE_DEFAULT_DOWNLOAD_ENABLED=VITE_DEFAULT_DOWNLOAD_ENABLED_PLACEHOLDER" >> .env && \
echo "VITE_DEFAULT_SYNCIOUS_INSTANCE=VITE_DEFAULT_SYNCIOUS_INSTANCE_PLACEHOLDER" >> .env && \
echo "VITE_DEFAULT_SETTINGS='\"VITE_DEFAULT_SETTINGS_PLACEHOLDER\"'" >> .env && \
echo "VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE=VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE_PLACEHOLDER" >> .env
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 28
versionName "1.2.3"
versionCode 29
versionName "1.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.2.3",
"version": "1.3.0",
"description": "Modern material design for Invidious.",
"author": {
"name": "Ward Pearce",
+263 -2
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.2.0",
"version": "1.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.2.0",
"version": "1.2.3",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor/android": "6.1.2",
@@ -32,6 +32,8 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@sveltejs/adapter-auto": "^3.2.4",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.26",
@@ -2607,6 +2609,36 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@ffmpeg/ffmpeg": {
"version": "0.12.10",
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.10.tgz",
"integrity": "sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ==",
"dev": true,
"dependencies": {
"@ffmpeg/types": "^0.12.2"
},
"engines": {
"node": ">=18.x"
}
},
"node_modules/@ffmpeg/types": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.2.tgz",
"integrity": "sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA==",
"dev": true,
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/util": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.1.tgz",
"integrity": "sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ==",
"dev": true,
"engines": {
"node": ">=18.x"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.7.tgz",
@@ -3477,6 +3509,235 @@
"vite": "^5.0.0"
}
},
"node_modules/@swc/core": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.24.tgz",
"integrity": "sha512-FzJaai6z6DYdICAY1UKNN5pzTn296ksK2zzEjjaXlpZtoMkGktWT0ttS7hbdBCPGhLOu5Q9TA2zdPejKUFjgig==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.12"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.7.24",
"@swc/core-darwin-x64": "1.7.24",
"@swc/core-linux-arm-gnueabihf": "1.7.24",
"@swc/core-linux-arm64-gnu": "1.7.24",
"@swc/core-linux-arm64-musl": "1.7.24",
"@swc/core-linux-x64-gnu": "1.7.24",
"@swc/core-linux-x64-musl": "1.7.24",
"@swc/core-win32-arm64-msvc": "1.7.24",
"@swc/core-win32-ia32-msvc": "1.7.24",
"@swc/core-win32-x64-msvc": "1.7.24"
},
"peerDependencies": {
"@swc/helpers": "*"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.24.tgz",
"integrity": "sha512-s0k09qAcsoa8jIncwgRRd43VApYqXu28R4OmICtDffV4S01HtsRLRarXsMuLutoZk3tbxqitep+A8MPBuqNgdg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.24.tgz",
"integrity": "sha512-1dlsulJ/fiOoJoJyQgaCewIEaZ7Sh6aJN4r5Uhl4lIZuNWa27XOb28A3K29/6HDO9JML3IJrvXPnl5o0vxDQuQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.24.tgz",
"integrity": "sha512-2ft1NmxyvHCu5CY4r2rNVybPqZtJaxpRSzvCcPlVjN/2D5Q3QgM5kBoo1t+0RCFfk4TS2V0KWJhtqKz0CNX62Q==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.24.tgz",
"integrity": "sha512-v/Z8I9tUUNkNHKa1Sw4r1Q7Wp66ezbRhe6xMIxvPNKVJQFaMOsRpe0t8T5qbk5sV2hJGOCKpQynSpZqQXLcJDQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.24.tgz",
"integrity": "sha512-0jJx0IcajcyOXaJsx1jXy86lYVrbupyy2VUj/OiJux/ic4oBJLjfL+WOuc8T8/hZj2p6X0X4jvfSCqWSuic4kA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.24.tgz",
"integrity": "sha512-2+3aKQpSGjVnWKDTKUPuJzitQlTQrGorg+PVFMRkv6l+RcNCHZQNe/8VYpMhyBhxDMb3LUlbp7776FRevcruxg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.24.tgz",
"integrity": "sha512-PMQ6SkCtMoj0Ks77DiishpEmIuHpYjFLDuVOzzJCzGeGoii0yRP5lKy/VeglFYLPqJzmhK9BHlpVehVf/8ZpvA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.24.tgz",
"integrity": "sha512-SNdCa4DtGXNWrPVHqctVUxgEVZVETuqERpqF50KFHO0Bvf5V/m1IJ4hFr2BxXlrzgnIW4t1Dpi6YOJbcGbEmnA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.24.tgz",
"integrity": "sha512-5p3olHqwibMfrVFg2yVuSIPh9HArDYYlJXNZ9JKqeZk23A19J1pl9MuPmXDw+sxsiPfYJ/nUedIGeUHPF/+EDw==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.7.24",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.24.tgz",
"integrity": "sha512-gRyPIxDznS8d2ClfmWbytjp2d48bij6swHnDLWhukNuOvXdQkEmaIzjEsionFG/zhcFLnz8zKfTvjEjInAMzxg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/@swc/types": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz",
"integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@trapezedev/gradle-parse": {
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/@trapezedev/gradle-parse/-/gradle-parse-7.0.10.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.2.3",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -13,6 +13,8 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@sveltejs/adapter-auto": "^3.2.4",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.26",
+1
View File
@@ -10,5 +10,6 @@ find "$ROOT_DIR" -type f \( -name "*.js" -o -name "*.html" \) -exec sed -i 's|VI
find "$ROOT_DIR" -type f \( -name "*.js" -o -name "*.html" \) -exec sed -i 's|VITE_DEFAULT_PEERJS_HOST_PLACEHOLDER|'"$VITE_DEFAULT_PEERJS_HOST"'|g' {} +
find "$ROOT_DIR" -type f \( -name "*.js" -o -name "*.html" \) -exec sed -i 's|VITE_DEFAULT_PEERJS_PATH_PLACEHOLDER|'"$VITE_DEFAULT_PEERJS_PATH"'|g' {} +
find "$ROOT_DIR" -type f \( -name "*.js" -o -name "*.html" \) -exec sed -i 's|VITE_DEFAULT_PEERJS_PORT_PLACEHOLDER|'"$VITE_DEFAULT_PEERJS_PORT"'|g' {} +
find "$ROOT_DIR" -type f \( -name "*.js" -o -name "*.html" \) -exec sed -i 's|VITE_DEFAULT_DOWNLOAD_ENABLED_PLACEHOLDER|'"$VITE_DEFAULT_DOWNLOAD_ENABLED"'|g' {} +
find "$ROOT_DIR" -type f \( -name "*.js" -o -name "*.html" \) -exec sed -i 's|VITE_DEFAULT_SYNCIOUS_INSTANCE_PLACEHOLDER|'"$VITE_DEFAULT_SYNCIOUS_INSTANCE"'|g' {} +
find "$ROOT_DIR" -type f \( -name "*.js" -o -name "*.html" \) -exec sed -i 's|VITE_DEFAULT_SETTINGS_PLACEHOLDER|'"$VITE_DEFAULT_SETTINGS"'|g' {} +
+11 -15
View File
@@ -6,30 +6,26 @@
export let channel: Channel;
let loading = true;
let channelPfp: string;
let img: HTMLImageElement;
onMount(() => {
img = new Image();
img.src = proxyGoogleImage(getBestThumbnail(channel.authorThumbnails)) || '';
img.onload = () => {
loading = false;
};
img.onerror = () => {
loading = false;
};
onMount(async () => {
const pfpResp = await fetch(proxyGoogleImage(getBestThumbnail(channel.authorThumbnails)));
channelPfp = URL.createObjectURL(await pfpResp.blob());
});
</script>
<a href={`/channel/${channel.authorId}`} class="wave" style="min-width: 100%;min-height: 100%;">
<div class="padding">
<div class="center-align">
{#if loading}
{#if !channelPfp}
<progress class="circle"></progress>
{:else}
<img class="circle" style="width: 90px;height: 90px;" src={img.src} alt={channel.author} />
<img
class="circle"
style="width: 90px;height: 90px;"
src={channelPfp}
alt={channel.author}
/>
{/if}
</div>
<h5 class="center-align">{truncate(channel.author, 14)}</h5>
+11 -11
View File
@@ -9,6 +9,11 @@
let replies: Comments | undefined = undefined;
async function loadPfp(url: string): Promise<string> {
const resp = await fetch(url);
return URL.createObjectURL(await resp.blob());
}
async function loadReplies(continuation: string) {
try {
replies = await getComments(videoId, {
@@ -31,11 +36,9 @@
</script>
<div class="comment">
<img
class="circle small"
src={proxyGoogleImage(getBestThumbnail(comment.authorThumbnails))}
alt="comment profile"
/>
{#await loadPfp(proxyGoogleImage(getBestThumbnail(comment.authorThumbnails))) then pfp}
<img class="circle small" src={pfp} alt="comment profile" />
{/await}
<div>
<div class="row">
<a href={`/channel/${comment.authorId}`}>
@@ -60,12 +63,9 @@
<p><i>thumb_up</i> {numberWithCommas(comment.likeCount)}</p>
{#if comment.creatorHeart}
<div>
<img
class="circle"
style="width: 25px; height: 25px"
src={proxyGoogleImage(comment.creatorHeart.creatorThumbnail)}
alt="Creator profile"
/>
{#await loadPfp(proxyGoogleImage(comment.creatorHeart.creatorThumbnail)) then pfp}
<img class="circle" style="width: 25px; height: 25px" src={pfp} alt="Creator profile" />
{/await}
<i style="font-size: 20px;margin-left: 5px;" class="absolute left red-text bottom fill"
>favorite</i
>
+2 -7
View File
@@ -85,13 +85,8 @@
async function loadAuthor() {
const channel = await getChannel(video.authorId);
const authorImgObject = new Image();
authorImgObject.src = proxyGoogleImage(getBestThumbnail(channel.authorThumbnails));
authorImgObject.onload = () => {
authorImg = authorImgObject.src;
};
const imgResp = await fetch(proxyGoogleImage(getBestThumbnail(channel.authorThumbnails)));
authorImg = URL.createObjectURL(await imgResp.blob());
}
onMount(async () => {
@@ -70,6 +70,11 @@
"youtubeLink": "Copy Youtube link"
},
"download": "Download",
"downloadSteps": {
"merging": "Merging video & audio together",
"video": "Downloading video",
"audio": "Downloading audio"
},
"noPlaylists": "No playlists",
"unableToLoadComments": "Unable to load comments",
"addToPlaylist": "Add to playlist"
+165
View File
@@ -0,0 +1,165 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { proxyVideoUrl } from './misc';
interface MediaQuality {
bandwidth: string;
videoUrl: string;
audioUrl: string;
resolution?: string;
frameRate?: string;
audioChannels?: string;
audioBandwidth?: string;
}
// https://github.com/ffmpegwasm/ffmpeg.wasm/issues/603#issuecomment-1982289892
// Ripped from https://github.com/ffmpegwasm/ffmpeg.wasm/blob/ae1cdac7db79c5315f9a1b716fcbd9bcfe27c902/packages/util/src/index.ts#L49
export async function fetchFile(
file: string,
onProgress?: (progress: number) => void
): Promise<Uint8Array> {
const response = await fetch(file);
const contentLength = response.headers.get('content-length');
// If content-length is missing, download without progress tracking
if (!contentLength) {
const data = await response.arrayBuffer();
return new Uint8Array(data);
}
const totalBytes = parseInt(contentLength, 10);
const data = new Uint8Array(totalBytes);
let position = 0;
let downloadBytes = 0;
const reader = response.body!.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
data.set(value, position);
position += value.length;
downloadBytes += value.byteLength;
if (onProgress) {
onProgress((downloadBytes / totalBytes) * 100);
}
}
return data;
}
export async function toBlobURL(
url: string,
mimeType: string,
): Promise<string> {
const response = await fetch(url);
return URL.createObjectURL(new Blob([await response.arrayBuffer()], { type: mimeType }));
}
// Fetch and parse the DASH manifest
async function fetchDASHManifest(manifestUrl: string) {
const response = await fetch(manifestUrl);
const manifestText = await response.text();
const parser = new DOMParser();
const manifest = parser.parseFromString(manifestText, "text/xml");
return manifest;
}
export async function listCombinedQualities(manifestUrl: string): Promise<MediaQuality[]> {
const manifest = await fetchDASHManifest(manifestUrl);
const qualities: MediaQuality[] = [];
// Fetch all AdaptationSets (both video and audio)
const adaptationSets = manifest.getElementsByTagName('AdaptationSet');
let videoRepresentations: Element[] = [];
let audioRepresentations: Element[] = [];
// Separate video and audio AdaptationSets based on mimeType
for (let i = 0; i < adaptationSets.length; i++) {
const mimeType = adaptationSets[i].getAttribute('mimeType');
const representations = adaptationSets[i].getElementsByTagName('Representation');
if (mimeType && mimeType === 'video/mp4') {
videoRepresentations = [...videoRepresentations, ...Array.from(representations)];
} else if (mimeType && mimeType === 'audio/mp4') {
audioRepresentations = [...audioRepresentations, ...Array.from(representations)];
}
}
// Combine video and audio representations
videoRepresentations.forEach((videoRep) => {
const videoBandwidth = videoRep.getAttribute('bandwidth');
const videoUrl = proxyVideoUrl(videoRep.getElementsByTagName('BaseURL')[0].textContent as string);
const width = videoRep.getAttribute('width');
const height = videoRep.getAttribute('height');
const resolution = width && height ? `${width}x${height}` : undefined;
const frameRate = videoRep.getAttribute('frameRate') || '';
// Pair this video representation with each audio representation
audioRepresentations.forEach((audioRep) => {
const audioUrl = proxyVideoUrl(audioRep.getElementsByTagName('BaseURL')[0].textContent as string);
const audioChannels = audioRep.getElementsByTagName('AudioChannelConfiguration')[0]?.getAttribute('value') || '';
const audioBandwidth = audioRep.getAttribute('bandwidth') || '';
qualities.push({
bandwidth: videoBandwidth || '',
videoUrl,
audioUrl,
resolution,
frameRate,
audioChannels,
audioBandwidth
});
});
});
return qualities;
}
export async function mergeMediaFromDASH(
selectedQuality: MediaQuality,
fileName: string,
progressCallbacks?: {
video?: (progress: number) => void,
audio?: (progress: number) => void,
merging?: (progress: number) => void,
}
) {
const ffmpeg = new FFmpeg();
ffmpeg.on('progress', ({ progress }) => {
if (progressCallbacks?.merging) {
progressCallbacks.merging(progress * 100);
}
});
await ffmpeg.load({
coreURL: await toBlobURL('/ffmpeg/ffmpeg-core.js', 'text/javascript'),
wasmURL: await toBlobURL('/ffmpeg/ffmpeg-core.wasm', 'application/wasm'),
workerURL: await toBlobURL('/ffmpeg/ffmpeg-core.worker.js', 'text/javascript')
});
const { videoUrl, audioUrl } = selectedQuality;
ffmpeg.writeFile('input.mp4', await fetchFile(videoUrl, progressCallbacks?.video));
ffmpeg.writeFile('input.mp3', await fetchFile(audioUrl, progressCallbacks?.audio));
await ffmpeg.exec(['-i', 'input.mp4', '-i', 'input.mp3', '-c:v', 'copy', '-c:a', 'aac', 'output.mp4']);
const data = await ffmpeg.readFile('output.mp4');
const mergedFile = new Blob([data], { type: 'video/mp4' });
const url = URL.createObjectURL(mergedFile);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}.mp4`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
}
@@ -58,9 +58,15 @@
displayContent = await getChannelContent(data.channel.authorId, { type: tab });
}
let channelPfp: string;
onMount(async () => {
displayContent = await getChannelContent(data.channel.authorId, { type: 'videos' });
const channelPfpResp = await fetch(
proxyGoogleImage(getBestThumbnail(data.channel.authorThumbnails))
);
channelPfp = URL.createObjectURL(await channelPfpResp.blob());
if (get(authStore)) {
isSubscribed = await amSubscribed(data.channel.authorId);
}
@@ -86,12 +92,16 @@
/>
{/if}
<div class="description">
<img
style="margin-right: 1em;"
class="circle extra m l"
src={proxyGoogleImage(getBestThumbnail(data.channel.authorThumbnails))}
alt="Channel profile"
/>
{#if channelPfp}
<img
style="margin-right: 1em;"
class="circle extra m l"
src={channelPfp}
alt="Channel profile"
/>
{:else}
<progress style="padding: 15px;" class="circle"></progress>
{/if}
<div>
<h2>{data.channel.author}</h2>
@@ -38,6 +38,7 @@
syncPartyConnectionsStore,
syncPartyPeerStore
} from '$lib/store';
import { mergeMediaFromDASH } from '$lib/videoDownload';
import type { DataConnection } from 'peerjs';
import { onDestroy, onMount } from 'svelte';
import { _ } from 'svelte-i18n';
@@ -391,13 +392,26 @@
theatreMode = !theatreMode;
}
function downloadFile(url: string, container: string | undefined) {
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = container || 'mp3';
anchor.target = '_blank';
anchor.click();
document.body.removeChild(anchor);
let downloadStage: string | undefined;
let downloadProgress: number = 0;
function onVideoDownloadProgress(progress: number) {
downloadStage = $_('player.downloadSteps.video');
downloadProgress = progress;
}
function onAudioDownloadProgress(progress: number) {
downloadStage = $_('player.downloadSteps.audio');
downloadProgress = progress;
}
function onMergingProgress(progress: number) {
downloadStage = $_('player.downloadSteps.merging');
downloadProgress = progress;
if (progress >= 100) {
downloadStage = undefined;
}
}
</script>
@@ -418,6 +432,14 @@
</div>
{/key}
</div>
{#if downloadStage}
<article>
<h6>{downloadStage} ({Math.round(downloadProgress)}%)</h6>
<progress class="max" value={downloadProgress} max="100"></progress>
</article>
{/if}
<h5>{letterCase(data.video.title)}</h5>
<div class="grid no-padding">
@@ -506,22 +528,28 @@
<ShareVideo video={data.video} />
</menu></button
>
{#if data.downloadOptions.length > 0}
<button class="border"
><i>download</i>
<div class="tooltip">{$_('player.download')}</div>
<menu class="no-wrap">
{#each data.downloadOptions as download}
<a
class="row"
href="#download"
on:click={() => downloadFile(download.url, download.container)}
>{download.title}</a
>
{/each}
</menu></button
>
{/if}
{#await data.streamed.downloadQualitiesDash then downloadQualitiesDash}
{#if downloadQualitiesDash}
<button class="border"
><i>download</i>
<div class="tooltip">{$_('player.download')}</div>
<menu class="no-wrap">
{#each downloadQualitiesDash as quality}
<a
class="row"
href="#download"
on:click={async () =>
await mergeMediaFromDASH(quality, data.video.title, {
video: onVideoDownloadProgress,
audio: onAudioDownloadProgress,
merging: onMergingProgress
})}>{quality.resolution}</a
>
{/each}
</menu></button
>
{/if}
{/await}
{#if personalPlaylists}
<button class="border">
<i>add</i>
@@ -9,10 +9,12 @@ import {
import { phaseDescription } from '$lib/misc';
import {
authStore,
playerDashStore,
playerProxyVideosStore,
returnYTDislikesInstanceStore,
returnYtDislikesStore
} from '$lib/store';
import { listCombinedQualities } from '$lib/videoDownload';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
@@ -24,26 +26,6 @@ export async function load({ params, url }) {
error(500, errorMessage);
}
let downloadOptions: { title: string; url: string; container: string | undefined; }[] = [];
if (!video.hlsUrl) {
video.formatStreams.forEach((format) => {
downloadOptions.push({
title: `${format.type.split(';')[0].trim()} - ${format.qualityLabel} (With audio)`,
url: format.url,
container: format.container
});
});
video.adaptiveFormats.forEach((format) => {
downloadOptions.push({
title: `${format.type.split(';')[0].trim()} - ${format.qualityLabel || format.bitrate + ' bitrate'}`,
url: format.url,
container: format.container
});
});
}
let personalPlaylists;
if (get(authStore)) {
postHistory(video.videoId);
@@ -61,7 +43,7 @@ export async function load({ params, url }) {
comments = null;
}
let returnYTDislikes = null;
let returnYTDislikes;
const returnYTDislikesInstance = get(returnYTDislikesInstanceStore);
if (returnYTDislikesInstance && returnYTDislikesInstance !== '') {
try {
@@ -69,16 +51,25 @@ export async function load({ params, url }) {
} catch { }
}
let downloadQualitiesDash;
if (
typeof import.meta.env.VITE_DEFAULT_DOWNLOAD_ENABLED !== 'undefined' &&
import.meta.env.VITE_DEFAULT_DOWNLOAD_ENABLED.toString() === 'true' &&
typeof video.hlsUrl === 'undefined' && get(playerDashStore)
) {
downloadQualitiesDash = listCombinedQualities(video.dashUrl);
}
return {
video: video,
content: phaseDescription(video.descriptionHtml),
playlistId: url.searchParams.get('playlist'),
downloadOptions: downloadOptions,
streamed: {
personalPlaylists: personalPlaylists,
returnYTDislikes: returnYTDislikes,
comments: comments,
subscribed: amSubscribed(video.authorId)
subscribed: amSubscribed(video.authorId),
downloadQualitiesDash: downloadQualitiesDash
}
};
}
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1 @@
"use strict";var Module={};var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string";if(ENVIRONMENT_IS_NODE){var nodeWorkerThreads=require("worker_threads");var parentPort=nodeWorkerThreads.parentPort;parentPort.on("message",data=>onmessage({data:data}));var fs=require("fs");Object.assign(global,{self:global,require:require,Module:Module,location:{href:__filename},Worker:nodeWorkerThreads.Worker,importScripts:function(f){(0,eval)(fs.readFileSync(f,"utf8")+"//# sourceURL="+f)},postMessage:function(msg){parentPort.postMessage(msg)},performance:global.performance||{now:function(){return Date.now()}}})}var initializedJS=false;function threadPrintErr(){var text=Array.prototype.slice.call(arguments).join(" ");if(ENVIRONMENT_IS_NODE){fs.writeSync(2,text+"\n");return}console.error(text)}function threadAlert(){var text=Array.prototype.slice.call(arguments).join(" ");postMessage({cmd:"alert",text:text,threadId:Module["_pthread_self"]()})}var err=threadPrintErr;self.alert=threadAlert;Module["instantiateWasm"]=(info,receiveInstance)=>{var module=Module["wasmModule"];Module["wasmModule"]=null;var instance=new WebAssembly.Instance(module,info);return receiveInstance(instance)};self.onunhandledrejection=e=>{throw e.reason??e};function handleMessage(e){try{if(e.data.cmd==="load"){let messageQueue=[];self.onmessage=e=>messageQueue.push(e);self.startWorker=instance=>{Module=instance;postMessage({"cmd":"loaded"});for(let msg of messageQueue){handleMessage(msg)}self.onmessage=handleMessage};Module["wasmModule"]=e.data.wasmModule;for(const handler of e.data.handlers){Module[handler]=function(){postMessage({cmd:"callHandler",handler:handler,args:[...arguments]})}}Module["wasmMemory"]=e.data.wasmMemory;Module["buffer"]=Module["wasmMemory"].buffer;Module["ENVIRONMENT_IS_PTHREAD"]=true;(e.data.urlOrBlob?import(e.data.urlOrBlob):import("./ffmpeg-core.js")).then(exports=>exports.default(Module))}else if(e.data.cmd==="run"){Module["__emscripten_thread_init"](e.data.pthread_ptr,0,0,1);Module["__emscripten_thread_mailbox_await"](e.data.pthread_ptr);Module["establishStackSpace"]();Module["PThread"].receiveObjectTransfer(e.data);Module["PThread"].threadInitTLS();if(!initializedJS){initializedJS=true}try{Module["invokeEntryPoint"](e.data.start_routine,e.data.arg)}catch(ex){if(ex!="unwind"){throw ex}}}else if(e.data.cmd==="cancel"){if(Module["_pthread_self"]()){Module["__emscripten_thread_exit"](-1)}}else if(e.data.target==="setimmediate"){}else if(e.data.cmd==="checkMailbox"){if(initializedJS){Module["checkMailbox"]()}}else if(e.data.cmd){err("worker.js received unknown command "+e.data.cmd);err(e.data)}}catch(ex){if(Module["__emscripten_thread_crashed"]){Module["__emscripten_thread_crashed"]()}throw ex}}self.onmessage=handleMessage;
+11 -1
View File
@@ -1,8 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { fileURLToPath, URL } from 'node:url';
import { vite as vidstack } from 'vidstack/plugins';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
SvelteKitPWA({
@@ -32,5 +34,13 @@ export default defineConfig({
}),
vidstack(),
sveltekit()
]
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
optimizeDeps: {
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
}
});
+1 -1
View File
@@ -5,7 +5,7 @@ import json
import os
import re
LATEST_VERSION = "1.2.3"
LATEST_VERSION = "1.3.0"
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")
ROOT_PACKAGE = os.path.join(WORKING_DIR, "package.json")