mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
Merge branch 'p-stream:production' into production
This commit is contained in:
commit
02c2dcb4c9
143 changed files with 24401 additions and 19266 deletions
|
|
@ -3,18 +3,18 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
|
|||
acc[`jsx-a11y/${rule}`] = "off";
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
{},
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true
|
||||
browser: true,
|
||||
},
|
||||
extends: [
|
||||
"airbnb",
|
||||
"airbnb/hooks",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
"plugin:prettier/recommended",
|
||||
],
|
||||
ignorePatterns: [
|
||||
"public/*",
|
||||
|
|
@ -24,19 +24,19 @@ module.exports = {
|
|||
"/*.mts",
|
||||
"/plugins/*.ts",
|
||||
"/plugins/*.mjs",
|
||||
"/themes/**/*.ts"
|
||||
"/themes/**/*.ts",
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: "./"
|
||||
tsconfigRootDir: "./",
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {
|
||||
project: "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: ["@typescript-eslint", "import", "prettier"],
|
||||
rules: {
|
||||
|
|
@ -62,18 +62,21 @@ module.exports = {
|
|||
"no-nested-ternary": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"no-param-reassign": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{ extensions: [".js", ".tsx", ".jsx"] }
|
||||
{ extensions: [".js", ".tsx", ".jsx"] },
|
||||
],
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
ts: "never",
|
||||
tsx: "never"
|
||||
}
|
||||
tsx: "never",
|
||||
},
|
||||
],
|
||||
"import/order": [
|
||||
"error",
|
||||
|
|
@ -84,14 +87,14 @@ module.exports = {
|
|||
"internal",
|
||||
["sibling", "parent"],
|
||||
"index",
|
||||
"unknown"
|
||||
"unknown",
|
||||
],
|
||||
"newlines-between": "always",
|
||||
alphabetize: {
|
||||
order: "asc",
|
||||
caseInsensitive: true
|
||||
}
|
||||
}
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
"sort-imports": [
|
||||
"error",
|
||||
|
|
@ -100,9 +103,9 @@ module.exports = {
|
|||
ignoreDeclarationSort: true,
|
||||
ignoreMemberSort: false,
|
||||
memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
|
||||
allowSeparatedGroups: true
|
||||
}
|
||||
allowSeparatedGroups: true,
|
||||
},
|
||||
],
|
||||
...a11yOff
|
||||
}
|
||||
...a11yOff,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
22
.github/CODE_OF_CONDUCT.md
vendored
22
.github/CODE_OF_CONDUCT.md
vendored
|
|
@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
|
|||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
|
@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
|
|||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
|
|
|
|||
39
.github/CONTRIBUTING.md
vendored
39
.github/CONTRIBUTING.md
vendored
|
|
@ -5,15 +5,16 @@ Thank you for investing your time in contributing to our project! Your contribut
|
|||
Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectable.
|
||||
|
||||
## Contents
|
||||
- [New Contributor Guide](#new-contributor-guide)
|
||||
- [Requesting a feature or reporting a bug](#requesting-a-feature-or-reporting-a-bug)
|
||||
- [Discord Server](#discord-server)
|
||||
- [GitHub Issues](#github-issues)
|
||||
- [Before you start](#before-you-start)
|
||||
- [Contributing](#before-you-start)
|
||||
- [Recommended Development Environment](#recommended-development-environment)
|
||||
- [Tips](#tips)
|
||||
- [Language Contributions](#language-contributions)
|
||||
|
||||
- [New Contributor Guide](#new-contributor-guide)
|
||||
- [Requesting a feature or reporting a bug](#requesting-a-feature-or-reporting-a-bug)
|
||||
- [Discord Server](#discord-server)
|
||||
- [GitHub Issues](#github-issues)
|
||||
- [Before you start](#before-you-start)
|
||||
- [Contributing](#before-you-start)
|
||||
- [Recommended Development Environment](#recommended-development-environment)
|
||||
- [Tips](#tips)
|
||||
- [Language Contributions](#language-contributions)
|
||||
|
||||
## New contributor guide
|
||||
|
||||
|
|
@ -24,18 +25,21 @@ To get an overview of the project, read the [README](README.md). Here are some r
|
|||
- [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow)
|
||||
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
|
||||
|
||||
|
||||
## Requesting a feature or reporting a bug
|
||||
|
||||
There are two places where to request features or report bugs:
|
||||
- The P-Stream Discord server
|
||||
- GitHub Issues
|
||||
|
||||
- The P-Stream Discord server
|
||||
- GitHub Issues
|
||||
|
||||
### Discord Server
|
||||
|
||||
If you do not have a GitHub account or want to discuss a feature or bug with us before making an issue, you can join our Discord server.
|
||||
|
||||
<a href="https://docs.pstream.mov/links/discord"><img src="https://discord.com/api/guilds/1267558147682205738/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
|
||||
### GitHub Issues
|
||||
|
||||
To make a GitHub issue for P-Stream, please visit the [new issue page](https://github.com/p-stream/p-stream/issues/new/choose) where you can pick either the "Bug Report" or "Feature Request" template.
|
||||
|
||||
When filling out an issue template, please include as much detail as possible and any screenshots or console logs as appropriate.
|
||||
|
|
@ -43,7 +47,8 @@ When filling out an issue template, please include as much detail as possible an
|
|||
After an issue is created, it will be assigned either the https://github.com/p-stream/p-stream/labels/bug or https://github.com/p-stream/p-stream/labels/feature label, along with https://github.com/p-stream/p-stream/labels/awaiting-approval. One of our maintainers will review your issue and, if it's accepted, will set the https://github.com/p-stream/p-stream/labels/approved label.
|
||||
|
||||
## Before you start!
|
||||
Before starting a contribution, please check your contribution is part of an open issue on [our issues page](https://github.com/p-stream/p-stream/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved).
|
||||
|
||||
Before starting a contribution, please check your contribution is part of an open issue on [our issues page](https://github.com/p-stream/p-stream/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved).
|
||||
|
||||
GitHub issues are how we track our bugs and feature requests that will be implemented into P-Stream - all contributions **must** have an issue and be approved by a maintainer before a pull request can be worked on.
|
||||
|
||||
|
|
@ -55,6 +60,7 @@ If a pull request is opened before an issue is created and accepted, you may ris
|
|||
Also, make sure that the issue you would like to work on has been given the https://github.com/p-stream/p-stream/labels/approved label by a maintainer. Otherwise, if we reject the issue, it means your work will have gone to waste!
|
||||
|
||||
## Contributing
|
||||
|
||||
If you're here because you'd like to work on an issue, amazing! Thank you for even considering contributing to P-Stream; it means a lot :heart:
|
||||
|
||||
Firstly, make sure you've read the [Before you start!](#before-you-start) section!
|
||||
|
|
@ -64,16 +70,19 @@ When you have found a GitHub issue you would like to work on, you can request to
|
|||
If you are assigned to an issue but can't complete it for whatever reason, no problem! Just let us know, and we will open up the issue to have someone else assigned.
|
||||
|
||||
### Recommended Development Environment
|
||||
|
||||
Our recommended development environment to work on P-Stream is:
|
||||
|
||||
- [Visual Studio Code](https://code.visualstudio.com/)
|
||||
- [ESLint Extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
|
||||
- [EditorConfig Extension](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
|
||||
|
||||
When opening Visual Studio Code, you will be prompted to install our recommended extensions if they are not installed for you.
|
||||
|
||||
Our project is set up to enforce formatting and code style standards using ESLint.
|
||||
Our project is set up to enforce formatting and code style standards using ESLint.
|
||||
|
||||
### Tips
|
||||
|
||||
Here are some tips to make sure that your pull requests are :pinched_fingers: first time:
|
||||
|
||||
- KISS - Keep It Simple Soldier! - Simple code makes readable and efficient code!
|
||||
|
|
@ -83,11 +92,11 @@ Here are some tips to make sure that your pull requests are :pinched_fingers: fi
|
|||
- Test, test, test! Make sure you thoroughly test the features you are contributing.
|
||||
|
||||
### Language Contributions
|
||||
|
||||
Language contributions help P-Stream massively, allowing people worldwide to use our app!
|
||||
|
||||
We use Weblate for crowdsourcing our translations. [Click here to go to our translation tool.](https://docs.pstream.mov/links/weblate)
|
||||
|
||||
|
||||
1. First make sure you make an account. (click the link above)
|
||||
2. Click the language you want to help translate, if it's not listed you can click the plus top left to add a new language.
|
||||
3. In the top right of the screen, click "translate"
|
||||
|
|
|
|||
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
|
|
@ -47,4 +47,3 @@ body:
|
|||
Tip: You can attach files by clicking this textbox and dragging in files
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
|
|
|||
3
.github/SECURITY.md
vendored
3
.github/SECURITY.md
vendored
|
|
@ -7,4 +7,5 @@ The latest version of P-Stream is the only version that is supported, as it is t
|
|||
## Reporting a Vulnerability
|
||||
|
||||
You can contact the P-Stream maintainers to report a vulnerability:
|
||||
- Report the vulnerability in the [P-Stream Discord server](https://docs.pstream.mov/links/discord)
|
||||
|
||||
- Report the vulnerability in the [P-Stream Discord server](https://docs.pstream.mov/links/discord)
|
||||
|
|
|
|||
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
|
|
@ -1,6 +1,6 @@
|
|||
This pull request resolves #XXX
|
||||
|
||||
- [ ] I have read and agreed to the [code of conduct](https://github.com/p-stream/p-stream/blob/dev/.github/CODE_OF_CONDUCT.md).
|
||||
- [ ] I have read and complied with the [contributing guidelines](https://github.com/p-stream/p-stream/blob/dev/.github/CONTRIBUTING.md).
|
||||
- [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/p-stream/p-stream/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/p-stream/p-stream/projects).
|
||||
- [ ] I have tested all of my changes.
|
||||
- [ ] I have read and agreed to the [code of conduct](https://github.com/p-stream/p-stream/blob/dev/.github/CODE_OF_CONDUCT.md).
|
||||
- [ ] I have read and complied with the [contributing guidelines](https://github.com/p-stream/p-stream/blob/dev/.github/CONTRIBUTING.md).
|
||||
- [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/p-stream/p-stream/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/p-stream/p-stream/projects).
|
||||
- [ ] I have tested all of my changes.
|
||||
|
|
|
|||
188
.github/workflows/deploying.yml
vendored
188
.github/workflows/deploying.yml
vendored
|
|
@ -9,62 +9,62 @@ jobs:
|
|||
build_pwa:
|
||||
name: Build PWA
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
||||
- name: Build project
|
||||
run: pnpm run build:pwa
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pwa
|
||||
path: ./dist
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
||||
- name: Build project
|
||||
run: pnpm run build:pwa
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pwa
|
||||
path: ./dist
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
||||
- name: Build project
|
||||
run: pnpm run build
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: normal
|
||||
path: ./dist
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
||||
- name: Build project
|
||||
run: pnpm run build
|
||||
|
||||
- name: Upload production-ready build files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: normal
|
||||
path: ./dist
|
||||
|
||||
release:
|
||||
name: Release
|
||||
|
|
@ -72,61 +72,61 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download PWA artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pwa
|
||||
path: ./dist_pwa
|
||||
- name: Download PWA artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pwa
|
||||
path: ./dist_pwa
|
||||
|
||||
- name: Zip PWA files
|
||||
run: cd dist_pwa && zip -r ../p-stream.pwa.zip .
|
||||
- name: Zip PWA files
|
||||
run: cd dist_pwa && zip -r ../p-stream.pwa.zip .
|
||||
|
||||
- name: Download normal artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: normal
|
||||
path: ./dist_normal
|
||||
- name: Download normal artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: normal
|
||||
path: ./dist_normal
|
||||
|
||||
- name: Zip normal files
|
||||
run: cd dist_normal && zip -r ../p-stream.zip .
|
||||
- name: Zip normal files
|
||||
run: cd dist_normal && zip -r ../p-stream.zip .
|
||||
|
||||
- name: Get version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
- name: Get version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.package-version.outputs.current-version }}
|
||||
release_name: P-Stream v${{ steps.package-version.outputs.current-version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ steps.package-version.outputs.current-version }}
|
||||
release_name: P-Stream v${{ steps.package-version.outputs.current-version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload release (PWA)
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./p-stream.pwa.zip
|
||||
asset_name: p-stream.pwa.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload release (PWA)
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./p-stream.pwa.zip
|
||||
asset_name: p-stream.pwa.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Release (Normal)
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./p-stream.zip
|
||||
asset_name: p-stream.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Release (Normal)
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./p-stream.zip
|
||||
asset_name: p-stream.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
registry:
|
||||
name: Push to registry
|
||||
|
|
|
|||
8
.github/workflows/linting_testing.yml
vendored
8
.github/workflows/linting_testing.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
|
@ -23,11 +23,11 @@ jobs:
|
|||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
||||
|
||||
- name: Run ESLint
|
||||
run: pnpm run lint
|
||||
|
||||
|
|
|
|||
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
|
|
@ -1,6 +1,3 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"editorconfig.editorconfig"
|
||||
]
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "editorconfig.editorconfig"]
|
||||
}
|
||||
|
|
|
|||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -8,4 +8,4 @@
|
|||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -1,8 +1,8 @@
|
|||
# P-Stream
|
||||
[](https://docs.pstream.mov)
|
||||
|
||||
**I *do not* endorse piracy of any kind I simply enjoy programming and large user counts.**
|
||||
[](https://docs.pstream.mov)
|
||||
|
||||
**I _do not_ endorse piracy of any kind I simply enjoy programming and large user counts.**
|
||||
|
||||
## Quick Deploy
|
||||
|
||||
|
|
@ -12,21 +12,21 @@
|
|||
|
||||
**NOTE: To self-host, more setup is required. Check the [docs](https://docs.pstream.mov) to properly set up!!!!**
|
||||
|
||||
|
||||
## Links And Resources
|
||||
| Service | Link | Source Code |
|
||||
|----------------|------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| P-Stream Docs | [docs](https://docs.pstream.mov) | [source code](https://github.com/p-stream/docs) |
|
||||
| Extension | [extension](https://docs.pstream.mov/extension) | [source code](https://github.com/p-stream/browser-ext) |
|
||||
| Proxy | [simple-proxy](https://docs.pstream.mov/proxy) | [source code](https://github.com/p-stream/sudo-proxy) |
|
||||
| Backend | [backend](https://server.fifthwit.net) | [source code](https://github.com/p-stream/backend) |
|
||||
| Frontend | [P-Stream](https://docs.pstream.mov/instances) | [source code](https://github.com/p-stream/p-stream) |
|
||||
| Weblate | [weblate](https://weblate.pstream.mov) | |
|
||||
|
||||
***I provide these if you are not able to host yourself, though I do encourage hosting the frontend.***
|
||||
| Service | Link | Source Code |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------------------------------ |
|
||||
| P-Stream Docs | [docs](https://docs.pstream.mov) | [source code](https://github.com/p-stream/docs) |
|
||||
| Extension | [extension](https://docs.pstream.mov/extension) | [source code](https://github.com/p-stream/browser-ext) |
|
||||
| Proxy | [simple-proxy](https://docs.pstream.mov/proxy) | [source code](https://github.com/p-stream/sudo-proxy) |
|
||||
| Backend | [backend](https://server.fifthwit.net) | [source code](https://github.com/p-stream/backend) |
|
||||
| Frontend | [P-Stream](https://docs.pstream.mov/instances) | [source code](https://github.com/p-stream/p-stream) |
|
||||
| Weblate | [weblate](https://weblate.pstream.mov) | |
|
||||
|
||||
**_I provide these if you are not able to host yourself, though I do encourage hosting the frontend._**
|
||||
|
||||
## Referrers
|
||||
|
||||
- [FMHY (Voted as #1 multi-server streaming site of 2024)](https://fmhy.net)
|
||||
- [Piracy Subreddit Megathread](https://www.reddit.com/r/Piracy/s/iymSloEpXn)
|
||||
- [Toon's Instances](https://erynith.github.io/movie-web-instances)
|
||||
|
|
@ -34,9 +34,10 @@
|
|||
- Search Engines: DuckDuckGo, Bing, Google
|
||||
- Rentry.co
|
||||
|
||||
|
||||
## Running Locally
|
||||
|
||||
Type the following commands into your terminal / command line to run P-Stream locally
|
||||
|
||||
```bash
|
||||
git clone https://github.com/p-stream/p-stream.git
|
||||
cd smov
|
||||
|
|
@ -44,11 +45,13 @@ git pull
|
|||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Then you can visit the local instance [here](http://localhost:5173) or, at local host on port 5173.
|
||||
|
||||
|
||||
## Updating a P-Stream Instance
|
||||
|
||||
To update a P-Stream instance you can type the below commands into a terminal at the root of your project.
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/p-stream/p-stream.git
|
||||
git fetch upstream # Grab the contents of the new remote source
|
||||
|
|
@ -60,6 +63,6 @@ git commit -m "Update p-stream instance (merge upstream/production)"
|
|||
git push # Push to YOUR repository
|
||||
```
|
||||
|
||||
|
||||
## Contact Me / Discord
|
||||
|
||||
[Discord](https://discord.gg/7z6znYgrTG)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
|
||||
movieweb:
|
||||
build:
|
||||
context: .
|
||||
|
|
|
|||
413
index.html
413
index.html
|
|
@ -1,183 +1,276 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en" dir="ltr">
|
||||
<!--https://www.youtube.com/watch?v=dQw4w9WgXcQ-->
|
||||
|
||||
<!--https://www.youtube.com/watch?v=dQw4w9WgXcQ-->
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico?v=2" />
|
||||
<meta itemprop="image" content="/android-chrome-192x192.png?v=2" />
|
||||
<!-- <meta property="og:image" content="/android-chrome-192x192.png?v=2"> -->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="Watch your favorite shows and movies for free with no ads ever! (っ'ヮ'c)"
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico?v=2" />
|
||||
<meta itemprop="image" content="/android-chrome-192x192.png?v=2">
|
||||
<!-- <meta property="og:image" content="/android-chrome-192x192.png?v=2"> -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="description" content="Watch your favorite shows and movies for free with no ads ever! (っ'ヮ'c)" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png?v=2"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png?v=2"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png?v=2"
|
||||
/>
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
|
||||
<meta name="msapplication-TileColor" content="#120f1d" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=2" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=2" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=2" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
|
||||
<meta name="msapplication-TileColor" content="#120f1d" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<!-- P-Stream Preview Embed -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="/embed-preview.png" />
|
||||
<meta property="og:image" content="/embed-preview.png" />
|
||||
|
||||
<!-- P-Stream Preview Embed -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="/embed-preview.png" />
|
||||
<meta property="og:image" content="/embed-preview.png">
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_11__iPhone_XR_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/12.9__iPad_Pro_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/10.9__iPad_Air_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/10.5__iPad_Air_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/10.2__iPad_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/8.3__iPad_Mini_landscape.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_11__iPhone_XR_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/12.9__iPad_Pro_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/10.9__iPad_Air_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/10.5__iPad_Air_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/10.2__iPad_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/8.3__iPad_Mini_portrait.png"
|
||||
/>
|
||||
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_11__iPhone_XR_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/12.9__iPad_Pro_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/10.9__iPad_Air_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/10.5__iPad_Air_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/10.2__iPad_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
href="/splash_screens/8.3__iPad_Mini_landscape.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_11__iPhone_XR_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/12.9__iPad_Pro_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/10.9__iPad_Air_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/10.5__iPad_Air_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/10.2__iPad_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png">
|
||||
<link rel="apple-touch-startup-image"
|
||||
media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
href="/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Open+Sans:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Open+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="/config.js"></script>
|
||||
|
||||
<script src="/config.js"></script>
|
||||
|
||||
{{#if env.VITE_TRACK_SCRIPT }}
|
||||
{{{ env.VITE_TRACK_SCRIPT }}}
|
||||
{{/if}}
|
||||
{{#if env.VITE_TRACK_SCRIPT }} {{{ env.VITE_TRACK_SCRIPT }}} {{/if}}
|
||||
|
||||
<!-- prevent darkreader extension from messing with our already dark site -->
|
||||
<meta name="darkreader-lock" />
|
||||
<!-- prevent darkreader extension from messing with our already dark site -->
|
||||
<meta name="darkreader-lock" />
|
||||
|
||||
<!-- disabling referrer can fix some provider problems -->
|
||||
<!-- <meta name="referrer" content="no-referrer" /> -->
|
||||
<meta name="referrer" content="always">
|
||||
<!-- disabling referrer can fix some provider problems -->
|
||||
<!-- <meta name="referrer" content="no-referrer" /> -->
|
||||
<meta name="referrer" content="always" />
|
||||
|
||||
<title>P-Stream</title>
|
||||
|
||||
<title>P-Stream</title>
|
||||
{{#if opensearchEnabled }}
|
||||
<!-- OpenSearch -->
|
||||
<link
|
||||
rel="search"
|
||||
type="application/opensearchdescription+xml"
|
||||
title="P-Stream"
|
||||
href="/opensearch.xml"
|
||||
/>
|
||||
|
||||
{{#if opensearchEnabled }}
|
||||
<!-- OpenSearch -->
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="P-Stream" href="/opensearch.xml">
|
||||
|
||||
<!-- Google Sitelinks -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"url": "{{ routeDomain }}",
|
||||
"potentialAction": {
|
||||
<!-- Google Sitelinks -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"url": "{{ routeDomain }}",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "{{ routeDomain }}/browse/?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{/if}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
}
|
||||
</script>
|
||||
{{/if}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"packageManager": "pnpm@9.14.4",
|
||||
"name": "P-Stream",
|
||||
"version": "5.2.1",
|
||||
"version": "5.3.0",
|
||||
"private": true,
|
||||
"homepage": "https://github.com/p-stream/p-stream",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const fileLocation = "./figmaTokens.json";
|
|||
const theme = "blue";
|
||||
|
||||
const fileContents = fs.readFileSync(fileLocation, {
|
||||
encoding: "utf-8"
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const tokens = JSON.parse(fileContents);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { globSync } from "glob";
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import { PluginOption } from "vite";
|
||||
import Handlebars from "handlebars";
|
||||
import path from "path";
|
||||
|
||||
export const handlebars = (options: { vars?: Record<string, any> } = {}): PluginOption[] => {
|
||||
export const handlebars = (
|
||||
options: { vars?: Record<string, any> } = {},
|
||||
): PluginOption[] => {
|
||||
const files = globSync("src/assets/**/**.hbs");
|
||||
|
||||
function render(content: string): string {
|
||||
|
|
@ -14,28 +16,28 @@ export const handlebars = (options: { vars?: Record<string, any> } = {}): Plugin
|
|||
|
||||
return [
|
||||
{
|
||||
name: 'hbs-templating',
|
||||
name: "hbs-templating",
|
||||
enforce: "pre",
|
||||
transformIndexHtml: {
|
||||
order: 'pre',
|
||||
order: "pre",
|
||||
handler(html) {
|
||||
return render(html);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
viteStaticCopy({
|
||||
silent: true,
|
||||
targets: files.map(file => ({
|
||||
targets: files.map((file) => ({
|
||||
src: file,
|
||||
dest: '',
|
||||
dest: "",
|
||||
rename: path.basename(file).slice(0, -4), // remove .hbs file extension
|
||||
transform: {
|
||||
encoding: 'utf8',
|
||||
encoding: "utf8",
|
||||
handler(content: string) {
|
||||
return render(content);
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
})),
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
|
|
|||
9021
pnpm-lock.yaml
9021
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -3,4 +3,4 @@ module.exports = {
|
|||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
module.exports = {
|
||||
trailingComma: "all",
|
||||
singleQuote: true
|
||||
singleQuote: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,5 +16,5 @@ window.__CONFIG__ = {
|
|||
VITE_BACKEND_URL: null,
|
||||
|
||||
// A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-<id>" and "movie-<id>"
|
||||
VITE_DISALLOWED_IDS: ""
|
||||
VITE_DISALLOWED_IDS: "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,50 @@
|
|||
<lastBuildDate>Mon, 29 Sep 2025 18:00:00 MST</lastBuildDate>
|
||||
<atom:link href="https://pstream.mov/notifications.xml" rel="self" type="application/rss+xml" />
|
||||
|
||||
<item>
|
||||
<guid>notification-048</guid>
|
||||
<title>P-Stream v5.3.0 released!</title>
|
||||
<description>Lots of quality of life improvements and changes and a huge number of bugs fixed!
|
||||
|
||||
Additions:
|
||||
- Made the skip intro button available to all users, not just those with a febbox token. Though the one with febbox is still the best quality!
|
||||
- Added a new "Last Used Source" setting. This will automatically prioritize the source that successfully provided content for the previous episode.
|
||||
- Improved thumbnail generation to be more accurate. Now generates 127 thumbnails instead of 63, and generates them randomly rather than sequentially.
|
||||
- Added the ability to copy a specific subtitle with its delay value to your clipboard and a paste button to paste it as the current subtitle. Great for moving subtitles between sources!
|
||||
- Reworked the settings page to be more user friendly on mobile devices. It's now broken into seperate pages for each section.
|
||||
- Added a subtle fade in effect to all images being loaded.
|
||||
- Added new toggle to disable the caption background blur effect. This fixes flickering on some browsers with HDR content.
|
||||
- Added the ability to bookmark an entire movie collection at once as a group.
|
||||
- Added the ability to rename and edit bookmark groups.
|
||||
- Added the ability to edit the title and year of a bookmark.
|
||||
- Moved reorder button to the settings page.
|
||||
- Hid arrow buttons on carousels when at the start or end of the carousel. (Thanks @Smith M.)
|
||||
- Added a border to the time stamp popup when hovering over the progress bar. (Thanks @Zisra)
|
||||
|
||||
Fixes:
|
||||
- Fixed watch progress getting reset when using manual source selection.
|
||||
- Encoded all search queries so it's now possible to search for movies and shows with special characters like "V/H/S".
|
||||
- Fixed native subtitles not being displayed correctly and not showing in PiP and for mp4 streams.
|
||||
- Fixed the PiP button not displaying on mobile devices anymore.
|
||||
- Reworked the discover page to load much faster and more efficiently.
|
||||
- Fixed the febbox token input not fitting correctly on mobile devices.
|
||||
- Fixed finished and unwatched movies and episodes being saved, yet never being shown. They wont save anymore!
|
||||
- 0-9 keyboard shortcuts to skip to 0-90% progress no longer work when other keys are held down.
|
||||
- Fixed the search bar not being positioned correctly on mobile devices, especially within the PWA.
|
||||
- Fixed "i" being shown as "I" in subtitles, this was an issue with some languages that use lowercase "i" intentionally.
|
||||
- Fixed watch parties not able to be joined from the home page.
|
||||
- Updated the style of many outdated elements on the site.
|
||||
- Various other minor bugs squashed!
|
||||
|
||||
Thanks to everyone who has been testing the beta and providing feedback!
|
||||
|
||||
P-Stream has partnered with Neon Next Generation Pty Ltd. for this release! They offer high quality web and server hosting with a free virtual private container business plan! Use code "pstream" for 25% off your first 3 months! (link below)
|
||||
</description>
|
||||
<link>https://neonnextgeneration.com</link>
|
||||
<pubDate>Mon, 10 Nov 2025 13:00:00 MST</pubDate>
|
||||
<category>Feature</category>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>notification-047</guid>
|
||||
<title>Halloween Movies List Added!</title>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
Locales are difficult, here is some guidance.
|
||||
|
||||
## Process on adding new languages
|
||||
|
||||
1. Use [Weblate](https://docs.pstream.mov/links/weblate) to add translations, see contributing guidelines.
|
||||
2. Add your language to `@/assets/languages.ts`. Must be in ISO format (ISO-639 for language and ISO-3166 for country/region). For joke languages, use any format.
|
||||
3. If the language code doesn't have a region specified (Such as in `pt-BR`, `BR` being the region), add a default region in `@/utils/language.ts` at `defaultLanguageCodes`
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
html,
|
||||
body {
|
||||
font-family: "Lato", sans-serif !important;
|
||||
@apply bg-background-main font-main text-type-text;
|
||||
@apply bg-background-main !important;
|
||||
@apply font-main text-type-text;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
font-size: 1.0248em;
|
||||
|
|
@ -218,20 +219,38 @@ input[type="range"].styled-slider.slider-progress::-ms-fill-lower {
|
|||
border-right-width: 0;
|
||||
}
|
||||
|
||||
/* Modern thin rounded scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: theme("colors.video.context.border") transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: theme("colors.video.context.border");
|
||||
border: 5px solid transparent;
|
||||
border-left: 0;
|
||||
background-clip: content-box;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
/* For some reason the styles don't get applied without the width */
|
||||
width: 13px;
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(134, 82, 187, 0.8);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background-color: rgba(134, 82, 187, 1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.grecaptcha-badge {
|
||||
|
|
@ -408,3 +427,19 @@ input[type="range"].styled-slider.slider-progress::-ms-fill-lower {
|
|||
.google-cast-button:not(.casting) google-cast-launcher {
|
||||
@apply brightness-[500];
|
||||
}
|
||||
|
||||
/* Image fade-in on load */
|
||||
img:not([src=""]) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
/* Fade in when image has loaded class */
|
||||
img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* For images that are already cached/loaded, show them immediately */
|
||||
img[complete]:not([src=""]) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -286,7 +286,6 @@
|
|||
"dropdown": {
|
||||
"placeholderButton": "Add to group",
|
||||
"empty": "No groups yet",
|
||||
"addButton": "Add",
|
||||
"removeFromGroup": "Remove from group",
|
||||
"removeAll": "Remove all"
|
||||
},
|
||||
|
|
@ -297,7 +296,27 @@
|
|||
"description": "Drag and drop to reorder your bookmark groups",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
},
|
||||
"editGroup": {
|
||||
"title": "Edit Group",
|
||||
"description": "Edit the name and icon of your bookmark group",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"affectsBookmarks": "This will affect {{count}} bookmark(s)",
|
||||
"nameLabel": "Group name",
|
||||
"namePlaceholder": "Enter a name for your group"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Bookmark",
|
||||
"description": "Edit the details for this bookmark",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"groupsLabel": "Groups",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Enter a title for your bookmark",
|
||||
"yearLabel": "Year",
|
||||
"yearPlaceholder": "Enter a year for your bookmark"
|
||||
}
|
||||
},
|
||||
"continueWatching": {
|
||||
|
|
@ -736,6 +755,8 @@
|
|||
},
|
||||
"subtitles": {
|
||||
"customChoice": "Drop or upload file",
|
||||
"pasteChoice": "Paste subtitle data",
|
||||
"doubleClickToCopy": "Double click to copy subtitle data",
|
||||
"customizeLabel": "Customize",
|
||||
"previewLabel": "Subtitle preview:",
|
||||
"offChoice": "Off",
|
||||
|
|
@ -921,6 +942,9 @@
|
|||
"search": {
|
||||
"placeholder": "Search settings..."
|
||||
},
|
||||
"all": {
|
||||
"title": "All Settings"
|
||||
},
|
||||
"account": {
|
||||
"accountDetails": {
|
||||
"deviceNameLabel": "Device name",
|
||||
|
|
@ -1020,7 +1044,8 @@
|
|||
"forceCompactEpisodeViewDescription": "Force the episode carousel in the player to use the \"classic\" compact vertical view. Disabled by default.",
|
||||
"homeSectionOrder": "Home section order",
|
||||
"homeSectionOrderDescription": "Drag and drop to reorder the watching and bookmarks sections on your homepage. Group order can be editied from the home page.",
|
||||
"forceCompactEpisodeViewLabel": "Compact episodes"
|
||||
"forceCompactEpisodeViewLabel": "Compact episodes",
|
||||
"homeSectionOrderGroups": "Reorder bookmark groups"
|
||||
},
|
||||
"sections": {
|
||||
"watching": "Currently Watching",
|
||||
|
|
@ -1105,7 +1130,10 @@
|
|||
"embedOrderEnableLabel": "Custom embed order",
|
||||
"manualSource": "Manual source selection",
|
||||
"manualSourceDescription": "Require picking a source before scraping. Disables automatic source selection and opens the source picker when starting playback.",
|
||||
"manualSourceLabel": "Manual source selection"
|
||||
"manualSourceLabel": "Manual source selection",
|
||||
"lastSuccessfulSource": "Last used source",
|
||||
"lastSuccessfulSourceDescription": "Automatically prioritize the source that successfully provided content for the previous episode. This helps ensure continuity when watching series.",
|
||||
"lastSuccessfulSourceEnableLabel": "Last used source"
|
||||
},
|
||||
"reset": "Reset",
|
||||
"save": "Save",
|
||||
|
|
@ -1125,15 +1153,17 @@
|
|||
},
|
||||
"subtitles": {
|
||||
"backgroundLabel": "Background opacity",
|
||||
"backgroundBlurLabel": "Background blur",
|
||||
"backgroundBlurEnabledLabel": "Background blur",
|
||||
"backgroundBlurEnabledDescription": "Disabling caption background blur may fix some flickering issues",
|
||||
"backgroundBlurLabel": "Background blur intensity",
|
||||
"colorLabel": "Color",
|
||||
"previewQuote": "Convinced life is meaningless, I lack the courage of my conviction.",
|
||||
"textSizeLabel": "Text size",
|
||||
"title": "Subtitles",
|
||||
"textBoldLabel": "Bold text",
|
||||
"verticalPositionLabel": "Vertical position",
|
||||
"default": "Default",
|
||||
"low": "Low",
|
||||
"high": "High",
|
||||
"low": "Default",
|
||||
"textStyle": {
|
||||
"title": "Text style",
|
||||
"default": "Default",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,444 +1,440 @@
|
|||
{
|
||||
"about": {
|
||||
"description": "Minion-web is a banana application that searches the banana for bananas. The banana aims for a mostly banana approach to consuming banana.",
|
||||
"faqTitle": "Banana questions",
|
||||
"q1": {
|
||||
"body": "Minion-web does not banana any banana. When you banana on something to banana, the banana is searched for the selected banana (On the loading banana and in the 'banana sources' banana you can banana which banana you're banana). Banana never gets banana by Minion-web, everything is banana this banana mechanism.",
|
||||
"title": "Where does the banana come from?"
|
||||
},
|
||||
"q2": {
|
||||
"body": "It's not banana to banana a banana or banana, Minion-web does not banana any banana. All banana is banana through bananas on the banana.",
|
||||
"title": "Banana can I banana a banana or banana?",
|
||||
"section": "banana search"
|
||||
},
|
||||
"q3": {
|
||||
"body": "Our banana results are banana by The Banana Banana (TBMB) and banana regardless of whether our bananas actually have the banana.",
|
||||
"title": "The banana results banana the banana or banana, banana can't I banana it?",
|
||||
"section": "banana search"
|
||||
},
|
||||
"title": "About Minion-web",
|
||||
"q9": {
|
||||
"title": "This banana is missing / Can I request banana?"
|
||||
},
|
||||
"q11": {
|
||||
"title": "Why am i seeing a banana screen?"
|
||||
},
|
||||
"q4": {
|
||||
"title": "What about my banana and stuff?"
|
||||
},
|
||||
"q6": {
|
||||
"title": "Is there a Banana app?"
|
||||
}
|
||||
"about": {
|
||||
"description": "Minion-web is a banana application that searches the banana for bananas. The banana aims for a mostly banana approach to consuming banana.",
|
||||
"faqTitle": "Banana questions",
|
||||
"q1": {
|
||||
"body": "Minion-web does not banana any banana. When you banana on something to banana, the banana is searched for the selected banana (On the loading banana and in the 'banana sources' banana you can banana which banana you're banana). Banana never gets banana by Minion-web, everything is banana this banana mechanism.",
|
||||
"title": "Where does the banana come from?"
|
||||
},
|
||||
"actions": {
|
||||
"copied": "Banana",
|
||||
"copy": "Banana"
|
||||
"q2": {
|
||||
"body": "It's not banana to banana a banana or banana, Minion-web does not banana any banana. All banana is banana through bananas on the banana.",
|
||||
"title": "Banana can I banana a banana or banana?",
|
||||
"section": "banana search"
|
||||
},
|
||||
"auth": {
|
||||
"createAccount": "Whaaaat? Don't have an account yet? <0>Create an account.</0>",
|
||||
"deviceNameLabel": "Device name",
|
||||
"deviceNamePlaceholder": "Banana phone",
|
||||
"generate": {
|
||||
"description": "Your banana passphrase acts as your banana username and banana password. Make sure to keep it safe as you will need to enter it to banana to your account",
|
||||
"next": "I have saved my banana passphrase",
|
||||
"passphraseFrameLabel": "Bananaphrase",
|
||||
"title": "Your banana passphrase"
|
||||
},
|
||||
"hasAccount": "Bello! Already have an account? <0>Login here.</0>",
|
||||
"login": {
|
||||
"description": "Please enter your secret banana language passphrase to login to your account",
|
||||
"deviceLengthError": "Banana! Please enter a device name",
|
||||
"passphraseLabel": "12-Banana passphrase",
|
||||
"passphrasePlaceholder": "Banana Passphrase",
|
||||
"submit": "Bello! Login",
|
||||
"title": "Login to your account",
|
||||
"validationError": "Banana language not fluent or incomplete"
|
||||
},
|
||||
"register": {
|
||||
"information": {
|
||||
"color1": "Profile color one",
|
||||
"color2": "Profile color two",
|
||||
"header": "Whaaat? Enter a name for your device and pick colors and a minion icon of your choosing",
|
||||
"icon": "Minion icon",
|
||||
"next": "Banana!",
|
||||
"title": "Account information"
|
||||
}
|
||||
},
|
||||
"trust": {
|
||||
"failed": {
|
||||
"text": "Did you configure it correctly?",
|
||||
"title": "Failed to reach server"
|
||||
},
|
||||
"host": "You are connecting to <0>{{hostname}}</0> - please confirm you trust it before making a banana account",
|
||||
"no": "Go back, banana",
|
||||
"title": "Do you trust this server?",
|
||||
"yes": "I trust this server, banana!"
|
||||
},
|
||||
"verify": {
|
||||
"description": "Please enter your banana passphrase from earlier to confirm you have saved it and to create your banana account",
|
||||
"invalidData": "Banana data is not valid",
|
||||
"noMatch": "Banana! Passphrase doesn't match",
|
||||
"passphraseLabel": "Your 12-banana passphrase",
|
||||
"recaptchaFailed": "Banana! ReCaptcha validation failed",
|
||||
"register": "Create banana account",
|
||||
"title": "Confirm your banana passphrase"
|
||||
}
|
||||
"q3": {
|
||||
"body": "Our banana results are banana by The Banana Banana (TBMB) and banana regardless of whether our bananas actually have the banana.",
|
||||
"title": "The banana results banana the banana or banana, banana can't I banana it?",
|
||||
"section": "banana search"
|
||||
},
|
||||
"errors": {
|
||||
"badge": "It broke",
|
||||
"details": "Error banana details",
|
||||
"reloadPage": "Reload the banana",
|
||||
"showError": "Show banana details",
|
||||
"title": "We encountered a banana!"
|
||||
"title": "About Minion-web",
|
||||
"q9": {
|
||||
"title": "This banana is missing / Can I request banana?"
|
||||
},
|
||||
"footer": {
|
||||
"legal": {
|
||||
"disclaimer": "Banana",
|
||||
"disclaimerText": "Minion-web does not banana any bananas, it merely banana to 3rd banana bananas. Banana issues should be banana up with the banana bananas and bananas. Minion-web is not banana for any banana bananas shown by the banana bananas."
|
||||
},
|
||||
"links": {
|
||||
"discord": "Banana",
|
||||
"legal": "Banana",
|
||||
"github": "Banana"
|
||||
},
|
||||
"tagline": "Banana your favourite bananas and bananas with this open source banana app."
|
||||
"q11": {
|
||||
"title": "Why am i seeing a banana screen?"
|
||||
},
|
||||
"global": {
|
||||
"name": "banana-web",
|
||||
"pages": {
|
||||
"about": "About banana",
|
||||
"legal": "Legal / DMCA",
|
||||
"login": "Banana Login",
|
||||
"pagetitle": "{{title}} - banana-web",
|
||||
"register": "Banana Register",
|
||||
"settings": "Banana Settings"
|
||||
}
|
||||
"q4": {
|
||||
"title": "What about my banana and stuff?"
|
||||
},
|
||||
"home": {
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Banana"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Continue Banana"
|
||||
},
|
||||
"mediaList": {
|
||||
"stopEditing": "Stop banana"
|
||||
},
|
||||
"search": {
|
||||
"allResults": "Banana's all we banana!",
|
||||
"failed": "Failed to banana banana, try again!",
|
||||
"loading": "Loading...",
|
||||
"noResults": "We couldn't banana anything!",
|
||||
"placeholder": {
|
||||
"default": "Banana do you want to banana?"
|
||||
},
|
||||
"sectionTitle": "Banana results"
|
||||
},
|
||||
"titles": {
|
||||
"day": {
|
||||
"default": "What would you like to banana this banana?",
|
||||
"extra": [
|
||||
"Feeling banana? Jurassic banana banana banana banana perfect banana."
|
||||
]
|
||||
},
|
||||
"morning": {
|
||||
"default": "What would you like to banana this banana?",
|
||||
"extra": [
|
||||
"Banana! I hear Banana Sunrise is banana"
|
||||
]
|
||||
},
|
||||
"night": {
|
||||
"default": "What would you like to banana banana?",
|
||||
"extra": [
|
||||
"Banana? I hear The Banana is banana."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"episodeDisplay": "S{{season}} E{{episode}}",
|
||||
"types": {
|
||||
"movie": "Banana Movie",
|
||||
"show": "Banana Show"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Check your banana connection"
|
||||
},
|
||||
"menu": {
|
||||
"about": "Banana us",
|
||||
"logout": "Banana out",
|
||||
"register": "Banana to banana",
|
||||
"settings": "Banana",
|
||||
"support": "Banana"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Not banana",
|
||||
"goHome": "Back to banana",
|
||||
"message": "We looked everywhere: under the banana, in the banana, behind the banana but ultimately couldn't find the banana you are looking for.",
|
||||
"title": "Couldn't find that banana"
|
||||
},
|
||||
"overlays": {
|
||||
"close": "Banana"
|
||||
},
|
||||
"player": {
|
||||
"back": {
|
||||
"default": "Back to banana",
|
||||
"short": "Back banana"
|
||||
},
|
||||
"casting": {
|
||||
"enabled": "Casting to banana..."
|
||||
},
|
||||
"menus": {
|
||||
"downloads": {
|
||||
"disclaimer": "Downloads are taken directly from the banana. banana-web does not have banana over how the banana are banana.",
|
||||
"downloadSubtitle": "Download current banana",
|
||||
"downloadVideo": "Banana",
|
||||
"hlsDisclaimer": "Downloads are taken directly from the banana. Banana-web does not have control over how the downloads are banana. please note that you are downloading Banana playlist, this is intended for minions familiar with advanced multimedia banana.",
|
||||
"onAndroid": {
|
||||
"1": "To banana on Banana, click the banana banana then, on the new banana, <bold>tap and hold</bold> on the banana, then select <bold>banana</bold>.",
|
||||
"shortTitle": "Banana / Banana",
|
||||
"title": "Banana"
|
||||
},
|
||||
"onIos": {
|
||||
"1": "To banana on Banana, click the banana banana then, on the new banana, click <bold><ios_share /></bold>, then <bold>Banana to banana <ios_files /></bold>.",
|
||||
"shortTitle": "Banana / Banana",
|
||||
"title": "Banana"
|
||||
},
|
||||
"onPc": {
|
||||
"1": "On PC, click the banana banana then, on the new banana, right click the banana and select <bold>Banana</bold>",
|
||||
"shortTitle": "Banana / PC",
|
||||
"title": "Banana"
|
||||
},
|
||||
"title": "Banana"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Banana",
|
||||
"emptyState": "There are no banana in this banana, check back banana!",
|
||||
"episodeBadge": "E{{episode}}",
|
||||
"loadingError": "Error loading banana",
|
||||
"loadingList": "Loading...",
|
||||
"loadingTitle": "Loading...",
|
||||
"unairedEpisodes": "One or more banana in this banana have been banana because they haven't been aired yet."
|
||||
},
|
||||
"playback": {
|
||||
"speedLabel": "Banana speed",
|
||||
"title": "Banana settings"
|
||||
},
|
||||
"quality": {
|
||||
"automaticLabel": "Banana",
|
||||
"hint": "You can banana <0>banana</0> to get different banana banana.",
|
||||
"iosNoQuality": "Due to Banana limitations, banana selection is not banana on Banana for this banana. You can banana <0>banana</0> to get different banana banana.",
|
||||
"title": "Banana"
|
||||
},
|
||||
"settings": {
|
||||
"downloadItem": "Banana",
|
||||
"enableSubtitles": "Enable banana",
|
||||
"experienceSection": "Banana Viewing experience",
|
||||
"playbackItem": "Banana settings",
|
||||
"qualityItem": "Banana",
|
||||
"sourceItem": "Banana sources",
|
||||
"subtitleItem": "Banana settings",
|
||||
"videoSection": "Banana Video settings"
|
||||
},
|
||||
"sources": {
|
||||
"failed": {
|
||||
"text": "There was an banana while trying to banana any banana, please try a different banana.",
|
||||
"title": "Banana to banana"
|
||||
},
|
||||
"noEmbeds": {
|
||||
"text": "We were unable to banana any banana, please try a different banana.",
|
||||
"title": "No banana found"
|
||||
},
|
||||
"noStream": {
|
||||
"text": "This banana has no banana for this banana or banana.",
|
||||
"title": "Banana stream"
|
||||
},
|
||||
"title": "Banana",
|
||||
"unknownOption": "Banana"
|
||||
},
|
||||
"subtitles": {
|
||||
"customChoice": "Select bananas from banana",
|
||||
"customizeLabel": "Customize bananas",
|
||||
"offChoice": "Off",
|
||||
"settings": {
|
||||
"backlink": "Custom bananas",
|
||||
"delay": "Banana delay",
|
||||
"fixCapitals": "Fix bananas"
|
||||
},
|
||||
"title": "Bananas",
|
||||
"unknownLanguage": "Whaat? Unknown banana!"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"api": {
|
||||
"text": "Could not load API banana, please check your banana connection.",
|
||||
"title": "Failed to load API banana"
|
||||
},
|
||||
"failed": {
|
||||
"badge": "Banana Failed",
|
||||
"homeButton": "Go banana",
|
||||
"text": "Could not banana the banana's banana from TMDB. Please banana whether TMDB is down or banana on your banana connection.",
|
||||
"title": "Failed to load banana metadata"
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Banana Not found",
|
||||
"homeButton": "Back to banana",
|
||||
"text": "We couldn't find the banana you requested. Either it's been banana or you tampered with the banana.",
|
||||
"title": "Couldn't find that banana."
|
||||
}
|
||||
},
|
||||
"nextEpisode": {
|
||||
"next": "Next banana"
|
||||
},
|
||||
"playbackError": {
|
||||
"badge": "Banana Playback error",
|
||||
"errors": {
|
||||
"errorAborted": "The fetching of the banana was aborted by the user's banana.",
|
||||
"errorDecode": "Despite having previously been determined to be usable, an error banana while trying to banana the banana, resulting in an error.",
|
||||
"errorGenericMedia": "Unknown banana error occurred.",
|
||||
"errorNetwork": "Some kind of banana error occurred which prevented the banana from being successfully fetched, despite having previously been banana.",
|
||||
"errorNotSupported": "The banana or banana provider object is not banana."
|
||||
},
|
||||
"homeButton": "Go home",
|
||||
"text": "There was an error trying to play the banana. Please try again.",
|
||||
"title": "Failed to play banana video!"
|
||||
},
|
||||
"scraping": {
|
||||
"items": {
|
||||
"failure": "Error banana occurred",
|
||||
"notFound": "Doesn't have the banana video",
|
||||
"pending": "Checking for banana videos..."
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Not found",
|
||||
"detailsButton": "Show details",
|
||||
"homeButton": "Go home",
|
||||
"text": "We have searched through our banana providers and cannot find the banana you are looking for! We do not host the banana and have no control over what is available. Please click 'Show details' below for more details.",
|
||||
"title": "We couldn't find that banana"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"regular": "{{timeWatched}} / {{duration}}",
|
||||
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}",
|
||||
"shortRegular": "{{timeWatched}}",
|
||||
"shortRemaining": "-{{timeLeft}}"
|
||||
},
|
||||
"turnstile": {
|
||||
"description": "Please verify that you are banana by completing the banana on the right. This is to keep banana-web banana!",
|
||||
"error": "Failed to verify your banananess. Please try banana.",
|
||||
"title": "We banana to verify that you're banana",
|
||||
"verifyingHumanity": "Verifying your banana..."
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"legal": {
|
||||
"title": "Banana"
|
||||
},
|
||||
"loadingApp": "Loading banana",
|
||||
"loadingUser": "Loading your banana",
|
||||
"loadingUserError": {
|
||||
"logout": "Banana",
|
||||
"reset": "Banana banana banana",
|
||||
"text": "Failed to banana your banana",
|
||||
"textWithReset": "Failed to banana your banana from your banana banana, banana to banana back to the banana banana?"
|
||||
},
|
||||
"migration": {
|
||||
"failed": "Banana to banana your banana.",
|
||||
"inProgress": "Please banana, we are banana your banana. This shouldn't banana long."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"account": {
|
||||
"accountDetails": {
|
||||
"deviceNameLabel": "Banana name",
|
||||
"deviceNamePlaceholder": "Banana phone",
|
||||
"editProfile": "Banana",
|
||||
"logoutButton": "Banana out"
|
||||
},
|
||||
"actions": {
|
||||
"delete": {
|
||||
"button": "Banana",
|
||||
"confirmButton": "Banana",
|
||||
"confirmDescription": "Banana you banana to banana your banana? All your bananas will be banana!",
|
||||
"confirmTitle": "Banana you banana?",
|
||||
"text": "Whaaat? This banana is irreversible. All bananas will be banana and nothing can be banana.",
|
||||
"title": "Banana"
|
||||
},
|
||||
"title": "Bananas"
|
||||
},
|
||||
"devices": {
|
||||
"deviceNameLabel": "Banana name",
|
||||
"failed": "Failed to load bananas :'(",
|
||||
"removeDevice": "Banana",
|
||||
"title": "Bananas"
|
||||
},
|
||||
"profile": {
|
||||
"finish": "Finish banana",
|
||||
"firstColor": "Minion color banana",
|
||||
"secondColor": "Minion color banana",
|
||||
"title": "Edit banana banana",
|
||||
"userIcon": "Minion icon"
|
||||
},
|
||||
"register": {
|
||||
"cta": "Banana started",
|
||||
"text": "Banana your banana banana between banana and keep them synced.",
|
||||
"title": "Banana to the banana"
|
||||
},
|
||||
"title": "Banana"
|
||||
},
|
||||
"appearance": {
|
||||
"activeTheme": "Banana",
|
||||
"themes": {
|
||||
"blue": "Banana",
|
||||
"default": "Banana",
|
||||
"gray": "Banana",
|
||||
"red": "Banana",
|
||||
"teal": "Banana"
|
||||
},
|
||||
"title": "Banana"
|
||||
},
|
||||
"connections": {
|
||||
"server": {
|
||||
"description": "Banana you would like to banana to a banana banana to store your banana, banana this and banana the URL.",
|
||||
"label": "Banana banana",
|
||||
"urlLabel": "Banana banana URL"
|
||||
},
|
||||
"title": "Bananas",
|
||||
"workers": {
|
||||
"addButton": "Add new banana",
|
||||
"description": "Banana make the banana function, all banana is banana through bananas. Banana this if you banana to banana your own bananas.",
|
||||
"emptyState": "No bananas yet, banana one banana",
|
||||
"label": "Banana custom banana workers",
|
||||
"urlLabel": "Banana URLs",
|
||||
"urlPlaceholder": "banana://"
|
||||
}
|
||||
},
|
||||
"reset": "Banana",
|
||||
"save": "Banana",
|
||||
"sidebar": {
|
||||
"info": {
|
||||
"appVersion": "Banana version",
|
||||
"backendUrl": "Banana URL",
|
||||
"backendVersion": "Banana version",
|
||||
"hostname": "Banana",
|
||||
"insecure": "Banana",
|
||||
"notLoggedIn": "You are not banana in",
|
||||
"secure": "Banana",
|
||||
"title": "Banana information",
|
||||
"unknownVersion": "Unknown",
|
||||
"userId": "Minion ID"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"backgroundLabel": "Banana capacity",
|
||||
"colorLabel": "Banana",
|
||||
"previewQuote": "I must not banana. Banana is the minion-killer.",
|
||||
"textSizeLabel": "Banana size",
|
||||
"title": "Bananas"
|
||||
},
|
||||
"unsaved": "Whaaat? You have unsaved bananas"
|
||||
"q6": {
|
||||
"title": "Is there a Banana app?"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"copied": "Banana",
|
||||
"copy": "Banana"
|
||||
},
|
||||
"auth": {
|
||||
"createAccount": "Whaaaat? Don't have an account yet? <0>Create an account.</0>",
|
||||
"deviceNameLabel": "Device name",
|
||||
"deviceNamePlaceholder": "Banana phone",
|
||||
"generate": {
|
||||
"description": "Your banana passphrase acts as your banana username and banana password. Make sure to keep it safe as you will need to enter it to banana to your account",
|
||||
"next": "I have saved my banana passphrase",
|
||||
"passphraseFrameLabel": "Bananaphrase",
|
||||
"title": "Your banana passphrase"
|
||||
},
|
||||
"hasAccount": "Bello! Already have an account? <0>Login here.</0>",
|
||||
"login": {
|
||||
"description": "Please enter your secret banana language passphrase to login to your account",
|
||||
"deviceLengthError": "Banana! Please enter a device name",
|
||||
"passphraseLabel": "12-Banana passphrase",
|
||||
"passphrasePlaceholder": "Banana Passphrase",
|
||||
"submit": "Bello! Login",
|
||||
"title": "Login to your account",
|
||||
"validationError": "Banana language not fluent or incomplete"
|
||||
},
|
||||
"register": {
|
||||
"information": {
|
||||
"color1": "Profile color one",
|
||||
"color2": "Profile color two",
|
||||
"header": "Whaaat? Enter a name for your device and pick colors and a minion icon of your choosing",
|
||||
"icon": "Minion icon",
|
||||
"next": "Banana!",
|
||||
"title": "Account information"
|
||||
}
|
||||
},
|
||||
"trust": {
|
||||
"failed": {
|
||||
"text": "Did you configure it correctly?",
|
||||
"title": "Failed to reach server"
|
||||
},
|
||||
"host": "You are connecting to <0>{{hostname}}</0> - please confirm you trust it before making a banana account",
|
||||
"no": "Go back, banana",
|
||||
"title": "Do you trust this server?",
|
||||
"yes": "I trust this server, banana!"
|
||||
},
|
||||
"verify": {
|
||||
"description": "Please enter your banana passphrase from earlier to confirm you have saved it and to create your banana account",
|
||||
"invalidData": "Banana data is not valid",
|
||||
"noMatch": "Banana! Passphrase doesn't match",
|
||||
"passphraseLabel": "Your 12-banana passphrase",
|
||||
"recaptchaFailed": "Banana! ReCaptcha validation failed",
|
||||
"register": "Create banana account",
|
||||
"title": "Confirm your banana passphrase"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"badge": "It broke",
|
||||
"details": "Error banana details",
|
||||
"reloadPage": "Reload the banana",
|
||||
"showError": "Show banana details",
|
||||
"title": "We encountered a banana!"
|
||||
},
|
||||
"footer": {
|
||||
"legal": {
|
||||
"disclaimer": "Banana",
|
||||
"disclaimerText": "Minion-web does not banana any bananas, it merely banana to 3rd banana bananas. Banana issues should be banana up with the banana bananas and bananas. Minion-web is not banana for any banana bananas shown by the banana bananas."
|
||||
},
|
||||
"links": {
|
||||
"discord": "Banana",
|
||||
"legal": "Banana",
|
||||
"github": "Banana"
|
||||
},
|
||||
"tagline": "Banana your favourite bananas and bananas with this open source banana app."
|
||||
},
|
||||
"global": {
|
||||
"name": "banana-web",
|
||||
"pages": {
|
||||
"about": "About banana",
|
||||
"legal": "Legal / DMCA",
|
||||
"login": "Banana Login",
|
||||
"pagetitle": "{{title}} - banana-web",
|
||||
"register": "Banana Register",
|
||||
"settings": "Banana Settings"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Banana"
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Continue Banana"
|
||||
},
|
||||
"mediaList": {
|
||||
"stopEditing": "Stop banana"
|
||||
},
|
||||
"search": {
|
||||
"allResults": "Banana's all we banana!",
|
||||
"failed": "Failed to banana banana, try again!",
|
||||
"loading": "Loading...",
|
||||
"noResults": "We couldn't banana anything!",
|
||||
"placeholder": {
|
||||
"default": "Banana do you want to banana?"
|
||||
},
|
||||
"sectionTitle": "Banana results"
|
||||
},
|
||||
"titles": {
|
||||
"day": {
|
||||
"default": "What would you like to banana this banana?",
|
||||
"extra": [
|
||||
"Feeling banana? Jurassic banana banana banana banana perfect banana."
|
||||
]
|
||||
},
|
||||
"morning": {
|
||||
"default": "What would you like to banana this banana?",
|
||||
"extra": ["Banana! I hear Banana Sunrise is banana"]
|
||||
},
|
||||
"night": {
|
||||
"default": "What would you like to banana banana?",
|
||||
"extra": ["Banana? I hear The Banana is banana."]
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"episodeDisplay": "S{{season}} E{{episode}}",
|
||||
"types": {
|
||||
"movie": "Banana Movie",
|
||||
"show": "Banana Show"
|
||||
}
|
||||
},
|
||||
"navigation": {
|
||||
"banner": {
|
||||
"offline": "Check your banana connection"
|
||||
},
|
||||
"menu": {
|
||||
"about": "Banana us",
|
||||
"logout": "Banana out",
|
||||
"register": "Banana to banana",
|
||||
"settings": "Banana",
|
||||
"support": "Banana"
|
||||
}
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Not banana",
|
||||
"goHome": "Back to banana",
|
||||
"message": "We looked everywhere: under the banana, in the banana, behind the banana but ultimately couldn't find the banana you are looking for.",
|
||||
"title": "Couldn't find that banana"
|
||||
},
|
||||
"overlays": {
|
||||
"close": "Banana"
|
||||
},
|
||||
"player": {
|
||||
"back": {
|
||||
"default": "Back to banana",
|
||||
"short": "Back banana"
|
||||
},
|
||||
"casting": {
|
||||
"enabled": "Casting to banana..."
|
||||
},
|
||||
"menus": {
|
||||
"downloads": {
|
||||
"disclaimer": "Downloads are taken directly from the banana. banana-web does not have banana over how the banana are banana.",
|
||||
"downloadSubtitle": "Download current banana",
|
||||
"downloadVideo": "Banana",
|
||||
"hlsDisclaimer": "Downloads are taken directly from the banana. Banana-web does not have control over how the downloads are banana. please note that you are downloading Banana playlist, this is intended for minions familiar with advanced multimedia banana.",
|
||||
"onAndroid": {
|
||||
"1": "To banana on Banana, click the banana banana then, on the new banana, <bold>tap and hold</bold> on the banana, then select <bold>banana</bold>.",
|
||||
"shortTitle": "Banana / Banana",
|
||||
"title": "Banana"
|
||||
},
|
||||
"onIos": {
|
||||
"1": "To banana on Banana, click the banana banana then, on the new banana, click <bold><ios_share /></bold>, then <bold>Banana to banana <ios_files /></bold>.",
|
||||
"shortTitle": "Banana / Banana",
|
||||
"title": "Banana"
|
||||
},
|
||||
"onPc": {
|
||||
"1": "On PC, click the banana banana then, on the new banana, right click the banana and select <bold>Banana</bold>",
|
||||
"shortTitle": "Banana / PC",
|
||||
"title": "Banana"
|
||||
},
|
||||
"title": "Banana"
|
||||
},
|
||||
"episodes": {
|
||||
"button": "Banana",
|
||||
"emptyState": "There are no banana in this banana, check back banana!",
|
||||
"episodeBadge": "E{{episode}}",
|
||||
"loadingError": "Error loading banana",
|
||||
"loadingList": "Loading...",
|
||||
"loadingTitle": "Loading...",
|
||||
"unairedEpisodes": "One or more banana in this banana have been banana because they haven't been aired yet."
|
||||
},
|
||||
"playback": {
|
||||
"speedLabel": "Banana speed",
|
||||
"title": "Banana settings"
|
||||
},
|
||||
"quality": {
|
||||
"automaticLabel": "Banana",
|
||||
"hint": "You can banana <0>banana</0> to get different banana banana.",
|
||||
"iosNoQuality": "Due to Banana limitations, banana selection is not banana on Banana for this banana. You can banana <0>banana</0> to get different banana banana.",
|
||||
"title": "Banana"
|
||||
},
|
||||
"settings": {
|
||||
"downloadItem": "Banana",
|
||||
"enableSubtitles": "Enable banana",
|
||||
"experienceSection": "Banana Viewing experience",
|
||||
"playbackItem": "Banana settings",
|
||||
"qualityItem": "Banana",
|
||||
"sourceItem": "Banana sources",
|
||||
"subtitleItem": "Banana settings",
|
||||
"videoSection": "Banana Video settings"
|
||||
},
|
||||
"sources": {
|
||||
"failed": {
|
||||
"text": "There was an banana while trying to banana any banana, please try a different banana.",
|
||||
"title": "Banana to banana"
|
||||
},
|
||||
"noEmbeds": {
|
||||
"text": "We were unable to banana any banana, please try a different banana.",
|
||||
"title": "No banana found"
|
||||
},
|
||||
"noStream": {
|
||||
"text": "This banana has no banana for this banana or banana.",
|
||||
"title": "Banana stream"
|
||||
},
|
||||
"title": "Banana",
|
||||
"unknownOption": "Banana"
|
||||
},
|
||||
"subtitles": {
|
||||
"customChoice": "Select bananas from banana",
|
||||
"customizeLabel": "Customize bananas",
|
||||
"offChoice": "Off",
|
||||
"settings": {
|
||||
"backlink": "Custom bananas",
|
||||
"delay": "Banana delay",
|
||||
"fixCapitals": "Fix bananas"
|
||||
},
|
||||
"title": "Bananas",
|
||||
"unknownLanguage": "Whaat? Unknown banana!"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"api": {
|
||||
"text": "Could not load API banana, please check your banana connection.",
|
||||
"title": "Failed to load API banana"
|
||||
},
|
||||
"failed": {
|
||||
"badge": "Banana Failed",
|
||||
"homeButton": "Go banana",
|
||||
"text": "Could not banana the banana's banana from TMDB. Please banana whether TMDB is down or banana on your banana connection.",
|
||||
"title": "Failed to load banana metadata"
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Banana Not found",
|
||||
"homeButton": "Back to banana",
|
||||
"text": "We couldn't find the banana you requested. Either it's been banana or you tampered with the banana.",
|
||||
"title": "Couldn't find that banana."
|
||||
}
|
||||
},
|
||||
"nextEpisode": {
|
||||
"next": "Next banana"
|
||||
},
|
||||
"playbackError": {
|
||||
"badge": "Banana Playback error",
|
||||
"errors": {
|
||||
"errorAborted": "The fetching of the banana was aborted by the user's banana.",
|
||||
"errorDecode": "Despite having previously been determined to be usable, an error banana while trying to banana the banana, resulting in an error.",
|
||||
"errorGenericMedia": "Unknown banana error occurred.",
|
||||
"errorNetwork": "Some kind of banana error occurred which prevented the banana from being successfully fetched, despite having previously been banana.",
|
||||
"errorNotSupported": "The banana or banana provider object is not banana."
|
||||
},
|
||||
"homeButton": "Go home",
|
||||
"text": "There was an error trying to play the banana. Please try again.",
|
||||
"title": "Failed to play banana video!"
|
||||
},
|
||||
"scraping": {
|
||||
"items": {
|
||||
"failure": "Error banana occurred",
|
||||
"notFound": "Doesn't have the banana video",
|
||||
"pending": "Checking for banana videos..."
|
||||
},
|
||||
"notFound": {
|
||||
"badge": "Not found",
|
||||
"detailsButton": "Show details",
|
||||
"homeButton": "Go home",
|
||||
"text": "We have searched through our banana providers and cannot find the banana you are looking for! We do not host the banana and have no control over what is available. Please click 'Show details' below for more details.",
|
||||
"title": "We couldn't find that banana"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"regular": "{{timeWatched}} / {{duration}}",
|
||||
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}",
|
||||
"shortRegular": "{{timeWatched}}",
|
||||
"shortRemaining": "-{{timeLeft}}"
|
||||
},
|
||||
"turnstile": {
|
||||
"description": "Please verify that you are banana by completing the banana on the right. This is to keep banana-web banana!",
|
||||
"error": "Failed to verify your banananess. Please try banana.",
|
||||
"title": "We banana to verify that you're banana",
|
||||
"verifyingHumanity": "Verifying your banana..."
|
||||
}
|
||||
},
|
||||
"screens": {
|
||||
"legal": {
|
||||
"title": "Banana"
|
||||
},
|
||||
"loadingApp": "Loading banana",
|
||||
"loadingUser": "Loading your banana",
|
||||
"loadingUserError": {
|
||||
"logout": "Banana",
|
||||
"reset": "Banana banana banana",
|
||||
"text": "Failed to banana your banana",
|
||||
"textWithReset": "Failed to banana your banana from your banana banana, banana to banana back to the banana banana?"
|
||||
},
|
||||
"migration": {
|
||||
"failed": "Banana to banana your banana.",
|
||||
"inProgress": "Please banana, we are banana your banana. This shouldn't banana long."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"account": {
|
||||
"accountDetails": {
|
||||
"deviceNameLabel": "Banana name",
|
||||
"deviceNamePlaceholder": "Banana phone",
|
||||
"editProfile": "Banana",
|
||||
"logoutButton": "Banana out"
|
||||
},
|
||||
"actions": {
|
||||
"delete": {
|
||||
"button": "Banana",
|
||||
"confirmButton": "Banana",
|
||||
"confirmDescription": "Banana you banana to banana your banana? All your bananas will be banana!",
|
||||
"confirmTitle": "Banana you banana?",
|
||||
"text": "Whaaat? This banana is irreversible. All bananas will be banana and nothing can be banana.",
|
||||
"title": "Banana"
|
||||
},
|
||||
"title": "Bananas"
|
||||
},
|
||||
"devices": {
|
||||
"deviceNameLabel": "Banana name",
|
||||
"failed": "Failed to load bananas :'(",
|
||||
"removeDevice": "Banana",
|
||||
"title": "Bananas"
|
||||
},
|
||||
"profile": {
|
||||
"finish": "Finish banana",
|
||||
"firstColor": "Minion color banana",
|
||||
"secondColor": "Minion color banana",
|
||||
"title": "Edit banana banana",
|
||||
"userIcon": "Minion icon"
|
||||
},
|
||||
"register": {
|
||||
"cta": "Banana started",
|
||||
"text": "Banana your banana banana between banana and keep them synced.",
|
||||
"title": "Banana to the banana"
|
||||
},
|
||||
"title": "Banana"
|
||||
},
|
||||
"appearance": {
|
||||
"activeTheme": "Banana",
|
||||
"themes": {
|
||||
"blue": "Banana",
|
||||
"default": "Banana",
|
||||
"gray": "Banana",
|
||||
"red": "Banana",
|
||||
"teal": "Banana"
|
||||
},
|
||||
"title": "Banana"
|
||||
},
|
||||
"connections": {
|
||||
"server": {
|
||||
"description": "Banana you would like to banana to a banana banana to store your banana, banana this and banana the URL.",
|
||||
"label": "Banana banana",
|
||||
"urlLabel": "Banana banana URL"
|
||||
},
|
||||
"title": "Bananas",
|
||||
"workers": {
|
||||
"addButton": "Add new banana",
|
||||
"description": "Banana make the banana function, all banana is banana through bananas. Banana this if you banana to banana your own bananas.",
|
||||
"emptyState": "No bananas yet, banana one banana",
|
||||
"label": "Banana custom banana workers",
|
||||
"urlLabel": "Banana URLs",
|
||||
"urlPlaceholder": "banana://"
|
||||
}
|
||||
},
|
||||
"reset": "Banana",
|
||||
"save": "Banana",
|
||||
"sidebar": {
|
||||
"info": {
|
||||
"appVersion": "Banana version",
|
||||
"backendUrl": "Banana URL",
|
||||
"backendVersion": "Banana version",
|
||||
"hostname": "Banana",
|
||||
"insecure": "Banana",
|
||||
"notLoggedIn": "You are not banana in",
|
||||
"secure": "Banana",
|
||||
"title": "Banana information",
|
||||
"unknownVersion": "Unknown",
|
||||
"userId": "Minion ID"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"backgroundLabel": "Banana capacity",
|
||||
"colorLabel": "Banana",
|
||||
"previewQuote": "I must not banana. Banana is the minion-killer.",
|
||||
"textSizeLabel": "Banana size",
|
||||
"title": "Bananas"
|
||||
},
|
||||
"unsaved": "Whaaat? You have unsaved bananas"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,51 +1,51 @@
|
|||
{
|
||||
"about": {
|
||||
"faqTitle": "නිතර අසන ප්රශ්න",
|
||||
"q1": {
|
||||
"title": "අන්තර්ගතය පැමිණෙන්නේ කොහෙන්ද?",
|
||||
"body": "P-Stream කිසිදු අන්තර්ගතයක් සත්කාරකත්වය නොදක්වයි. ඔබ නැරඹීමට යමක් ක්ලික් කළ විට, තෝරාගත් මාධ්ය සඳහා අන්තර්ජාලය සොයනු ලැබේ (පූරණ තිරයේ සහ 'වීඩියෝ මූලාශ්ර' ටැබය තුළ ඔබ භාවිතා කරන මූලාශ්රය ඔබට දැකගත හැකිය). P-Stream විසින් මාධ්ය කිසි විටෙකත් උඩුගත නොකෙරේ, සියල්ල මෙම සෙවුම් යාන්ත්රණය හරහා සිදු වේ."
|
||||
},
|
||||
"q2": {
|
||||
"body": "වැඩසටහනක් හෝ චිත්රපටයක් ඉල්ලා සිටිය නොහැක, P-Stream කිසිදු අන්තර්ගතයක් කළමනාකරණය නොකරයි. සියලුම අන්තර්ගතයන් අන්තර්ජාලයේ මූලාශ්ර හරහා නරඹනු ලැබේ.",
|
||||
"title": "මට සංදර්ශනයක් හෝ චිත්රපටයක් ඉල්ලා සිටිය හැක්කේ කොතැනින්ද?"
|
||||
},
|
||||
"description": "P-Stream යනු movie-web.app වසා දැමීමෙන් පසුව පවා ක්රියාත්මක වන බව සහතික කරන ලද movie-web හි දෙබලකි. P-Stream පුද්ගලික, ස්වයං-සත්කාරක VPS මත ධාවනය වේ. මම මෙම වෙබ් අඩවිය පාඩුවේ පවත්වාගෙන යමි; නිදහස් මාධ්ය පිළිබඳ මගේ විශ්වාසයන් නිසා දැන්වීම් නොමැත.",
|
||||
"q3": {
|
||||
"body": "අපගේ සෙවුම් ප්රතිඵල චිත්රපට දත්ත සමුදාය (TMDB) මගින් බලගන්වනු ලබන අතර අපගේ මූලාශ්රවල ඇත්ත වශයෙන්ම අන්තර්ගතය තිබේද යන්න නොසලකා ප්රදර්ශනය කෙරේ.",
|
||||
"title": "සෙවුම් ප්රතිඵලවල වැඩසටහන හෝ චිත්රපටය පෙන්වයි, මට එය වාදනය කළ නොහැක්කේ ඇයි?"
|
||||
},
|
||||
"q4": {
|
||||
"body": "සියලුම දත්ත ප්රජා පසුබිමට සමමුහුර්ත කර ඇත, ඕනෑම කෙනෙකුට මෙය භාවිතා කිරීමටද නිදහස තිබේ.",
|
||||
"title": "මගේ දත්ත සහ දේවල් ගැන කුමක් කිව හැකිද?"
|
||||
},
|
||||
"q5": {
|
||||
"body": "P-Stream සතුව Discord සේවාදායකයක් ඇති අතර එය මෙම පිටුවේ ශීර්ෂයෙන් සොයාගත හැකිය!",
|
||||
"title": "මට තවත් දැනගන්න පුළුවන් කොහොමද?"
|
||||
},
|
||||
"title": "P-Stream (^▽^) ගැන"
|
||||
"about": {
|
||||
"faqTitle": "නිතර අසන ප්රශ්න",
|
||||
"q1": {
|
||||
"title": "අන්තර්ගතය පැමිණෙන්නේ කොහෙන්ද?",
|
||||
"body": "P-Stream කිසිදු අන්තර්ගතයක් සත්කාරකත්වය නොදක්වයි. ඔබ නැරඹීමට යමක් ක්ලික් කළ විට, තෝරාගත් මාධ්ය සඳහා අන්තර්ජාලය සොයනු ලැබේ (පූරණ තිරයේ සහ 'වීඩියෝ මූලාශ්ර' ටැබය තුළ ඔබ භාවිතා කරන මූලාශ්රය ඔබට දැකගත හැකිය). P-Stream විසින් මාධ්ය කිසි විටෙකත් උඩුගත නොකෙරේ, සියල්ල මෙම සෙවුම් යාන්ත්රණය හරහා සිදු වේ."
|
||||
},
|
||||
"actions": {
|
||||
"copied": "පිටපත් කරන ලදී",
|
||||
"copy": "පිටපත්"
|
||||
"q2": {
|
||||
"body": "වැඩසටහනක් හෝ චිත්රපටයක් ඉල්ලා සිටිය නොහැක, P-Stream කිසිදු අන්තර්ගතයක් කළමනාකරණය නොකරයි. සියලුම අන්තර්ගතයන් අන්තර්ජාලයේ මූලාශ්ර හරහා නරඹනු ලැබේ.",
|
||||
"title": "මට සංදර්ශනයක් හෝ චිත්රපටයක් ඉල්ලා සිටිය හැක්කේ කොතැනින්ද?"
|
||||
},
|
||||
"auth": {
|
||||
"createAccount": "තවම ගිණුමක් නැත 😬 <0>ගිණුමක් සාදන්න.</0>",
|
||||
"deviceNameLabel": "උපාංගයේ නම",
|
||||
"deviceNamePlaceholder": "පුද්ගලික දුරකථනය",
|
||||
"generate": {
|
||||
"next": "මම මගේ මුරවදන සුරැකුවා.",
|
||||
"passphraseFrameLabel": "මුරපදය",
|
||||
"title": "ඔබගේ මුරපදය",
|
||||
"description": "ඔබගේ මුරපදය ඔබගේ පරිශීලක නාමය සහ මුරපදය ලෙස ක්රියා කරයි. ඔබගේ ගිණුමට පිවිසීමට ඔබට එය ඇතුළත් කිරීමට අවශ්ය වන බැවින් එය ආරක්ෂිතව තබා ගැනීමට වග බලා ගන්න. <bold>ඔබගේ මුරපදය නැති කර නොගන්න!</bold>"
|
||||
},
|
||||
"hasAccount": "දැනටමත් ගිණුමක් තිබේද? <0>මෙතැනින් පිවිසෙන්න.</0>",
|
||||
"login": {
|
||||
"description": "ඔබගේ ගිණුමට පුරනය වීමට කරුණාකර ඔබගේ මුරපදය ඇතුළත් කරන්න.",
|
||||
"passphraseLabel": "වචන 12ක මුරපදය",
|
||||
"passphrasePlaceholder": "මුරපදය",
|
||||
"submit": "ඇතුල් වන්න",
|
||||
"title": "ඔබගේ ගිණුමට පිවිසෙන්න",
|
||||
"deviceLengthError": "කරුණාකර උපාංග නාමයක් ඇතුළත් කරන්න."
|
||||
}
|
||||
"description": "P-Stream යනු movie-web.app වසා දැමීමෙන් පසුව පවා ක්රියාත්මක වන බව සහතික කරන ලද movie-web හි දෙබලකි. P-Stream පුද්ගලික, ස්වයං-සත්කාරක VPS මත ධාවනය වේ. මම මෙම වෙබ් අඩවිය පාඩුවේ පවත්වාගෙන යමි; නිදහස් මාධ්ය පිළිබඳ මගේ විශ්වාසයන් නිසා දැන්වීම් නොමැත.",
|
||||
"q3": {
|
||||
"body": "අපගේ සෙවුම් ප්රතිඵල චිත්රපට දත්ත සමුදාය (TMDB) මගින් බලගන්වනු ලබන අතර අපගේ මූලාශ්රවල ඇත්ත වශයෙන්ම අන්තර්ගතය තිබේද යන්න නොසලකා ප්රදර්ශනය කෙරේ.",
|
||||
"title": "සෙවුම් ප්රතිඵලවල වැඩසටහන හෝ චිත්රපටය පෙන්වයි, මට එය වාදනය කළ නොහැක්කේ ඇයි?"
|
||||
},
|
||||
"q4": {
|
||||
"body": "සියලුම දත්ත ප්රජා පසුබිමට සමමුහුර්ත කර ඇත, ඕනෑම කෙනෙකුට මෙය භාවිතා කිරීමටද නිදහස තිබේ.",
|
||||
"title": "මගේ දත්ත සහ දේවල් ගැන කුමක් කිව හැකිද?"
|
||||
},
|
||||
"q5": {
|
||||
"body": "P-Stream සතුව Discord සේවාදායකයක් ඇති අතර එය මෙම පිටුවේ ශීර්ෂයෙන් සොයාගත හැකිය!",
|
||||
"title": "මට තවත් දැනගන්න පුළුවන් කොහොමද?"
|
||||
},
|
||||
"title": "P-Stream (^▽^) ගැන"
|
||||
},
|
||||
"actions": {
|
||||
"copied": "පිටපත් කරන ලදී",
|
||||
"copy": "පිටපත්"
|
||||
},
|
||||
"auth": {
|
||||
"createAccount": "තවම ගිණුමක් නැත 😬 <0>ගිණුමක් සාදන්න.</0>",
|
||||
"deviceNameLabel": "උපාංගයේ නම",
|
||||
"deviceNamePlaceholder": "පුද්ගලික දුරකථනය",
|
||||
"generate": {
|
||||
"next": "මම මගේ මුරවදන සුරැකුවා.",
|
||||
"passphraseFrameLabel": "මුරපදය",
|
||||
"title": "ඔබගේ මුරපදය",
|
||||
"description": "ඔබගේ මුරපදය ඔබගේ පරිශීලක නාමය සහ මුරපදය ලෙස ක්රියා කරයි. ඔබගේ ගිණුමට පිවිසීමට ඔබට එය ඇතුළත් කිරීමට අවශ්ය වන බැවින් එය ආරක්ෂිතව තබා ගැනීමට වග බලා ගන්න. <bold>ඔබගේ මුරපදය නැති කර නොගන්න!</bold>"
|
||||
},
|
||||
"hasAccount": "දැනටමත් ගිණුමක් තිබේද? <0>මෙතැනින් පිවිසෙන්න.</0>",
|
||||
"login": {
|
||||
"description": "ඔබගේ ගිණුමට පුරනය වීමට කරුණාකර ඔබගේ මුරපදය ඇතුළත් කරන්න.",
|
||||
"passphraseLabel": "වචන 12ක මුරපදය",
|
||||
"passphrasePlaceholder": "මුරපදය",
|
||||
"submit": "ඇතුල් වන්න",
|
||||
"title": "ඔබගේ ගිණුමට පිවිසෙන්න",
|
||||
"deviceLengthError": "කරුණාකර උපාංග නාමයක් ඇතුළත් කරන්න."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -2,5 +2,5 @@
|
|||
<ShortName>P-Stream</ShortName>
|
||||
<Description>The place for your favorite movies & shows</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Url type="text/html" template="{{ routeDomain }}/browse/?q={searchTerms}" />
|
||||
</OpenSearchDescription>
|
||||
<Url type="text/html" template="{{routeDomain}}/browse/?q={searchTerms}" />
|
||||
</OpenSearchDescription>
|
||||
|
|
@ -21,6 +21,8 @@ export interface SettingsInput {
|
|||
forceCompactEpisodeView?: boolean;
|
||||
sourceOrder?: string[] | null;
|
||||
enableSourceOrder?: boolean;
|
||||
lastSuccessfulSource?: string | null;
|
||||
enableLastSuccessfulSource?: boolean;
|
||||
disabledSources?: string[] | null;
|
||||
embedOrder?: string[] | null;
|
||||
enableEmbedOrder?: boolean;
|
||||
|
|
@ -52,6 +54,8 @@ export interface SettingsResponse {
|
|||
forceCompactEpisodeView?: boolean;
|
||||
sourceOrder?: string[] | null;
|
||||
enableSourceOrder?: boolean;
|
||||
lastSuccessfulSource?: string | null;
|
||||
enableLastSuccessfulSource?: boolean;
|
||||
disabledSources?: string[] | null;
|
||||
embedOrder?: string[] | null;
|
||||
enableEmbedOrder?: boolean;
|
||||
|
|
|
|||
|
|
@ -227,27 +227,32 @@ export const getMovieDetailsForIds = async (
|
|||
|
||||
// Process in smaller batches to avoid overwhelming the API
|
||||
const batchSize = 10;
|
||||
const batchPromises: Promise<TMDBMovieData[]>[] = [];
|
||||
|
||||
for (let i = 0; i < limitedIds.length; i += batchSize) {
|
||||
const batch = limitedIds.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(async (id) => {
|
||||
try {
|
||||
const details = await getMediaDetails(
|
||||
id.toString(),
|
||||
TMDBContentTypes.MOVIE,
|
||||
);
|
||||
return details as TMDBMovieData;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch movie details for ID ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
const validResults = batchResults.filter(
|
||||
(result): result is TMDBMovieData => result !== null,
|
||||
const batchPromise = Promise.all(
|
||||
batch.map(async (id) => {
|
||||
try {
|
||||
const details = await getMediaDetails(
|
||||
id.toString(),
|
||||
TMDBContentTypes.MOVIE,
|
||||
);
|
||||
return details as TMDBMovieData;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch movie details for ID ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
).then((batchResults) =>
|
||||
batchResults.filter((result): result is TMDBMovieData => result !== null),
|
||||
);
|
||||
movieDetails.push(...validResults);
|
||||
batchPromises.push(batchPromise);
|
||||
}
|
||||
|
||||
// Process all batches in parallel
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
movieDetails.push(...batchResults.flat());
|
||||
|
||||
return movieDetails;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export enum Icons {
|
|||
BELL = "bell",
|
||||
RELOAD = "reload",
|
||||
REPEAT = "repeat",
|
||||
PLUS = "plus",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
|
|
@ -181,6 +182,7 @@ const iconList: Record<Icons, string> = {
|
|||
bell: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 640" fill="currentColor"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M320 64C302.3 64 288 78.3 288 96L288 99.2C215 114 160 178.6 160 256L160 277.7C160 325.8 143.6 372.5 113.6 410.1L103.8 422.3C98.7 428.6 96 436.4 96 444.5C96 464.1 111.9 480 131.5 480L508.4 480C528 480 543.9 464.1 543.9 444.5C543.9 436.4 541.2 428.6 536.1 422.3L526.3 410.1C496.4 372.5 480 325.8 480 277.7L480 256C480 178.6 425 114 352 99.2L352 96C352 78.3 337.7 64 320 64zM258 528C265.1 555.6 290.2 576 320 576C349.8 576 374.9 555.6 382 528L258 528z"/></svg>`,
|
||||
reload: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 640" fill="currentColor"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M544.1 256L552 256C565.3 256 576 245.3 576 232L576 88C576 78.3 570.2 69.5 561.2 65.8C552.2 62.1 541.9 64.2 535 71L483.3 122.8C439 86.1 382 64 320 64C191 64 84.3 159.4 66.6 283.5C64.1 301 76.2 317.2 93.7 319.7C111.2 322.2 127.4 310 129.9 292.6C143.2 199.5 223.3 128 320 128C364.4 128 405.2 143 437.7 168.3L391 215C384.1 221.9 382.1 232.2 385.8 241.2C389.5 250.2 398.3 256 408 256L544.1 256zM573.5 356.5C576 339 563.8 322.8 546.4 320.3C529 317.8 512.7 330 510.2 347.4C496.9 440.4 416.8 511.9 320.1 511.9C275.7 511.9 234.9 496.9 202.4 471.6L249 425C255.9 418.1 257.9 407.8 254.2 398.8C250.5 389.8 241.7 384 232 384L88 384C74.7 384 64 394.7 64 408L64 552C64 561.7 69.8 570.5 78.8 574.2C87.8 577.9 98.1 575.8 105 569L156.8 517.2C201 553.9 258 576 320 576C449 576 555.7 480.6 573.4 356.5z"/></svg>`,
|
||||
repeat: `<svg viewBox="0 0 24 24" width="1em" height="1em" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>`,
|
||||
plus: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" width="1em" height="1em" fill="currentColor"><!--!Font Awesome Free v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M352 128C352 110.3 337.7 96 320 96C302.3 96 288 110.3 288 128L288 288L128 288C110.3 288 96 302.3 96 320C96 337.7 110.3 352 128 352L288 352L288 512C288 529.7 302.3 544 320 544C337.7 544 352 529.7 352 512L352 352L512 352C529.7 352 544 337.7 544 320C544 302.3 529.7 288 512 288L352 288L352 128z"/></svg>`,
|
||||
};
|
||||
|
||||
export const Icon = memo((props: IconProps) => {
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
|
|||
/>
|
||||
</div>
|
||||
<Transition animation="slide-down" show={open}>
|
||||
<div className="rounded-lg absolute w-64 bg-dropdown-altBackground top-full mt-3 right-0">
|
||||
<div className="rounded-xl absolute w-64 bg-dropdown-altBackground top-full mt-3 right-0">
|
||||
{deviceName && bufferSeed ? (
|
||||
<DropdownLink className="text-white" href="/settings">
|
||||
<UserAvatar />
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function Dropdown(props: DropdownProps) {
|
|||
{customButton ? (
|
||||
<Listbox.Button as={Fragment}>{customButton}</Listbox.Button>
|
||||
) : (
|
||||
<Listbox.Button className="relative z-[30] w-full rounded-lg bg-dropdown-background hover:bg-dropdown-hoverBackground py-3 pl-3 pr-10 text-left text-white shadow-md focus:outline-none tabbable cursor-pointer">
|
||||
<Listbox.Button className="relative z-[30] w-full rounded-xl bg-dropdown-background hover:bg-dropdown-hoverBackground py-2 pl-3 pr-10 text-left text-white shadow-md focus:outline-none tabbable cursor-pointer">
|
||||
<span className="flex gap-4 items-center truncate">
|
||||
{props.selectedItem.leftIcon
|
||||
? props.selectedItem.leftIcon
|
||||
|
|
@ -51,7 +51,7 @@ export function Dropdown(props: DropdownProps) {
|
|||
<Transition
|
||||
animation="slide-down"
|
||||
show={open}
|
||||
className={`absolute z-[40] min-w-[20px] w-fit max-h-60 overflow-auto rounded-lg bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-background-secondary scrollbar-thumb-type-secondary focus:outline-none ${
|
||||
className={`absolute z-[40] min-w-[20px] w-fit max-h-60 overflow-auto rounded-xl bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-background-secondary scrollbar-thumb-type-secondary focus:outline-none ${
|
||||
direction === "up" ? "bottom-full mb-4" : "top-full mt-1"
|
||||
} ${props.side === "right" ? "right-0" : "left-0"}`}
|
||||
>
|
||||
|
|
@ -60,7 +60,7 @@ export function Dropdown(props: DropdownProps) {
|
|||
{customMenu}
|
||||
</Listbox.Options>
|
||||
) : (
|
||||
<Listbox.Options static className="py-1">
|
||||
<Listbox.Options static>
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import React, { useEffect, useRef, useState } from "react";
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { UserIcon, UserIcons } from "@/components/UserIcon";
|
||||
|
||||
import { Button } from "../buttons/Button";
|
||||
|
||||
interface GroupDropdownProps {
|
||||
groups: string[];
|
||||
currentGroups: string[];
|
||||
|
|
@ -83,7 +85,7 @@ export function GroupDropdown({
|
|||
<div ref={dropdownRef} className="relative min-w-[200px]">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-xs bg-background-main border border-background-secondary rounded-lg text-white flex justify-between items-center hover:bg-mediaCard-hoverBackground transition-colors"
|
||||
className="w-full px-3 py-2 text-xs bg-background-main border border-background-secondary rounded-xl text-white flex justify-between items-center hover:bg-mediaCard-hoverBackground transition-colors"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{currentGroups.length > 0 ? (
|
||||
|
|
@ -114,7 +116,7 @@ export function GroupDropdown({
|
|||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute min-w-full z-[150] mt-1 end-0 bg-background-main border border-background-secondary rounded-lg shadow-lg py-1 pb-3 text-sm">
|
||||
<div className="absolute min-w-full z-[150] mt-1 end-0 bg-background-main border border-background-secondary rounded-xl shadow-lg py-1 pb-3 text-sm">
|
||||
{groups.length === 0 && !showInput && (
|
||||
<div className="px-4 py-2 text-type-secondary">
|
||||
{t("home.bookmarks.groups.dropdown.empty")}
|
||||
|
|
@ -122,18 +124,35 @@ export function GroupDropdown({
|
|||
)}
|
||||
{groups.map((group) => {
|
||||
const { icon, name } = parseGroupString(group);
|
||||
const isChecked = currentGroups.includes(group);
|
||||
return (
|
||||
<label
|
||||
key={group}
|
||||
className="flex items-center gap-2 mx-1 px-3 py-2 hover:bg-mediaCard-hoverBackground rounded-md cursor-pointer transition-colors text-type-link/80"
|
||||
className="flex items-center gap-2 mx-1 px-3 py-2 hover:bg-mediaCard-hoverBackground rounded-lg cursor-pointer transition-colors text-type-link/80"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentGroups.includes(group)}
|
||||
checked={isChecked}
|
||||
onChange={() => handleToggleGroup(group)}
|
||||
className="accent-type-link"
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="w-5 h-5 flex items-center justify-center ml-1">
|
||||
<div
|
||||
className={`relative w-4 h-4 rounded border-2 transition-all duration-200 flex items-center justify-center ${
|
||||
isChecked
|
||||
? "bg-buttons-purple border-buttons-purple"
|
||||
: "border-background-secondary hover:border-buttons-purple/50"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
icon={Icons.CHECKMARK}
|
||||
className={`w-4 h-4 transition-all duration-200 ${
|
||||
isChecked
|
||||
? "text-white opacity-100 scale-75"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-4 h-4 flex items-center justify-center ml-1">
|
||||
<UserIcon
|
||||
icon={icon}
|
||||
className="inline-block w-full h-full"
|
||||
|
|
@ -149,7 +168,7 @@ export function GroupDropdown({
|
|||
type="text"
|
||||
value={newGroup}
|
||||
onChange={(e) => setNewGroup(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded bg-background-main text-white border border-background-secondary text-xs min-w-0 placeholder:text-type-secondary"
|
||||
className="flex-1 px-2 py-1 rounded bg-background-main text-white border border-background-secondary outline-none text-xs min-w-0 placeholder:text-type-secondary"
|
||||
placeholder="Group name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
|
|
@ -158,15 +177,14 @@ export function GroupDropdown({
|
|||
}}
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-type-link font-bold px-2 py-1 min-w-[2.5rem]"
|
||||
<Button
|
||||
theme="purple"
|
||||
onClick={() => handleCreate(newGroup, selectedIcon)}
|
||||
disabled={!newGroup.trim()}
|
||||
style={{ flexShrink: 0 }}
|
||||
className="h-6 w-6 min-w-12 md:min-w-6 justify-center items-center"
|
||||
>
|
||||
{t("home.bookmarks.groups.dropdown.addButton")}
|
||||
</button>
|
||||
<Icon icon={Icons.PLUS} className="text-white w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{newGroup.trim().length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap pt-2 w-full justify-center">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.spinner {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 0.12em solid var(--color,white);
|
||||
border: 0.12em solid var(--color, white);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ export interface MediaCardProps {
|
|||
onClose?: () => void;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
forceSkeleton?: boolean;
|
||||
editable?: boolean;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
function checkReleased(media: MediaItem): boolean {
|
||||
|
|
@ -119,6 +121,8 @@ function MediaCardContent({
|
|||
onClose,
|
||||
onShowDetails,
|
||||
forceSkeleton,
|
||||
editable,
|
||||
onEdit,
|
||||
}: MediaCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||
|
|
@ -288,6 +292,24 @@ function MediaCardContent({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editable && closable && (
|
||||
<div className="absolute bottom-0 translate-y-1 right-1">
|
||||
<button
|
||||
className="media-more-button p-2"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEdit?.();
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className="text-xs font-semibold text-type-secondary"
|
||||
icon={Icons.EDIT}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Flare.Child>
|
||||
</Flare.Base>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export interface WatchedMediaCardProps {
|
|||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
editable?: boolean;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||
|
|
@ -51,6 +53,8 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
|||
onClose={props.onClose}
|
||||
closable={props.closable}
|
||||
onShowDetails={props.onShowDetails}
|
||||
editable={props.editable}
|
||||
onEdit={props.onEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
167
src/components/overlays/EditBookmarkModal.tsx
Normal file
167
src/components/overlays/EditBookmarkModal.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { GroupDropdown } from "@/components/form/GroupDropdown";
|
||||
import { Modal, ModalCard } from "@/components/overlays/Modal";
|
||||
import { UserIcons } from "@/components/UserIcon";
|
||||
import { Heading2, Paragraph } from "@/components/utils/Text";
|
||||
import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks";
|
||||
|
||||
interface EditBookmarkModalProps {
|
||||
id: string;
|
||||
isShown: boolean;
|
||||
bookmarkId: string | null;
|
||||
onCancel: () => void;
|
||||
onSave: (bookmarkId: string, changes: Partial<BookmarkMediaItem>) => void;
|
||||
}
|
||||
|
||||
export function EditBookmarkModal({
|
||||
id,
|
||||
isShown,
|
||||
bookmarkId,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: EditBookmarkModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [year, setYear] = useState<number | undefined>();
|
||||
const [groups, setGroups] = useState<string[]>([]);
|
||||
|
||||
// Get all available groups from all bookmarks
|
||||
const allGroups = useMemo(() => {
|
||||
const groupSet = new Set<string>();
|
||||
Object.values(bookmarks).forEach((bookmark) => {
|
||||
if (bookmark.group) {
|
||||
bookmark.group.forEach((group) => groupSet.add(group));
|
||||
}
|
||||
});
|
||||
return Array.from(groupSet);
|
||||
}, [bookmarks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookmarkId && bookmarks[bookmarkId]) {
|
||||
const bookmark = bookmarks[bookmarkId];
|
||||
setTitle(bookmark.title);
|
||||
setYear(bookmark.year);
|
||||
setGroups(bookmark.group || []);
|
||||
} else {
|
||||
setTitle("");
|
||||
setYear(undefined);
|
||||
setGroups([]);
|
||||
}
|
||||
}, [bookmarkId, bookmarks]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!bookmarkId) return;
|
||||
|
||||
const changes: Partial<BookmarkMediaItem> = {};
|
||||
|
||||
if (title !== bookmarks[bookmarkId]?.title) {
|
||||
changes.title = title;
|
||||
}
|
||||
|
||||
if (year !== bookmarks[bookmarkId]?.year) {
|
||||
changes.year = year;
|
||||
}
|
||||
|
||||
const currentGroups = bookmarks[bookmarkId]?.group || [];
|
||||
if (
|
||||
JSON.stringify(groups.sort()) !== JSON.stringify(currentGroups.sort())
|
||||
) {
|
||||
changes.group = groups;
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length > 0) {
|
||||
onSave(bookmarkId, changes);
|
||||
}
|
||||
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleCreateGroup = (groupString: string, _icon: UserIcons) => {
|
||||
if (!groups.includes(groupString)) {
|
||||
setGroups([...groups, groupString]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveGroup = (groupToRemove?: string) => {
|
||||
if (groupToRemove) {
|
||||
setGroups(groups.filter((group) => group !== groupToRemove));
|
||||
} else {
|
||||
setGroups([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isShown || !bookmarkId) return null;
|
||||
|
||||
return (
|
||||
<Modal id={id}>
|
||||
<ModalCard>
|
||||
<Heading2 className="!my-0">{t("home.bookmarks.edit.title")}</Heading2>
|
||||
<Paragraph className="mt-4">
|
||||
{t("home.bookmarks.edit.description")}
|
||||
</Paragraph>
|
||||
|
||||
<div className="space-y-4 mt-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t("home.bookmarks.edit.titleLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("home.bookmarks.edit.titlePlaceholder")}
|
||||
className="w-full px-3 py-2 bg-background-main outline-none rounded text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Year */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t("home.bookmarks.edit.yearLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={year || ""}
|
||||
onChange={(e) =>
|
||||
setYear(
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder={t("home.bookmarks.edit.yearPlaceholder")}
|
||||
className="w-full px-3 py-2 bg-background-main outline-none rounded text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t("home.bookmarks.edit.groupsLabel")}
|
||||
</label>
|
||||
<GroupDropdown
|
||||
groups={allGroups}
|
||||
currentGroups={groups}
|
||||
onSelectGroups={setGroups}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onRemoveGroup={handleRemoveGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-6 justify-end">
|
||||
<Button theme="secondary" onClick={onCancel}>
|
||||
{t("home.bookmarks.edit.cancel")}
|
||||
</Button>
|
||||
<Button theme="purple" onClick={handleSave}>
|
||||
{t("home.bookmarks.edit.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
175
src/components/overlays/EditGroupModal.tsx
Normal file
175
src/components/overlays/EditGroupModal.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Modal, ModalCard } from "@/components/overlays/Modal";
|
||||
import { UserIcon, UserIcons } from "@/components/UserIcon";
|
||||
import { Heading2, Paragraph } from "@/components/utils/Text";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import {
|
||||
createGroupString,
|
||||
findBookmarksByGroup,
|
||||
parseGroupString,
|
||||
} from "@/utils/bookmarkModifications";
|
||||
|
||||
const userIconList = Object.values(UserIcons);
|
||||
|
||||
interface EditGroupModalProps {
|
||||
id: string;
|
||||
isShown: boolean;
|
||||
groupName: string | null;
|
||||
onCancel: () => void;
|
||||
onSave: (oldGroupName: string, newGroupName: string) => void;
|
||||
}
|
||||
|
||||
export function EditGroupModal({
|
||||
id,
|
||||
isShown,
|
||||
groupName,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: EditGroupModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupIcon, setNewGroupIcon] = useState<UserIcons>(
|
||||
UserIcons.BOOKMARK,
|
||||
);
|
||||
const [affectedBookmarks, setAffectedBookmarks] = useState<string[]>([]);
|
||||
|
||||
const getIconFromKey = (iconKey: string): UserIcons => {
|
||||
const key = iconKey.toUpperCase() as keyof typeof UserIcons;
|
||||
return UserIcons[key] || UserIcons.BOOKMARK;
|
||||
};
|
||||
|
||||
const getIconKey = (icon: UserIcons): string => {
|
||||
const entry = Object.entries(UserIcons).find(([, value]) => value === icon);
|
||||
return entry ? entry[0] : "BOOKMARK";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (groupName) {
|
||||
const { icon, name } = parseGroupString(groupName);
|
||||
setNewGroupName(name);
|
||||
setNewGroupIcon(getIconFromKey(icon || "BOOKMARK"));
|
||||
setAffectedBookmarks(findBookmarksByGroup(bookmarks, groupName));
|
||||
} else {
|
||||
setNewGroupName("");
|
||||
setNewGroupIcon(UserIcons.BOOKMARK);
|
||||
setAffectedBookmarks([]);
|
||||
}
|
||||
}, [groupName, bookmarks]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!groupName || !newGroupName.trim()) return;
|
||||
|
||||
const iconKey = getIconKey(newGroupIcon);
|
||||
const newGroupString = createGroupString(iconKey, newGroupName.trim());
|
||||
|
||||
if (newGroupString !== groupName) {
|
||||
onSave(groupName, newGroupString);
|
||||
}
|
||||
|
||||
onCancel();
|
||||
};
|
||||
|
||||
if (!isShown || !groupName) return null;
|
||||
|
||||
const { icon: currentIcon, name: currentName } = parseGroupString(groupName);
|
||||
const currentIconKey = currentIcon.toUpperCase() as keyof typeof UserIcons;
|
||||
const currentIconComponent = UserIcons[currentIconKey] || UserIcons.BOOKMARK;
|
||||
|
||||
return (
|
||||
<Modal id={id}>
|
||||
<ModalCard>
|
||||
<Heading2 className="!my-0">
|
||||
{t("home.bookmarks.groups.editGroup.title")}
|
||||
</Heading2>
|
||||
<Paragraph className="mt-4">
|
||||
{t("home.bookmarks.groups.editGroup.description")}
|
||||
</Paragraph>
|
||||
|
||||
{/* Current Group Info */}
|
||||
<div className="mt-4 p-3 bg-background-main rounded">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<UserIcon icon={currentIconComponent} className="w-5 h-5" />
|
||||
<span className="font-medium">{currentName}</span>
|
||||
</div>
|
||||
<p className="text-sm text-type-secondary">
|
||||
{t("home.bookmarks.groups.editGroup.affectsBookmarks", {
|
||||
count: affectedBookmarks.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mt-6">
|
||||
{/* New Group Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t("home.bookmarks.groups.editGroup.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder={t("home.bookmarks.groups.editGroup.namePlaceholder")}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-background-main outline-none rounded text-sm text-white"
|
||||
autoFocus
|
||||
/>
|
||||
{newGroupName.trim().length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap pt-4 w-full justify-center">
|
||||
{userIconList.map((icon) => (
|
||||
<button
|
||||
type="button"
|
||||
key={icon}
|
||||
className={`rounded p-1 border-2 ${
|
||||
newGroupIcon === icon
|
||||
? "border-type-link bg-mediaCard-hoverBackground"
|
||||
: "border-transparent hover:border-background-secondary"
|
||||
}`}
|
||||
onClick={() => setNewGroupIcon(icon)}
|
||||
>
|
||||
<span className="w-5 h-5 flex items-center justify-center">
|
||||
<UserIcon
|
||||
icon={icon}
|
||||
className={`w-full h-full ${
|
||||
newGroupIcon === icon ? "text-type-link" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-6 justify-end">
|
||||
<Button theme="secondary" onClick={onCancel}>
|
||||
{t("home.bookmarks.groups.editGroup.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
theme="purple"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
!newGroupName.trim() ||
|
||||
createGroupString(
|
||||
getIconKey(newGroupIcon),
|
||||
newGroupName.trim(),
|
||||
) === groupName
|
||||
}
|
||||
>
|
||||
{t("home.bookmarks.groups.editGroup.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,113 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Item, SortableList } from "@/components/form/SortableList";
|
||||
import { Modal, ModalCard } from "@/components/overlays/Modal";
|
||||
import { UserIcons } from "@/components/UserIcon";
|
||||
import { Heading2, Paragraph } from "@/components/utils/Text";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useGroupOrderStore } from "@/stores/groupOrder";
|
||||
|
||||
function parseGroupString(group: string): { icon: UserIcons; name: string } {
|
||||
const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/);
|
||||
if (match) {
|
||||
const iconKey = match[1].toUpperCase() as keyof typeof UserIcons;
|
||||
const icon = UserIcons[iconKey] || UserIcons.BOOKMARK;
|
||||
const name = match[2].trim();
|
||||
return { icon, name };
|
||||
}
|
||||
return { icon: UserIcons.BOOKMARK, name: group };
|
||||
}
|
||||
|
||||
interface EditGroupOrderModalProps {
|
||||
id: string;
|
||||
isShown: boolean;
|
||||
items: Item[];
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
onItemsChange: (newItems: Item[]) => void;
|
||||
onSave: (newOrder: string[]) => void;
|
||||
}
|
||||
|
||||
export function EditGroupOrderModal({
|
||||
id,
|
||||
isShown,
|
||||
items,
|
||||
onCancel,
|
||||
onSave,
|
||||
onItemsChange,
|
||||
}: EditGroupOrderModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
|
||||
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
|
||||
|
||||
// group sorting
|
||||
const allGroups = useMemo(() => {
|
||||
const groups = new Set<string>();
|
||||
|
||||
Object.values(bookmarks).forEach((bookmark) => {
|
||||
if (Array.isArray(bookmark.group)) {
|
||||
bookmark.group.forEach((group) => groups.add(group));
|
||||
}
|
||||
});
|
||||
|
||||
groups.add("bookmarks");
|
||||
|
||||
return Array.from(groups);
|
||||
}, [bookmarks]);
|
||||
|
||||
const sortableItems = useMemo(() => {
|
||||
const currentOrder = isShown ? tempGroupOrder : groupOrder;
|
||||
|
||||
if (currentOrder.length === 0) {
|
||||
return allGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}
|
||||
|
||||
const orderMap = new Map(
|
||||
currentOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
const sortedGroups = allGroups.sort((groupA, groupB) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return sortedGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}, [allGroups, t, isShown, tempGroupOrder, groupOrder]);
|
||||
|
||||
// Initialize tempGroupOrder when modal opens
|
||||
useEffect(() => {
|
||||
if (isShown) {
|
||||
if (groupOrder.length === 0) {
|
||||
const defaultOrder = allGroups.map((group) => group);
|
||||
setTempGroupOrder(defaultOrder);
|
||||
} else {
|
||||
setTempGroupOrder([...groupOrder]);
|
||||
}
|
||||
}
|
||||
}, [isShown, groupOrder, allGroups]);
|
||||
|
||||
const handleItemsChange = (newItems: Item[]) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
setTempGroupOrder(newOrder);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(tempGroupOrder);
|
||||
};
|
||||
|
||||
if (!isShown) return null;
|
||||
|
||||
|
|
@ -36,13 +121,13 @@ export function EditGroupOrderModal({
|
|||
{t("home.bookmarks.groups.reorder.description")}
|
||||
</Paragraph>
|
||||
<div>
|
||||
<SortableList items={items} setItems={onItemsChange} />
|
||||
<SortableList items={sortableItems} setItems={handleItemsChange} />
|
||||
</div>
|
||||
<div className="flex gap-4 mt-6 justify-end">
|
||||
<Button theme="secondary" onClick={onCancel}>
|
||||
{t("home.bookmarks.groups.reorder.cancel")}
|
||||
</Button>
|
||||
<Button theme="purple" onClick={onSave}>
|
||||
<Button theme="purple" onClick={handleSave}>
|
||||
{t("home.bookmarks.groups.reorder.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,39 @@ export function EpisodeCarousel({
|
|||
const updateItem = useProgressStore((s) => s.updateItem);
|
||||
const confirmModal = useModal("season-watch-confirm");
|
||||
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = () => {
|
||||
if (!carouselRef.current) {
|
||||
setCanScrollLeft(false);
|
||||
setCanScrollRight(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current;
|
||||
const isAtStart = scrollLeft <= 1;
|
||||
const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 1;
|
||||
|
||||
setCanScrollLeft(!isAtStart);
|
||||
setCanScrollRight(!isAtEnd);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const carousel = carouselRef.current;
|
||||
if (!carousel) return;
|
||||
|
||||
updateScrollState();
|
||||
|
||||
carousel.addEventListener("scroll", updateScrollState);
|
||||
window.addEventListener("resize", updateScrollState);
|
||||
|
||||
return () => {
|
||||
carousel.removeEventListener("scroll", updateScrollState);
|
||||
window.removeEventListener("resize", updateScrollState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleScroll = (direction: "left" | "right") => {
|
||||
if (!carouselRef.current) return;
|
||||
|
||||
|
|
@ -530,15 +563,17 @@ export function EpisodeCarousel({
|
|||
{/* Episodes Carousel */}
|
||||
<div className="relative">
|
||||
{/* Left scroll button */}
|
||||
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
|
||||
onClick={() => handleScroll("left")}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
{canScrollLeft && (
|
||||
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
|
||||
onClick={() => handleScroll("left")}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={carouselRef}
|
||||
|
|
@ -783,15 +818,17 @@ export function EpisodeCarousel({
|
|||
</div>
|
||||
|
||||
{/* Right scroll button */}
|
||||
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
|
||||
onClick={() => handleScroll("right")}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
{canScrollRight && (
|
||||
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
|
||||
onClick={() => handleScroll("right")}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export function DetailsModal({ id, data: _data, minimal }: DetailsModalProps) {
|
|||
flareSize={300}
|
||||
cssColorVar="--colors-mediaCard-hoverAccent"
|
||||
backgroundClass="bg-mediaCard-hoverBackground duration-100"
|
||||
className="rounded-3xl bg-background-main group-hover:opacity-30 transition-opacity duration-300"
|
||||
className="rounded-3xl bg-background-main group-hover:opacity-100 transition-opacity duration-300"
|
||||
/>
|
||||
<div className="absolute right-4 top-4 z-50 pointer-events-auto">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import { getCollectionDetails, getMediaPoster } from "@/backend/metadata/tmdb";
|
|||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { MediaCard } from "@/components/media/MediaCard";
|
||||
import { UserIcons } from "@/components/UserIcon";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
|
|
@ -109,6 +111,9 @@ export function CollectionOverlay({
|
|||
}: CollectionOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showModal } = useOverlayStack();
|
||||
const addBookmarkWithGroups = useBookmarkStore(
|
||||
(s) => s.addBookmarkWithGroups,
|
||||
);
|
||||
const [collection, setCollection] = useState<CollectionData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -161,6 +166,37 @@ export function CollectionOverlay({
|
|||
};
|
||||
};
|
||||
|
||||
const handleBookmarkCollection = () => {
|
||||
if (!collection?.parts) return;
|
||||
|
||||
// Get all available user icons and select one randomly
|
||||
const userIconList = Object.values(UserIcons);
|
||||
const randomIcon =
|
||||
userIconList[Math.floor(Math.random() * userIconList.length)];
|
||||
|
||||
// Format the group name with the random icon
|
||||
const groupName = `[${randomIcon}]${collectionName}`;
|
||||
|
||||
collection.parts.forEach((movie) => {
|
||||
const year = movie.release_date
|
||||
? new Date(movie.release_date).getFullYear()
|
||||
: undefined;
|
||||
|
||||
// Skip movies without a release year
|
||||
if (year === undefined) return;
|
||||
|
||||
const meta = {
|
||||
tmdbId: movie.id.toString(),
|
||||
type: "movie" as const,
|
||||
title: movie.title,
|
||||
releaseYear: year,
|
||||
poster: getMediaPoster(movie.poster_path) || "/placeholder.png",
|
||||
};
|
||||
|
||||
addBookmarkWithGroups(meta, [groupName]);
|
||||
});
|
||||
};
|
||||
|
||||
const handleShowDetails = (media: MediaItem) => {
|
||||
// Show details modal and close collection overlay
|
||||
showModal("details", {
|
||||
|
|
@ -203,14 +239,28 @@ export function CollectionOverlay({
|
|||
</h2>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{collection && (
|
||||
<p className="text-sm text-white/80">
|
||||
<span className="text-white font-semibold">
|
||||
{collection.parts.length}
|
||||
</span>{" "}
|
||||
{collection.parts.length > 1
|
||||
? t("details.collection.movies")
|
||||
: t("details.collection.movie")}
|
||||
</p>
|
||||
<>
|
||||
<p className="text-sm text-white/80">
|
||||
<span className="text-white font-semibold">
|
||||
{collection.parts.length}
|
||||
</span>{" "}
|
||||
{collection.parts.length > 1
|
||||
? t("details.collection.movies")
|
||||
: t("details.collection.movie")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBookmarkCollection}
|
||||
className="flex items-center gap-2 px-3 py-1 rounded-md text-xs font-medium bg-white/10 hover:bg-white/20 text-white/70 transition-colors"
|
||||
title={`Bookmark entire ${collectionName} collection`}
|
||||
>
|
||||
<Icon
|
||||
icon={Icons.BOOKMARK_OUTLINE}
|
||||
className="text-xs"
|
||||
/>
|
||||
<span>Bookmark All</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!loading && !error && sortedMovies.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -3,29 +3,41 @@
|
|||
Video player is quite a complex component, so here is a rundown of all the parts
|
||||
|
||||
# Composable parts
|
||||
|
||||
These parts can be used to build any shape of a video player.
|
||||
- `/atoms`- any ui element that controls the player. (Seekbar, Pause button, quality selection, etc)
|
||||
- `/base` - base components that are used to build a player. Like the main container
|
||||
|
||||
- `/atoms`- any ui element that controls the player. (Seekbar, Pause button, quality selection, etc)
|
||||
- `/base` - base components that are used to build a player. Like the main container
|
||||
|
||||
# internal parts
|
||||
|
||||
These parts are internally used, they aren't exported. Do not use them outside of player internals.
|
||||
|
||||
### `/display`
|
||||
|
||||
The display interface, abstraction on how to actually play the content (e.g Video element, chrome casting, etc)
|
||||
- It must be completely separate from any react code
|
||||
- It must not interact with state, pass async data back with events
|
||||
|
||||
- It must be completely separate from any react code
|
||||
- It must not interact with state, pass async data back with events
|
||||
|
||||
### `/internals`
|
||||
|
||||
Internal components that are always rendered on every player.
|
||||
- Only components that are always present on the player instance, they must never unmount
|
||||
|
||||
- Only components that are always present on the player instance, they must never unmount
|
||||
|
||||
### `/utils`
|
||||
|
||||
miscellaneous logic, put anything that is unique to the video player internals.
|
||||
|
||||
### `/hooks`
|
||||
|
||||
Hooks only used for video player.
|
||||
- only exception is usePlayer, as its used outside of the player to control the player
|
||||
|
||||
- only exception is usePlayer, as its used outside of the player to control the player
|
||||
|
||||
### `~/src/stores/player`
|
||||
|
||||
State for the video player.
|
||||
- Only parts related to the video player may utilize the state
|
||||
|
||||
- Only parts related to the video player may utilize the state
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { PlayerMeta } from "@/stores/player/slices/source";
|
|||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
import { scrollToElement } from "@/utils/scroll";
|
||||
|
||||
import { hasAired } from "../utils/aired";
|
||||
|
||||
|
|
@ -765,6 +766,39 @@ export function EpisodesView({
|
|||
],
|
||||
);
|
||||
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = () => {
|
||||
if (!carouselRef.current) {
|
||||
setCanScrollLeft(false);
|
||||
setCanScrollRight(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current;
|
||||
const isAtStart = scrollLeft <= 1;
|
||||
const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 1;
|
||||
|
||||
setCanScrollLeft(!isAtStart);
|
||||
setCanScrollRight(!isAtEnd);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const carousel = carouselRef.current;
|
||||
if (!carousel) return;
|
||||
|
||||
updateScrollState();
|
||||
|
||||
carousel.addEventListener("scroll", updateScrollState);
|
||||
window.addEventListener("resize", updateScrollState);
|
||||
|
||||
return () => {
|
||||
carousel.removeEventListener("scroll", updateScrollState);
|
||||
window.removeEventListener("resize", updateScrollState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleScroll = (direction: "left" | "right") => {
|
||||
if (!carouselRef.current) return;
|
||||
|
||||
|
|
@ -799,7 +833,7 @@ export function EpisodesView({
|
|||
carouselRef.current.scrollLeft += scrollPosition;
|
||||
} else {
|
||||
// vertical scroll
|
||||
activeEpisodeRef.current.scrollIntoView({
|
||||
scrollToElement(activeEpisodeRef.current, {
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
|
|
@ -915,20 +949,22 @@ export function EpisodesView({
|
|||
content = (
|
||||
<div className="relative">
|
||||
{/* Horizontal scroll buttons */}
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
|
||||
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
|
||||
onClick={() => handleScroll("left")}
|
||||
{canScrollLeft && (
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
|
||||
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
|
||||
)}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
|
||||
onClick={() => handleScroll("left")}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={carouselRef}
|
||||
|
|
@ -995,20 +1031,22 @@ export function EpisodesView({
|
|||
</div>
|
||||
|
||||
{/* Right scroll button */}
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
|
||||
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
|
||||
onClick={() => handleScroll("right")}
|
||||
{canScrollRight && (
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
|
||||
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
|
||||
)}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
|
||||
onClick={() => handleScroll("right")}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,12 +106,16 @@ export function NextEpisodeButton(props: {
|
|||
const time = usePlayerStore((s) => s.progress.time);
|
||||
const enableAutoplay = usePreferencesStore((s) => s.enableAutoplay);
|
||||
const enableSkipCredits = usePreferencesStore((s) => s.enableSkipCredits);
|
||||
const setLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setLastSuccessfulSource,
|
||||
);
|
||||
const showingState = shouldShowNextEpisodeButton(time, duration);
|
||||
const status = usePlayerStore((s) => s.status);
|
||||
const setShouldStartFromBeginning = usePlayerStore(
|
||||
(s) => s.setShouldStartFromBeginning,
|
||||
);
|
||||
const updateItem = useProgressStore((s) => s.updateItem);
|
||||
const sourceId = usePlayerStore((s) => s.sourceId);
|
||||
|
||||
const isLastEpisode =
|
||||
!meta?.episode?.number || !meta?.episodes?.at(-1)?.number
|
||||
|
|
@ -147,6 +151,12 @@ export function NextEpisodeButton(props: {
|
|||
|
||||
const loadNextEpisode = useCallback(() => {
|
||||
if (!meta || !nextEp) return;
|
||||
|
||||
// Store the current source as the last successful source
|
||||
if (sourceId) {
|
||||
setLastSuccessfulSource(sourceId);
|
||||
}
|
||||
|
||||
const metaCopy = { ...meta };
|
||||
metaCopy.episode = nextEp;
|
||||
metaCopy.season =
|
||||
|
|
@ -173,6 +183,8 @@ export function NextEpisodeButton(props: {
|
|||
updateItem,
|
||||
isLastEpisode,
|
||||
nextSeason,
|
||||
sourceId,
|
||||
setLastSuccessfulSource,
|
||||
]);
|
||||
|
||||
const startCurrentEpisodeFromBeginning = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import classNames from "classnames";
|
|||
import { useCallback } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useSkipTracking } from "@/components/player/hooks/useSkipTracking";
|
||||
import { Transition } from "@/components/utils/Transition";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
function shouldShowSkipButton(
|
||||
|
|
@ -49,7 +49,7 @@ export function SkipIntroButton(props: {
|
|||
const status = usePlayerStore((s) => s.status);
|
||||
const display = usePlayerStore((s) => s.display);
|
||||
const meta = usePlayerStore((s) => s.meta);
|
||||
const account = useAuthStore((s) => s.account);
|
||||
const { addSkipEvent } = useSkipTracking(30);
|
||||
const showingState = shouldShowSkipButton(time, props.skipTime);
|
||||
const animation = showingState === "hover" ? "slide-up" : "fade";
|
||||
let bottom = "bottom-[calc(6rem+env(safe-area-inset-bottom))]";
|
||||
|
|
@ -59,32 +59,6 @@ export function SkipIntroButton(props: {
|
|||
: "bottom-[calc(3rem+env(safe-area-inset-bottom))]";
|
||||
}
|
||||
|
||||
const sendSkipAnalytics = useCallback(
|
||||
async (startTime: number, endTime: number, skipDuration: number) => {
|
||||
try {
|
||||
await fetch("https://skips.pstream.mov/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
skip_duration: skipDuration,
|
||||
content_id: meta?.tmdbId,
|
||||
content_type: meta?.type,
|
||||
season_id: meta?.season?.tmdbId,
|
||||
episode_id: meta?.episode?.tmdbId,
|
||||
user_id: account?.userId,
|
||||
session_id: `session_${Date.now()}`,
|
||||
turnstile_token: "",
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send skip analytics:", error);
|
||||
}
|
||||
},
|
||||
[meta, account],
|
||||
);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (typeof props.skipTime === "number" && display) {
|
||||
const startTime = time;
|
||||
|
|
@ -93,12 +67,30 @@ export function SkipIntroButton(props: {
|
|||
|
||||
display.setTime(props.skipTime);
|
||||
|
||||
// Send analytics for intro skip button usage
|
||||
// Add manual skip event with high confidence (user explicitly clicked skip intro)
|
||||
addSkipEvent({
|
||||
startTime,
|
||||
endTime,
|
||||
skipDuration,
|
||||
confidence: 0.95, // High confidence for explicit user action
|
||||
meta: meta
|
||||
? {
|
||||
title:
|
||||
meta.type === "show" && meta.episode
|
||||
? `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}`
|
||||
: meta.title,
|
||||
type: meta.type === "movie" ? "Movie" : "TV Show",
|
||||
tmdbId: meta.tmdbId,
|
||||
seasonNumber: meta.season?.number,
|
||||
episodeNumber: meta.episode?.number,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Skip intro button used: ${skipDuration}s total`);
|
||||
sendSkipAnalytics(startTime, endTime, skipDuration);
|
||||
}
|
||||
}, [props.skipTime, display, time, sendSkipAnalytics]);
|
||||
}, [props.skipTime, display, time, addSkipEvent, meta]);
|
||||
if (!props.inControl) return null;
|
||||
|
||||
let show = false;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useProgressBar } from "@/hooks/useProgressBar";
|
|||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles";
|
||||
import { isFirefox } from "@/utils/detectFeatures";
|
||||
|
||||
export function ColorOption(props: {
|
||||
color: string;
|
||||
|
|
@ -427,10 +428,12 @@ export function CaptionSettingsView({
|
|||
const resetSubStyling = () => {
|
||||
subtitleStore.updateStyling({
|
||||
color: "#ffffff",
|
||||
backgroundOpacity: 0.5,
|
||||
size: 1,
|
||||
backgroundBlur: 0.5,
|
||||
backgroundOpacity: 0.25,
|
||||
size: 0.75,
|
||||
backgroundBlur: 0.25,
|
||||
backgroundBlurEnabled: !isFirefox,
|
||||
bold: false,
|
||||
verticalPosition: 1,
|
||||
fontStyle: "default",
|
||||
borderThickness: 1,
|
||||
});
|
||||
|
|
@ -497,16 +500,37 @@ export function CaptionSettingsView({
|
|||
value={styling.backgroundOpacity * 100}
|
||||
textTransformer={(s) => `${s}%`}
|
||||
/>
|
||||
<CaptionSetting
|
||||
label={t("settings.subtitles.backgroundBlurLabel")}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(v) =>
|
||||
handleStylingChange({ ...styling, backgroundBlur: v / 100 })
|
||||
}
|
||||
value={styling.backgroundBlur * 100}
|
||||
textTransformer={(s) => `${s}%`}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<Menu.FieldTitle>
|
||||
{t("settings.subtitles.backgroundBlurEnabledLabel")}
|
||||
</Menu.FieldTitle>
|
||||
<div className="flex justify-center items-center">
|
||||
<Toggle
|
||||
enabled={styling.backgroundBlurEnabled}
|
||||
onClick={() =>
|
||||
handleStylingChange({
|
||||
...styling,
|
||||
backgroundBlurEnabled: !styling.backgroundBlurEnabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-type-secondary">
|
||||
{t("settings.subtitles.backgroundBlurEnabledDescription")}
|
||||
</span>
|
||||
{styling.backgroundBlurEnabled && (
|
||||
<CaptionSetting
|
||||
label={t("settings.subtitles.backgroundBlurLabel")}
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(v) =>
|
||||
handleStylingChange({ ...styling, backgroundBlur: v / 100 })
|
||||
}
|
||||
value={styling.backgroundBlur * 100}
|
||||
textTransformer={(s) => `${s}%`}
|
||||
/>
|
||||
)}
|
||||
<CaptionSetting
|
||||
label={t("settings.subtitles.textSizeLabel")}
|
||||
max={200}
|
||||
|
|
@ -618,23 +642,6 @@ export function CaptionSettingsView({
|
|||
{t("settings.subtitles.verticalPositionLabel")}
|
||||
</Menu.FieldTitle>
|
||||
<div className="flex justify-center items-center space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
"px-3 py-1 rounded transition-colors duration-100",
|
||||
styling.verticalPosition === 3
|
||||
? "bg-video-context-buttonFocus"
|
||||
: "bg-video-context-buttonFocus bg-opacity-0 hover:bg-opacity-50",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleStylingChange({
|
||||
...styling,
|
||||
verticalPosition: 3,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("settings.subtitles.default")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
|
|
@ -652,6 +659,23 @@ export function CaptionSettingsView({
|
|||
>
|
||||
{t("settings.subtitles.low")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
"px-3 py-1 rounded transition-colors duration-100",
|
||||
styling.verticalPosition === 3
|
||||
? "bg-video-context-buttonFocus"
|
||||
: "bg-video-context-buttonFocus bg-opacity-0 hover:bg-opacity-50",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleStylingChange({
|
||||
...styling,
|
||||
verticalPosition: 3,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("settings.subtitles.high")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -40,9 +40,11 @@ export function CaptionOption(props: {
|
|||
subtitleSource?: string;
|
||||
subtitleEncoding?: string;
|
||||
isHearingImpaired?: boolean;
|
||||
onDoubleClick?: () => void;
|
||||
}) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const tooltipTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tooltipContent = useMemo(() => {
|
||||
if (!props.subtitleUrl && !props.subtitleSource) return null;
|
||||
|
|
@ -107,6 +109,7 @@ export function CaptionOption(props: {
|
|||
loading={props.loading}
|
||||
error={props.error}
|
||||
onClick={props.onClick}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
>
|
||||
<span
|
||||
data-active-link={props.selected ? true : undefined}
|
||||
|
|
@ -143,8 +146,13 @@ export function CaptionOption(props: {
|
|||
</span>
|
||||
</SelectableLink>
|
||||
{tooltipContent && showTooltip && (
|
||||
<div className="absolute z-50 left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-black/80 text-white/80 text-xs rounded-lg backdrop-blur-sm w-60 break-all whitespace-pre-line">
|
||||
<div className="flex flex-col absolute z-50 left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-black/80 text-white/80 text-xs rounded-lg backdrop-blur-sm w-60 break-all whitespace-pre-line">
|
||||
{tooltipContent}
|
||||
{props.onDoubleClick && (
|
||||
<span className="text-white/50 text-xs">
|
||||
{t("player.menus.subtitles.doubleClickToCopy")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -219,6 +227,76 @@ export function CustomCaptionOption() {
|
|||
);
|
||||
}
|
||||
|
||||
export function PasteCaptionOption(props: { selected?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const setCaption = usePlayerStore((s) => s.setCaption);
|
||||
const setCustomSubs = useSubtitleStore((s) => s.setCustomSubs);
|
||||
const setDelay = useSubtitleStore((s) => s.setDelay);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handlePaste = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
const parsedData = JSON.parse(clipboardText);
|
||||
|
||||
// Validate the structure
|
||||
if (!parsedData.id || !parsedData.url || !parsedData.language) {
|
||||
throw new Error("Invalid subtitle data format");
|
||||
}
|
||||
|
||||
// Check for CORS restrictions
|
||||
if (parsedData.hasCorsRestrictions) {
|
||||
throw new Error("Protected subtitle url, cannot be used");
|
||||
}
|
||||
|
||||
// Fetch the subtitle content
|
||||
const response = await fetch(parsedData.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch subtitle: ${response.status}`);
|
||||
}
|
||||
|
||||
const subtitleText = await response.text();
|
||||
|
||||
// Convert to SRT format
|
||||
const converted = convert(subtitleText, "srt");
|
||||
|
||||
setCaption({
|
||||
language: parsedData.language,
|
||||
srtData: converted,
|
||||
id: "pasted-caption",
|
||||
});
|
||||
setCustomSubs();
|
||||
|
||||
// Set delay if included in the pasted data, otherwise reset to 0
|
||||
if (parsedData.delay !== undefined) {
|
||||
setDelay(parsedData.delay);
|
||||
} else {
|
||||
setDelay(0);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to paste subtitle:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to paste subtitle");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CaptionOption
|
||||
onClick={handlePaste}
|
||||
loading={isLoading}
|
||||
error={error}
|
||||
selected={props.selected}
|
||||
>
|
||||
{t("player.menus.subtitles.pasteChoice")}
|
||||
</CaptionOption>
|
||||
);
|
||||
}
|
||||
|
||||
export function CaptionsView({
|
||||
id,
|
||||
backLink,
|
||||
|
|
@ -315,28 +393,55 @@ export function CaptionsView({
|
|||
// Render subtitle option
|
||||
const renderSubtitleOption = (
|
||||
v: CaptionListItem & { languageName: string },
|
||||
) => (
|
||||
<CaptionOption
|
||||
key={v.id}
|
||||
countryCode={v.language}
|
||||
selected={v.id === selectedCaptionId}
|
||||
loading={v.id === currentlyDownloading && downloadReq.loading}
|
||||
error={
|
||||
v.id === currentlyDownloading && downloadReq.error
|
||||
? downloadReq.error.toString()
|
||||
: undefined
|
||||
) => {
|
||||
const handleDoubleClick = async () => {
|
||||
const copyData = {
|
||||
id: v.id,
|
||||
url: v.url,
|
||||
language: v.language,
|
||||
type: v.type,
|
||||
hasCorsRestrictions: v.needsProxy,
|
||||
opensubtitles: v.opensubtitles,
|
||||
display: v.display,
|
||||
media: v.media,
|
||||
isHearingImpaired: v.isHearingImpaired,
|
||||
source: v.source,
|
||||
encoding: v.encoding,
|
||||
delay,
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(copyData, null, 2));
|
||||
// Could add a toast notification here if needed
|
||||
} catch (err) {
|
||||
console.error("Failed to copy subtitle data:", err);
|
||||
}
|
||||
onClick={() => startDownload(v.id)}
|
||||
flag
|
||||
subtitleUrl={v.url}
|
||||
subtitleType={v.type}
|
||||
subtitleSource={v.source}
|
||||
subtitleEncoding={v.encoding}
|
||||
isHearingImpaired={v.isHearingImpaired}
|
||||
>
|
||||
{v.languageName}
|
||||
</CaptionOption>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CaptionOption
|
||||
key={v.id}
|
||||
countryCode={v.language}
|
||||
selected={v.id === selectedCaptionId}
|
||||
loading={v.id === currentlyDownloading && downloadReq.loading}
|
||||
error={
|
||||
v.id === currentlyDownloading && downloadReq.error
|
||||
? downloadReq.error.toString()
|
||||
: undefined
|
||||
}
|
||||
onClick={() => startDownload(v.id)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
flag
|
||||
subtitleUrl={v.url}
|
||||
subtitleType={v.type}
|
||||
subtitleSource={v.source}
|
||||
subtitleEncoding={v.encoding}
|
||||
isHearingImpaired={v.isHearingImpaired}
|
||||
>
|
||||
{v.languageName}
|
||||
</CaptionOption>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -431,11 +536,16 @@ export function CaptionsView({
|
|||
{/* Custom upload option */}
|
||||
<CustomCaptionOption />
|
||||
|
||||
{/* Paste subtitle option */}
|
||||
<PasteCaptionOption
|
||||
selected={selectedCaptionId === "pasted-caption"}
|
||||
/>
|
||||
|
||||
<div className="h-1" />
|
||||
|
||||
{/* Search input */}
|
||||
{(sourceCaptions.length || externalCaptions.length) > 0 && (
|
||||
<div className="mt-3">
|
||||
<Input value={searchQuery} onInput={setSearchQuery} />
|
||||
</div>
|
||||
<Input value={searchQuery} onInput={setSearchQuery} />
|
||||
)}
|
||||
|
||||
{/* No subtitles available message */}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,12 @@ export function SourceSelectionView({
|
|||
const currentSourceId = usePlayerStore((s) => s.sourceId);
|
||||
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
|
||||
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
|
||||
const lastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.lastSuccessfulSource,
|
||||
);
|
||||
const enableLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.enableLastSuccessfulSource,
|
||||
);
|
||||
const disabledSources = usePreferencesStore((s) => s.disabledSources);
|
||||
|
||||
const sources = useMemo(() => {
|
||||
|
|
@ -154,13 +160,34 @@ export function SourceSelectionView({
|
|||
.filter((v) => !disabledSources.includes(v.id));
|
||||
|
||||
if (!enableSourceOrder || preferredSourceOrder.length === 0) {
|
||||
// Even without custom source order, prioritize last successful source if enabled
|
||||
if (enableLastSuccessfulSource && lastSuccessfulSource) {
|
||||
const lastSourceIndex = allSources.findIndex(
|
||||
(s) => s.id === lastSuccessfulSource,
|
||||
);
|
||||
if (lastSourceIndex !== -1) {
|
||||
const lastSource = allSources.splice(lastSourceIndex, 1)[0];
|
||||
return [lastSource, ...allSources];
|
||||
}
|
||||
}
|
||||
return allSources;
|
||||
}
|
||||
|
||||
// Sort sources according to preferred order
|
||||
// Sort sources according to preferred order, but prioritize last successful source
|
||||
const orderedSources = [];
|
||||
const remainingSources = [...allSources];
|
||||
|
||||
// First, add the last successful source if it exists, is available, and the feature is enabled
|
||||
if (enableLastSuccessfulSource && lastSuccessfulSource) {
|
||||
const lastSourceIndex = remainingSources.findIndex(
|
||||
(s) => s.id === lastSuccessfulSource,
|
||||
);
|
||||
if (lastSourceIndex !== -1) {
|
||||
orderedSources.push(remainingSources[lastSourceIndex]);
|
||||
remainingSources.splice(lastSourceIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Add sources in preferred order
|
||||
for (const sourceId of preferredSourceOrder) {
|
||||
const sourceIndex = remainingSources.findIndex((s) => s.id === sourceId);
|
||||
|
|
@ -174,7 +201,14 @@ export function SourceSelectionView({
|
|||
orderedSources.push(...remainingSources);
|
||||
|
||||
return orderedSources;
|
||||
}, [metaType, preferredSourceOrder, enableSourceOrder, disabledSources]);
|
||||
}, [
|
||||
metaType,
|
||||
preferredSourceOrder,
|
||||
enableSourceOrder,
|
||||
disabledSources,
|
||||
lastSuccessfulSource,
|
||||
enableLastSuccessfulSource,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -183,7 +217,9 @@ export function SourceSelectionView({
|
|||
rightSide={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open("/settings#source-order")}
|
||||
onClick={() => {
|
||||
window.location.href = "/settings#source-order";
|
||||
}}
|
||||
className="-mr-2 -my-1 px-2 p-[0.4em] rounded tabbable hover:bg-video-context-light hover:bg-opacity-10"
|
||||
>
|
||||
{t("player.menus.sources.editOrder")}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect } from "react";
|
|||
|
||||
import { Transition } from "@/components/utils/Transition";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
|
||||
export function BottomControls(props: {
|
||||
show?: boolean;
|
||||
|
|
@ -10,6 +11,9 @@ export function BottomControls(props: {
|
|||
const setHoveringAnyControls = usePlayerStore(
|
||||
(s) => s.setHoveringAnyControls,
|
||||
);
|
||||
const backgroundBlurEnabled = useSubtitleStore(
|
||||
(s) => s.styling.backgroundBlurEnabled,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -19,11 +23,13 @@ export function BottomControls(props: {
|
|||
|
||||
return (
|
||||
<div className="w-full text-white">
|
||||
<Transition
|
||||
animation="fade"
|
||||
show={props.show}
|
||||
className="pointer-events-none flex justify-end pt-32 bg-gradient-to-t from-black to-transparent transition-opacity duration-200 absolute bottom-0 w-full"
|
||||
/>
|
||||
{backgroundBlurEnabled && (
|
||||
<Transition
|
||||
animation="fade"
|
||||
show={props.show}
|
||||
className="pointer-events-none flex justify-end pt-32 bg-gradient-to-t from-black to-transparent transition-opacity duration-200 absolute bottom-0 w-full"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
onMouseOver={() => setHoveringAnyControls(true)}
|
||||
onMouseOut={() => setHoveringAnyControls(false)}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { usePlayerStore } from "@/stores/player/store";
|
|||
import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles";
|
||||
|
||||
const wordOverrides: Record<string, string> = {
|
||||
i: "I",
|
||||
// Example: i: "I", but in polish "i" is "and" so this is disabled.
|
||||
};
|
||||
|
||||
export function CaptionCue({
|
||||
|
|
@ -95,7 +95,7 @@ export function CaptionCue({
|
|||
fontSize: `${(1.5 * styling.size).toFixed(2)}em`,
|
||||
backgroundColor: `rgba(0,0,0,${styling.backgroundOpacity.toFixed(2)})`,
|
||||
backdropFilter:
|
||||
styling.backgroundBlur !== 0
|
||||
styling.backgroundBlurEnabled && styling.backgroundBlur !== 0
|
||||
? `blur(${Math.floor(styling.backgroundBlur * 64)}px)`
|
||||
: "none",
|
||||
fontWeight: styling.bold ? "bold" : "normal",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Transition } from "@/components/utils/Transition";
|
|||
import { useBannerSize } from "@/stores/banner";
|
||||
import { BannerLocation } from "@/stores/banner/BannerLocation";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
|
||||
export function TopControls(props: {
|
||||
show?: boolean;
|
||||
|
|
@ -13,6 +14,9 @@ export function TopControls(props: {
|
|||
const setHoveringAnyControls = usePlayerStore(
|
||||
(s) => s.setHoveringAnyControls,
|
||||
);
|
||||
const backgroundBlurEnabled = useSubtitleStore(
|
||||
(s) => s.styling.backgroundBlurEnabled,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -22,14 +26,16 @@ export function TopControls(props: {
|
|||
|
||||
return (
|
||||
<div className="w-full text-white">
|
||||
<Transition
|
||||
animation="fade"
|
||||
show={props.show}
|
||||
style={{
|
||||
top: `${bannerSize}px`,
|
||||
}}
|
||||
className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full"
|
||||
/>
|
||||
{backgroundBlurEnabled && (
|
||||
<Transition
|
||||
animation="fade"
|
||||
show={props.show}
|
||||
style={{
|
||||
top: `${bannerSize}px`,
|
||||
}}
|
||||
className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full"
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10">
|
||||
<BannerLocation location="player" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||
let videoElement: HTMLVideoElement | null = null;
|
||||
let containerElement: HTMLElement | null = null;
|
||||
let isFullscreen = false;
|
||||
let isPictureInPicture = false;
|
||||
let isPausedBeforeSeeking = false;
|
||||
let isSeeking = false;
|
||||
let startAt = 0;
|
||||
|
|
@ -95,6 +96,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||
let lastVolume = 1;
|
||||
let lastValidDuration = 0; // Store the last valid duration to prevent reset during source switches
|
||||
let lastValidTime = 0; // Store the last valid time to prevent reset during source switches
|
||||
let shouldAutoplayAfterLoad = false; // Flag to track if we should autoplay after loading completes
|
||||
|
||||
const languagePromises = new Map<
|
||||
string,
|
||||
|
|
@ -326,6 +328,25 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||
vid.currentTime = startAt;
|
||||
}
|
||||
|
||||
function webkitPresentationModeChange() {
|
||||
if (!videoElement) return;
|
||||
const webkitPlayer = videoElement as any;
|
||||
const isInWebkitPip =
|
||||
webkitPlayer.webkitPresentationMode === "picture-in-picture";
|
||||
isPictureInPicture = isInWebkitPip;
|
||||
// Use native tracks in WebKit PiP mode for iOS compatibility
|
||||
emit("needstrack", isInWebkitPip);
|
||||
|
||||
// On iOS, entering PiP may allow autoplay that was previously blocked
|
||||
if (isInWebkitPip && videoElement.paused && shouldAutoplayAfterLoad) {
|
||||
shouldAutoplayAfterLoad = false;
|
||||
videoElement.play().catch(() => {
|
||||
// If still blocked, emit pause to show play button
|
||||
emit("pause", undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setSource() {
|
||||
if (!videoElement || !source) return;
|
||||
setupSource(videoElement, source);
|
||||
|
|
@ -345,7 +366,22 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||
});
|
||||
videoElement.addEventListener("playing", () => emit("play", undefined));
|
||||
videoElement.addEventListener("pause", () => emit("pause", undefined));
|
||||
videoElement.addEventListener("canplay", () => emit("loading", false));
|
||||
videoElement.addEventListener("canplay", () => {
|
||||
emit("loading", false);
|
||||
// Attempt autoplay if this was an autoplay transition (startAt = 0)
|
||||
if (shouldAutoplayAfterLoad && startAt === 0 && videoElement) {
|
||||
shouldAutoplayAfterLoad = false; // Reset the flag
|
||||
// Try to play - this will work on most platforms, but iOS may block it
|
||||
const playPromise = videoElement.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(() => {
|
||||
// Play was blocked (likely iOS), emit that we're not playing
|
||||
// The AutoPlayStart component will show a play button
|
||||
emit("pause", undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
videoElement.addEventListener("waiting", () => emit("loading", true));
|
||||
videoElement.addEventListener("volumechange", () =>
|
||||
emit(
|
||||
|
|
@ -406,6 +442,10 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||
}
|
||||
},
|
||||
);
|
||||
videoElement.addEventListener(
|
||||
"webkitpresentationmodechanged",
|
||||
webkitPresentationModeChange,
|
||||
);
|
||||
videoElement.addEventListener("ratechange", () => {
|
||||
if (videoElement) emit("playbackrate", videoElement.playbackRate);
|
||||
});
|
||||
|
|
@ -450,9 +490,46 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||
!!(document as any).webkitFullscreenElement; // safari
|
||||
emit("fullscreen", isFullscreen);
|
||||
if (!isFullscreen) emit("needstrack", false);
|
||||
|
||||
// On iOS, entering fullscreen may allow autoplay that was previously blocked
|
||||
if (
|
||||
isFullscreen &&
|
||||
videoElement &&
|
||||
videoElement.paused &&
|
||||
shouldAutoplayAfterLoad
|
||||
) {
|
||||
shouldAutoplayAfterLoad = false;
|
||||
videoElement.play().catch(() => {
|
||||
// If still blocked, emit pause to show play button
|
||||
emit("pause", undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
fscreen.addEventListener("fullscreenchange", fullscreenChange);
|
||||
|
||||
function pictureInPictureChange() {
|
||||
isPictureInPicture = !!document.pictureInPictureElement;
|
||||
// Use native tracks in PiP mode for better compatibility with iOS and other platforms
|
||||
emit("needstrack", isPictureInPicture);
|
||||
|
||||
// Entering PiP may allow autoplay that was previously blocked
|
||||
if (
|
||||
isPictureInPicture &&
|
||||
videoElement &&
|
||||
videoElement.paused &&
|
||||
shouldAutoplayAfterLoad
|
||||
) {
|
||||
shouldAutoplayAfterLoad = false;
|
||||
videoElement.play().catch(() => {
|
||||
// If still blocked, emit pause to show play button
|
||||
emit("pause", undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("enterpictureinpicture", pictureInPictureChange);
|
||||
document.addEventListener("leavepictureinpicture", pictureInPictureChange);
|
||||
|
||||
return {
|
||||
on,
|
||||
off,
|
||||
|
|
@ -462,6 +539,14 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||
destroy: () => {
|
||||
destroyVideoElement();
|
||||
fscreen.removeEventListener("fullscreenchange", fullscreenChange);
|
||||
document.removeEventListener(
|
||||
"enterpictureinpicture",
|
||||
pictureInPictureChange,
|
||||
);
|
||||
document.removeEventListener(
|
||||
"leavepictureinpicture",
|
||||
pictureInPictureChange,
|
||||
);
|
||||
},
|
||||
load(ops) {
|
||||
if (!ops.source) unloadSource();
|
||||
|
|
@ -470,6 +555,8 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
|||
source = ops.source;
|
||||
emit("loading", true);
|
||||
startAt = ops.startAt;
|
||||
// Set autoplay flag if starting from beginning (indicates autoplay transition)
|
||||
shouldAutoplayAfterLoad = ops.startAt === 0;
|
||||
setSource();
|
||||
},
|
||||
changeQuality(newAutomaticQuality, newPreferredQuality) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import subsrt from "subsrt-ts";
|
|||
import { downloadCaption, downloadWebVTT } from "@/backend/helpers/subs";
|
||||
import { Caption } from "@/stores/player/slices/source";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
|
||||
import {
|
||||
|
|
@ -23,11 +24,17 @@ export function useCaptions() {
|
|||
|
||||
const captionList = usePlayerStore((s) => s.captionList);
|
||||
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
|
||||
const source = usePlayerStore((s) => s.source);
|
||||
const selectedCaption = usePlayerStore((s) => s.caption.selected);
|
||||
|
||||
const getSubtitleTracks = usePlayerStore((s) => s.display?.getSubtitleTracks);
|
||||
const setSubtitlePreference = usePlayerStore(
|
||||
(s) => s.display?.setSubtitlePreference,
|
||||
);
|
||||
const setCaptionAsTrack = usePlayerStore((s) => s.setCaptionAsTrack);
|
||||
const enableNativeSubtitles = usePreferencesStore(
|
||||
(s) => s.enableNativeSubtitles,
|
||||
);
|
||||
|
||||
const captions = useMemo(
|
||||
() =>
|
||||
|
|
@ -80,8 +87,21 @@ export function useCaptions() {
|
|||
|
||||
setIsOpenSubtitles(!!caption.opensubtitles);
|
||||
setCaption(captionToSet);
|
||||
resetSubtitleSpecificSettings();
|
||||
|
||||
// Only reset subtitle settings if selecting a different caption
|
||||
if (selectedCaption?.id !== caption.id) {
|
||||
resetSubtitleSpecificSettings();
|
||||
}
|
||||
|
||||
setLanguage(caption.language);
|
||||
|
||||
// Use native tracks for MP4 streams instead of custom rendering
|
||||
if (source?.type === "file" && enableNativeSubtitles) {
|
||||
setCaptionAsTrack(true);
|
||||
} else {
|
||||
// For HLS sources or when native subtitles are disabled, use custom rendering
|
||||
setCaptionAsTrack(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
setIsOpenSubtitles,
|
||||
|
|
@ -91,6 +111,10 @@ export function useCaptions() {
|
|||
resetSubtitleSpecificSettings,
|
||||
getSubtitleTracks,
|
||||
setSubtitlePreference,
|
||||
source,
|
||||
setCaptionAsTrack,
|
||||
enableNativeSubtitles,
|
||||
selectedCaption,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
|
||||
import { conf } from "@/setup/config";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { getTurnstileToken } from "@/utils/turnstile";
|
||||
|
||||
// Thanks Nemo for this API
|
||||
const BASE_URL = "https://fed-skips.pstream.mov";
|
||||
const FED_SKIPS_BASE_URL = "https://fed-skips.pstream.mov";
|
||||
const VELORA_BASE_URL = "https://veloratv.ru/api/intro-end/confirmed";
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export function useSkipTime() {
|
||||
|
|
@ -15,17 +17,40 @@ export function useSkipTime() {
|
|||
const febboxKey = usePreferencesStore((s) => s.febboxKey);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSkipTime = async (retries = 0): Promise<void> => {
|
||||
if (!meta?.imdbId || meta.type === "movie") return;
|
||||
if (!conf().ALLOW_FEBBOX_KEY) return;
|
||||
if (!febboxKey) return;
|
||||
const fetchVeloraSkipTime = async (): Promise<number | null> => {
|
||||
if (!meta?.tmdbId) return null;
|
||||
|
||||
try {
|
||||
let apiUrl = `${VELORA_BASE_URL}?tmdbId=${meta.tmdbId}`;
|
||||
if (meta.type !== "movie") {
|
||||
apiUrl += `&season=${meta.season?.number}&episode=${meta.episode?.number}`;
|
||||
}
|
||||
const data = await proxiedFetch(apiUrl);
|
||||
|
||||
if (data.introSkippable && typeof data.introEnd === "number") {
|
||||
return data.introEnd;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error fetching velora skip time:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFedSkipsTime = async (retries = 0): Promise<number | null> => {
|
||||
if (!meta?.imdbId || meta.type === "movie") return null;
|
||||
if (!conf().ALLOW_FEBBOX_KEY) return null;
|
||||
if (!febboxKey) return null;
|
||||
|
||||
try {
|
||||
const apiUrl = `${FED_SKIPS_BASE_URL}/${meta.imdbId}/${meta.season?.number}/${meta.episode?.number}`;
|
||||
|
||||
const turnstileToken = await getTurnstileToken(
|
||||
"0x4AAAAAAB6ocCCpurfWRZyC",
|
||||
);
|
||||
if (!turnstileToken) return null;
|
||||
|
||||
const apiUrl = `${BASE_URL}/${meta.imdbId}/${meta.season?.number}/${meta.episode?.number}`;
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
"cf-turnstile-response": turnstileToken,
|
||||
|
|
@ -34,9 +59,9 @@ export function useSkipTime() {
|
|||
|
||||
if (!response.ok) {
|
||||
if (response.status === 500 && retries < MAX_RETRIES) {
|
||||
return fetchSkipTime(retries + 1);
|
||||
return fetchFedSkipsTime(retries + 1);
|
||||
}
|
||||
throw new Error("API request failed");
|
||||
throw new Error("Fed-skips API request failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
|
@ -50,15 +75,28 @@ export function useSkipTime() {
|
|||
|
||||
const skipTime = parseSkipTime(data.introSkipTime);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Skip time:", skipTime);
|
||||
setSkiptime(skipTime);
|
||||
return skipTime;
|
||||
} catch (error) {
|
||||
console.error("Error fetching skip time:", error);
|
||||
setSkiptime(null);
|
||||
console.error("Error fetching fed-skips time:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSkipTime = async (): Promise<void> => {
|
||||
// If user has febbox key, prioritize Fed-skips (better quality)
|
||||
if (febboxKey) {
|
||||
const fedSkipsTime = await fetchFedSkipsTime();
|
||||
if (fedSkipsTime !== null) {
|
||||
setSkiptime(fedSkipsTime);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Velora API (available to all users)
|
||||
const veloraSkipTime = await fetchVeloraSkipTime();
|
||||
setSkiptime(veloraSkipTime);
|
||||
};
|
||||
|
||||
fetchSkipTime();
|
||||
}, [
|
||||
meta?.tmdbId,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface SkipEvent {
|
|||
endTime: number;
|
||||
skipDuration: number;
|
||||
timestamp: number;
|
||||
confidence: number; // 0.0-1.0 confidence score
|
||||
meta?: {
|
||||
title: string;
|
||||
type: string;
|
||||
|
|
@ -23,6 +24,31 @@ interface SkipTrackingResult {
|
|||
latestSkip: SkipEvent | null;
|
||||
/** Clear the skip history */
|
||||
clearHistory: () => void;
|
||||
/** Add a manual skip event (e.g., from skip intro button) */
|
||||
addSkipEvent: (event: Omit<SkipEvent, "timestamp">) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate confidence score for automatic skip detection
|
||||
* Based on skip duration and timing within the video
|
||||
*/
|
||||
function calculateSkipConfidence(
|
||||
skipDuration: number,
|
||||
startTime: number,
|
||||
duration: number,
|
||||
): number {
|
||||
// Duration confidence: longer skips are more confident
|
||||
// 30s = 0.5, 60s = 0.75, 90s+ = 0.85
|
||||
const durationConfidence = Math.min(0.85, 0.5 + (skipDuration - 30) * 0.01);
|
||||
|
||||
// Timing confidence: earlier skips are more confident
|
||||
// Start time as percentage of total duration
|
||||
const startPercentage = startTime / duration;
|
||||
// Higher confidence for earlier starts (0% = 1.0, 20% = 0.8)
|
||||
const timingConfidence = Math.max(0.7, 1.0 - startPercentage * 0.75);
|
||||
|
||||
// Combine factors (weighted average)
|
||||
return durationConfidence * 0.6 + timingConfidence * 0.4;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,6 +85,23 @@ export function useSkipTracking(
|
|||
sessionTotalRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const addSkipEvent = useCallback(
|
||||
(event: Omit<SkipEvent, "timestamp">) => {
|
||||
const skipEvent: SkipEvent = {
|
||||
...event,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setSkipHistory((prev) => {
|
||||
const newHistory = [...prev, skipEvent];
|
||||
return newHistory.length > maxHistory
|
||||
? newHistory.slice(newHistory.length - maxHistory)
|
||||
: newHistory;
|
||||
});
|
||||
},
|
||||
[maxHistory],
|
||||
);
|
||||
|
||||
const detectSkip = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const currentTime = progress.time;
|
||||
|
|
@ -123,6 +166,11 @@ export function useSkipTracking(
|
|||
endTime: currentTime,
|
||||
skipDuration: sessionTotalRef.current,
|
||||
timestamp: now,
|
||||
confidence: calculateSkipConfidence(
|
||||
sessionTotalRef.current,
|
||||
skipSessionStartRef.current,
|
||||
duration,
|
||||
),
|
||||
meta: meta
|
||||
? {
|
||||
title:
|
||||
|
|
@ -170,5 +218,6 @@ export function useSkipTracking(
|
|||
latestSkip:
|
||||
skipHistory.length > 0 ? skipHistory[skipHistory.length - 1] : null,
|
||||
clearHistory,
|
||||
addSkipEvent,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ export function useEmbedScraping(
|
|||
const progressItems = useProgressStore((s) => s.items);
|
||||
const router = useOverlayRouter(routerId);
|
||||
const { report } = useReportProviders();
|
||||
const setLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setLastSuccessfulSource,
|
||||
);
|
||||
const enableLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.enableLastSuccessfulSource,
|
||||
);
|
||||
|
||||
const [request, run] = useAsyncFn(async () => {
|
||||
const providerApiUrl = getLoadbalancedProviderApiUrl();
|
||||
|
|
@ -98,8 +104,21 @@ export function useEmbedScraping(
|
|||
convertProviderCaption(result.stream[0].captions),
|
||||
getSavedProgress(progressItems, meta),
|
||||
);
|
||||
// Save the last successful source when manually selected
|
||||
if (enableLastSuccessfulSource) {
|
||||
setLastSuccessfulSource(sourceId);
|
||||
}
|
||||
router.close();
|
||||
}, [embedId, sourceId, meta, router, report, setCaption]);
|
||||
}, [
|
||||
embedId,
|
||||
sourceId,
|
||||
meta,
|
||||
router,
|
||||
report,
|
||||
setCaption,
|
||||
enableLastSuccessfulSource,
|
||||
setLastSuccessfulSource,
|
||||
]);
|
||||
|
||||
return {
|
||||
run,
|
||||
|
|
@ -117,6 +136,12 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
|||
const progressItems = useProgressStore((s) => s.items);
|
||||
const router = useOverlayRouter(routerId);
|
||||
const { report } = useReportProviders();
|
||||
const setLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setLastSuccessfulSource,
|
||||
);
|
||||
const enableLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.enableLastSuccessfulSource,
|
||||
);
|
||||
|
||||
const [request, run] = useAsyncFn(async () => {
|
||||
if (!sourceId || !meta) return null;
|
||||
|
|
@ -162,6 +187,10 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
|||
getSavedProgress(progressItems, meta),
|
||||
);
|
||||
setSourceId(sourceId);
|
||||
// Save the last successful source when manually selected
|
||||
if (enableLastSuccessfulSource) {
|
||||
setLastSuccessfulSource(sourceId);
|
||||
}
|
||||
router.close();
|
||||
return null;
|
||||
}
|
||||
|
|
@ -218,10 +247,21 @@ export function useSourceScraping(sourceId: string | null, routerId: string) {
|
|||
convertProviderCaption(embedResult.stream[0].captions),
|
||||
getSavedProgress(progressItems, meta),
|
||||
);
|
||||
// Save the last successful source when manually selected
|
||||
if (enableLastSuccessfulSource) {
|
||||
setLastSuccessfulSource(sourceId);
|
||||
}
|
||||
router.close();
|
||||
}
|
||||
return result.embeds;
|
||||
}, [sourceId, meta, router, setCaption]);
|
||||
}, [
|
||||
sourceId,
|
||||
meta,
|
||||
router,
|
||||
setCaption,
|
||||
enableLastSuccessfulSource,
|
||||
setLastSuccessfulSource,
|
||||
]);
|
||||
|
||||
return {
|
||||
run,
|
||||
|
|
|
|||
|
|
@ -1,48 +1,95 @@
|
|||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useSkipTracking } from "@/components/player/hooks/useSkipTracking";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
// Import SkipEvent type
|
||||
type SkipEvent = NonNullable<ReturnType<typeof useSkipTracking>["latestSkip"]>;
|
||||
|
||||
/**
|
||||
* Component that tracks and reports completed skip sessions to analytics backend.
|
||||
* Sessions are detected when users accumulate 30+ seconds of forward movement
|
||||
* within a 5-second window and end after 8 seconds of no activity.
|
||||
* Ignores skips that start after 20% of video duration (unlikely to be intro skipping).
|
||||
*/
|
||||
interface PendingSkip {
|
||||
skip: SkipEvent;
|
||||
originalConfidence: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
hasBackwardMovement: boolean;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export function SkipTracker() {
|
||||
const { latestSkip } = useSkipTracking(30);
|
||||
const lastLoggedSkipRef = useRef<number>(0);
|
||||
const [pendingSkips, setPendingSkips] = useState<PendingSkip[]>([]);
|
||||
const lastPlayerTimeRef = useRef<number>(0);
|
||||
|
||||
// Player metadata for context
|
||||
const meta = usePlayerStore((s) => s.meta);
|
||||
const account = useAuthStore((s) => s.account);
|
||||
const progress = usePlayerStore((s) => s.progress);
|
||||
const turnstileToken = "";
|
||||
|
||||
const sendSkipAnalytics = useCallback(async () => {
|
||||
if (!latestSkip) return;
|
||||
const sendSkipAnalytics = useCallback(
|
||||
async (skip: SkipEvent, adjustedConfidence: number) => {
|
||||
try {
|
||||
await fetch("https://skips.pstream.mov/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
start_time: skip.startTime,
|
||||
end_time: skip.endTime,
|
||||
skip_duration: skip.skipDuration,
|
||||
content_id: meta?.tmdbId,
|
||||
content_type: meta?.type,
|
||||
season_id: meta?.season?.tmdbId,
|
||||
episode_id: meta?.episode?.tmdbId,
|
||||
confidence: adjustedConfidence,
|
||||
turnstile_token: turnstileToken ?? "",
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send skip analytics:", error);
|
||||
}
|
||||
},
|
||||
[meta, turnstileToken],
|
||||
);
|
||||
|
||||
try {
|
||||
await fetch("https://skips.pstream.mov/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
start_time: latestSkip.startTime,
|
||||
end_time: latestSkip.endTime,
|
||||
skip_duration: latestSkip.skipDuration,
|
||||
content_id: meta?.tmdbId,
|
||||
content_type: meta?.type,
|
||||
season_id: meta?.season?.tmdbId,
|
||||
episode_id: meta?.episode?.tmdbId,
|
||||
user_id: account?.userId,
|
||||
session_id: `session_${Date.now()}`,
|
||||
turnstile_token: turnstileToken ?? "",
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send skip analytics:", error);
|
||||
}
|
||||
}, [latestSkip, meta, account]);
|
||||
const createPendingSkip = useCallback(
|
||||
(skip: SkipEvent) => {
|
||||
const timer = setTimeout(() => {
|
||||
// Timer expired, send analytics with final confidence
|
||||
setPendingSkips((prev) => {
|
||||
const pendingSkip = prev.find(
|
||||
(p) => p.skip.timestamp === skip.timestamp,
|
||||
);
|
||||
if (!pendingSkip) return prev;
|
||||
|
||||
const adjustedConfidence = pendingSkip.hasBackwardMovement
|
||||
? Math.max(0.1, pendingSkip.originalConfidence * 0.5) // Reduce confidence by half if adjusted
|
||||
: pendingSkip.originalConfidence;
|
||||
|
||||
// Send analytics
|
||||
sendSkipAnalytics(pendingSkip.skip, adjustedConfidence);
|
||||
|
||||
// Remove from pending
|
||||
return prev.filter((p) => p.skip.timestamp !== skip.timestamp);
|
||||
});
|
||||
}, 10000); // 10 second delay
|
||||
|
||||
return {
|
||||
skip,
|
||||
originalConfidence: skip.confidence,
|
||||
startTime: progress.time,
|
||||
endTime: skip.endTime,
|
||||
hasBackwardMovement: false,
|
||||
timer,
|
||||
};
|
||||
},
|
||||
[progress.time, sendSkipAnalytics],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestSkip || !meta) return;
|
||||
|
|
@ -54,11 +101,51 @@ export function SkipTracker() {
|
|||
// eslint-disable-next-line no-console
|
||||
console.log(`Skip session completed: ${latestSkip.skipDuration}s total`);
|
||||
|
||||
// Send analytics data to backend
|
||||
sendSkipAnalytics();
|
||||
// Create pending skip with 10-second delay
|
||||
const pendingSkip = createPendingSkip(latestSkip);
|
||||
setPendingSkips((prev) => [...prev, pendingSkip]);
|
||||
|
||||
lastLoggedSkipRef.current = latestSkip.timestamp;
|
||||
}, [latestSkip, meta, sendSkipAnalytics]);
|
||||
}, [latestSkip, meta, createPendingSkip]);
|
||||
|
||||
// Monitor for backward movements during pending skip periods
|
||||
useEffect(() => {
|
||||
const currentTime = progress.time;
|
||||
|
||||
// Check for backward movement
|
||||
if (
|
||||
lastPlayerTimeRef.current > 0 &&
|
||||
currentTime < lastPlayerTimeRef.current
|
||||
) {
|
||||
// Backward movement detected, mark relevant pending skips as adjusted
|
||||
setPendingSkips((prev) =>
|
||||
prev.map((pending) => {
|
||||
// Check if we're within the monitoring period (between start and end time of skip)
|
||||
const isWithinSkipRange =
|
||||
currentTime >= pending.startTime && currentTime <= pending.endTime;
|
||||
if (isWithinSkipRange && !pending.hasBackwardMovement) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Backward adjustment detected for skip, reducing confidence`,
|
||||
);
|
||||
return { ...pending, hasBackwardMovement: true };
|
||||
}
|
||||
return pending;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
lastPlayerTimeRef.current = currentTime;
|
||||
}, [progress.time]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
pendingSkips.forEach((pending) => {
|
||||
clearTimeout(pending.timer);
|
||||
});
|
||||
};
|
||||
}, [pendingSkips]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export function Link(props: {
|
|||
clickable?: boolean;
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
box?: boolean;
|
||||
|
|
@ -126,6 +127,7 @@ export function Link(props: {
|
|||
className={classes}
|
||||
style={props.box ? {} : styles}
|
||||
onClick={props.onClick}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
data-active-link={props.active ? true : undefined}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
|
|
@ -162,6 +164,7 @@ export function SelectableLink(props: {
|
|||
selected?: boolean;
|
||||
loading?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
error?: ReactNode;
|
||||
|
|
@ -187,6 +190,7 @@ export function SelectableLink(props: {
|
|||
return (
|
||||
<Link
|
||||
onClick={props.onClick}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
clickable={!props.disabled}
|
||||
rightSide={rightContent}
|
||||
box={props.box}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,11 @@ export function KeyboardEvents() {
|
|||
!dataRef.current.isInWatchParty &&
|
||||
dataRef.current.enableHoldToBoost
|
||||
) {
|
||||
// Skip if it's a repeated event
|
||||
if (evt.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if a button is targeted
|
||||
if (
|
||||
evt.target &&
|
||||
|
|
@ -234,6 +239,11 @@ export function KeyboardEvents() {
|
|||
k === " " &&
|
||||
(!dataRef.current.enableHoldToBoost || dataRef.current.isInWatchParty)
|
||||
) {
|
||||
// Skip if it's a repeated event
|
||||
if (evt.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if a button is targeted
|
||||
if (
|
||||
evt.target &&
|
||||
|
|
@ -265,7 +275,12 @@ export function KeyboardEvents() {
|
|||
dataRef.current.display?.setTime(dataRef.current.time - 1);
|
||||
|
||||
// Skip to percentage with number keys (0-9)
|
||||
if (/^[0-9]$/.test(k) && dataRef.current.duration > 0) {
|
||||
if (
|
||||
/^[0-9]$/.test(k) &&
|
||||
dataRef.current.duration > 0 &&
|
||||
!evt.ctrlKey &&
|
||||
!evt.metaKey
|
||||
) {
|
||||
const percentage = parseInt(k, 10) * 10; // 0 = 0%, 1 = 10%, 2 = 20%, ..., 9 = 90%
|
||||
const targetTime = (dataRef.current.duration * percentage) / 100;
|
||||
dataRef.current.display?.setTime(targetTime);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
|
|||
import { VideoPlayerButton } from "@/components/player/internals/Button";
|
||||
import { PlayerMeta } from "@/stores/player/slices/source";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
|
||||
interface SkipEpisodeButtonProps {
|
||||
|
|
@ -19,13 +20,19 @@ export function SkipEpisodeButton(props: SkipEpisodeButtonProps) {
|
|||
(s) => s.setShouldStartFromBeginning,
|
||||
);
|
||||
const updateItem = useProgressStore((s) => s.updateItem);
|
||||
|
||||
const sourceId = usePlayerStore((s) => s.sourceId);
|
||||
const setLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setLastSuccessfulSource,
|
||||
);
|
||||
const nextEp = meta?.episodes?.find(
|
||||
(v) => v.number === (meta?.episode?.number ?? 0) + 1,
|
||||
);
|
||||
|
||||
const loadNextEpisode = useCallback(() => {
|
||||
if (!meta || !nextEp) return;
|
||||
if (sourceId) {
|
||||
setLastSuccessfulSource(sourceId);
|
||||
}
|
||||
const metaCopy = { ...meta };
|
||||
metaCopy.episode = nextEp;
|
||||
setShouldStartFromBeginning(true);
|
||||
|
|
@ -43,6 +50,8 @@ export function SkipEpisodeButton(props: SkipEpisodeButtonProps) {
|
|||
props,
|
||||
setShouldStartFromBeginning,
|
||||
updateItem,
|
||||
sourceId,
|
||||
setLastSuccessfulSource,
|
||||
]);
|
||||
|
||||
// Don't show button if not in control, not a show, or no next episode
|
||||
|
|
|
|||
|
|
@ -3,25 +3,66 @@ import { useCallback, useEffect, useRef } from "react";
|
|||
|
||||
import { ThumbnailImage } from "@/stores/player/slices/thumbnails";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { LoadableSource, selectQuality } from "@/stores/player/utils/qualities";
|
||||
import {
|
||||
LoadableSource,
|
||||
SourceQuality,
|
||||
SourceSliceSource,
|
||||
} from "@/stores/player/utils/qualities";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { processCdnLink } from "@/utils/cdn";
|
||||
import { isSafari } from "@/utils/detectFeatures";
|
||||
|
||||
function makeQueue(layers: number): number[] {
|
||||
const output = [0, 1];
|
||||
let segmentSize = 0.5;
|
||||
let lastSegmentAmount = 0;
|
||||
for (let layer = 0; layer < layers; layer += 1) {
|
||||
const segmentAmount = 1 / segmentSize - 1;
|
||||
for (let i = 0; i < segmentAmount - lastSegmentAmount; i += 1) {
|
||||
const offset = i * segmentSize * 2;
|
||||
output.push(offset + segmentSize);
|
||||
}
|
||||
lastSegmentAmount = segmentAmount;
|
||||
segmentSize /= 2;
|
||||
function makeQueue(thumbnails: number): number[] {
|
||||
// Create a shuffled array of indices to ensure even distribution
|
||||
const indices = Array.from({ length: thumbnails }, (_, i) => i);
|
||||
for (let i = indices.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||
}
|
||||
return output;
|
||||
|
||||
// Convert shuffled indices to evenly distributed positions
|
||||
return indices.map((i) => i / (thumbnails - 1));
|
||||
}
|
||||
|
||||
function selectLowestQuality(source: SourceSliceSource): LoadableSource {
|
||||
if (source.type === "hls") return source;
|
||||
|
||||
if (source.type === "file") {
|
||||
const availableQualities = Object.entries(source.qualities)
|
||||
.filter((entry) => (entry[1].url.length ?? 0) > 0)
|
||||
.map((entry) => entry[0]) as SourceQuality[];
|
||||
|
||||
// Quality sorting by priority (higher number = higher quality)
|
||||
const qualityPriority: Record<SourceQuality, number> = {
|
||||
"360": 10,
|
||||
"480": 20,
|
||||
"720": 30,
|
||||
"4k": 35,
|
||||
"1080": 40,
|
||||
unknown: 50, // unknown is typically the largest quality
|
||||
};
|
||||
|
||||
// Find the lowest quality (smallest priority number) that's available
|
||||
let lowestQuality: SourceQuality | null = null;
|
||||
let lowestPriority = Infinity;
|
||||
|
||||
for (const quality of availableQualities) {
|
||||
const priority = qualityPriority[quality] ?? 0;
|
||||
if (priority < lowestPriority) {
|
||||
lowestPriority = priority;
|
||||
lowestQuality = quality;
|
||||
}
|
||||
}
|
||||
|
||||
if (lowestQuality) {
|
||||
const stream = source.qualities[lowestQuality];
|
||||
if (stream) {
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("couldn't select lowest quality");
|
||||
}
|
||||
|
||||
class ThumnbnailWorker {
|
||||
|
|
@ -41,6 +82,12 @@ class ThumnbnailWorker {
|
|||
}
|
||||
|
||||
start(source: LoadableSource) {
|
||||
// afari has extremely strict security policies around canvas operations with video content. When the thumbnail generation tries to:
|
||||
// Load cross-origin video content into an off-screen <video> element
|
||||
// Draw video frames to a <canvas> using drawImage()
|
||||
// Extract image data with canvas.toDataURL()
|
||||
// Safari marks the canvas as "tainted" and throws a security error, preventing the thumbnail generation entirely.
|
||||
// While still technically possible to generate thumbnails in Safari, it's not worth the effort to fight their strict CORS policies and we just don't support it.
|
||||
if (isSafari) return false;
|
||||
const el = document.createElement("video");
|
||||
el.setAttribute("muted", "true");
|
||||
|
|
@ -114,7 +161,7 @@ class ThumnbnailWorker {
|
|||
if (!vid) return;
|
||||
await this.initVideo();
|
||||
|
||||
const queue = makeQueue(6); // 7 layers is 63 thumbnails evenly distributed
|
||||
const queue = makeQueue(127); // 127 thumbnails evenly distributed across the video
|
||||
for (let i = 0; i < queue.length; i += 1) {
|
||||
if (this.interrupted) return;
|
||||
await this.takeSnapshot(vid.duration * queue[i]);
|
||||
|
|
@ -137,11 +184,7 @@ export function ThumbnailScraper() {
|
|||
|
||||
const start = useCallback(() => {
|
||||
let inputStream = null;
|
||||
if (source)
|
||||
inputStream = selectQuality(source, {
|
||||
automaticQuality: false,
|
||||
lastChosenQuality: "360",
|
||||
});
|
||||
if (source) inputStream = selectLowestQuality(source);
|
||||
// dont interrupt existing working
|
||||
if (workerRef.current) return;
|
||||
// Allow thumbnail generation when video is loaded and has duration
|
||||
|
|
@ -152,7 +195,7 @@ export function ThumbnailScraper() {
|
|||
addImage,
|
||||
});
|
||||
workerRef.current = ins;
|
||||
ins.start(inputStream.stream);
|
||||
ins.start(inputStream);
|
||||
}, [source, addImage, resetImages, hasPlayedOnce, duration]);
|
||||
|
||||
const startRef = useRef(start);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
.lightbar, .lightbar-visual {
|
||||
.lightbar,
|
||||
.lightbar-visual {
|
||||
position: absolute;
|
||||
width: 500vw;
|
||||
height: 800px;
|
||||
|
|
@ -12,7 +13,8 @@
|
|||
}
|
||||
|
||||
@screen sm {
|
||||
.lightbar, .lightbar-visual {
|
||||
.lightbar,
|
||||
.lightbar-visual {
|
||||
width: 150vw;
|
||||
}
|
||||
|
||||
|
|
@ -20,7 +22,6 @@
|
|||
left: -25vw;
|
||||
transform: initial;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[dir] .lightbar {
|
||||
|
|
@ -28,21 +29,23 @@
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
--d: 3s;
|
||||
--animation: cubic-bezier(.75, -0.00, .25, 1);
|
||||
--animation: cubic-bezier(0.75, -0, 0.25, 1);
|
||||
animation: boot var(--d) var(--animation) forwards;
|
||||
}
|
||||
|
||||
[dir] .lightbar-visual {
|
||||
left: 0;
|
||||
--top: theme('colors.background.main');
|
||||
--bottom: theme('colors.lightBar.light');
|
||||
--top: theme("colors.background.main");
|
||||
--bottom: theme("colors.lightBar.light");
|
||||
--first: conic-gradient(from 90deg at 80% 50%, var(--top), var(--bottom));
|
||||
--second: conic-gradient(from 270deg at 20% 50%, var(--bottom), var(--top));
|
||||
mask-image: radial-gradient(100% 50% at center center, black, transparent);
|
||||
background-image: var(--first), var(--second);
|
||||
background-position-x: 1%, 99%;
|
||||
background-position-y: 0%, 0%;
|
||||
background-size: 50% 100%, 50% 100%;
|
||||
background-size:
|
||||
50% 100%,
|
||||
50% 100%;
|
||||
opacity: 1;
|
||||
transform: rotate(180deg) translateZ(0px) translateY(400px);
|
||||
transform-origin: center center;
|
||||
|
|
@ -74,4 +77,4 @@
|
|||
100% {
|
||||
transform: rotate(180deg) translateZ(0px) translateY(400px) scaleX(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ export function useAuthData() {
|
|||
const setEnableSourceOrder = usePreferencesStore(
|
||||
(s) => s.setEnableSourceOrder,
|
||||
);
|
||||
const setLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setLastSuccessfulSource,
|
||||
);
|
||||
const setEnableLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setEnableLastSuccessfulSource,
|
||||
);
|
||||
const setDisabledSources = usePreferencesStore((s) => s.setDisabledSources);
|
||||
const setEmbedOrder = usePreferencesStore((s) => s.setEmbedOrder);
|
||||
const setEnableEmbedOrder = usePreferencesStore((s) => s.setEnableEmbedOrder);
|
||||
|
|
@ -193,6 +199,14 @@ export function useAuthData() {
|
|||
setEnableSourceOrder(settings.enableSourceOrder);
|
||||
}
|
||||
|
||||
if (settings.lastSuccessfulSource !== undefined) {
|
||||
setLastSuccessfulSource(settings.lastSuccessfulSource);
|
||||
}
|
||||
|
||||
if (settings.enableLastSuccessfulSource !== undefined) {
|
||||
setEnableLastSuccessfulSource(settings.enableLastSuccessfulSource);
|
||||
}
|
||||
|
||||
if (settings.disabledSources !== undefined) {
|
||||
setDisabledSources(settings.disabledSources ?? []);
|
||||
}
|
||||
|
|
@ -265,6 +279,8 @@ export function useAuthData() {
|
|||
setForceCompactEpisodeView,
|
||||
setSourceOrder,
|
||||
setEnableSourceOrder,
|
||||
setLastSuccessfulSource,
|
||||
setEnableLastSuccessfulSource,
|
||||
setDisabledSources,
|
||||
setEmbedOrder,
|
||||
setEnableEmbedOrder,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,12 @@ export function useScrape() {
|
|||
|
||||
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
|
||||
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
|
||||
const lastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.lastSuccessfulSource,
|
||||
);
|
||||
const enableLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.enableLastSuccessfulSource,
|
||||
);
|
||||
const disabledSources = usePreferencesStore((s) => s.disabledSources);
|
||||
const preferredEmbedOrder = usePreferencesStore((s) => s.embedOrder);
|
||||
const enableEmbedOrder = usePreferencesStore((s) => s.enableEmbedOrder);
|
||||
|
|
@ -162,11 +168,29 @@ export function useScrape() {
|
|||
|
||||
const startScraping = useCallback(
|
||||
async (media: ScrapeMedia) => {
|
||||
// Filter out disabled sources from the source order
|
||||
const filteredSourceOrder = enableSourceOrder
|
||||
// Create source order that prioritizes last successful source
|
||||
let filteredSourceOrder = enableSourceOrder
|
||||
? preferredSourceOrder.filter((id) => !disabledSources.includes(id))
|
||||
: undefined;
|
||||
|
||||
// If we have a last successful source and the feature is enabled, prioritize it
|
||||
if (enableLastSuccessfulSource && lastSuccessfulSource) {
|
||||
// Get all available sources (either from custom order or default)
|
||||
const availableSources = filteredSourceOrder || [];
|
||||
|
||||
// If the last successful source is not disabled and exists in available sources,
|
||||
// move it to the front
|
||||
if (
|
||||
!disabledSources.includes(lastSuccessfulSource) &&
|
||||
availableSources.includes(lastSuccessfulSource)
|
||||
) {
|
||||
filteredSourceOrder = [
|
||||
lastSuccessfulSource,
|
||||
...availableSources.filter((id) => id !== lastSuccessfulSource),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out disabled embeds from the embed order
|
||||
const filteredEmbedOrder = enableEmbedOrder
|
||||
? preferredEmbedOrder.filter((id) => !disabledEmbeds.includes(id))
|
||||
|
|
@ -223,6 +247,8 @@ export function useScrape() {
|
|||
startScrape,
|
||||
preferredSourceOrder,
|
||||
enableSourceOrder,
|
||||
lastSuccessfulSource,
|
||||
enableLastSuccessfulSource,
|
||||
disabledSources,
|
||||
preferredEmbedOrder,
|
||||
enableEmbedOrder,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function useSearchQuery(): [
|
|||
}
|
||||
navigate(
|
||||
generatePath("/browse/:query", {
|
||||
query: inp,
|
||||
query: encodeURIComponent(inp),
|
||||
}),
|
||||
{ replace: true },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ export function useSettingsState(
|
|||
enableDetailsModal: boolean,
|
||||
sourceOrder: string[],
|
||||
enableSourceOrder: boolean,
|
||||
lastSuccessfulSource: string | null,
|
||||
enableLastSuccessfulSource: boolean,
|
||||
disabledSources: string[],
|
||||
embedOrder: string[],
|
||||
enableEmbedOrder: boolean,
|
||||
|
|
@ -164,6 +166,18 @@ export function useSettingsState(
|
|||
resetEnableSourceOrder,
|
||||
enableSourceOrderChanged,
|
||||
] = useDerived(enableSourceOrder);
|
||||
const [
|
||||
lastSuccessfulSourceState,
|
||||
setLastSuccessfulSourceState,
|
||||
resetLastSuccessfulSource,
|
||||
lastSuccessfulSourceChanged,
|
||||
] = useDerived(lastSuccessfulSource);
|
||||
const [
|
||||
enableLastSuccessfulSourceState,
|
||||
setEnableLastSuccessfulSourceState,
|
||||
resetEnableLastSuccessfulSource,
|
||||
enableLastSuccessfulSourceChanged,
|
||||
] = useDerived(enableLastSuccessfulSource);
|
||||
const [
|
||||
disabledSourcesState,
|
||||
setDisabledSourcesState,
|
||||
|
|
@ -259,6 +273,8 @@ export function useSettingsState(
|
|||
resetEnableImageLogos();
|
||||
resetSourceOrder();
|
||||
resetEnableSourceOrder();
|
||||
resetLastSuccessfulSource();
|
||||
resetEnableLastSuccessfulSource();
|
||||
resetDisabledSources();
|
||||
resetEmbedOrder();
|
||||
resetEnableEmbedOrder();
|
||||
|
|
@ -293,6 +309,8 @@ export function useSettingsState(
|
|||
enableImageLogosChanged ||
|
||||
sourceOrderChanged ||
|
||||
enableSourceOrderChanged ||
|
||||
lastSuccessfulSourceChanged ||
|
||||
enableLastSuccessfulSourceChanged ||
|
||||
disabledSourcesChanged ||
|
||||
embedOrderChanged ||
|
||||
enableEmbedOrderChanged ||
|
||||
|
|
@ -400,6 +418,16 @@ export function useSettingsState(
|
|||
set: setEnableSourceOrderState,
|
||||
changed: enableSourceOrderChanged,
|
||||
},
|
||||
lastSuccessfulSource: {
|
||||
state: lastSuccessfulSourceState,
|
||||
set: setLastSuccessfulSourceState,
|
||||
changed: lastSuccessfulSourceChanged,
|
||||
},
|
||||
enableLastSuccessfulSource: {
|
||||
state: enableLastSuccessfulSourceState,
|
||||
set: setEnableLastSuccessfulSourceState,
|
||||
changed: enableLastSuccessfulSourceChanged,
|
||||
},
|
||||
proxyTmdb: {
|
||||
state: proxyTmdbState,
|
||||
set: setProxyTmdbState,
|
||||
|
|
|
|||
|
|
@ -37,10 +37,12 @@ import {
|
|||
isExtensionActiveCached,
|
||||
} from "./backend/extension/messaging";
|
||||
import { initializeChromecast } from "./setup/chromecast";
|
||||
import { initializeImageFadeIn } from "./setup/imageFadeIn";
|
||||
import { initializeOldStores } from "./stores/__old/migrations";
|
||||
|
||||
// initialize
|
||||
initializeChromecast();
|
||||
initializeImageFadeIn();
|
||||
|
||||
function LoadingScreen(props: { type: "user" | "lazy" }) {
|
||||
const mapping = {
|
||||
|
|
|
|||
|
|
@ -56,10 +56,20 @@ export function RealPlayerView() {
|
|||
const manualSourceSelection = usePreferencesStore(
|
||||
(s) => s.manualSourceSelection,
|
||||
);
|
||||
const setLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setLastSuccessfulSource,
|
||||
);
|
||||
const router = useOverlayRouter("settings");
|
||||
const openedWatchPartyRef = useRef<boolean>(false);
|
||||
const progressItems = useProgressStore((s) => s.items);
|
||||
|
||||
// Reset last successful source when leaving the player
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setLastSuccessfulSource(null);
|
||||
};
|
||||
}, [setLastSuccessfulSource]);
|
||||
|
||||
const paramsData = JSON.stringify({
|
||||
media: params.media,
|
||||
season: params.season,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import classNames from "classnames";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncFn, useWindowSize } from "react-use";
|
||||
import { useAsyncFn } from "react-use";
|
||||
|
||||
import {
|
||||
base64ToBuffer,
|
||||
|
|
@ -17,6 +17,7 @@ import { SearchBarInput } from "@/components/form/SearchBar";
|
|||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { UserIcons } from "@/components/UserIcon";
|
||||
import { Divider } from "@/components/utils/Divider";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { Transition } from "@/components/utils/Transition";
|
||||
import { useAuth } from "@/hooks/auth/useAuth";
|
||||
|
|
@ -33,47 +34,37 @@ import { RegisterCalloutPart } from "@/pages/parts/settings/RegisterCalloutPart"
|
|||
import { SidebarPart } from "@/pages/parts/settings/SidebarPart";
|
||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||
import { AccountWithToken, useAuthStore } from "@/stores/auth";
|
||||
import { useBannerSize } from "@/stores/banner";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
import { usePreviewThemeStore, useThemeStore } from "@/stores/theme";
|
||||
import { scrollToElement, scrollToHash } from "@/utils/scroll";
|
||||
|
||||
import { SubPageLayout } from "./layouts/SubPageLayout";
|
||||
import { AppInfoPart } from "./parts/settings/AppInfoPart";
|
||||
import { PreferencesPart } from "./parts/settings/PreferencesPart";
|
||||
|
||||
function SettingsLayout(props: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
searchQuery: string;
|
||||
onSearchChange: (value: string, force: boolean) => void;
|
||||
onSearchUnFocus: (newSearch?: string) => void;
|
||||
selectedCategory: string | null;
|
||||
setSelectedCategory: (category: string | null) => void;
|
||||
}) {
|
||||
const { className } = props;
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useIsMobile();
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const bannerSize = useBannerSize();
|
||||
|
||||
// Dynamic offset calculation like HeroPart
|
||||
const topSpacing = 16; // Base spacing
|
||||
const [stickyOffset, setStickyOffset] = useState(topSpacing);
|
||||
|
||||
// Detect if running as a PWA on iOS
|
||||
const isIOSPWA =
|
||||
/iPad|iPhone|iPod/i.test(navigator.userAgent) &&
|
||||
window.matchMedia("(display-mode: standalone)").matches;
|
||||
|
||||
const adjustedTopSpacing = isIOSPWA ? 60 : topSpacing;
|
||||
const isLandscape = windowHeight < windowWidth && isIOSPWA;
|
||||
const adjustedOffset = isLandscape ? -40 : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (windowWidth > 1280) {
|
||||
// On large screens the bar goes inline with the nav elements
|
||||
setStickyOffset(adjustedTopSpacing);
|
||||
} else {
|
||||
// On smaller screens the bar goes below the nav elements
|
||||
setStickyOffset(adjustedTopSpacing + 60 + adjustedOffset);
|
||||
}
|
||||
}, [adjustedOffset, adjustedTopSpacing, windowWidth]);
|
||||
// Navbar height is 80px (h-20)
|
||||
const navbarHeight = 80;
|
||||
// On desktop: inline with navbar (same top position + 14px adjustment)
|
||||
// On mobile: below navbar (navbar height + banner)
|
||||
const topOffset = isMobile ? navbarHeight + bannerSize : bannerSize + 14;
|
||||
|
||||
return (
|
||||
<WideContainer ultraWide classNames="overflow-visible">
|
||||
|
|
@ -81,7 +72,7 @@ function SettingsLayout(props: {
|
|||
<div
|
||||
className="fixed left-0 right-0 z-50"
|
||||
style={{
|
||||
top: `${stickyOffset}px`,
|
||||
top: `${topOffset}px`,
|
||||
}}
|
||||
>
|
||||
<ThinContainer>
|
||||
|
|
@ -104,8 +95,16 @@ function SettingsLayout(props: {
|
|||
)}
|
||||
data-settings-content
|
||||
>
|
||||
<SidebarPart />
|
||||
<div>{props.children}</div>
|
||||
<SidebarPart
|
||||
selectedCategory={props.selectedCategory}
|
||||
setSelectedCategory={props.setSelectedCategory}
|
||||
searchQuery={props.searchQuery}
|
||||
/>
|
||||
<div className={className}>{props.children}</div>
|
||||
<div className="block lg:hidden">
|
||||
<Divider />
|
||||
<AppInfoPart />
|
||||
</div>
|
||||
</div>
|
||||
</WideContainer>
|
||||
);
|
||||
|
|
@ -157,17 +156,122 @@ export function AccountSettings(props: {
|
|||
|
||||
export function SettingsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const prevCategoryRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const element = document.querySelector(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
const hashId = hash.substring(1); // Remove the # symbol
|
||||
// Check if it's a valid settings category
|
||||
const validCategories = [
|
||||
"settings-account",
|
||||
"settings-preferences",
|
||||
"settings-appearance",
|
||||
"settings-captions",
|
||||
"settings-connection",
|
||||
];
|
||||
|
||||
// Map sub-section hashes to their parent categories
|
||||
const subSectionToCategory: Record<string, string> = {
|
||||
"source-order": "settings-preferences",
|
||||
};
|
||||
|
||||
// Check if it's a sub-section hash
|
||||
if (subSectionToCategory[hashId]) {
|
||||
const categoryId = subSectionToCategory[hashId];
|
||||
setSelectedCategory(categoryId);
|
||||
// Wait for the section to render, then scroll
|
||||
scrollToHash(hash, { delay: 100 });
|
||||
} else if (validCategories.includes(hashId)) {
|
||||
// It's a category hash
|
||||
setSelectedCategory(hashId);
|
||||
scrollToHash(hash);
|
||||
} else {
|
||||
// Try to find the element anyway (might be a sub-section)
|
||||
const element = document.querySelector(hash);
|
||||
if (element) {
|
||||
// Find which category this element belongs to
|
||||
const parentSection = element.closest('[id^="settings-"]');
|
||||
if (parentSection) {
|
||||
const categoryId = parentSection.id;
|
||||
if (validCategories.includes(categoryId)) {
|
||||
setSelectedCategory(categoryId);
|
||||
scrollToHash(hash, { delay: 100 });
|
||||
}
|
||||
} else {
|
||||
scrollToHash(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Handle hash changes after initial load
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const hashId = hash.substring(1);
|
||||
const validCategories = [
|
||||
"settings-account",
|
||||
"settings-preferences",
|
||||
"settings-appearance",
|
||||
"settings-captions",
|
||||
"settings-connection",
|
||||
];
|
||||
const subSectionToCategory: Record<string, string> = {
|
||||
"source-order": "settings-preferences",
|
||||
};
|
||||
|
||||
if (subSectionToCategory[hashId]) {
|
||||
const categoryId = subSectionToCategory[hashId];
|
||||
setSelectedCategory(categoryId);
|
||||
scrollToHash(hash, { delay: 100 });
|
||||
} else if (validCategories.includes(hashId)) {
|
||||
setSelectedCategory(hashId);
|
||||
scrollToHash(hash, { delay: 100 });
|
||||
} else {
|
||||
const element = document.querySelector(hash);
|
||||
if (element) {
|
||||
const parentSection = element.closest('[id^="settings-"]');
|
||||
if (parentSection) {
|
||||
const categoryId = parentSection.id;
|
||||
if (validCategories.includes(categoryId)) {
|
||||
setSelectedCategory(categoryId);
|
||||
scrollToHash(hash, { delay: 100 });
|
||||
}
|
||||
} else {
|
||||
scrollToHash(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
return () => {
|
||||
window.removeEventListener("hashchange", handleHashChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Scroll to top when category changes (but not on initial load or when searching)
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevCategoryRef.current !== null &&
|
||||
prevCategoryRef.current !== selectedCategory &&
|
||||
!searchQuery.trim()
|
||||
) {
|
||||
// Only scroll to top if we're actually switching categories (not initial load)
|
||||
// Use requestAnimationFrame to ensure DOM has updated
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
}
|
||||
prevCategoryRef.current = selectedCategory;
|
||||
}, [selectedCategory, searchQuery]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const activeTheme = useThemeStore((s) => s.theme);
|
||||
const setTheme = useThemeStore((s) => s.setTheme);
|
||||
|
|
@ -177,6 +281,10 @@ export function SettingsPage() {
|
|||
// Simple text search with highlighting
|
||||
const handleSearchChange = useCallback((value: string, _force: boolean) => {
|
||||
setSearchQuery(value);
|
||||
// When searching, clear category selection to show all sections
|
||||
if (value.trim()) {
|
||||
setSelectedCategory(null);
|
||||
}
|
||||
|
||||
// Remove existing highlights
|
||||
const existingHighlights = document.querySelectorAll(".search-highlight");
|
||||
|
|
@ -229,13 +337,10 @@ export function SettingsPage() {
|
|||
}
|
||||
|
||||
// Scroll to first highlighted element
|
||||
const firstHighlighted = document.querySelector(".search-highlight");
|
||||
if (firstHighlighted) {
|
||||
firstHighlighted.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
scrollToElement(".search-highlight", {
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -282,6 +387,20 @@ export function SettingsPage() {
|
|||
(s) => s.setEnableSourceOrder,
|
||||
);
|
||||
|
||||
const lastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.lastSuccessfulSource,
|
||||
);
|
||||
const setLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setLastSuccessfulSource,
|
||||
);
|
||||
|
||||
const enableLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.enableLastSuccessfulSource,
|
||||
);
|
||||
const setEnableLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setEnableLastSuccessfulSource,
|
||||
);
|
||||
|
||||
const disabledSources = usePreferencesStore((s) => s.disabledSources);
|
||||
const setDisabledSources = usePreferencesStore((s) => s.setDisabledSources);
|
||||
|
||||
|
|
@ -406,6 +525,8 @@ export function SettingsPage() {
|
|||
enableDetailsModal,
|
||||
sourceOrder,
|
||||
enableSourceOrder,
|
||||
lastSuccessfulSource,
|
||||
enableLastSuccessfulSource,
|
||||
disabledSources,
|
||||
embedOrder,
|
||||
enableEmbedOrder,
|
||||
|
|
@ -475,6 +596,8 @@ export function SettingsPage() {
|
|||
state.enableImageLogos.changed ||
|
||||
state.sourceOrder.changed ||
|
||||
state.enableSourceOrder.changed ||
|
||||
state.lastSuccessfulSource.changed ||
|
||||
state.enableLastSuccessfulSource.changed ||
|
||||
state.disabledSources.changed ||
|
||||
state.proxyTmdb.changed ||
|
||||
state.enableCarouselView.changed ||
|
||||
|
|
@ -500,6 +623,8 @@ export function SettingsPage() {
|
|||
enableImageLogos: state.enableImageLogos.state,
|
||||
sourceOrder: state.sourceOrder.state,
|
||||
enableSourceOrder: state.enableSourceOrder.state,
|
||||
lastSuccessfulSource: state.lastSuccessfulSource.state,
|
||||
enableLastSuccessfulSource: state.enableLastSuccessfulSource.state,
|
||||
disabledSources: state.disabledSources.state,
|
||||
proxyTmdb: state.proxyTmdb.state,
|
||||
enableCarouselView: state.enableCarouselView.state,
|
||||
|
|
@ -537,6 +662,8 @@ export function SettingsPage() {
|
|||
setEnableImageLogos(state.enableImageLogos.state);
|
||||
setSourceOrder(state.sourceOrder.state);
|
||||
setEnableSourceOrder(state.enableSourceOrder.state);
|
||||
setLastSuccessfulSource(state.lastSuccessfulSource.state);
|
||||
setEnableLastSuccessfulSource(state.enableLastSuccessfulSource.state);
|
||||
setDisabledSources(state.disabledSources.state);
|
||||
setAppLanguage(state.appLanguage.state);
|
||||
setTheme(state.theme.state);
|
||||
|
|
@ -584,6 +711,8 @@ export function SettingsPage() {
|
|||
setEnableImageLogos,
|
||||
setSourceOrder,
|
||||
setEnableSourceOrder,
|
||||
setLastSuccessfulSource,
|
||||
setEnableLastSuccessfulSource,
|
||||
setDisabledSources,
|
||||
setAppLanguage,
|
||||
setTheme,
|
||||
|
|
@ -609,101 +738,134 @@ export function SettingsPage() {
|
|||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchUnFocus={handleSearchUnFocus}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
className="space-y-28"
|
||||
>
|
||||
<div id="settings-account">
|
||||
<Heading1 border className="!mb-0">
|
||||
{t("settings.account.title")}
|
||||
</Heading1>
|
||||
{user.account && state.profile.state ? (
|
||||
<AccountSettings
|
||||
account={user.account}
|
||||
deviceName={state.deviceName.state}
|
||||
setDeviceName={state.deviceName.set}
|
||||
colorA={state.profile.state.colorA}
|
||||
setColorA={(v) => {
|
||||
state.profile.set((s) => (s ? { ...s, colorA: v } : undefined));
|
||||
}}
|
||||
colorB={state.profile.state.colorB}
|
||||
setColorB={(v) =>
|
||||
state.profile.set((s) => (s ? { ...s, colorB: v } : undefined))
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-account") && (
|
||||
<div id="settings-account">
|
||||
<Heading1 border className="!mb-0">
|
||||
{t("settings.account.title")}
|
||||
</Heading1>
|
||||
{user.account && state.profile.state ? (
|
||||
<AccountSettings
|
||||
account={user.account}
|
||||
deviceName={state.deviceName.state}
|
||||
setDeviceName={state.deviceName.set}
|
||||
colorA={state.profile.state.colorA}
|
||||
setColorA={(v) => {
|
||||
state.profile.set((s) =>
|
||||
s ? { ...s, colorA: v } : undefined,
|
||||
);
|
||||
}}
|
||||
colorB={state.profile.state.colorB}
|
||||
setColorB={(v) =>
|
||||
state.profile.set((s) =>
|
||||
s ? { ...s, colorB: v } : undefined,
|
||||
)
|
||||
}
|
||||
userIcon={state.profile.state.icon as any}
|
||||
setUserIcon={(v) =>
|
||||
state.profile.set((s) => (s ? { ...s, icon: v } : undefined))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RegisterCalloutPart />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-preferences") && (
|
||||
<div id="settings-preferences">
|
||||
<PreferencesPart
|
||||
language={state.appLanguage.state}
|
||||
setLanguage={state.appLanguage.set}
|
||||
enableThumbnails={state.enableThumbnails.state}
|
||||
setEnableThumbnails={state.enableThumbnails.set}
|
||||
enableAutoplay={state.enableAutoplay.state}
|
||||
setEnableAutoplay={state.enableAutoplay.set}
|
||||
enableSkipCredits={state.enableSkipCredits.state}
|
||||
setEnableSkipCredits={state.enableSkipCredits.set}
|
||||
sourceOrder={availableSources}
|
||||
setSourceOrder={state.sourceOrder.set}
|
||||
enableSourceOrder={state.enableSourceOrder.state}
|
||||
setenableSourceOrder={state.enableSourceOrder.set}
|
||||
enableLastSuccessfulSource={
|
||||
state.enableLastSuccessfulSource.state
|
||||
}
|
||||
userIcon={state.profile.state.icon as any}
|
||||
setUserIcon={(v) =>
|
||||
state.profile.set((s) => (s ? { ...s, icon: v } : undefined))
|
||||
setEnableLastSuccessfulSource={
|
||||
state.enableLastSuccessfulSource.set
|
||||
}
|
||||
disabledSources={state.disabledSources.state}
|
||||
setDisabledSources={state.disabledSources.set}
|
||||
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
|
||||
setEnableLowPerformanceMode={state.enableLowPerformanceMode.set}
|
||||
enableHoldToBoost={state.enableHoldToBoost.state}
|
||||
setEnableHoldToBoost={state.enableHoldToBoost.set}
|
||||
manualSourceSelection={state.manualSourceSelection.state}
|
||||
setManualSourceSelection={state.manualSourceSelection.set}
|
||||
enableDoubleClickToSeek={state.enableDoubleClickToSeek.state}
|
||||
setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set}
|
||||
/>
|
||||
) : (
|
||||
<RegisterCalloutPart />
|
||||
)}
|
||||
</div>
|
||||
<div id="settings-preferences" className="mt-28">
|
||||
<PreferencesPart
|
||||
language={state.appLanguage.state}
|
||||
setLanguage={state.appLanguage.set}
|
||||
enableThumbnails={state.enableThumbnails.state}
|
||||
setEnableThumbnails={state.enableThumbnails.set}
|
||||
enableAutoplay={state.enableAutoplay.state}
|
||||
setEnableAutoplay={state.enableAutoplay.set}
|
||||
enableSkipCredits={state.enableSkipCredits.state}
|
||||
setEnableSkipCredits={state.enableSkipCredits.set}
|
||||
sourceOrder={availableSources}
|
||||
setSourceOrder={state.sourceOrder.set}
|
||||
enableSourceOrder={state.enableSourceOrder.state}
|
||||
setenableSourceOrder={state.enableSourceOrder.set}
|
||||
disabledSources={state.disabledSources.state}
|
||||
setDisabledSources={state.disabledSources.set}
|
||||
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
|
||||
setEnableLowPerformanceMode={state.enableLowPerformanceMode.set}
|
||||
enableHoldToBoost={state.enableHoldToBoost.state}
|
||||
setEnableHoldToBoost={state.enableHoldToBoost.set}
|
||||
manualSourceSelection={state.manualSourceSelection.state}
|
||||
setManualSourceSelection={state.manualSourceSelection.set}
|
||||
enableDoubleClickToSeek={state.enableDoubleClickToSeek.state}
|
||||
setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-appearance" className="mt-28">
|
||||
<AppearancePart
|
||||
active={previewTheme ?? "default"}
|
||||
inUse={activeTheme ?? "default"}
|
||||
setTheme={setThemeWithPreview}
|
||||
enableDiscover={state.enableDiscover.state}
|
||||
setEnableDiscover={state.enableDiscover.set}
|
||||
enableFeatured={state.enableFeatured.state}
|
||||
setEnableFeatured={state.enableFeatured.set}
|
||||
enableDetailsModal={state.enableDetailsModal.state}
|
||||
setEnableDetailsModal={state.enableDetailsModal.set}
|
||||
enableImageLogos={state.enableImageLogos.state}
|
||||
setEnableImageLogos={state.enableImageLogos.set}
|
||||
enableCarouselView={state.enableCarouselView.state}
|
||||
setEnableCarouselView={state.enableCarouselView.set}
|
||||
forceCompactEpisodeView={state.forceCompactEpisodeView.state}
|
||||
setForceCompactEpisodeView={state.forceCompactEpisodeView.set}
|
||||
homeSectionOrder={state.homeSectionOrder.state}
|
||||
setHomeSectionOrder={state.homeSectionOrder.set}
|
||||
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-captions" className="mt-28">
|
||||
<CaptionsPart
|
||||
styling={state.subtitleStyling.state}
|
||||
setStyling={state.subtitleStyling.set}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-connection" className="mt-28">
|
||||
<ConnectionsPart
|
||||
backendUrl={state.backendUrl.state}
|
||||
setBackendUrl={state.backendUrl.set}
|
||||
proxyUrls={state.proxyUrls.state}
|
||||
setProxyUrls={state.proxyUrls.set}
|
||||
febboxKey={state.febboxKey.state}
|
||||
setFebboxKey={state.febboxKey.set}
|
||||
realDebridKey={state.realDebridKey.state}
|
||||
setRealDebridKey={state.realDebridKey.set}
|
||||
proxyTmdb={state.proxyTmdb.state}
|
||||
setProxyTmdb={state.proxyTmdb.set}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-appearance") && (
|
||||
<div id="settings-appearance">
|
||||
<AppearancePart
|
||||
active={previewTheme ?? "default"}
|
||||
inUse={activeTheme ?? "default"}
|
||||
setTheme={setThemeWithPreview}
|
||||
enableDiscover={state.enableDiscover.state}
|
||||
setEnableDiscover={state.enableDiscover.set}
|
||||
enableFeatured={state.enableFeatured.state}
|
||||
setEnableFeatured={state.enableFeatured.set}
|
||||
enableDetailsModal={state.enableDetailsModal.state}
|
||||
setEnableDetailsModal={state.enableDetailsModal.set}
|
||||
enableImageLogos={state.enableImageLogos.state}
|
||||
setEnableImageLogos={state.enableImageLogos.set}
|
||||
enableCarouselView={state.enableCarouselView.state}
|
||||
setEnableCarouselView={state.enableCarouselView.set}
|
||||
forceCompactEpisodeView={state.forceCompactEpisodeView.state}
|
||||
setForceCompactEpisodeView={state.forceCompactEpisodeView.set}
|
||||
homeSectionOrder={state.homeSectionOrder.state}
|
||||
setHomeSectionOrder={state.homeSectionOrder.set}
|
||||
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-captions") && (
|
||||
<div id="settings-captions">
|
||||
<CaptionsPart
|
||||
styling={state.subtitleStyling.state}
|
||||
setStyling={state.subtitleStyling.set}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-connection") && (
|
||||
<div id="settings-connection">
|
||||
<ConnectionsPart
|
||||
backendUrl={state.backendUrl.state}
|
||||
setBackendUrl={state.backendUrl.set}
|
||||
proxyUrls={state.proxyUrls.state}
|
||||
setProxyUrls={state.proxyUrls.set}
|
||||
febboxKey={state.febboxKey.state}
|
||||
setFebboxKey={state.febboxKey.set}
|
||||
realDebridKey={state.realDebridKey.state}
|
||||
setRealDebridKey={state.realDebridKey.set}
|
||||
proxyTmdb={state.proxyTmdb.state}
|
||||
setProxyTmdb={state.proxyTmdb.set}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
<Transition
|
||||
animation="fade"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useNavigate } from "react-router-dom";
|
|||
import { Button } from "@/components/buttons/Button";
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
|
||||
import { Item } from "@/components/form/SortableList";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
|
|
@ -54,7 +53,6 @@ export function AllBookmarks({ onShowDetails }: AllBookmarksProps) {
|
|||
const [editing, setEditing] = useState(false);
|
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const editOrderModal = useModal("bookmark-edit-order-all");
|
||||
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
|
||||
const backendUrl = useBackendUrl();
|
||||
const account = useAuthStore((s) => s.account);
|
||||
const { showModal } = useOverlayStack();
|
||||
|
|
@ -143,41 +141,6 @@ export function AllBookmarks({ onShowDetails }: AllBookmarksProps) {
|
|||
return Array.from(groups);
|
||||
}, [bookmarks]);
|
||||
|
||||
const sortableItems = useMemo(() => {
|
||||
const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder;
|
||||
|
||||
if (currentOrder.length === 0) {
|
||||
return allGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}
|
||||
|
||||
const orderMap = new Map(
|
||||
currentOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
const sortedGroups = allGroups.sort((groupA, groupB) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return sortedGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]);
|
||||
|
||||
const sortedSections = useMemo(() => {
|
||||
const sections: Array<{
|
||||
type: "grouped" | "regular";
|
||||
|
|
@ -231,13 +194,6 @@ export function AllBookmarks({ onShowDetails }: AllBookmarksProps) {
|
|||
}, [groupedItems, regularItems, groupOrder]);
|
||||
|
||||
const handleEditGroupOrder = () => {
|
||||
// Initialize with current order or default order
|
||||
if (groupOrder.length === 0) {
|
||||
const defaultOrder = allGroups.map((group) => group);
|
||||
setTempGroupOrder(defaultOrder);
|
||||
} else {
|
||||
setTempGroupOrder([...groupOrder]);
|
||||
}
|
||||
editOrderModal.show();
|
||||
};
|
||||
|
||||
|
|
@ -251,8 +207,8 @@ export function AllBookmarks({ onShowDetails }: AllBookmarksProps) {
|
|||
editOrderModal.hide();
|
||||
};
|
||||
|
||||
const handleSaveOrderClick = () => {
|
||||
setGroupOrder(tempGroupOrder);
|
||||
const handleSaveOrderClick = (newOrder: string[]) => {
|
||||
setGroupOrder(newOrder);
|
||||
editOrderModal.hide();
|
||||
|
||||
// Save to backend
|
||||
|
|
@ -420,13 +376,8 @@ export function AllBookmarks({ onShowDetails }: AllBookmarksProps) {
|
|||
<EditGroupOrderModal
|
||||
id={editOrderModal.id}
|
||||
isShown={editOrderModal.isShown}
|
||||
items={sortableItems}
|
||||
onCancel={handleCancelOrder}
|
||||
onSave={handleSaveOrderClick}
|
||||
onItemsChange={(newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
setTempGroupOrder(newOrder);
|
||||
}}
|
||||
/>
|
||||
</WideContainer>
|
||||
</SubPageLayout>
|
||||
|
|
|
|||
|
|
@ -38,13 +38,14 @@ export function DiscoverMore() {
|
|||
const lists = await getCuratedMovieLists();
|
||||
setCuratedLists(lists);
|
||||
|
||||
// Fetch movie details for each list
|
||||
// Fetch movie details for each list one after another
|
||||
const details: { [listSlug: string]: TMDBMovieData[] } = {};
|
||||
for (const list of lists) {
|
||||
try {
|
||||
const movies = await getMovieDetailsForIds(list.tmdbIds, 50);
|
||||
if (movies.length > 0) {
|
||||
details[list.listSlug] = movies;
|
||||
setMovieDetails({ ...details });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
|
@ -53,7 +54,6 @@ export function DiscoverMore() {
|
|||
);
|
||||
}
|
||||
}
|
||||
setMovieDetails(details);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch curated lists:", error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
|
||||
|
|
@ -11,9 +13,12 @@ interface CarouselNavButtonsProps {
|
|||
interface NavButtonProps {
|
||||
direction: "left" | "right";
|
||||
onClick: () => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function NavButton({ direction, onClick }: NavButtonProps) {
|
||||
function NavButton({ direction, onClick, visible }: NavButtonProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -43,6 +48,40 @@ export function CarouselNavButtons({
|
|||
categorySlug,
|
||||
carouselRefs,
|
||||
}: CarouselNavButtonsProps) {
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const carousel = carouselRefs.current[categorySlug];
|
||||
if (!carousel) {
|
||||
setCanScrollLeft(false);
|
||||
setCanScrollRight(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = carousel;
|
||||
const isAtStart = scrollLeft <= 1;
|
||||
const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 1;
|
||||
|
||||
setCanScrollLeft(!isAtStart);
|
||||
setCanScrollRight(!isAtEnd);
|
||||
}, [categorySlug, carouselRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
const carousel = carouselRefs.current[categorySlug];
|
||||
if (!carousel) return;
|
||||
|
||||
updateScrollState();
|
||||
|
||||
carousel.addEventListener("scroll", updateScrollState);
|
||||
window.addEventListener("resize", updateScrollState);
|
||||
|
||||
return () => {
|
||||
carousel.removeEventListener("scroll", updateScrollState);
|
||||
window.removeEventListener("resize", updateScrollState);
|
||||
};
|
||||
}, [categorySlug, carouselRefs, updateScrollState]);
|
||||
|
||||
const handleScroll = (direction: "left" | "right") => {
|
||||
const carousel = carouselRefs.current[categorySlug];
|
||||
if (!carousel) return;
|
||||
|
|
@ -76,8 +115,16 @@ export function CarouselNavButtons({
|
|||
|
||||
return (
|
||||
<>
|
||||
<NavButton direction="left" onClick={() => handleScroll("left")} />
|
||||
<NavButton direction="right" onClick={() => handleScroll("right")} />
|
||||
<NavButton
|
||||
direction="left"
|
||||
onClick={() => handleScroll("left")}
|
||||
visible={canScrollLeft}
|
||||
/>
|
||||
<NavButton
|
||||
direction="right"
|
||||
onClick={() => handleScroll("right")}
|
||||
visible={canScrollRight}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
interface CategoryButtonsProps {
|
||||
|
|
@ -15,34 +17,84 @@ export function CategoryButtons({
|
|||
isMobile,
|
||||
showAlwaysScroll,
|
||||
}: CategoryButtonsProps) {
|
||||
const renderScrollButton = (direction: "left" | "right") => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-full px-4 text-white py-3"
|
||||
onClick={() => {
|
||||
const element = document.getElementById(
|
||||
`button-carousel-${categoryType}`,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollBy({
|
||||
left: direction === "left" ? -200 : 200,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT}
|
||||
className="text-2xl rtl:-scale-x-100"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const element = document.getElementById(`button-carousel-${categoryType}`);
|
||||
if (!element) {
|
||||
setCanScrollLeft(false);
|
||||
setCanScrollRight(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = element;
|
||||
const isAtStart = scrollLeft <= 1;
|
||||
const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 1;
|
||||
|
||||
setCanScrollLeft(!isAtStart);
|
||||
setCanScrollRight(!isAtEnd);
|
||||
}, [categoryType]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = document.getElementById(`button-carousel-${categoryType}`);
|
||||
if (!element) return;
|
||||
|
||||
updateScrollState();
|
||||
|
||||
element.addEventListener("scroll", updateScrollState);
|
||||
window.addEventListener("resize", updateScrollState);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", updateScrollState);
|
||||
window.removeEventListener("resize", updateScrollState);
|
||||
};
|
||||
}, [categoryType, updateScrollState]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
updateScrollState();
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [categories, categoryType, updateScrollState]);
|
||||
|
||||
const renderScrollButton = (direction: "left" | "right") => {
|
||||
const shouldShow = direction === "left" ? canScrollLeft : canScrollRight;
|
||||
|
||||
if (!shouldShow && !showAlwaysScroll && !isMobile) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-full px-4 text-white py-3"
|
||||
onClick={() => {
|
||||
const element = document.getElementById(
|
||||
`button-carousel-${categoryType}`,
|
||||
);
|
||||
if (element) {
|
||||
element.scrollBy({
|
||||
left: direction === "left" ? -200 : 200,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT
|
||||
}
|
||||
className="text-2xl rtl:-scale-x-100"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex overflow-x-auto">
|
||||
{(showAlwaysScroll || isMobile) && renderScrollButton("left")}
|
||||
{(showAlwaysScroll || isMobile || canScrollLeft) &&
|
||||
renderScrollButton("left")}
|
||||
|
||||
<div
|
||||
id={`button-carousel-${categoryType}`}
|
||||
|
|
@ -62,7 +114,8 @@ export function CategoryButtons({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{(showAlwaysScroll || isMobile) && renderScrollButton("right")}
|
||||
{(showAlwaysScroll || isMobile || canScrollRight) &&
|
||||
renderScrollButton("right")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,9 +115,11 @@ export function MediaCarousel({
|
|||
}));
|
||||
|
||||
// Set up intersection observer for lazy loading
|
||||
const { targetRef, isIntersecting } = useIntersectionObserver({
|
||||
rootMargin: "300px",
|
||||
});
|
||||
const { targetRef, isIntersecting, hasIntersected } = useIntersectionObserver(
|
||||
{
|
||||
rootMargin: "300px",
|
||||
},
|
||||
);
|
||||
|
||||
// Handle provider/genre selection
|
||||
const handleProviderChange = React.useCallback((id: string, name: string) => {
|
||||
|
|
@ -195,7 +197,7 @@ export function MediaCarousel({
|
|||
content.type,
|
||||
]);
|
||||
|
||||
// Fetch media using our hook
|
||||
// Fetch media using our hook - only when carousel has been visible
|
||||
const { media, sectionTitle } = useDiscoverMedia({
|
||||
contentType,
|
||||
mediaType,
|
||||
|
|
@ -205,6 +207,7 @@ export function MediaCarousel({
|
|||
providerName: selectedProviderName,
|
||||
mediaTitle: selectedRecommendationTitle,
|
||||
isCarouselView: true,
|
||||
enabled: hasIntersected,
|
||||
});
|
||||
|
||||
// Find active button
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ export function useDiscoverMedia({
|
|||
providerName,
|
||||
mediaTitle,
|
||||
isCarouselView = false,
|
||||
enabled = true,
|
||||
}: UseDiscoverMediaProps): UseDiscoverMediaReturn {
|
||||
const [media, setMedia] = useState<DiscoverMedia[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -512,8 +513,11 @@ export function useDiscoverMedia({
|
|||
setMedia([]);
|
||||
setCurrentContentType(contentType);
|
||||
}
|
||||
fetchMedia();
|
||||
}, [fetchMedia, contentType, currentContentType, page, id]);
|
||||
// Only fetch when enabled
|
||||
if (enabled) {
|
||||
fetchMedia();
|
||||
}
|
||||
}, [fetchMedia, contentType, currentContentType, page, id, enabled]);
|
||||
|
||||
return {
|
||||
media,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface UseDiscoverMediaProps {
|
|||
providerName?: string;
|
||||
mediaTitle?: string;
|
||||
isCarouselView?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoverMedia {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,16 @@ import { Button } from "@/components/buttons/Button";
|
|||
import { Toggle } from "@/components/buttons/Toggle";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Box } from "@/components/layout/Box";
|
||||
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
||||
import { Divider } from "@/components/utils/Divider";
|
||||
import { Heading2 } from "@/components/utils/Text";
|
||||
import { getM3U8ProxyUrls } from "@/utils/proxyUrls";
|
||||
|
||||
interface M3U8Proxy {
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function M3U8ProxyItem(props: {
|
||||
name: string;
|
||||
errored?: boolean;
|
||||
|
|
@ -57,13 +63,26 @@ export function M3U8ProxyItem(props: {
|
|||
}
|
||||
|
||||
export function M3U8TestPart() {
|
||||
const m3u8ProxyList = useMemo(() => {
|
||||
const defaultProxyList = useMemo(() => {
|
||||
return getM3U8ProxyUrls().map((v, ind) => ({
|
||||
id: ind.toString(),
|
||||
url: v,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Load editable proxy list from localStorage
|
||||
const [m3u8ProxyList, setM3u8ProxyList] = useState<M3U8Proxy[]>(() => {
|
||||
const saved = localStorage.getItem("m3u8-proxy-list");
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch {
|
||||
return defaultProxyList;
|
||||
}
|
||||
}
|
||||
return defaultProxyList;
|
||||
});
|
||||
|
||||
// Load enabled proxies from localStorage
|
||||
const [enabledProxies, setEnabledProxies] = useState<Record<string, boolean>>(
|
||||
() => {
|
||||
|
|
@ -80,6 +99,11 @@ export function M3U8TestPart() {
|
|||
},
|
||||
);
|
||||
|
||||
// Save proxy list to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("m3u8-proxy-list", JSON.stringify(m3u8ProxyList));
|
||||
}, [m3u8ProxyList]);
|
||||
|
||||
// Save enabled proxies to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("m3u8-proxy-enabled", JSON.stringify(enabledProxies));
|
||||
|
|
@ -106,9 +130,9 @@ export function M3U8TestPart() {
|
|||
setProxyState([]);
|
||||
|
||||
const activeProxies = m3u8ProxyList.filter(
|
||||
(proxy) => enabledProxies[proxy.id],
|
||||
(proxy: M3U8Proxy) => enabledProxies[proxy.id],
|
||||
);
|
||||
const proxyPromises = activeProxies.map(async (proxy) => {
|
||||
const proxyPromises = activeProxies.map(async (proxy: M3U8Proxy) => {
|
||||
try {
|
||||
if (proxy.url.endsWith("/")) {
|
||||
updateProxy(proxy.id, {
|
||||
|
|
@ -153,29 +177,76 @@ export function M3U8TestPart() {
|
|||
}));
|
||||
};
|
||||
|
||||
const allEnabled = m3u8ProxyList.every((proxy) => enabledProxies[proxy.id]);
|
||||
const noneEnabled = m3u8ProxyList.every((proxy) => !enabledProxies[proxy.id]);
|
||||
const addProxy = () => {
|
||||
const newId = Date.now().toString();
|
||||
setM3u8ProxyList((prev: M3U8Proxy[]) => [...prev, { id: newId, url: "" }]);
|
||||
setEnabledProxies((prev) => ({ ...prev, [newId]: true }));
|
||||
};
|
||||
|
||||
const changeProxy = (id: string, url: string) => {
|
||||
setM3u8ProxyList((prev: M3U8Proxy[]) =>
|
||||
prev.map((proxy: M3U8Proxy) =>
|
||||
proxy.id === id ? { ...proxy, url } : proxy,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeProxy = (id: string) => {
|
||||
setM3u8ProxyList((prev: M3U8Proxy[]) =>
|
||||
prev.filter((proxy: M3U8Proxy) => proxy.id !== id),
|
||||
);
|
||||
setEnabledProxies((prev) => {
|
||||
const newEnabled = { ...prev };
|
||||
delete newEnabled[id];
|
||||
return newEnabled;
|
||||
});
|
||||
};
|
||||
|
||||
const resetProxies = () => {
|
||||
setM3u8ProxyList(defaultProxyList);
|
||||
setEnabledProxies(
|
||||
Object.fromEntries(
|
||||
defaultProxyList.map((proxy: M3U8Proxy) => [proxy.id, true]),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const allEnabled = m3u8ProxyList.every(
|
||||
(proxy: M3U8Proxy) => enabledProxies[proxy.id],
|
||||
);
|
||||
const noneEnabled = m3u8ProxyList.every(
|
||||
(proxy: M3U8Proxy) => !enabledProxies[proxy.id],
|
||||
);
|
||||
|
||||
const handleToggleAll = () => {
|
||||
if (allEnabled) {
|
||||
// Disable all
|
||||
setEnabledProxies(
|
||||
Object.fromEntries(m3u8ProxyList.map((proxy) => [proxy.id, false])),
|
||||
Object.fromEntries(
|
||||
m3u8ProxyList.map((proxy: M3U8Proxy) => [proxy.id, false]),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Enable all
|
||||
setEnabledProxies(
|
||||
Object.fromEntries(m3u8ProxyList.map((proxy) => [proxy.id, true])),
|
||||
Object.fromEntries(
|
||||
m3u8ProxyList.map((proxy: M3U8Proxy) => [proxy.id, true]),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const enabledCount = m3u8ProxyList.filter(
|
||||
(proxy) => enabledProxies[proxy.id],
|
||||
(proxy: M3U8Proxy) => enabledProxies[proxy.id],
|
||||
).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading2 className="!mb-0 mt-12">M3U8 Proxy Configuration</Heading2>
|
||||
<Box>
|
||||
<p className="text-white font-bold mb-3">M3U8 Proxy URLs</p>
|
||||
</Box>
|
||||
|
||||
<Heading2 className="!mb-0 mt-12">M3U8 Proxy tests</Heading2>
|
||||
<div className="flex items-center justify-between mb-8 mt-2">
|
||||
<p>
|
||||
|
|
@ -191,7 +262,7 @@ export function M3U8TestPart() {
|
|||
</Button>
|
||||
</div>
|
||||
<Box>
|
||||
{m3u8ProxyList.map((v, i) => {
|
||||
{m3u8ProxyList.map((v: M3U8Proxy, i: number) => {
|
||||
const s = proxyState.find((segment) => segment.id === v.id);
|
||||
const name = `M3U8 Proxy ${i + 1}`;
|
||||
const enabled = enabledProxies[v.id];
|
||||
|
|
@ -309,6 +380,40 @@ export function M3U8TestPart() {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="my-6 space-y-2">
|
||||
{m3u8ProxyList.length === 0 ? (
|
||||
<p>No M3U8 proxies configured.</p>
|
||||
) : (
|
||||
m3u8ProxyList.map((proxy: M3U8Proxy) => (
|
||||
<div
|
||||
key={proxy.id}
|
||||
className="grid grid-cols-[1fr,auto] items-center gap-2"
|
||||
>
|
||||
<AuthInputBox
|
||||
value={proxy.url}
|
||||
onChange={(url) => changeProxy(proxy.id, url)}
|
||||
placeholder="https://"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeProxy(proxy.id)}
|
||||
className="h-full scale-90 hover:scale-100 rounded-full aspect-square bg-authentication-inputBg hover:bg-authentication-inputBgHover flex justify-center items-center transition-transform duration-200 hover:text-white cursor-pointer"
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.X} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button theme="purple" onClick={addProxy}>
|
||||
Add Proxy
|
||||
</Button>
|
||||
<Button theme="secondary" onClick={resetProxies}>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function AdsPart(): JSX.Element | null {
|
|||
<button
|
||||
onClick={dismissAd}
|
||||
type="button"
|
||||
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
className="absolute z-20 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
aria-label="Dismiss ad"
|
||||
>
|
||||
<Icon
|
||||
|
|
@ -95,7 +95,7 @@ export function AdsPart(): JSX.Element | null {
|
|||
<button
|
||||
onClick={dismissAd}
|
||||
type="button"
|
||||
className="absolute z-50 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
className="absolute z-20 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
aria-label="Dismiss ad"
|
||||
>
|
||||
<Icon
|
||||
|
|
|
|||
|
|
@ -4,18 +4,16 @@ import { Link } from "react-router-dom";
|
|||
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
|
||||
import { Item } from "@/components/form/SortableList";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
|
||||
import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal";
|
||||
import { EditGroupModal } from "@/components/overlays/EditGroupModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { UserIcon, UserIcons } from "@/components/UserIcon";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useGroupOrderStore } from "@/stores/groupOrder";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
|
|
@ -91,14 +89,18 @@ export function BookmarksCarousel({
|
|||
let isScrolling = false;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
|
||||
const backendUrl = useBackendUrl();
|
||||
const account = useAuthStore((s) => s.account);
|
||||
|
||||
// Group order editing state
|
||||
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
|
||||
const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
|
||||
const editOrderModal = useModal("bookmark-edit-order-carousel");
|
||||
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
|
||||
// Editing modals
|
||||
const editBookmarkModal = useModal("bookmark-edit-carousel");
|
||||
const editGroupModal = useModal("bookmark-edit-group-carousel");
|
||||
const [editingBookmarkId, setEditingBookmarkId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editingGroupName, setEditingGroupName] = useState<string | null>(null);
|
||||
const modifyBookmarks = useBookmarkStore((s) => s.modifyBookmarks);
|
||||
const modifyBookmarksByGroup = useBookmarkStore(
|
||||
(s) => s.modifyBookmarksByGroup,
|
||||
);
|
||||
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
|
|
@ -108,6 +110,7 @@ export function BookmarksCarousel({
|
|||
|
||||
const progressItems = useProgressStore((state) => state.items);
|
||||
const bookmarks = useBookmarkStore((state) => state.bookmarks);
|
||||
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
|
||||
|
||||
const items = useMemo(() => {
|
||||
let output: MediaItem[] = [];
|
||||
|
|
@ -167,57 +170,6 @@ export function BookmarksCarousel({
|
|||
return { groupedItems: grouped, regularItems: regular };
|
||||
}, [items, bookmarks, progressItems]);
|
||||
|
||||
// group sorting
|
||||
const allGroups = useMemo(() => {
|
||||
const groups = new Set<string>();
|
||||
|
||||
Object.values(bookmarks).forEach((bookmark) => {
|
||||
if (Array.isArray(bookmark.group)) {
|
||||
bookmark.group.forEach((group) => groups.add(group));
|
||||
}
|
||||
});
|
||||
|
||||
groups.add("bookmarks");
|
||||
|
||||
return Array.from(groups);
|
||||
}, [bookmarks]);
|
||||
|
||||
const sortableItems = useMemo(() => {
|
||||
const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder;
|
||||
|
||||
if (currentOrder.length === 0) {
|
||||
return allGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}
|
||||
|
||||
const orderMap = new Map(
|
||||
currentOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
const sortedGroups = allGroups.sort((groupA, groupB) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return sortedGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]);
|
||||
|
||||
// Create a unified list of sections including both grouped and regular bookmarks
|
||||
const sortedSections = useMemo(() => {
|
||||
const sections: Array<{
|
||||
type: "grouped" | "regular";
|
||||
|
|
@ -295,37 +247,36 @@ export function BookmarksCarousel({
|
|||
}
|
||||
};
|
||||
|
||||
const handleEditGroupOrder = () => {
|
||||
// Initialize with current order or default order
|
||||
if (groupOrder.length === 0) {
|
||||
const defaultOrder = allGroups.map((group) => group);
|
||||
setTempGroupOrder(defaultOrder);
|
||||
} else {
|
||||
setTempGroupOrder([...groupOrder]);
|
||||
}
|
||||
editOrderModal.show();
|
||||
const handleEditBookmark = (bookmarkId: string) => {
|
||||
setEditingBookmarkId(bookmarkId);
|
||||
editBookmarkModal.show();
|
||||
};
|
||||
|
||||
const handleReorderClick = () => {
|
||||
handleEditGroupOrder();
|
||||
// Keep editing state active by setting it to true
|
||||
setEditing(true);
|
||||
const handleSaveBookmark = (bookmarkId: string, changes: any) => {
|
||||
modifyBookmarks([bookmarkId], changes);
|
||||
editBookmarkModal.hide();
|
||||
setEditingBookmarkId(null);
|
||||
};
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
editOrderModal.hide();
|
||||
const handleEditGroup = (groupName: string) => {
|
||||
setEditingGroupName(groupName);
|
||||
editGroupModal.show();
|
||||
};
|
||||
|
||||
const handleSaveOrderClick = () => {
|
||||
setGroupOrder(tempGroupOrder);
|
||||
editOrderModal.hide();
|
||||
const handleSaveGroup = (oldGroupName: string, newGroupName: string) => {
|
||||
modifyBookmarksByGroup({ oldGroupName, newGroupName });
|
||||
editGroupModal.hide();
|
||||
setEditingGroupName(null);
|
||||
};
|
||||
|
||||
// Save to backend
|
||||
if (backendUrl && account) {
|
||||
useGroupOrderStore
|
||||
.getState()
|
||||
.saveGroupOrderToBackend(backendUrl, account);
|
||||
}
|
||||
const handleCancelEditBookmark = () => {
|
||||
editBookmarkModal.hide();
|
||||
setEditingBookmarkId(null);
|
||||
};
|
||||
|
||||
const handleCancelEditGroup = () => {
|
||||
editGroupModal.hide();
|
||||
setEditingGroupName(null);
|
||||
};
|
||||
|
||||
const categorySlug = "bookmarks";
|
||||
|
|
@ -351,13 +302,15 @@ export function BookmarksCarousel({
|
|||
className="ml-4 md:ml-12 mt-2 -mb-5"
|
||||
>
|
||||
<div className="mr-4 md:mr-8 flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
{editing && section.group && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button-carousel"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
onEdit={() => handleEditGroup(section.group!)}
|
||||
id="edit-group-button"
|
||||
text={t("home.bookmarks.groups.editGroup.title")}
|
||||
secondaryText={t(
|
||||
"home.bookmarks.groups.editGroup.cancel",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
|
|
@ -394,6 +347,8 @@ export function BookmarksCarousel({
|
|||
onShowDetails={onShowDetails}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(media.id)}
|
||||
editable={editing}
|
||||
onEdit={() => handleEditBookmark(media.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -423,15 +378,6 @@ export function BookmarksCarousel({
|
|||
className="ml-4 md:ml-12 mt-2 -mb-5"
|
||||
>
|
||||
<div className="mr-4 md:mr-8 flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button-carousel"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
|
|
@ -467,6 +413,8 @@ export function BookmarksCarousel({
|
|||
onShowDetails={onShowDetails}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(media.id)}
|
||||
editable={editing}
|
||||
onEdit={() => handleEditBookmark(media.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
|
@ -494,17 +442,22 @@ export function BookmarksCarousel({
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Edit Order Modal */}
|
||||
<EditGroupOrderModal
|
||||
id={editOrderModal.id}
|
||||
isShown={editOrderModal.isShown}
|
||||
items={sortableItems}
|
||||
onCancel={handleCancelOrder}
|
||||
onSave={handleSaveOrderClick}
|
||||
onItemsChange={(newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
setTempGroupOrder(newOrder);
|
||||
}}
|
||||
{/* Edit Bookmark Modal */}
|
||||
<EditBookmarkModal
|
||||
id={editBookmarkModal.id}
|
||||
isShown={editBookmarkModal.isShown}
|
||||
bookmarkId={editingBookmarkId}
|
||||
onCancel={handleCancelEditBookmark}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
|
||||
{/* Edit Group Modal */}
|
||||
<EditGroupModal
|
||||
id={editGroupModal.id}
|
||||
isShown={editGroupModal.isShown}
|
||||
groupName={editingGroupName}
|
||||
onCancel={handleCancelEditGroup}
|
||||
onSave={handleSaveGroup}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,16 +4,14 @@ import { useTranslation } from "react-i18next";
|
|||
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
|
||||
import { Item } from "@/components/form/SortableList";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { MediaGrid } from "@/components/media/MediaGrid";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
|
||||
import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal";
|
||||
import { EditGroupModal } from "@/components/overlays/EditGroupModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { UserIcon, UserIcons } from "@/components/UserIcon";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useGroupOrderStore } from "@/stores/groupOrder";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
|
|
@ -41,14 +39,19 @@ export function BookmarksPart({
|
|||
const progressItems = useProgressStore((s) => s.items);
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
|
||||
const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
|
||||
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const editOrderModal = useModal("bookmark-edit-order");
|
||||
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
|
||||
const backendUrl = useBackendUrl();
|
||||
const account = useAuthStore((s) => s.account);
|
||||
const editBookmarkModal = useModal("bookmark-edit");
|
||||
const editGroupModal = useModal("bookmark-edit-group");
|
||||
const [editingBookmarkId, setEditingBookmarkId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editingGroupName, setEditingGroupName] = useState<string | null>(null);
|
||||
const modifyBookmarks = useBookmarkStore((s) => s.modifyBookmarks);
|
||||
const modifyBookmarksByGroup = useBookmarkStore(
|
||||
(s) => s.modifyBookmarksByGroup,
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
let output: MediaItem[] = [];
|
||||
|
|
@ -108,56 +111,6 @@ export function BookmarksPart({
|
|||
return { groupedItems: grouped, regularItems: regular };
|
||||
}, [items, bookmarks, progressItems]);
|
||||
|
||||
// group sorting
|
||||
const allGroups = useMemo(() => {
|
||||
const groups = new Set<string>();
|
||||
|
||||
Object.values(bookmarks).forEach((bookmark) => {
|
||||
if (Array.isArray(bookmark.group)) {
|
||||
bookmark.group.forEach((group) => groups.add(group));
|
||||
}
|
||||
});
|
||||
|
||||
groups.add("bookmarks");
|
||||
|
||||
return Array.from(groups);
|
||||
}, [bookmarks]);
|
||||
|
||||
const sortableItems = useMemo(() => {
|
||||
const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder;
|
||||
|
||||
if (currentOrder.length === 0) {
|
||||
return allGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}
|
||||
|
||||
const orderMap = new Map(
|
||||
currentOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
const sortedGroups = allGroups.sort((groupA, groupB) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return sortedGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]);
|
||||
|
||||
const sortedSections = useMemo(() => {
|
||||
const sections: Array<{
|
||||
type: "grouped" | "regular";
|
||||
|
|
@ -209,43 +162,41 @@ export function BookmarksPart({
|
|||
|
||||
return sections;
|
||||
}, [groupedItems, regularItems, groupOrder]);
|
||||
// kill me
|
||||
|
||||
useEffect(() => {
|
||||
onItemsChange(items.length > 0);
|
||||
}, [items, onItemsChange]);
|
||||
|
||||
const handleEditGroupOrder = () => {
|
||||
// Initialize with current order or default order
|
||||
if (groupOrder.length === 0) {
|
||||
const defaultOrder = allGroups.map((group) => group);
|
||||
setTempGroupOrder(defaultOrder);
|
||||
} else {
|
||||
setTempGroupOrder([...groupOrder]);
|
||||
}
|
||||
editOrderModal.show();
|
||||
const handleEditBookmark = (bookmarkId: string) => {
|
||||
setEditingBookmarkId(bookmarkId);
|
||||
editBookmarkModal.show();
|
||||
};
|
||||
|
||||
const handleReorderClick = () => {
|
||||
handleEditGroupOrder();
|
||||
// Keep editing state active by setting it to true
|
||||
setEditing(true);
|
||||
const handleSaveBookmark = (bookmarkId: string, changes: any) => {
|
||||
modifyBookmarks([bookmarkId], changes);
|
||||
editBookmarkModal.hide();
|
||||
setEditingBookmarkId(null);
|
||||
};
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
editOrderModal.hide();
|
||||
const handleEditGroup = (groupName: string) => {
|
||||
setEditingGroupName(groupName);
|
||||
editGroupModal.show();
|
||||
};
|
||||
|
||||
const handleSaveOrderClick = () => {
|
||||
setGroupOrder(tempGroupOrder);
|
||||
editOrderModal.hide();
|
||||
const handleSaveGroup = (oldGroupName: string, newGroupName: string) => {
|
||||
modifyBookmarksByGroup({ oldGroupName, newGroupName });
|
||||
editGroupModal.hide();
|
||||
setEditingGroupName(null);
|
||||
};
|
||||
|
||||
// Save to backend
|
||||
if (backendUrl && account) {
|
||||
useGroupOrderStore
|
||||
.getState()
|
||||
.saveGroupOrderToBackend(backendUrl, account);
|
||||
}
|
||||
const handleCancelEditBookmark = () => {
|
||||
editBookmarkModal.hide();
|
||||
setEditingBookmarkId(null);
|
||||
};
|
||||
|
||||
const handleCancelEditGroup = () => {
|
||||
editGroupModal.hide();
|
||||
setEditingGroupName(null);
|
||||
};
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
|
@ -267,13 +218,15 @@ export function BookmarksPart({
|
|||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
{editing && section.group && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
onEdit={() => handleEditGroup(section.group!)}
|
||||
id="edit-group-button"
|
||||
text={t("home.bookmarks.groups.editGroup.title")}
|
||||
secondaryText={t(
|
||||
"home.bookmarks.groups.editGroup.cancel",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
|
|
@ -290,12 +243,15 @@ export function BookmarksPart({
|
|||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
className="relative group"
|
||||
>
|
||||
<WatchedMediaCard
|
||||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(v.id)}
|
||||
onShowDetails={onShowDetails}
|
||||
editable={editing}
|
||||
onEdit={() => handleEditBookmark(v.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -310,15 +266,6 @@ export function BookmarksPart({
|
|||
icon={Icons.BOOKMARK}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
|
|
@ -333,12 +280,15 @@ export function BookmarksPart({
|
|||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
className="relative group"
|
||||
>
|
||||
<WatchedMediaCard
|
||||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(v.id)}
|
||||
onShowDetails={onShowDetails}
|
||||
editable={editing}
|
||||
onEdit={() => handleEditBookmark(v.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -347,17 +297,22 @@ export function BookmarksPart({
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Edit Order Modal */}
|
||||
<EditGroupOrderModal
|
||||
id={editOrderModal.id}
|
||||
isShown={editOrderModal.isShown}
|
||||
items={sortableItems}
|
||||
onCancel={handleCancelOrder}
|
||||
onSave={handleSaveOrderClick}
|
||||
onItemsChange={(newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
setTempGroupOrder(newOrder);
|
||||
}}
|
||||
{/* Edit Bookmark Modal */}
|
||||
<EditBookmarkModal
|
||||
id={editBookmarkModal.id}
|
||||
isShown={editBookmarkModal.isShown}
|
||||
bookmarkId={editingBookmarkId}
|
||||
onCancel={handleCancelEditBookmark}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
|
||||
{/* Edit Group Modal */}
|
||||
<EditGroupModal
|
||||
id={editGroupModal.id}
|
||||
isShown={editGroupModal.isShown}
|
||||
groupName={editingGroupName}
|
||||
onCancel={handleCancelEditGroup}
|
||||
onSave={handleSaveGroup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import classNames from "classnames";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import Sticky from "react-sticky-el";
|
||||
import { useWindowSize } from "react-use";
|
||||
|
||||
import { SearchBarInput } from "@/components/form/SearchBar";
|
||||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { useSlashFocus } from "@/components/player/hooks/useSlashFocus";
|
||||
import { HeroTitle } from "@/components/text/HeroTitle";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useIsTV } from "@/hooks/useIsTv";
|
||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
|
|
@ -44,41 +44,22 @@ export function HeroPart({
|
|||
const [search, setSearch, setSearchUnFocus] = searchParams;
|
||||
const [showBg, setShowBg] = useState(false);
|
||||
const bannerSize = useBannerSize();
|
||||
const { isMobile } = useIsMobile();
|
||||
const { isTV } = useIsTV();
|
||||
|
||||
const stickStateChanged = useCallback(
|
||||
(isFixed: boolean) => {
|
||||
setShowBg(isFixed);
|
||||
setIsSticky(isFixed);
|
||||
},
|
||||
[setShowBg, setIsSticky],
|
||||
[setIsSticky],
|
||||
);
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
|
||||
const { isTV } = useIsTV();
|
||||
|
||||
// Detect if running as a PWA on iOS
|
||||
const isIOSPWA =
|
||||
/iPad|iPhone|iPod/i.test(navigator.userAgent) &&
|
||||
window.matchMedia("(display-mode: standalone)").matches;
|
||||
|
||||
const topSpacing = isIOSPWA ? 60 : 16;
|
||||
const [stickyOffset, setStickyOffset] = useState(topSpacing);
|
||||
|
||||
const isLandscape = windowHeight < windowWidth && isIOSPWA;
|
||||
const adjustedOffset = isLandscape
|
||||
? -40 // landscape
|
||||
: 0; // portrait
|
||||
|
||||
useEffect(() => {
|
||||
if (windowWidth > 1280) {
|
||||
// On large screens the bar goes inline with the nav elements
|
||||
setStickyOffset(topSpacing);
|
||||
} else {
|
||||
// On smaller screens the bar goes below the nav elements
|
||||
setStickyOffset(topSpacing + 60 + adjustedOffset);
|
||||
}
|
||||
}, [adjustedOffset, topSpacing, windowWidth]);
|
||||
// Navbar height is 80px (h-20)
|
||||
const navbarHeight = 80;
|
||||
// On desktop: inline with navbar (same top position)
|
||||
// On mobile: below navbar (navbar height + banner)
|
||||
const topOffset = isMobile ? navbarHeight + bannerSize : bannerSize + 14;
|
||||
|
||||
const time = getTimeOfDay(new Date());
|
||||
const title = randomT(`home.titles.${time}`);
|
||||
|
|
@ -91,7 +72,7 @@ export function HeroPart({
|
|||
<div
|
||||
className={classNames(
|
||||
"space-y-16 text-center",
|
||||
showTitle ? "mt-44" : `mt-4`,
|
||||
showTitle ? "mt-44" : "mt-4",
|
||||
)}
|
||||
>
|
||||
{showTitle && (!isTV || search.length === 0) ? (
|
||||
|
|
@ -102,9 +83,9 @@ export function HeroPart({
|
|||
|
||||
<div className="relative h-20 z-30">
|
||||
<Sticky
|
||||
topOffset={stickyOffset * -1 + bannerSize}
|
||||
topOffset={-topOffset}
|
||||
stickyStyle={{
|
||||
paddingTop: `${stickyOffset + bannerSize}px`,
|
||||
paddingTop: `${topOffset}px`,
|
||||
}}
|
||||
onFixedToggle={stickStateChanged}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Title } from "@/components/text/Title";
|
|||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||
import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
|
||||
import { ErrorCardInModal } from "../errors/ErrorCard";
|
||||
|
||||
|
|
@ -19,15 +20,20 @@ export function PlaybackErrorPart() {
|
|||
const modal = useModal("error");
|
||||
const settingsRouter = useOverlayRouter("settings");
|
||||
const hasOpenedSettings = useRef(false);
|
||||
const setLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.setLastSuccessfulSource,
|
||||
);
|
||||
|
||||
// Automatically open the settings overlay when a playback error occurs
|
||||
useEffect(() => {
|
||||
if (playbackError && !hasOpenedSettings.current) {
|
||||
hasOpenedSettings.current = true;
|
||||
// Reset the last successful source when a playback error occurs
|
||||
setLastSuccessfulSource(null);
|
||||
settingsRouter.open();
|
||||
settingsRouter.navigate("/source");
|
||||
}
|
||||
}, [playbackError, settingsRouter]);
|
||||
}, [playbackError, settingsRouter, setLastSuccessfulSource]);
|
||||
|
||||
const handleOpenSourcePicker = () => {
|
||||
settingsRouter.open();
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ export function PlayerPart(props: PlayerPartProps) {
|
|||
<div />
|
||||
<div className="flex justify-center space-x-3">
|
||||
{/* Disable PiP for iOS PWA */}
|
||||
{!isPWA && !isIOS && status === playerStatus.PLAYING && (
|
||||
{!(isPWA && isIOS) && status === playerStatus.PLAYING && (
|
||||
<Player.Pip />
|
||||
)}
|
||||
<Player.Episodes inControl={inControl} />
|
||||
|
|
|
|||
|
|
@ -129,6 +129,12 @@ export function SourceSelectPart(props: { media: ScrapeMedia }) {
|
|||
const routerId = "manualSourceSelect";
|
||||
const preferredSourceOrder = usePreferencesStore((s) => s.sourceOrder);
|
||||
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
|
||||
const lastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.lastSuccessfulSource,
|
||||
);
|
||||
const enableLastSuccessfulSource = usePreferencesStore(
|
||||
(s) => s.enableLastSuccessfulSource,
|
||||
);
|
||||
|
||||
const sources = useMemo(() => {
|
||||
const metaType = props.media.type;
|
||||
|
|
@ -138,13 +144,34 @@ export function SourceSelectPart(props: { media: ScrapeMedia }) {
|
|||
.filter((v) => v.mediaTypes?.includes(metaType));
|
||||
|
||||
if (!enableSourceOrder || preferredSourceOrder.length === 0) {
|
||||
// Even without custom source order, prioritize last successful source if enabled
|
||||
if (enableLastSuccessfulSource && lastSuccessfulSource) {
|
||||
const lastSourceIndex = allSources.findIndex(
|
||||
(s) => s.id === lastSuccessfulSource,
|
||||
);
|
||||
if (lastSourceIndex !== -1) {
|
||||
const lastSource = allSources.splice(lastSourceIndex, 1)[0];
|
||||
return [lastSource, ...allSources];
|
||||
}
|
||||
}
|
||||
return allSources;
|
||||
}
|
||||
|
||||
// Sort sources according to preferred order
|
||||
// Sort sources according to preferred order, but prioritize last successful source
|
||||
const orderedSources = [];
|
||||
const remainingSources = [...allSources];
|
||||
|
||||
// First, add the last successful source if it exists, is available, and the feature is enabled
|
||||
if (enableLastSuccessfulSource && lastSuccessfulSource) {
|
||||
const lastSourceIndex = remainingSources.findIndex(
|
||||
(s) => s.id === lastSuccessfulSource,
|
||||
);
|
||||
if (lastSourceIndex !== -1) {
|
||||
orderedSources.push(remainingSources[lastSourceIndex]);
|
||||
remainingSources.splice(lastSourceIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Add sources in preferred order
|
||||
for (const sourceId of preferredSourceOrder) {
|
||||
const sourceIndex = remainingSources.findIndex((s) => s.id === sourceId);
|
||||
|
|
@ -158,7 +185,13 @@ export function SourceSelectPart(props: { media: ScrapeMedia }) {
|
|||
orderedSources.push(...remainingSources);
|
||||
|
||||
return orderedSources;
|
||||
}, [props.media.type, preferredSourceOrder, enableSourceOrder]);
|
||||
}, [
|
||||
props.media.type,
|
||||
preferredSourceOrder,
|
||||
enableSourceOrder,
|
||||
lastSuccessfulSource,
|
||||
enableLastSuccessfulSource,
|
||||
]);
|
||||
|
||||
if (selectedSourceId) {
|
||||
return (
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue