Merge remote-tracking branch 'origin/main' into support_nonroot_base
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+38
-116
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user