diff --git a/README.md b/README.md index 23a592de..67ed42fc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 1d19fa0f..c3924769 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -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 +``` + diff --git a/materialious/Dockerfile b/materialious/Dockerfile index 0a25e0d5..8d3eb0b0 100644 --- a/materialious/Dockerfile +++ b/materialious/Dockerfile @@ -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 diff --git a/materialious/android/app/build.gradle b/materialious/android/app/build.gradle index d2f11f3e..9f4336c8 100644 --- a/materialious/android/app/build.gradle +++ b/materialious/android/app/build.gradle @@ -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. diff --git a/materialious/electron/package.json b/materialious/electron/package.json index be7cee56..bc669ae8 100644 --- a/materialious/electron/package.json +++ b/materialious/electron/package.json @@ -1,6 +1,6 @@ { "name": "Materialious", - "version": "1.2.3", + "version": "1.3.0", "description": "Modern material design for Invidious.", "author": { "name": "Ward Pearce", diff --git a/materialious/package-lock.json b/materialious/package-lock.json index f8f12070..14c8dcd7 100644 --- a/materialious/package-lock.json +++ b/materialious/package-lock.json @@ -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", diff --git a/materialious/package.json b/materialious/package.json index 35770ba8..f3c4b846 100644 --- a/materialious/package.json +++ b/materialious/package.json @@ -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", diff --git a/materialious/replace_env_vars.sh b/materialious/replace_env_vars.sh index ba74d461..8462fb3c 100644 --- a/materialious/replace_env_vars.sh +++ b/materialious/replace_env_vars.sh @@ -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' {} + diff --git a/materialious/src/lib/ChannelThumbnail.svelte b/materialious/src/lib/ChannelThumbnail.svelte index 28b88f72..c91de580 100644 --- a/materialious/src/lib/ChannelThumbnail.svelte +++ b/materialious/src/lib/ChannelThumbnail.svelte @@ -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()); });
- {#if loading} + {#if !channelPfp} {:else} - {channel.author} + {channel.author} {/if}
{truncate(channel.author, 14)}
diff --git a/materialious/src/lib/Comment.svelte b/materialious/src/lib/Comment.svelte index 89c0bc82..271962f2 100644 --- a/materialious/src/lib/Comment.svelte +++ b/materialious/src/lib/Comment.svelte @@ -9,6 +9,11 @@ let replies: Comments | undefined = undefined; + async function loadPfp(url: string): Promise { + 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 @@
- comment profile + {#await loadPfp(proxyGoogleImage(getBestThumbnail(comment.authorThumbnails))) then pfp} + comment profile + {/await}
@@ -60,12 +63,9 @@

thumb_up {numberWithCommas(comment.likeCount)}

{#if comment.creatorHeart}
- Creator profile + {#await loadPfp(proxyGoogleImage(comment.creatorHeart.creatorThumbnail)) then pfp} + Creator profile + {/await} favorite diff --git a/materialious/src/lib/Thumbnail.svelte b/materialious/src/lib/Thumbnail.svelte index e51d3757..233e0ee8 100644 --- a/materialious/src/lib/Thumbnail.svelte +++ b/materialious/src/lib/Thumbnail.svelte @@ -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 () => { diff --git a/materialious/src/lib/i18n/locales/en.json b/materialious/src/lib/i18n/locales/en.json index 742df5a6..50dbee11 100644 --- a/materialious/src/lib/i18n/locales/en.json +++ b/materialious/src/lib/i18n/locales/en.json @@ -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" diff --git a/materialious/src/lib/videoDownload.ts b/materialious/src/lib/videoDownload.ts new file mode 100644 index 00000000..ac6049b9 --- /dev/null +++ b/materialious/src/lib/videoDownload.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/materialious/src/routes/(app)/channel/[slug]/+page.svelte b/materialious/src/routes/(app)/channel/[slug]/+page.svelte index 4afb507f..7744cef7 100644 --- a/materialious/src/routes/(app)/channel/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/channel/[slug]/+page.svelte @@ -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}
- Channel profile + {#if channelPfp} + Channel profile + {:else} + + {/if}

{data.channel.author}

diff --git a/materialious/src/routes/(app)/watch/[slug]/+page.svelte b/materialious/src/routes/(app)/watch/[slug]/+page.svelte index eff6e125..a3378f77 100644 --- a/materialious/src/routes/(app)/watch/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/watch/[slug]/+page.svelte @@ -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; + } } @@ -418,6 +432,14 @@
{/key}
+ + {#if downloadStage} +
+
{downloadStage} ({Math.round(downloadProgress)}%)
+ +
+ {/if} +
{letterCase(data.video.title)}
@@ -506,22 +528,28 @@ - {#if data.downloadOptions.length > 0} - - {/if} + {#await data.streamed.downloadQualitiesDash then downloadQualitiesDash} + {#if downloadQualitiesDash} + + {/if} + {/await} {#if personalPlaylists}