Merge remote-tracking branch 'origin/main' into support_nonroot_base

This commit is contained in:
Precific
2025-11-08 16:38:05 +01:00
48 changed files with 1015 additions and 496 deletions
-3
View File
@@ -20,15 +20,12 @@ jobs:
node-version: [latest]
runtime:
- linux-x64
- linux-arm64
- win-x64
- osx-x64
include:
- runtime: linux-x64
os: ubuntu-latest
- runtime: linux-arm64
os: ubuntu-latest
- runtime: osx-x64
os: macOS-latest
- runtime: win-x64
+32 -41
View File
@@ -1,59 +1,50 @@
name: Check desktop
on:
pull_request:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
workflow_dispatch:
permissions:
contents: write
contents: write
jobs:
desktop-check:
strategy:
matrix:
node-version: [latest]
runtime:
- linux-x64
- linux-arm64
- win-x64
- osx-x64
desktop-check:
strategy:
matrix:
node-version: [latest]
runtime:
- osx-x64
include:
- runtime: linux-x64
os: ubuntu-latest
- runtime: linux-arm64
os: ubuntu-latest
- runtime: osx-x64
os: macOS-latest
- runtime: win-x64
os: windows-latest
include:
- runtime: osx-x64
os: macOS-latest
runs-on: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install modules
run: cd ./materialious && npm install
- name: Install modules
run: cd ./materialious && npm install
- name: Build package
run: cd ./materialious && npm run build
- name: Build package
run: cd ./materialious && npm run build
- name: Sync electron package
run: cd ./materialious && npx cap sync @capacitor-community/electron
- name: Sync electron package
run: cd ./materialious && npx cap sync @capacitor-community/electron
- name: Patch capacitor plugin
run: cd ./materialious/electron && python patch_capacitor_plugin.py
- name: Patch capacitor plugin
run: cd ./materialious/electron && python patch_capacitor_plugin.py
- name: Install electron modules
run: cd ./materialious/electron && npm install
- name: Install electron modules
run: cd ./materialious/electron && npm install
- name: Build desktop packages
run: cd ./materialious/electron && npm run electron:make-no-publish
- name: Build desktop packages
run: cd ./materialious/electron && npm run electron:make-no-publish
+1
View File
@@ -35,6 +35,7 @@
- Dash support.
- Chapters.
- Audio only mode.
- Mini player (PIP.)
- Playlists.
- PWA support.
- YT path redirects (So your redirect plugins should still work!)
+9
View File
@@ -106,6 +106,15 @@ http:
### Invidious Companion support
The `VITE_DEFAULT_COMPANION_INSTANCE` environment variable allows you to specify a custom [Invidious Companion](https://github.com/iv-org/invidious-companion) instance.
`public_url` **MUST** be set in Invidious under **invidious_companion** for companion to work with Materialious.
e.g.
```yml
invidious_companion:
- private_url: "http://companion:8282/companion"
public_url: "http://companion.example.com/companion"
```
To use this with Materialious, your Invidious Companion instance must be accessible with proper CORS headers. Fortunately, you can reuse the same reverse proxy configuration (with CORS modifications) that you applied to your Invidious instance—just apply it to your companion domain as well.
For example, if you're using Caddy, you can configure your companion domain like this:
+1 -9
View File
@@ -1,10 +1,2 @@
# Public instances
- [app.materialio.us](https://app.materialio.us)
- **Location:** New Zealand
- **Threads:** 6
- **IPV6 rotation:** Yes
- **Man in the Middle:** No
- **Accounts:** Yes
- **RYD-Proxy:** Yes
- **API Extended integration:** Yes
- **Self-hosted PeerJS:** No
N/A
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 159
versionName "1.10.18"
versionCode 165
versionName "1.11.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -69,7 +69,31 @@
<releases>
<release version="1.10.18" date="2025-11-02">
<release version="1.11.5" date="2025-11-08">
<url>https://github.com/Materialious/Materialious/releases/tag/1.11.5</url>
</release>
<release version="1.11.4" date="2025-11-08">
<url>https://github.com/Materialious/Materialious/releases/tag/1.11.4</url>
</release>
<release version="1.11.3" date="2025-11-07">
<url>https://github.com/Materialious/Materialious/releases/tag/1.11.3</url>
</release>
<release version="1.11.2" date="2025-11-05">
<url>https://github.com/Materialious/Materialious/releases/tag/1.11.2</url>
</release>
<release version="1.11.1" date="2025-11-05">
<url>https://github.com/Materialious/Materialious/releases/tag/1.11.1</url>
</release>
<release version="1.11.0" date="2025-11-04">
<url>https://github.com/Materialious/Materialious/releases/tag/1.11.0</url>
</release>
<release version="1.10.18" date="2025-11-02">
<url>https://github.com/Materialious/Materialious/releases/tag/1.10.18</url>
</release>
<release version="1.10.17" date="2025-10-16">
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "Materialious",
"version": "1.10.18",
"version": "1.11.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Materialious",
"version": "1.10.18",
"version": "1.11.5",
"license": "MIT",
"dependencies": {
"@capacitor-community/electron": "^5.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.10.18",
"version": "1.11.5",
"description": "Modern material design for Invidious.",
"author": {
"name": "Ward Pearce",
@@ -44,4 +44,4 @@
"capacitor",
"electron"
]
}
}
+29
View File
@@ -5,6 +5,7 @@ import {
setupCapacitorElectronPlugins
} from '@capacitor-community/electron';
import { USER_AGENT } from 'bgutils-js';
import path from 'node:path';
import chokidar from 'chokidar';
import type { MenuItemConstructorOptions } from 'electron';
import {
@@ -219,6 +220,34 @@ export class ElectronCapacitorApp {
CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
}, 400);
});
// remove so we can register each time as we run the app.
app.removeAsDefaultProtocolClient('materialious');
// If we are running a non-packaged version of the app && on windows
if (process.env.NODE_ENV === 'development' && process.platform === 'win32') {
// Set the path of electron.exe and your app.
// These two additional parameters are only available on windows.
app.setAsDefaultProtocolClient('materialious', process.execPath, [
path.resolve(process.argv[1])
]);
} else {
app.setAsDefaultProtocolClient('materialious');
}
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
// Someone tried to run a second instance, we should focus our window.
if (this.MainWindow) {
if (this.MainWindow.isMinimized()) this.MainWindow.restore();
this.MainWindow.focus();
}
});
}
}
}
+38 -116
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.10.18",
"version": "1.11.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.10.18",
"version": "1.11.5",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
@@ -17,9 +17,10 @@
"@capacitor/clipboard": "^7.0.0",
"@capacitor/core": "^7.4.3",
"@capacitor/screen-orientation": "^7.0.0",
"beercss": "^3.12.11",
"beercss": "^3.12.13",
"bgutils-js": "^3.2.0",
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
"dayjs": "^1.11.19",
"fuse.js": "^7.0.0",
"googlevideo": "^4.0.4",
"he": "^1.2.0",
@@ -41,9 +42,9 @@
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^7.4.3",
"@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.46.5",
"@sveltejs/vite-plugin-svelte": "^6.1.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/eslint": "^8.56.7",
"@types/event-source-polyfill": "^1.0.5",
"@types/he": "^1.2.3",
@@ -55,13 +56,13 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.11.0",
"patch-package": "^8.0.0",
"patch-package": "^8.0.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.43.2",
"svelte-check": "^4.3.3",
"tslib": "^2.7.0",
"typescript": "^5.6.3",
"typescript": "^5.9.3",
"vite": "^7.1.12"
}
},
@@ -3248,9 +3249,9 @@
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz",
"integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==",
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -3297,9 +3298,9 @@
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.1.4.tgz",
"integrity": "sha512-4jfkfvsGI+U2OhHX8OPCKtMCf7g7ledXhs3E6UcA4EY0jQWsiVbe83pTAHp9XTifzYNOiD4AJieJUsI0qqxsbw==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz",
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4293,9 +4294,9 @@
}
},
"node_modules/beercss": {
"version": "3.12.11",
"resolved": "https://registry.npmjs.org/beercss/-/beercss-3.12.11.tgz",
"integrity": "sha512-9NQWL3kxSOb5RPWAV5BPVs64CS72F5Z4HgxusqD07Ho4asaTJB33/SkJBMclQeUQs7hFH2CJOgGgDnIsRk2RyQ==",
"version": "3.12.13",
"resolved": "https://registry.npmjs.org/beercss/-/beercss-3.12.13.tgz",
"integrity": "sha512-zsg15us8pSLTupIOzFFJqpJ7eJDLV6whsxT6GcRYGnYaF0ka45+HQ/u3+ldSUkCeyA5J24g6csEuMW8tqrI/0w==",
"license": "MIT",
"dependencies": {
"material-dynamic-colors": "^1.1.2"
@@ -5235,6 +5236,12 @@
"node": "*"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -9152,16 +9159,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -9272,9 +9269,9 @@
}
},
"node_modules/patch-package": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz",
"integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9283,15 +9280,14 @@
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^9.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"rimraf": "^2.6.3",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.0.33",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
@@ -9302,66 +9298,19 @@
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/patch-package/node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
"integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"at-least-node": "^1.0.0",
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/patch-package/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/patch-package/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
"node": ">=12"
}
},
"node_modules/patch-package/node_modules/open": {
@@ -9381,20 +9330,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
@@ -9405,19 +9340,6 @@
"node": ">=6"
}
},
"node_modules/patch-package/node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"os-tmpdir": "~1.0.2"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -12018,9 +11940,9 @@
}
},
"node_modules/tmp": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",
"integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==",
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12267,9 +12189,9 @@
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.10.18",
"version": "1.11.5",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -17,9 +17,9 @@
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^7.4.3",
"@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.46.5",
"@sveltejs/vite-plugin-svelte": "^6.1.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/eslint": "^8.56.7",
"@types/event-source-polyfill": "^1.0.5",
"@types/he": "^1.2.3",
@@ -31,13 +31,13 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.11.0",
"patch-package": "^8.0.0",
"patch-package": "^8.0.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.43.2",
"svelte-check": "^4.3.3",
"tslib": "^2.7.0",
"typescript": "^5.6.3",
"typescript": "^5.9.3",
"vite": "^7.1.12"
},
"type": "module",
@@ -50,9 +50,10 @@
"@capacitor/clipboard": "^7.0.0",
"@capacitor/core": "^7.4.3",
"@capacitor/screen-orientation": "^7.0.0",
"beercss": "^3.12.11",
"beercss": "^3.12.13",
"bgutils-js": "^3.2.0",
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
"dayjs": "^1.11.19",
"fuse.js": "^7.0.0",
"googlevideo": "^4.0.4",
"he": "^1.2.0",
@@ -70,4 +71,4 @@
"svelte-persisted-store": "^0.12.0",
"youtubei.js": "^16.0.1"
}
}
}
+29 -7
View File
@@ -145,11 +145,15 @@ export async function getChannel(
return await resp.json();
}
export type channelSortBy = 'oldest' | 'newest' | 'popular';
export type channelContentTypes = 'videos' | 'playlists' | 'streams' | 'shorts';
export async function getChannelContent(
channelId: string,
parameters: {
type?: 'videos' | 'playlists' | 'streams' | 'shorts';
type?: channelContentTypes;
continuation?: string;
sortBy?: channelSortBy;
},
fetchOptions?: RequestInit
): Promise<ChannelContentVideos | ChannelContentPlaylists> {
@@ -160,10 +164,23 @@ export async function getChannelContent(
if (typeof parameters.continuation !== 'undefined')
url.searchParams.set('continuation', parameters.continuation);
if (typeof parameters.sortBy !== 'undefined') url.searchParams.set('sort_by', parameters.sortBy);
const resp = await fetchErrorHandle(await fetch(url.toString(), fetchOptions));
return await resp.json();
}
export async function searchChannelContent(
channelId: string,
search: string,
fetchOptions?: RequestInit
) {
const path = buildPath(`channel/${channelId}/search`);
path.search = new URLSearchParams({ q: search }).toString();
const resp = await fetchErrorHandle(await fetch(path, fetchOptions));
return await resp.json();
}
export async function getSearchSuggestions(
search: string,
fetchOptions?: RequestInit
@@ -179,13 +196,18 @@ export async function getHashtag(tag: string, page: number = 0): Promise<{ resul
return await resp.json();
}
export interface SearchOptions {
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
type?: 'video' | 'playlist' | 'channel' | 'all';
duration?: 'short' | 'medium' | 'long';
date?: 'hour' | 'today' | 'week' | 'month' | 'year';
features?: string;
page?: string;
}
export async function getSearch(
search: string,
options: {
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
type?: 'video' | 'playlist' | 'channel' | 'all';
page?: string;
},
options: SearchOptions,
fetchOptions?: RequestInit
): Promise<(Channel | Video | Playlist | HashTag)[]> {
if (typeof options.sort_by === 'undefined') {
@@ -193,7 +215,7 @@ export async function getSearch(
}
if (typeof options.type === 'undefined') {
options.type = 'video';
options.type = 'all';
}
if (typeof options.page === 'undefined') {
+1 -1
View File
@@ -38,7 +38,7 @@ export interface Video extends VideoBase {
descriptionHtml: string;
published: number;
publishedText: string;
premiereTimestamp: number;
premiereTimestamp?: number;
liveNow: boolean;
premium: boolean;
isUpcoming: boolean;
@@ -162,14 +162,6 @@
}
onMount(async () => {
if ($feedLastItemId && !$isAndroidTvStore) {
document
.getElementById($feedLastItemId)
?.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' });
feedLastItemId.set(undefined);
}
if ($isAndroidTvStore) {
await tick();
// Setup Android TV navigation
@@ -200,6 +192,16 @@
// Watch for focus changes to detect when we're active
document.addEventListener('focusin', checkComponentFocus);
document.addEventListener('focusout', checkComponentFocus);
} else if ($feedLastItemId) {
const element = document.getElementById($feedLastItemId);
if (element) {
element.scrollIntoView({
behavior: 'instant',
block: 'start',
inline: 'nearest'
});
}
}
});
@@ -221,6 +223,18 @@
</script>
<div class={classes} class:android-container={$isAndroidTvStore}>
{#if items.length === 0}
<div class="space"></div>
<div class="center-align">
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
><path
style="fill: var(--secondary);"
d="M11 0h2v4h-2zM5.001.586L3.587 2L6.5 4.914L7.915 3.5zM1 13.734L5.42 6h13.16L23 13.734V22H1zM6.58 8l-2.857 5h5.178l.193.75a3.002 3.002 0 0 0 5.812 0l.193-.75h5.178L17.42 8zM18.999.586L16.085 3.5l1.414 1.414L20.413 2z"
/></svg
>
<h1>{$_('noResult')}</h1>
</div>
{/if}
<div class="grid" bind:this={gridElement}>
{#each items as item, index}
<ContentColumn>
@@ -251,7 +265,9 @@
}}
>
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream'}
<Thumbnail video={item} {playlistId} />
{#key item.videoId}
<Thumbnail video={item} {playlistId} />
{/key}
{#if $authStore && decodeURIComponent($authStore.username) === playlistAuthor && 'indexId' in item}
<div class="right-align" style="margin: 1em .5em;">
<button
+55 -87
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { getBestThumbnail } from '$lib/images';
import { padTime, videoLength } from '$lib/numbers';
@@ -19,14 +18,13 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { deleteVideoProgress, getVideoProgress, saveVideoProgress } from '../api';
import type { PlaylistPageVideo, VideoPlay } from '../api/model';
import type { VideoPlay } from '../api/model';
import {
authStore,
darkModeStore,
instanceStore,
isAndroidTvStore,
playerAndroidLockOrientation,
playerAutoplayNextByDefaultStore,
playerAutoPlayStore,
playerCCByDefault,
playerDefaultLanguage,
@@ -34,26 +32,21 @@
playerDefaultQualityStore,
playerProxyVideosStore,
playerSavePlaybackPositionStore,
playerState,
playerStatisticsByDefault,
playerYouTubeJsFallback,
playlistSettingsStore,
sponsorBlockCategoriesStore,
sponsorBlockDisplayToastStore,
sponsorBlockStore,
sponsorBlockUrlStore,
synciousInstanceStore,
synciousStore,
syncPartyConnectionsStore,
themeColorStore
} from '../store';
import { getDynamicTheme, setStatusBarColor } from '../theme';
import { patchYoutubeJs } from '$lib/patches/youtubejs';
import { playbackRates } from '$lib/player';
import { goToNextVideo, goToPreviousVideo, playbackRates } from '$lib/player';
import { EndTimeElement } from '$lib/shaka-elements/endTime';
import { loadEntirePlaylist } from '$lib/playlist';
import { goto } from '$app/navigation';
import { unsafeRandomItem } from '$lib/misc';
import type { PlayerEvents } from '$lib/player';
import { dashManifestDomainInclusion } from '$lib/android/youtube/dash';
import { injectSabr } from '$lib/sabr';
import type { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
@@ -62,16 +55,12 @@
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
isSyncing?: boolean;
isEmbed?: boolean;
segments?: Segment[];
playerElement?: HTMLMediaElement | undefined;
}
let {
data,
isEmbed = false,
segments = $bindable([]),
playerElement = $bindable(undefined)
}: Props = $props();
let { data, isEmbed = false, playerElement = $bindable(undefined) }: Props = $props();
let segments: Segment[] = [];
let snackBarAlert = $state('');
let originalOrigination: ScreenOrientationResult | undefined;
@@ -328,12 +317,11 @@
// Due to CORs issues with redirects, hosted instances of Materialious
// dirctly provide the companion instance
// while clients can just use the reirect provided by Invidious' API
if (import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE && Capacitor.getPlatform() === 'web') {
if (import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE) {
dashUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}/api/manifest/dash/id/${data.video.videoId}`;
} else {
if (!data.video.dashUrl) {
error(500, 'No dash manifest found');
return;
}
dashUrl = data.video.dashUrl;
}
@@ -355,8 +343,17 @@
if (data.video.captions) {
for (const caption of data.video.captions) {
let captionUrl: string;
if (!import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE) {
captionUrl = caption.url.startsWith('http')
? caption.url
: `${new URL(get(instanceStore)).origin}${caption.url}`;
} else {
captionUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}${caption.url}`;
}
await player.addTextTrackAsync(
caption.url.startsWith('http') ? caption.url : `${new URL(get(instanceStore)).origin}${caption.url}`,
captionUrl,
caption.language_code,
'captions',
undefined,
@@ -462,6 +459,24 @@
});
playerElement = document.getElementById('player') as HTMLMediaElement;
if ($playerState) {
playerState.set({ ...$playerState, playerElement: playerElement });
}
// Due to how our player is rendered in layout for stateful pip
// we calaculate player height to then allow children pages
// to wrap around it.
function updateVideoPlayerHeight() {
const container = document.getElementById('shaka-container') as HTMLElement;
if (container) {
const height = container.getBoundingClientRect().height;
document.documentElement.style.setProperty('--video-player-height', `${height + 10}px`);
}
}
window.addEventListener('resize', updateVideoPlayerHeight);
updateVideoPlayerHeight();
// Change instantly to stop video from being loud for a second
restoreVolumePreference();
@@ -531,7 +546,6 @@
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-expect-error Type is acceptable, is shaka player
response.data = new TextEncoder().encode(cleaned).buffer;
}
});
@@ -603,6 +617,16 @@
playerElement.pause();
playerElement.currentTime = 0;
});
if (data.playlistId) {
navigator.mediaSession.setActionHandler('previoustrack', () => {
goToPreviousVideo(data.playlistId);
});
navigator.mediaSession.setActionHandler('nexttrack', async () => {
await goToNextVideo(data.video, data.playlistId);
});
}
}
Mousetrap.bind('space', () => {
@@ -691,68 +715,21 @@
});
playerElement?.addEventListener('ended', async () => {
if (!data.playlistId) {
if ($playerAutoplayNextByDefaultStore) {
goto(
resolve(`/${$isAndroidTvStore ? 'tv' : 'watch'}/${data.video.recommendedVideos[0].videoId}`),
{ replaceState: $isAndroidTvStore }
);
}
return;
}
const playlist = await loadEntirePlaylist(data.playlistId);
const playlistVideoIds = playlist.videos.map((value) => {
return value.videoId;
});
let goToVideo: PlaylistPageVideo | undefined;
const shufflePlaylist = $playlistSettingsStore[data.playlistId]?.shuffle ?? false;
const loopPlaylist = $playlistSettingsStore[data.playlistId]?.loop ?? false;
if (shufflePlaylist) {
goToVideo = unsafeRandomItem(playlist.videos);
} else {
const currentVideoIndex = playlistVideoIds.indexOf(data.video.videoId);
const newIndex = currentVideoIndex + 1;
if (currentVideoIndex !== -1 && newIndex < playlistVideoIds.length) {
goToVideo = playlist.videos[newIndex];
} else if (loopPlaylist) {
// Loop playlist on end
goToVideo = playlist.videos[0];
}
}
if (typeof goToVideo !== 'undefined') {
if ($syncPartyConnectionsStore) {
$syncPartyConnectionsStore.forEach((conn) => {
if (typeof goToVideo === 'undefined') return;
conn.send({
events: [
{ type: 'change-video', videoId: goToVideo.videoId },
{ type: 'playlist', playlistId: data.playlistId }
]
} as PlayerEvents);
});
}
goto(
resolve(`/${$isAndroidTvStore ? 'tv' : 'watch'}/${goToVideo.videoId}?playlist=${data.playlistId}`),
{ replaceState: $isAndroidTvStore }
);
}
await goToNextVideo(data.video, data.playlistId);
});
try {
await loadVideo();
} catch (error: unknown) {
if (!Capacitor.isNativePlatform() || data.video.fallbackPatch === 'youtubejs') return;
showVideoRetry = true;
if (
!Capacitor.isNativePlatform() ||
data.video.fallbackPatch === 'youtubejs' ||
(error as shaka.extern.Error).code !== 1001
)
return;
if ((error as shaka.extern.Error).code === 1001 && $playerYouTubeJsFallback) {
showVideoRetry = true;
if ($playerYouTubeJsFallback) {
await reloadVideo();
}
}
@@ -864,7 +841,7 @@
</div>
{#if showVideoRetry}
<article class="fallback">
<article class="video-placeholder">
{#if $playerYouTubeJsFallback}
<p>{$_('player.youtubeJsLoading')}</p>
<progress class="circle large"></progress>
@@ -931,13 +908,4 @@
.hide {
display: none;
}
.fallback {
height: 30vh;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>
@@ -26,10 +26,9 @@
if (get(interfaceLowBandwidthMode)) return;
let imgSrc = '';
if (playlist.videos.length > 0) {
imgSrc = getBestThumbnail(playlist.videos[0].videoThumbnails) || '';
} else if (playlist.playlistThumbnail) {
} else if (playlist.playlistThumbnail.length > 0) {
imgSrc = playlist.playlistThumbnail;
} else {
imgSrc = '';
+15 -5
View File
@@ -11,6 +11,8 @@
isAndroidTvStore,
searchHistoryStore
} from '../store';
import { isVideoID } from '$lib/misc';
import { resolve } from '$app/paths';
const dispatch = createEventDispatcher();
@@ -38,22 +40,30 @@
}
function handleSubmit(event: Event | undefined = undefined) {
if (event) event.preventDefault();
event?.preventDefault();
if (search.trim() === '') return;
const searchTrimed = search.trim();
if (!searchTrimed) return;
if (isVideoID(searchTrimed)) {
// Go directly to video if Video ID provided
goto(resolve('/watch/[videoId]', { videoId: searchTrimed }));
return;
}
selectedSuggestionIndex = -1;
goto(resolve(`/search/${encodeURIComponent(search)}`));
goto(resolve(`/search/${encodeURIComponent(searchTrimed)}`));
suggestionsForSearch = [];
showSearchBox = false;
if ($interfaceSearchHistoryEnabled && !$searchHistoryStore.includes(search)) {
if ($interfaceSearchHistoryEnabled && !$searchHistoryStore.includes(searchTrimed)) {
let pastHistory = $searchHistoryStore;
if (pastHistory.length > 15) {
pastHistory.pop();
}
searchHistoryStore.set([search, ...pastHistory]);
searchHistoryStore.set([searchTrimed, ...pastHistory]);
}
}
@@ -21,9 +21,7 @@
synciousInstanceStore,
synciousStore
} from '../store';
import { goto } from '$app/navigation';
import { queueSyncious } from '$lib/api/apiExtended';
import { page } from '$app/state';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo;
@@ -13,6 +13,7 @@
playerDefaultLanguage,
playerDefaultPlaybackSpeed,
playerDefaultQualityStore,
playerMiniplayerEnabled,
playerProxyVideosStore,
playerSavePlaybackPositionStore,
playerStatisticsByDefault,
@@ -71,7 +72,7 @@
bind:value={defaultLanguage}
onchange={() => playerDefaultLanguage.set(defaultLanguage)}
>
<option selected={$playerDefaultLanguage === "original"} value="original">Original</option>
<option selected={$playerDefaultLanguage === 'original'} value="original">Original</option>
{#each languageNames as language}
<option selected={$playerDefaultLanguage === language} value={language}
>{titleCase(language)}</option
@@ -154,6 +155,23 @@
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.miniPlayer')}</div>
</div>
<label class="switch" tabindex="0">
<input
type="checkbox"
bind:checked={$playerMiniplayerEnabled}
onclick={() => playerMiniplayerEnabled.set(!$playerMiniplayerEnabled)}
role="switch"
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
@@ -7,8 +7,11 @@
import { authStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
import { _ } from '$lib/i18n';
let { video, subscribed = $bindable(false) }: { video: VideoPlay; subscribed?: boolean } =
$props();
let {
video,
subscribed = $bindable(false),
hideSubscribe = false
}: { video: VideoPlay; subscribed?: boolean; hideSubscribe?: boolean } = $props();
async function toggleSubscribed() {
if (subscribed) {
@@ -40,24 +43,26 @@
</div>
</nav>
</a>
{#if $authStore}
<button
onclick={toggleSubscribed}
class:inverse-surface={!subscribed}
class:border={subscribed}
>
{#if !subscribed}
{#if !hideSubscribe}
{#if $authStore}
<button
onclick={toggleSubscribed}
class:inverse-surface={!subscribed}
class:border={subscribed}
>
{#if !subscribed}
{$_('subscribe')}
{:else}
{$_('unsubscribe')}
{/if}
</button>
{:else}
<button class="inverse-surface" disabled>
{$_('subscribe')}
{:else}
{$_('unsubscribe')}
{/if}
</button>
{:else}
<button class="inverse-surface" disabled>
{$_('subscribe')}
<div class="tooltip">
{$_('loginRequired')}
</div>
</button>
<div class="tooltip">
{$_('loginRequired')}
</div>
</button>
{/if}
{/if}
</nav>
@@ -31,7 +31,7 @@
{@html description}
</div>
{#if video.keywords}
{#if video.keywords && video.keywords.length > 0}
<article class="border">
<nav class="scroll">
{#each video.keywords as keyword}
@@ -9,19 +9,17 @@
</script>
{#await returnYTDislikes then returnYTDislikes}
<nav class="no-space">
{#if returnYTDislikes}
<button style="cursor: default;margin-right: 0.5em;" class="border">
<i class="small">thumb_up</i>
<span>{cleanNumber(returnYTDislikes.likes)}</span>
<i class="small" style="margin-left: 0.5em;">thumb_down_alt</i>
<span>{cleanNumber(returnYTDislikes.dislikes)}</span>
</button>
{:else}
<button style="cursor: default;margin-right: 0.5em;" class="border">
<i class="small">thumb_up</i>
<span>{cleanNumber(video.likeCount)}</span>
</button>
{/if}
</nav>
{#if returnYTDislikes}
<button style="cursor: default;margin-left: 0;" class="border">
<i class="small">thumb_up</i>
<span>{cleanNumber(returnYTDislikes.likes)}</span>
<i class="small" style="margin-left: 0.5em;">thumb_down_alt</i>
<span>{cleanNumber(returnYTDislikes.dislikes)}</span>
</button>
{:else}
<button style="cursor: default;margin-left: 0;" class="border">
<i class="small">thumb_up</i>
<span>{cleanNumber(video.likeCount)}</span>
</button>
{/if}
{/await}
+17
View File
@@ -2,6 +2,10 @@
display: none;
}
:root {
--video-player-height: 0px;
}
@font-face {
font-family: Inter;
src: url('/static/inter.ttf');
@@ -29,6 +33,10 @@ button,
margin: 1em 0;
}
.video-actions button {
margin: 0.3em;
}
nav.top {
padding-top: calc(var(--safe-area-inset-top) + 1rem) !important;
padding-bottom: 1em;
@@ -51,6 +59,15 @@ dialog {
padding: 1em !important;
}
.video-placeholder {
height: 30vh;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
@media screen and (max-width: 1000px) {
menu.mobile {
position: fixed !important;
@@ -7,6 +7,11 @@
color: var(--on-surface) !important;
}
.shaka-current-time {
color: #fff !important;
opacity: 1 !important;
}
.player-theme .shaka-video-container * {
font-family: var(--font) !important;
color: var(--on-surface) !important;
+7 -1
View File
@@ -35,7 +35,8 @@ import {
interfaceAutoExpandChapters,
playerDefaultPlaybackSpeed,
playerStatisticsByDefault,
playerCCByDefault
playerCCByDefault,
playerMiniplayerEnabled
} from '$lib/store';
import { get, type Writable } from 'svelte/store';
@@ -223,6 +224,11 @@ const persistedStores: {
name: 'CCByDefault',
store: playerCCByDefault,
type: 'boolean'
},
{
name: 'miniplayerEnabled',
store: playerMiniplayerEnabled,
type: 'boolean'
}
];
+13 -1
View File
@@ -1,6 +1,8 @@
import i18next, { type InitOptions } from 'i18next';
import { writable, type Writable } from 'svelte/store';
import dayjs from 'dayjs';
const defaultLocale = 'en';
export const locale: Writable<string> = writable(defaultLocale);
@@ -40,6 +42,13 @@ function getUserLocale(): string {
return defaultLocale;
}
async function loadDayjsLocale(localeName: string) {
try {
const { default: locale } = await import(`dayjs/locale/${localeName}`);
dayjs.locale(locale);
} catch {}
}
export async function initI18n(selectedLocale: string = getUserLocale()): Promise<void> {
const langToLoad = resources[selectedLocale] ? selectedLocale : defaultLocale;
const translations = await resources[langToLoad]();
@@ -63,8 +72,11 @@ export async function initI18n(selectedLocale: string = getUserLocale()): Promis
locale.set(langToLoad);
_.set(i18next.t.bind(i18next));
window.addEventListener('languagechange', () => {
await loadDayjsLocale(selectedLocale);
window.addEventListener('languagechange', async () => {
const newLang = getUserLocale();
initI18n(newLang);
await loadDayjsLocale(newLang);
});
}
+15 -3
View File
@@ -49,7 +49,9 @@
"private": "Privat",
"playVideos": "Videos abspielen",
"shuffleVideos": "Videos in zufälliger Reihenfolge abspielen",
"loopPlaylist": "Wiedergabeliste in Schleife abspielen"
"loopPlaylist": "Wiedergabeliste in Schleife abspielen",
"next": "Weiter",
"previous": "Vorheriges"
},
"thumbnail": {
"live": "LIVE",
@@ -98,7 +100,8 @@
"enableYoutubejsTemp": "Nutze einmalig den lokalen Video-Fallback",
"chapters": "Kapitel",
"retryText": "Das Video konnte nicht geladen werden, wie möchtest du weiterfahren?",
"youtubeJsLoading": "Das Video konnte nicht geladen werden, es wird mit lokalem Video-Fallback erneut versucht."
"youtubeJsLoading": "Das Video konnte nicht geladen werden, es wird mit lokalem Video-Fallback erneut versucht.",
"premiere": "Video wird verfügbar sein"
},
"layout": {
"interface": "Benutzeroberfläche",
@@ -188,5 +191,14 @@
"premium": "YouTube-Premium-Inhalte können auf Materialious nicht angeschaut werden.",
"isUpcoming": "Video ist noch nicht in einem anschaubaren Zustand.",
"version": "Version",
"contributors": "Mitwirkende"
"contributors": "Mitwirkende",
"popularPageDisabled": "Die Seite „Populär“ wurde von den Administratoren deaktiviert",
"filters": {
"filters": "Filter",
"type": "Typ",
"uploadDate": "Hochladedatum",
"duration": "Dauer",
"sortBy": "Sortieren nach",
"features": "Eigenschaften"
}
}
+16 -2
View File
@@ -1,8 +1,8 @@
{
"enabled": "Enabled",
"disabled": "Disabled",
"popularPageDisabled": "Popular page has been disabled by Admins",
"premium": "Premium YouTube content can't be watched on Materialious.",
"isUpcoming": "Video is not in a watchable state yet.",
"copyUrl": "Copy URL",
"loadMore": "Load more",
"views": "views",
@@ -15,6 +15,14 @@
"videos": "videos",
"cancel": "Cancel",
"create": "Create",
"filters": {
"filters": "Filters",
"type": "Type",
"uploadDate": "Upload date",
"duration": "Duration",
"sortBy": "Sort By",
"features": "Features"
},
"title": "Title",
"searchPlaceholder": "Search",
"delete": "Delete",
@@ -37,6 +45,9 @@
"hideReplies": "Hide replies",
"hideVideo": "Hide video",
"defaultPage": "Default page",
"newest": "Newest",
"oldest": "Oldest",
"popular": "Popular",
"syncParty": {
"userJoined": "User joined your watch party.",
"userLeft": "User left your watch party.",
@@ -60,7 +71,9 @@
"private": "Private",
"playVideos": "Play videos",
"shuffleVideos": "Play videos shuffled",
"loopPlaylist": "Loop playlist"
"loopPlaylist": "Loop playlist",
"next": "Next",
"previous": "Previous"
},
"thumbnail": {
"live": "LIVE",
@@ -82,6 +95,7 @@
"pauseTimer": "Pause timer",
"pauseVideoIn": "Pause video in ",
"preferredQuality": "Preferred quality",
"premiere": "Video will be available",
"chapters": "Chapters",
"share": {
"title": "Share",
+15 -3
View File
@@ -135,7 +135,9 @@
"private": "Privat",
"playVideos": "Videos abspiele",
"loopPlaylist": "Widergabelischte inere Schleife abspiele",
"shuffleVideos": "Videos in zuefälliger Reihefolg abspiele"
"shuffleVideos": "Videos in zuefälliger Reihefolg abspiele",
"next": "Wiiter",
"previous": "Vorherigs"
},
"pages": {
"home": "Startsiite",
@@ -179,7 +181,8 @@
"retryText": "S Video het nöd chönne glade werde, wie willsch wiitermache?",
"enableYoutubejsPerm": "Lokale Video-Fallback immer erlaube",
"enableYoutubejsTemp": "Nutz eimalig de lokali Video-Fallback",
"youtubeJsFallBack": "Lokale Video-Fallback wird gnutzt"
"youtubeJsFallBack": "Lokale Video-Fallback wird gnutzt",
"premiere": "Video wird verfüegbar sii"
},
"subscribe": "Abonniere",
"invidiousLogin": "Bitte meld dich mit dim Invidious-Konto aa",
@@ -188,5 +191,14 @@
"isUpcoming": "Video isch nonig imene aaluegbare Zuestand.",
"premium": "YouTube-Premium-Inhält chönd uf Materialious nonig aagluegt werde.",
"version": "Version",
"contributors": "Mitwirkendi"
"contributors": "Mitwirkendi",
"popularPageDisabled": "D Siite «Populär» isch vo de Administratore deaktiviert worde",
"filters": {
"filters": "Filter",
"type": "Typ",
"uploadDate": "Hochladedatum",
"duration": "Duur",
"sortBy": "Sortiere nach",
"features": "Eigeschafte"
}
}
+28 -6
View File
@@ -43,7 +43,10 @@
"searchHistory": "Meklēšanas vēsture",
"theme": {
"theme": "Motīvs",
"color": "Krāsa"
"color": "Krāsa",
"darkMode": "Tumšais režīms",
"lightMode": "Gaišais režīms",
"AmoledTheme": "AMOLED motīvs"
},
"player": {
"savePlaybackPosition": "Saglabāt atskaņošanas pozīciju",
@@ -51,7 +54,8 @@
"autoPlay": "Automātiski atskaņot video",
"youtubeJsAlways": "Vienmēr izmantot",
"localVideoFallback": "Vietējais video ieguves rezerves variants",
"youtubeJsFallback": "Vietējais video apstrādes rezerves variants"
"youtubeJsFallback": "Vietējais video apstrādes rezerves variants",
"alwaysLoopVideo": "Vienmēr atskaņot (malt) uz riņķi video"
},
"deArrow": {
"titleOnly": "Tikai virsrakstu",
@@ -76,7 +80,12 @@
"about": "Par lietotni",
"expandDescription": "Izvērst aprakstu",
"expandChapters": "Izvērst nodaļas",
"expandComments": "Izvērst komentārus"
"expandComments": "Izvērst komentārus",
"logout": "Iziet no konta",
"noNewNotifications": "Nav jaunu paziņojumu",
"previewVideoOnHover": "Priekšskatīt video, kad kursors ir virs tā",
"allowInsecureRequests": "Atļaut nedrošus pieprasījumus",
"disableAutoUpdate": "Atspējot automātiskos atjauninājumus"
},
"channels": "kanāli",
"copyUrl": "Kopēt URL saiti",
@@ -97,7 +106,7 @@
"channels": "Kanāli",
"shorts": "Īsie video",
"all": "Viss",
"streams": "Plūsmas (Pārraides)"
"streams": "Tiešraides"
},
"playlist": {
"createPlaylist": "Izveidot atskaņošanas sarakstu",
@@ -105,7 +114,9 @@
"private": "Privāts",
"playVideos": "Atskaņot video",
"shuffleVideos": "Atskaņot video jauktā secībā",
"loopPlaylist": "Atskaņot (malt) uz riņķi atskaņošanas sarakstu"
"loopPlaylist": "Atskaņot (malt) uz riņķi atskaņošanas sarakstu",
"next": "Nākamais",
"previous": "Iepriekšējais"
},
"pages": {
"subscriptions": "Abonementi",
@@ -140,5 +151,16 @@
"invidiousLogin": "Lūdzu, autorizējieties ar savu Invidious kontu",
"noResult": "Bez rezultātiem",
"letterCase": "Virsrakstu burtu reģistrs",
"version": "Versija"
"version": "Versija",
"premium": "YouTube Premium saturu nevar skatīties Materialious.",
"recommendedVideos": "Ieteiktie video",
"filters": {
"filters": "Filtrēt pēc",
"type": "Tips",
"uploadDate": "Augšupielādes datums",
"duration": "Ilgums"
},
"comments": "komentāri",
"login": "Autorizēties",
"transcript": "Transkripcija"
}
+5 -14
View File
@@ -15,6 +15,11 @@ import type {
VideoBase
} from './api/model';
export function isVideoID(videoId: string): boolean {
var regExp = /^[a-zA-Z0-9_-]{11}$/;
return regExp.test(videoId);
}
export function truncate(value: string, maxLength: number = 50): string {
return value.length > maxLength ? `${value.substring(0, maxLength)}...` : value;
}
@@ -24,20 +29,6 @@ export function decodeHtmlCharCodes(str: string): string {
return decode(str);
}
export function proxyVideoUrl(source: string): string {
let videoUrl = new URL(source);
const instUrl = new URL(get(instanceStore));
videoUrl.host = instUrl.host;
videoUrl.protocol = instUrl.protocol;
let pathPrefix = instUrl.pathname;
if (pathPrefix.endsWith("/")) {
pathPrefix = pathPrefix.substring(0, pathPrefix.length - 1);
}
videoUrl.pathname = pathPrefix + videoUrl.pathname;
return videoUrl.toString();
}
export function unsafeRandomItem(array: any[]): any {
return array[Math.floor(Math.random() * array.length)];
}
+3 -3
View File
@@ -153,8 +153,6 @@ export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
return;
}
console.log(recommended);
recommendedVideos.push({
videoThumbnails: (recommended?.content_image?.image as Thumbnail[]) || [],
videoId: recommended?.content_id || '',
@@ -233,7 +231,9 @@ export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
descriptionHtml: video.secondary_info.description?.toHTML() || descString,
published: 0,
publishedText: video.primary_info.published?.toString() || '',
premiereTimestamp: 0,
premiereTimestamp: video.basic_info.is_upcoming
? Math.floor(video.basic_info.start_timestamp?.getTime() || 0 / 1000)
: undefined,
hlsUrl: video.streaming_data?.hls_manifest_url || undefined,
liveNow: video.basic_info.is_live || false,
// @ts-expect-error Type does have offer_id
+80
View File
@@ -1,3 +1,17 @@
import { get } from 'svelte/store';
import type { PlaylistPageVideo, VideoPlay } from './api/model';
import {
isAndroidTvStore,
playerAutoplayNextByDefaultStore,
playerPlaylistHistory,
playlistSettingsStore,
syncPartyConnectionsStore
} from './store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { loadEntirePlaylist } from './playlist';
import { unsafeRandomItem } from './misc';
export interface PlayerEvent {
type: 'pause' | 'seek' | 'change-video' | 'play' | 'playlist' | 'goto';
path?: string;
@@ -11,3 +25,69 @@ export interface PlayerEvents {
}
export const playbackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3];
export function goToPreviousVideo(playlistId: string | null) {
const previousVideos = get(playerPlaylistHistory);
if (previousVideos.length > 1) {
goto(
resolve('/watch/[videoId]?playlist=[playlistId]', {
videoId: previousVideos[1],
playlistId: playlistId ?? ''
})
);
delete previousVideos[1];
}
}
export async function goToNextVideo(video: VideoPlay, playlistId: string | null) {
const isAndroidTv = get(isAndroidTvStore);
if (!playlistId) {
if (get(playerAutoplayNextByDefaultStore)) {
goto(resolve(`/${isAndroidTv ? 'tv' : 'watch'}/${video.recommendedVideos[0].videoId}`), {
replaceState: isAndroidTv
});
}
return;
}
const playlist = await loadEntirePlaylist(playlistId);
const playlistVideoIds = playlist.videos.map((value) => {
return value.videoId;
});
let goToVideo: PlaylistPageVideo | undefined;
const shufflePlaylist = get(playlistSettingsStore)[playlistId]?.shuffle ?? false;
const loopPlaylist = get(playlistSettingsStore)[playlistId]?.loop ?? false;
if (shufflePlaylist) {
goToVideo = unsafeRandomItem(playlist.videos);
} else {
const currentVideoIndex = playlistVideoIds.indexOf(video.videoId);
const newIndex = currentVideoIndex + 1;
if (currentVideoIndex !== -1 && newIndex < playlistVideoIds.length) {
goToVideo = playlist.videos[newIndex];
} else if (loopPlaylist) {
// Loop playlist on end
goToVideo = playlist.videos[0];
}
}
if (typeof goToVideo !== 'undefined') {
get(syncPartyConnectionsStore)?.forEach((conn) => {
if (typeof goToVideo === 'undefined') return;
conn.send({
events: [
{ type: 'change-video', videoId: goToVideo.videoId },
{ type: 'playlist', playlistId: playlistId }
]
} as PlayerEvents);
});
goto(resolve(`/${isAndroidTv ? 'tv' : 'watch'}/${goToVideo.videoId}?playlist=${playlistId}`), {
replaceState: isAndroidTv
});
}
}
+14 -2
View File
@@ -11,9 +11,11 @@ import type {
PlaylistPage,
PlaylistPageVideo,
Video,
VideoBase
VideoBase,
VideoPlay
} from './api/model';
import { ensureNoTrailingSlash } from './misc';
import type { PhasedDescription } from './timestamps';
function platformDependentDefault(givenValue: any, defaultValue: any): any {
if (typeof givenValue !== 'undefined' && givenValue !== null) {
@@ -57,6 +59,17 @@ export const playerDefaultLanguage = persisted('defaultLanguage', 'original');
export const playerCCByDefault = persisted('CCByDefault', false);
export const playerDefaultPlaybackSpeed: Writable<number> = persisted('defaultPlaybackSpeed', 1);
export const playerStatisticsByDefault = persisted('playerStatistics', false);
export const playerMiniplayerEnabled = persisted('miniplayerEnabled', true);
export const playerPlaylistHistory: Writable<string[]> = writable([]);
export interface PlayerState {
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
isSyncing?: boolean;
playerElement?: HTMLMediaElement | undefined;
}
export const playerState: Writable<PlayerState | undefined> = writable(undefined);
export const playertheatreModeIsActive = writable(false);
export const returnYtDislikesStore = persisted('returnYtDislikes', false);
export const returnYTDislikesInstanceStore: Writable<string | null | undefined> = persisted(
@@ -145,4 +158,3 @@ export const playlistCacheStore: Writable<{
}> = writable({});
export const isAndroidTvStore: Writable<boolean> = writable(false);
export const isTvRouteLoading: Writable<boolean> = writable(false);
+32
View File
@@ -0,0 +1,32 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import advancedFormat from 'dayjs/plugin/advancedFormat';
dayjs.extend(utc);
dayjs.extend(duration);
dayjs.extend(relativeTime);
dayjs.extend(advancedFormat);
export function humanFriendlyTimestamp(utcTimestamp: number): string {
const now = dayjs();
const timestamp = dayjs.utc(utcTimestamp).local();
const isSameDay = now.isSame(timestamp, 'day');
const isSameMonth = now.isSame(timestamp, 'month');
const isThisYear = now.isSame(timestamp, 'year');
const diffMilliseconds = timestamp.diff(now);
const diffDuration = dayjs.duration(diffMilliseconds);
if (isSameDay) {
return diffDuration.humanize(true);
} else if (isSameMonth) {
return timestamp.format('Do @ h:mm A');
} else if (isThisYear) {
return timestamp.format('MMMM Do @ h:mm A');
} else {
return timestamp.format('MMMM Do YYYY h:mm A');
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ export function phaseDescription(
// Handle youtubejs timestamps
if (
fallbackPatch === 'youtubejs' &&
href.includes(`https://www.youtube.com/watch?v=${videoId}&t=`)
href.includes(`https://www.youtube.com/watch?v=${videoId}`)
) {
const url = new URL(href);
const timeParam = url.searchParams.get('t') || '0';
+12 -8
View File
@@ -10,6 +10,7 @@ import { loadEntirePlaylist } from '$lib/playlist';
import {
authStore,
playerProxyVideosStore,
playerState,
returnYTDislikesInstanceStore,
returnYtDislikesStore
} from '$lib/store';
@@ -19,21 +20,24 @@ import { get } from 'svelte/store';
import { _ } from './i18n';
export async function getWatchDetails(videoId: string, url: URL) {
const playerStateRetrieved = get(playerState);
let video;
try {
video = await getVideo(videoId, get(playerProxyVideosStore), { priority: 'high' });
} catch (errorMessage: any) {
error(500, errorMessage);
if (playerStateRetrieved && playerStateRetrieved.data.video.videoId === videoId) {
video = playerStateRetrieved.data.video;
} else {
try {
video = await getVideo(videoId, get(playerProxyVideosStore), { priority: 'high' });
} catch (errorMessage: any) {
error(500, errorMessage);
}
}
if (video.premium) {
error(400, get(_)('premium'));
}
if (video.isUpcoming) {
error(400, get(_)('isUpcoming'));
}
let personalPlaylists;
if (get(authStore)) {
postHistory(video.videoId);
@@ -13,6 +13,7 @@
import Settings from '$lib/components/settings/Settings.svelte';
import SyncParty from '$lib/components/SyncParty.svelte';
import Thumbnail from '$lib/components/Thumbnail.svelte';
import Player from '$lib/components/Player.svelte';
import '$lib/css/global.css';
import { bookmarkletLoadFromUrl, loadSettingsFromEnv } from '$lib/externalSettings';
import { getPages } from '$lib/navPages';
@@ -24,6 +25,8 @@
interfaceAmoledTheme,
interfaceDefaultPage,
isAndroidTvStore,
playerState,
playertheatreModeIsActive,
playlistCacheStore,
searchCacheStore,
syncPartyPeerStore,
@@ -41,6 +44,8 @@
import { get } from 'svelte/store';
import { pwaInfo } from 'virtual:pwa-info';
import Mousetrap from 'mousetrap';
import { truncate } from '$lib/misc';
import Author from '$lib/components/watch/Author.svelte';
let { children } = $props();
@@ -53,6 +58,12 @@
let notifications: Notification[] = $state([]);
let playerIsPip: boolean = $state(false);
page.subscribe((pageData) => {
playerIsPip = !pageData.url.pathname.includes('/watch');
});
interfaceAmoledTheme.subscribe(async () => {
setAmoledTheme();
@@ -384,6 +395,53 @@
</dialog>
<main id="main-content" class="responsive max root" tabindex="0" role="region">
{#if $playerState}
<div class="grid">
<div
class:pip={playerIsPip}
class:s12={!playerIsPip}
class:m12={!playerIsPip}
class:l12={$playertheatreModeIsActive && !playerIsPip}
class:l9={!$playertheatreModeIsActive && !playerIsPip}
>
<div class="pip-info">
{#if playerIsPip}
<nav>
<h6 class="max">{truncate($playerState.data.video.title, 20)}</h6>
<button class="border m l" onclick={() => playerState.set(undefined)}>
<i>close</i>
</button>
</nav>
<div class="space"></div>
{/if}
<div class="player">
{#key $playerState.data.video.videoId}
<Player data={$playerState.data} isSyncing={$playerState.isSyncing} />
{/key}
</div>
</div>
{#if playerIsPip}
<nav class="s">
<a class="button border" href={resolve(`/watch/${$playerState.data.video.videoId}`)}
><i>keyboard_arrow_right</i></a
>
<button class="border" onclick={() => playerState.set(undefined)}>
<i>close</i>
</button>
</nav>
<nav class="m l">
<Author video={$playerState.data.video} hideSubscribe={true} />
<div class="max"></div>
<a class="button border" href={resolve(`/watch/${$playerState.data.video.videoId}`)}
><i>keyboard_arrow_right</i></a
>
</nav>
{/if}
</div>
</div>
{/if}
{#if $navigating}
<PageLoading />
{:else}
@@ -426,4 +484,45 @@
min-inline-size: 0.5rem;
padding: 0;
}
.pip {
position: fixed;
bottom: 10px;
right: 30px;
width: 400px;
z-index: 99999;
padding: 1em;
border: 0.0625rem solid var(--outline-variant);
background-color: var(--surface-container-low);
color: var(--on-surface);
border-radius: 0.75rem;
}
.player {
display: flex;
justify-content: center;
}
@media only screen and (max-width: 993px) {
.pip {
width: 100%;
bottom: 100px;
right: 0px;
}
.pip > .pip-info > .player {
height: 80px;
width: 150px;
}
.pip > .pip-info > nav {
align-items: start;
padding-right: 0.5em;
}
.pip > .pip-info {
display: flex;
justify-content: space-between;
}
}
</style>
+12 -3
View File
@@ -1,14 +1,23 @@
<script lang="ts">
import ItemsList from '$lib/components/ItemsList.svelte';
import { _ } from '$lib/i18n/index';
import { feedCacheStore } from '$lib/store';
let { data } = $props();
</script>
{#if data.popularDisabled}
<nav class="center-align">
<p>Popular page has been disabled</p>
</nav>
<div class="center-align">
<div class="space"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 24 24"
><path
style="fill: var(--secondary);"
d="M11.25 21.975Q9.3 21.825 7.612 21t-2.937-2.162q-1.25-1.338-1.963-3.1T2 12q0-2.075.788-3.9t2.137-3.175q1.35-1.35 3.175-2.137T12 2q2.075 0 3.9.788t3.175 2.137q1.35 1.35 2.138 3.175T22 12v.475q-.425-.275-.988-.537t-1.037-.438q-.2-3.15-2.488-5.325T12 4q-1.4 0-2.638.45T7.1 5.7l6.125 6.125q-.475.2-.912.463t-.863.562L5.7 7.1q-.8 1.025-1.25 2.263T4 12q0 2.475 1.338 4.438T8.8 19.325q.45.7 1.125 1.425t1.325 1.225ZM17 20q1.475 0 2.738-.675T21.75 17.5q-.75-1.15-2.013-1.825T17 15q-1.475 0-2.738.675T12.25 17.5q.75 1.15 2.013 1.825T17 20Zm0 2q-2.4 0-4.288-1.263T10 17.5q.825-1.975 2.713-3.238T17 13q2.4 0 4.288 1.263T24 17.5q-.825 1.975-2.713 3.238T17 22Zm0-3q-.625 0-1.063-.438T15.5 17.5q0-.625.438-1.063T17 16q.625 0 1.063.438T18.5 17.5q0 .625-.438 1.063T17 19Z"
/></svg
>
<h1>{$_('disabled')}</h1>
<p>{$_('popularPageDisabled')}</p>
</div>
{:else}
<ItemsList items={$feedCacheStore.popular ?? []} />
{/if}
@@ -1,9 +1,15 @@
<script lang="ts">
import { amSubscribed, deleteUnsubscribe, getChannelContent, postSubscribe } from '$lib/api';
import {
amSubscribed,
deleteUnsubscribe,
getChannelContent,
postSubscribe,
searchChannelContent,
type channelContentTypes,
type channelSortBy
} from '$lib/api';
import type { ChannelContentPlaylists, ChannelContentVideos } from '$lib/api/model';
import ContentColumn from '$lib/components/ContentColumn.svelte';
import PageLoading from '$lib/components/PageLoading.svelte';
import PlaylistThumbnail from '$lib/components/PlaylistThumbnail.svelte';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { cleanNumber } from '$lib/numbers';
import { authStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
@@ -19,11 +25,21 @@
let isSubscribed = $state(false);
let tab: 'videos' | 'playlists' | 'streams' | 'shorts' = $state('videos');
let tab: channelContentTypes = $state('videos');
let sortBy: channelSortBy = $state('newest');
const sortByOptions: channelSortBy[] = ['newest', 'oldest', 'popular'];
let showSearch: boolean = $state(false);
let channelSearch: string = $state('');
let displayContent: ChannelContentPlaylists | ChannelContentVideos | undefined =
$state(undefined);
async function searchChannel() {
displayContent = await searchChannelContent(data.channel.authorId, channelSearch);
}
async function loadMore(event: InfiniteEvent) {
if (typeof displayContent === 'undefined') return;
@@ -34,7 +50,8 @@
const newContent = await getChannelContent(data.channel.authorId, {
type: tab,
continuation: displayContent.continuation
continuation: displayContent.continuation,
sortBy: sortBy
});
if ('videos' in newContent && 'videos' in displayContent) {
if (displayContent.continuation === newContent.continuation) {
@@ -62,7 +79,10 @@
let channelPfp: string | undefined = $state();
onMount(async () => {
displayContent = await getChannelContent(data.channel.authorId, { type: 'videos' });
displayContent = await getChannelContent(data.channel.authorId, {
type: 'videos',
sortBy: sortBy
});
if (!get(interfaceLowBandwidthMode)) {
const channelPfpResp = await fetch(
@@ -201,6 +221,46 @@
</div>
</div>
<div class="grid">
<div class="s12 m6 l6">
<nav class="group">
{#each sortByOptions as sortingOption}
<button
class="no-round"
onclick={async () => {
sortBy = sortingOption;
displayContent = await getChannelContent(data.channel.authorId, {
type: tab,
sortBy: sortBy
});
}}
class:active={sortBy === sortingOption}>{$_(sortingOption)}</button
>
{/each}
</nav>
</div>
<div class="s12 m6 l6">
{#if showSearch}
<div class="max field suffix prefix small no-margin surface-variant">
<i class="front">search</i><input
bind:value={channelSearch}
oninput={searchChannel}
type="text"
placeholder={$_('searchPlaceholder')}
/>
</div>
{:else}
<nav class="right-align m l">
<button onclick={() => (showSearch = true)}><i>search</i></button>
</nav>
<button class="s" onclick={() => (showSearch = true)}><i>search</i></button>
{/if}
</div>
</div>
<div class="space"></div>
{#if displayContent}
{#if 'videos' in displayContent}
<ItemsList items={displayContent.videos} />
@@ -40,7 +40,9 @@
{#each playlists.playlists as playlist}
<ContentColumn>
<article class="no-padding" style="height: 100%;">
<PlaylistThumbnail disabled={playlist.videoCount === 0} {playlist} />
{#key playlist.playlistId}
<PlaylistThumbnail disabled={playlist.videoCount === 0} {playlist} />
{/key}
<nav class="right-align padding">
<button
@@ -1,46 +1,79 @@
<script lang="ts">
import { getSearch } from '$lib/api';
import ChannelThumbnail from '$lib/components/ChannelThumbnail.svelte';
import ContentColumn from '$lib/components/ContentColumn.svelte';
import HashtagThumbnail from '$lib/components/HashtagThumbnail.svelte';
import { getSearch, type SearchOptions } from '$lib/api';
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';
import { feedLastItemId, searchCacheStore } from '$lib/store';
import { onMount } from 'svelte';
import { searchCacheStore } from '$lib/store';
import { _ } from '$lib/i18n';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
import ItemsList from '$lib/components/ItemsList.svelte';
let { data } = $props();
let currentType: 'playlist' | 'all' | 'video' | 'channel' = $state('all');
let currentPage = 1;
onMount(() => {
if ($feedLastItemId) {
document
.getElementById($feedLastItemId)
?.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' });
let filtersOpen = $state(false);
feedLastItemId.set(undefined);
}
let searchOptions: SearchOptions = $state({
type: 'all',
page: currentPage.toString(),
sort_by: 'relevance'
});
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 }) });
const filters: {
options: string[];
title: string;
key: 'type' | 'date' | 'duration' | 'sort_by' | 'features';
}[] = $state([
{
options: ['all', 'video', 'playlist', 'channel'],
title: $_('filters.type'),
key: 'type'
},
{
options: ['hour', 'today', 'week', 'month', 'year'],
title: $_('filters.uploadDate'),
key: 'date'
},
{
options: ['short', 'medium', 'long'],
title: $_('filters.duration'),
key: 'duration'
},
{
options: ['relevance', 'rating', 'upload_date', 'view_count'],
title: $_('filters.sortBy'),
key: 'sort_by'
},
{
options: [
'hd',
'subtitles',
'creative_commons',
'3d',
'live',
'purchased',
'4k',
'360',
'location',
'hdr',
'vr180'
],
title: $_('filters.features'),
key: 'features'
}
]);
async function updateSearch() {
data.searchStoreId = JSON.stringify(searchOptions) + data.slug;
searchCacheStore.set({ [data.searchStoreId]: await getSearch(data.slug, searchOptions) });
}
async function loadMore(event: InfiniteEvent) {
currentPage++;
const newSearch = await getSearch(data.slug, {
page: currentPage.toString(),
type: currentType
});
searchOptions = {
...searchOptions,
page: currentPage.toString()
};
const newSearch = await getSearch(data.slug, searchOptions);
if (newSearch.length === 0) {
event.detail.complete();
@@ -53,38 +86,58 @@
}
</script>
<div style="margin-bottom: 1em;">
<div class="tabs left-align min scroll">
<a class:active={currentType === 'all'} href="#all" onclick={async () => changeType('all')}>
<i>home</i>
<span>{$_('videoTabs.all')}</span>
</a>
<a
class:active={currentType === 'video'}
href="#videos"
onclick={async () => changeType('video')}
>
<i>movie</i>
<span>{$_('videoTabs.videos')}</span>
</a>
<a
class:active={currentType === 'playlist'}
href="#playlists"
onclick={async () => changeType('playlist')}
>
<i>playlist_add_check</i>
<span>{$_('videoTabs.playlists')}</span>
</a>
<a
class:active={currentType === 'channel'}
href="#channels"
onclick={async () => changeType('channel')}
>
<i>person</i>
<span>{$_('videoTabs.channels')}</span>
</a>
</div>
</div>
<details open={filtersOpen}>
<summary>
<nav>
<button
class="secondary"
onclick={() => {
filtersOpen = !filtersOpen;
}}
>
<i>filter_alt</i>
<span>{$_('filters.filters')}</span>
</button>
</nav>
</summary>
<article class="scroll medium">
<div class="grid">
{#each filters as filter}
<div class="s12 m2 l2">
<h6>{filter.title}</h6>
<ul class="list no-margin no-padding">
{#each filter.options as filterOption}
<li>
<button
class="small border"
style="text-transform: capitalize;"
disabled={filter.key !== 'type' &&
!['all', 'video', undefined].includes(searchOptions.type)}
class:active={filterOption === searchOptions[filter.key]}
onclick={async () => {
if (filterOption === searchOptions[filter.key]) {
delete searchOptions[filter.key];
} else {
searchOptions = {
...searchOptions,
[filter.key]: filterOption
};
}
await updateSearch();
}}
>
<span>{filterOption.replaceAll('_', ' ')}</span>
</button>
</li>
{/each}
</ul>
</div>
{/each}
</div>
</article>
</details>
<div class="space"></div>
{#if $searchCacheStore[data.searchStoreId]}
<ItemsList items={$searchCacheStore[data.searchStoreId]} />
@@ -34,7 +34,7 @@
<div class="space"></div>
<div class="max field round suffix prefix small no-margin surface-variant">
<div class="max field suffix prefix small no-margin surface-variant">
<i class="front">search</i><input
bind:value={search}
oninput={searchSubs}
@@ -7,27 +7,30 @@
removePlaylistVideo
} from '$lib/api/index';
import type { Comments, PlaylistPage } from '$lib/api/model';
import Player from '$lib/components/Player.svelte';
import ShareVideo from '$lib/components/ShareVideo.svelte';
import Thumbnail from '$lib/components/Thumbnail.svelte';
import Transcript from '$lib/components/Transcript.svelte';
import { getBestThumbnail } from '$lib/images';
import { letterCase } from '$lib/letterCasing';
import { cleanNumber, humanizeSeconds, numberWithCommas } from '$lib/numbers';
import type { PlayerEvents } from '$lib/player';
import { goToNextVideo, goToPreviousVideo, type PlayerEvents } from '$lib/player';
import {
authStore,
interfaceAutoExpandChapters,
interfaceAutoExpandComments,
playerMiniplayerEnabled,
playerPlaylistHistory,
playerState,
playerTheatreModeByDefaultStore,
playertheatreModeIsActive,
playlistCacheStore,
playlistSettingsStore,
syncPartyConnectionsStore,
syncPartyPeerStore
syncPartyPeerStore,
type PlayerState
} from '$lib/store';
import ui from 'beercss';
import type { DataConnection } from 'peerjs';
import { type Segment } from 'sponsorblock-api';
import { onDestroy, onMount, tick } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
@@ -37,6 +40,9 @@
import LikesDislikes from '$lib/components/watch/LikesDislikes.svelte';
import Comment from '$lib/components/watch/Comment.svelte';
import { expandSummery } from '$lib/misc';
import { humanFriendlyTimestamp } from '$lib/time.js';
import { getWatchDetails } from '$lib/watch.js';
import { page } from '$app/state';
let { data = $bindable() } = $props();
@@ -56,9 +62,7 @@
let loopPlaylist: boolean = $state(false);
let shufflePlaylist: boolean = $state(false);
let theatreMode = $state(get(playerTheatreModeByDefaultStore));
let segments: Segment[] = $state([]);
playertheatreModeIsActive.set(get(playerTheatreModeByDefaultStore));
let pauseTimerSeconds: number = $state(-1);
@@ -66,6 +70,19 @@
let playerCurrentTime: number = $state(0);
let premiereTime = $state('');
let premiereUpdateInterval: NodeJS.Timeout;
if (
!data.video.premiereTimestamp &&
(!$playerState || $playerState.data.video.videoId !== data.video.videoId)
) {
playerState.set({
data: data,
isSyncing: $syncPartyPeerStore !== null
});
}
$effect(() => {
if ($interfaceAutoExpandComments && comments) {
expandSummery('comment-section');
@@ -211,9 +228,12 @@
playerSyncEvents(connections[connections.length - 1]);
});
onMount(async () => {
async function load(state: PlayerState) {
playerElement = state.playerElement;
if (data.playlistId) {
await goToCurrentPlaylistItem();
playerPlaylistHistory.set([data.video.videoId, ...$playerPlaylistHistory]);
}
if ($interfaceAutoExpandChapters) {
@@ -232,6 +252,37 @@
playerCurrentTime = playerElement.currentTime;
});
}
if (data.video.premiereTimestamp) {
premiereTime = humanFriendlyTimestamp(data.video.premiereTimestamp);
premiereUpdateInterval = setInterval(async () => {
data = await getWatchDetails(data.video.videoId, page.url);
if (data.video.premiereTimestamp) {
premiereTime = humanFriendlyTimestamp(data.video.premiereTimestamp);
} else {
clearInterval(premiereUpdateInterval);
playerState.set({ ...$playerState, data: { ...data } });
}
}, 60000);
}
}
onMount(async () => {
// Required due to needing the playerElement
if ($playerState?.playerElement) {
await load($playerState);
} else {
let loadedPlayer = false;
playerState.subscribe(async (updatedPlayerState) => {
if (!updatedPlayerState?.playerElement || loadedPlayer) {
return;
}
loadedPlayer = true;
await load(updatedPlayerState);
});
}
});
onDestroy(() => {
@@ -241,6 +292,21 @@
if (pauseTimeout) {
clearTimeout(pauseTimeout);
}
if (premiereUpdateInterval) {
clearInterval(premiereUpdateInterval);
}
if (
!playerElement ||
playerElement.paused ||
playerElement.ended ||
playerElement.currentTime === 0 ||
playerElement.readyState <= 2 ||
!playerMiniplayerEnabled
) {
playerState.set(undefined);
}
});
async function goToCurrentPlaylistItem() {
@@ -295,7 +361,7 @@
}
function toggleTheatreMode() {
theatreMode = !theatreMode;
playertheatreModeIsActive.set(!$playertheatreModeIsActive);
}
let pauseTimeout: NodeJS.Timeout | undefined = $state();
@@ -317,25 +383,34 @@
<title>{data.video.title}</title>
</svelte:head>
<div class="grid">
<div class={`s12 m12 l${theatreMode ? '12' : '9'}`}>
<div class="grid no-padding">
<div class={`s12 m12 l${$playertheatreModeIsActive ? '12' : '9'}`}>
<div style="display: flex;justify-content: center;">
{#key data.video.videoId}
<Player bind:playerElement bind:segments {data} isSyncing={$syncPartyPeerStore !== null} />
{/key}
{#if data.video.premiereTimestamp}
<article class="video-placeholder">
<p>{$_('player.premiere')}</p>
<h6 class="no-margin no-padding">
{premiereTime}
</h6>
</article>
{/if}
</div>
<h5>{letterCase(data.video.title)}</h5>
<h5 class="no-margin">{letterCase(data.video.title)}</h5>
<div class="grid no-padding">
<div class="s12 m12 l5">
<div class="s12 m12 l5" style="height: 100%;display: flex;align-items: center;">
<Author video={data.video} bind:subscribed />
</div>
<div class="s12 m12 l7 video-actions">
<LikesDislikes video={data.video} returnYTDislikes={data.streamed.returnYTDislikes} />
<div>
<button onclick={toggleTheatreMode} class="m l" class:border={!theatreMode}>
<LikesDislikes video={data.video} returnYTDislikes={data.streamed.returnYTDislikes} />
<button
onclick={toggleTheatreMode}
class="m l"
class:border={!$playertheatreModeIsActive}
>
<i>width_wide</i>
<div class="tooltip">{$_('player.theatreMode')}</div>
</button>
@@ -354,7 +429,10 @@
</button>
{/if}
<button
onclick={() => ((showTranscript = !showTranscript), (theatreMode = false))}
onclick={() => (
(showTranscript = !showTranscript),
playertheatreModeIsActive.set(false)
)}
class:border={!showTranscript}
>
<i>description</i>
@@ -400,7 +478,7 @@
</menu>
</button>
{:else}
<button disabled class="border no-margin">
<button disabled class="border">
<i>add</i>
<div class="tooltip">
{#if $authStore}
@@ -429,6 +507,7 @@
</div>
</nav>
</summary>
<div class="space"></div>
<div class="chapter-list" id="chapters">
<ul class="list">
{#each data.content.timestamps as timestamp}
@@ -483,8 +562,8 @@
</article>
{/if}
</div>
{#if !theatreMode}
<div class="s12 m12 l3">
{#if !$playertheatreModeIsActive}
<div class="s12 m12 l3 recommended">
{#if showTranscript && playerElement}
<Transcript video={data.video} bind:playerElement />
{/if}
@@ -537,6 +616,21 @@
{$_('playlist.shuffleVideos')}
</div>
</button>
<button class="circle fill" onclick={() => goToPreviousVideo(data.playlistId)}>
<i>skip_previous</i>
<div class="tooltip bottom">
{$_('playlist.previous')}
</div>
</button>
<button
class="circle fill"
onclick={async () => await goToNextVideo(data.video, data.playlistId)}
>
<i>skip_next</i>
<div class="tooltip bottom">
{$_('playlist.next')}
</div>
</button>
</nav>
<div class="space"></div>
@@ -617,38 +711,26 @@
overflow-x: hidden;
}
.recommended {
margin-top: calc(var(--video-player-height) * -1);
}
.video-actions {
display: flex;
align-items: center;
justify-content: flex-end;
}
.video-actions button:not(.left-round):not(.right-round) {
margin: 0.3em;
}
@media screen and (max-width: 1000px) {
.video-actions {
align-items: flex-start;
flex-direction: column;
}
.video-actions > div {
margin-top: 1em;
}
nav.group {
flex-direction: column;
}
nav.group button {
border-radius: 0.5rem !important;
}
}
@media screen and (max-width: 1646px) {
.grid {
padding: 0;
@media only screen and (max-width: 993px) {
.recommended {
margin-top: 0;
}
}
</style>
@@ -1,7 +1,6 @@
<script lang="ts">
import { navigating } from '$app/stores';
import PageLoading from '$lib/components/PageLoading.svelte';
import { isTvRouteLoading } from '$lib/store';
let { children } = $props();
</script>
@@ -15,7 +14,7 @@
</style>
</svelte:head>
{#if $isTvRouteLoading || $navigating}
{#if $navigating}
<PageLoading />
{:else}
{@render children?.()}
@@ -1,9 +1,5 @@
import { isTvRouteLoading } from '$lib/store';
import { getWatchDetails } from '$lib/watch';
export async function load({ params, url }) {
isTvRouteLoading.set(true);
const details = await getWatchDetails(params.slug, url);
isTvRouteLoading.set(false);
return details;
return await getWatchDetails(params.slug, url);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import os
import re
from datetime import datetime
LATEST_VERSION = "1.10.18"
LATEST_VERSION = "1.11.5"
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")