mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-21 19:22:19 +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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
20
.github/CODE_OF_CONDUCT.md
vendored
20
.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
|
||||
|
|
|
|||
13
.github/CONTRIBUTING.md
vendored
13
.github/CONTRIBUTING.md
vendored
|
|
@ -5,6 +5,7 @@ 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)
|
||||
|
|
@ -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
|
||||
|
||||
### 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,6 +47,7 @@ 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).
|
||||
|
||||
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,7 +70,9 @@ 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)
|
||||
|
|
@ -74,6 +82,7 @@ When opening Visual Studio Code, you will be prompted to install our recommended
|
|||
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
|
||||
|
||||
|
|
|
|||
1
.github/SECURITY.md
vendored
1
.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)
|
||||
|
|
|
|||
4
.github/workflows/deploying.yml
vendored
4
.github/workflows/deploying.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
|
|
|||
2
.github/workflows/linting_testing.yml
vendored
2
.github/workflows/linting_testing.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install pnpm packages
|
||||
run: pnpm install
|
||||
|
|
|
|||
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"]
|
||||
}
|
||||
|
|
|
|||
21
README.md
21
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.**
|
||||
|
||||
**I _do not_ endorse piracy of any kind I simply enjoy programming and large user counts.**
|
||||
|
||||
## Quick Deploy
|
||||
|
||||
|
|
@ -12,10 +12,10 @@
|
|||
|
||||
**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) |
|
||||
|
|
@ -23,10 +23,10 @@
|
|||
| 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.***
|
||||
|
||||
**_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: .
|
||||
|
|
|
|||
267
index.html
267
index.html
|
|
@ -1,159 +1,253 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<!--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 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)" />
|
||||
<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="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-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">
|
||||
<meta property="og:image" content="/embed-preview.png" />
|
||||
|
||||
<link rel="apple-touch-startup-image"
|
||||
<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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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">
|
||||
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
|
||||
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>
|
||||
|
||||
{{#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" />
|
||||
|
||||
<!-- disabling referrer can fix some provider problems -->
|
||||
<!-- <meta name="referrer" content="no-referrer" /> -->
|
||||
<meta name="referrer" content="always">
|
||||
|
||||
<meta name="referrer" content="always" />
|
||||
|
||||
<title>P-Stream</title>
|
||||
|
||||
{{#if opensearchEnabled }}
|
||||
<!-- OpenSearch -->
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="P-Stream" href="/opensearch.xml">
|
||||
<link
|
||||
rel="search"
|
||||
type="application/opensearchdescription+xml"
|
||||
title="P-Stream"
|
||||
href="/opensearch.xml"
|
||||
/>
|
||||
|
||||
<!-- Google Sitelinks -->
|
||||
<script type="application/ld+json">
|
||||
|
|
@ -179,5 +273,4 @@
|
|||
<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);
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
})),
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
|
|
|||
8921
pnpm-lock.yaml
8921
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,33 +276,23 @@
|
|||
"titles": {
|
||||
"day": {
|
||||
"default": "ماذا تريد أن تشاهد في هذه الظهيرة؟",
|
||||
"extra": [
|
||||
"يحيا P-Stream!"
|
||||
]
|
||||
"extra": ["يحيا P-Stream!"]
|
||||
},
|
||||
"morning": {
|
||||
"default": "ماذا تريد أن تشاهد في هذا الصباح؟",
|
||||
"extra": [
|
||||
"تحيا p-stream!"
|
||||
]
|
||||
"extra": ["تحيا p-stream!"]
|
||||
},
|
||||
"night": {
|
||||
"default": "ماذا تريد أن تشاهد في هذه الليلة؟",
|
||||
"extra": [
|
||||
"تحيا p-stream!"
|
||||
]
|
||||
"extra": ["تحيا p-stream!"]
|
||||
},
|
||||
"420": {
|
||||
"default": "ماذا تريد أن تشاهد اليوم؟",
|
||||
"extra": [
|
||||
"يوم 4/20 سعيد 🥳!"
|
||||
]
|
||||
"extra": ["يوم 4/20 سعيد 🥳!"]
|
||||
},
|
||||
"69": {
|
||||
"default": "هل ترغب في شيء مثير؟",
|
||||
"extra": [
|
||||
"يوم 69 سعيد 😘!"
|
||||
]
|
||||
"extra": ["يوم 69 سعيد 😘!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -160,33 +160,23 @@
|
|||
"titles": {
|
||||
"day": {
|
||||
"default": "Hvad vil du gerne se i eftermiddag?",
|
||||
"extra": [
|
||||
"Længe leve P-Stream!"
|
||||
]
|
||||
"extra": ["Længe leve P-Stream!"]
|
||||
},
|
||||
"morning": {
|
||||
"default": "Hvad vil du gerne se her til morgen?",
|
||||
"extra": [
|
||||
"Længe leve P-Stream!"
|
||||
]
|
||||
"extra": ["Længe leve P-Stream!"]
|
||||
},
|
||||
"night": {
|
||||
"extra": [
|
||||
"Længe leve P-Stream!"
|
||||
],
|
||||
"extra": ["Længe leve P-Stream!"],
|
||||
"default": "Hvad vil du gerne se i aften?"
|
||||
},
|
||||
"420": {
|
||||
"default": "Hvad vil du gerne se denne 4/20?",
|
||||
"extra": [
|
||||
"Glædelig 4/20 🥳!"
|
||||
]
|
||||
"extra": ["Glædelig 4/20 🥳!"]
|
||||
},
|
||||
"69": {
|
||||
"default": "Har du lyst til at \"hygge\"?",
|
||||
"extra": [
|
||||
"Glædelig 69-dag 😘!"
|
||||
]
|
||||
"extra": ["Glædelig 69-dag 😘!"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -234,33 +234,23 @@
|
|||
"titles": {
|
||||
"day": {
|
||||
"default": "Was möchtisch a däm schöne nomitag aluege?",
|
||||
"extra": [
|
||||
"Viva la P-Stream e ciao Svizzera"
|
||||
]
|
||||
"extra": ["Viva la P-Stream e ciao Svizzera"]
|
||||
},
|
||||
"morning": {
|
||||
"default": "Was welsch a däm morge aluege?",
|
||||
"extra": [
|
||||
"Viva la P-Stream!"
|
||||
]
|
||||
"extra": ["Viva la P-Stream!"]
|
||||
},
|
||||
"night": {
|
||||
"default": "Besch no wach? Was möchtisch luege?",
|
||||
"extra": [
|
||||
"Viva la Svizzera!"
|
||||
]
|
||||
"extra": ["Viva la Svizzera!"]
|
||||
},
|
||||
"69": {
|
||||
"default": "Hesch loscht of öpis scharfs?",
|
||||
"extra": [
|
||||
"Schöne 69. Tag 😘!"
|
||||
]
|
||||
"extra": ["Schöne 69. Tag 😘!"]
|
||||
},
|
||||
"420": {
|
||||
"default": "Gets öpis, wodu am 4/20 welsch luege?",
|
||||
"extra": [
|
||||
"Fröhleche 4/20 🥳!"
|
||||
]
|
||||
"extra": ["Fröhleche 4/20 🥳!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -184,27 +184,19 @@
|
|||
},
|
||||
"morning": {
|
||||
"default": "Was würdest du diesen Morgen gerne schauen?",
|
||||
"extra": [
|
||||
"Before Sunrise soll gut sein"
|
||||
]
|
||||
"extra": ["Before Sunrise soll gut sein"]
|
||||
},
|
||||
"night": {
|
||||
"default": "Was möchtest du diesen Abend gerne schauen?",
|
||||
"extra": [
|
||||
"Müde? Ich hab gehört The Exorcist soll gut sein."
|
||||
]
|
||||
"extra": ["Müde? Ich hab gehört The Exorcist soll gut sein."]
|
||||
},
|
||||
"69": {
|
||||
"default": "Lust auf etwas scharfes?",
|
||||
"extra": [
|
||||
"Schönen 69. Tag 😘!"
|
||||
]
|
||||
"extra": ["Schönen 69. Tag 😘!"]
|
||||
},
|
||||
"420": {
|
||||
"default": "Was möchten Sie am 4/20 sehen?",
|
||||
"extra": [
|
||||
"Schönen 4/20 🥳!"
|
||||
]
|
||||
"extra": ["Schönen 4/20 🥳!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -162,33 +162,23 @@
|
|||
"titles": {
|
||||
"day": {
|
||||
"default": "Τι θα θέλατε να παρακολουθήσετε σήμερα το απόγευμα;",
|
||||
"extra": [
|
||||
"Ζήτω το P-Stream!"
|
||||
]
|
||||
"extra": ["Ζήτω το P-Stream!"]
|
||||
},
|
||||
"morning": {
|
||||
"default": "Τι θα θέλατε να παρακολουθήσετε σήμερα το πρωί;",
|
||||
"extra": [
|
||||
"Έχω ακούσει ότι το Before Sunrise είναι καλό!"
|
||||
]
|
||||
"extra": ["Έχω ακούσει ότι το Before Sunrise είναι καλό!"]
|
||||
},
|
||||
"night": {
|
||||
"default": "Τι θα θέλατε να παρακολουθήσετε απόψε;",
|
||||
"extra": [
|
||||
"Βαρεμάρα; Έχω ακούσει ότι ο Εξορκιστής είναι καλός!"
|
||||
]
|
||||
"extra": ["Βαρεμάρα; Έχω ακούσει ότι ο Εξορκιστής είναι καλός!"]
|
||||
},
|
||||
"420": {
|
||||
"default": "Τι θα ήθελες να δεις αυτή τη 4/20 ;",
|
||||
"extra": [
|
||||
"Χαρούμενη μέρα 4/20 🥳!"
|
||||
]
|
||||
"extra": ["Χαρούμενη μέρα 4/20 🥳!"]
|
||||
},
|
||||
"69": {
|
||||
"default": "Θές να δεις τίποτα πικάντικο;",
|
||||
"extra": [
|
||||
"Χαρούμενη μέρα 69 😘!"
|
||||
]
|
||||
"extra": ["Χαρούμενη μέρα 69 😘!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -167,27 +167,19 @@
|
|||
},
|
||||
"morning": {
|
||||
"default": "¿Qué te gustaría ver esta mañana?",
|
||||
"extra": [
|
||||
"Escuché que “Antes del amanecer” es buena"
|
||||
]
|
||||
"extra": ["Escuché que “Antes del amanecer” es buena"]
|
||||
},
|
||||
"night": {
|
||||
"default": "¿Qué te gustaría ver esta noche?",
|
||||
"extra": [
|
||||
"¿Cansado? Escuché que “El Exorcista” es buena."
|
||||
]
|
||||
"extra": ["¿Cansado? Escuché que “El Exorcista” es buena."]
|
||||
},
|
||||
"420": {
|
||||
"default": "¿Qué te gustaría ver este 4/20?",
|
||||
"extra": [
|
||||
"¡Feliz 4/20 🥳!"
|
||||
]
|
||||
"extra": ["¡Feliz 4/20 🥳!"]
|
||||
},
|
||||
"69": {
|
||||
"default": "¿Te apetece algo picante?",
|
||||
"extra": [
|
||||
"¡Feliz día 69 😘!"
|
||||
]
|
||||
"extra": ["¡Feliz día 69 😘!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -301,15 +301,11 @@
|
|||
"titles": {
|
||||
"day": {
|
||||
"default": "Que voulez-vous regarder cet après-midi ?",
|
||||
"extra": [
|
||||
"Viva la P-Stream !"
|
||||
]
|
||||
"extra": ["Viva la P-Stream !"]
|
||||
},
|
||||
"morning": {
|
||||
"default": "Que voulez-vous regarder ce matin ?",
|
||||
"extra": [
|
||||
"Les films, c'est comme les voyages : ça nous ouvre l'esprit"
|
||||
]
|
||||
"extra": ["Les films, c'est comme les voyages : ça nous ouvre l'esprit"]
|
||||
},
|
||||
"night": {
|
||||
"default": "Que voulez-vous regarder ce soir ?",
|
||||
|
|
@ -318,16 +314,12 @@
|
|||
]
|
||||
},
|
||||
"420": {
|
||||
"extra": [
|
||||
"Joyeux 4/20 🥳!"
|
||||
],
|
||||
"extra": ["Joyeux 4/20 🥳!"],
|
||||
"default": "Qu'aimeriez-vous regarder ce 20/4?"
|
||||
},
|
||||
"69": {
|
||||
"default": "Partant pour quelque chose de corsé ?",
|
||||
"extra": [
|
||||
"Joyeux jour 69 😘 !"
|
||||
]
|
||||
"extra": ["Joyeux jour 69 😘 !"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -276,33 +276,23 @@
|
|||
"titles": {
|
||||
"day": {
|
||||
"default": "במה תרצה לצפות באחר צהריים זה?",
|
||||
"extra": [
|
||||
"עם ישראל חיי P-Stream!"
|
||||
]
|
||||
"extra": ["עם ישראל חיי P-Stream!"]
|
||||
},
|
||||
"morning": {
|
||||
"default": "אני אוהב את חברה שלי?",
|
||||
"extra": [
|
||||
"עם ישראל חיי P-Stream!"
|
||||
]
|
||||
"extra": ["עם ישראל חיי P-Stream!"]
|
||||
},
|
||||
"night": {
|
||||
"default": "במה תרצה לצפות הלילה?",
|
||||
"extra": [
|
||||
"עם ישראל חיי P-Stream!"
|
||||
]
|
||||
"extra": ["עם ישראל חיי P-Stream!"]
|
||||
},
|
||||
"420": {
|
||||
"default": "מה היית רוצה לראות ב-4/20 הזה?",
|
||||
"extra": [
|
||||
"4/20 שמח 🥳!"
|
||||
]
|
||||
"extra": ["4/20 שמח 🥳!"]
|
||||
},
|
||||
"69": {
|
||||
"default": "באלך משהו חריף?",
|
||||
"extra": [
|
||||
"יום 69 שמח 😘!"
|
||||
]
|
||||
"extra": ["יום 69 שמח 😘!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -163,34 +163,24 @@
|
|||
},
|
||||
"titles": {
|
||||
"day": {
|
||||
"extra": [
|
||||
"Viva la P-Stream!"
|
||||
],
|
||||
"extra": ["Viva la P-Stream!"],
|
||||
"default": "Mit szeretnél nézni e délután?"
|
||||
},
|
||||
"morning": {
|
||||
"default": "Mit szeretnél nézni e reggelen?",
|
||||
"extra": [
|
||||
"Viva la P-Stream!"
|
||||
]
|
||||
"extra": ["Viva la P-Stream!"]
|
||||
},
|
||||
"night": {
|
||||
"default": "Mit szeretnél nézni ezen az estén?",
|
||||
"extra": [
|
||||
"Viva la P-Stream!"
|
||||
]
|
||||
"extra": ["Viva la P-Stream!"]
|
||||
},
|
||||
"420": {
|
||||
"extra": [
|
||||
"Boldog 4/20-át!"
|
||||
],
|
||||
"extra": ["Boldog 4/20-át!"],
|
||||
"default": "Mit szeretnél nézni ezen a 4/20-adikán?"
|
||||
},
|
||||
"69": {
|
||||
"default": "Felvagy készülve valami szaftós, tüzes kalandra?",
|
||||
"extra": [
|
||||
"Boldog 69 napot!"
|
||||
]
|
||||
"extra": ["Boldog 69 napot!"]
|
||||
}
|
||||
},
|
||||
"bookmarks": {
|
||||
|
|
|
|||
|
|
@ -143,15 +143,11 @@
|
|||
},
|
||||
"morning": {
|
||||
"default": "What would you like to banana this banana?",
|
||||
"extra": [
|
||||
"Banana! I hear Banana Sunrise is 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."
|
||||
]
|
||||
"extra": ["Banana? I hear The Banana is banana."]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -270,32 +270,22 @@
|
|||
"titles": {
|
||||
"day": {
|
||||
"default": "Co chciałbyś obejrzeć dziś po południu?",
|
||||
"extra": [
|
||||
"Viva la P-Stream!"
|
||||
]
|
||||
"extra": ["Viva la P-Stream!"]
|
||||
},
|
||||
"morning": {
|
||||
"default": "Co chciałbyś obejrzeć dziś rano?",
|
||||
"extra": [
|
||||
"Viva la P-Stream!"
|
||||
]
|
||||
"extra": ["Viva la P-Stream!"]
|
||||
},
|
||||
"night": {
|
||||
"default": "Co chciałbyś obejrzeć dziś wieczorem?",
|
||||
"extra": [
|
||||
"Viva la P-Stream!"
|
||||
]
|
||||
"extra": ["Viva la P-Stream!"]
|
||||
},
|
||||
"69": {
|
||||
"default": "Masz ochotę na coś pikantnego?",
|
||||
"extra": [
|
||||
"Wesołego dnia 69 😘!"
|
||||
]
|
||||
"extra": ["Wesołego dnia 69 😘!"]
|
||||
},
|
||||
"420": {
|
||||
"extra": [
|
||||
"Wesołego 420 🥳!"
|
||||
],
|
||||
"extra": ["Wesołego 420 🥳!"],
|
||||
"default": "Dziś jest 20 kwietnia, co chciałbyś obejrzeć?"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -168,27 +168,19 @@
|
|||
},
|
||||
"morning": {
|
||||
"default": "O que você gostaria de assistir esta manhã?",
|
||||
"extra": [
|
||||
"Ouvi dizer que Antes do Amanhecer é bom"
|
||||
]
|
||||
"extra": ["Ouvi dizer que Antes do Amanhecer é bom"]
|
||||
},
|
||||
"night": {
|
||||
"default": "O que você gostaria de assistir esta noite?",
|
||||
"extra": [
|
||||
"Cansado? Ouvi dizer que O Exorcista é bom."
|
||||
]
|
||||
"extra": ["Cansado? Ouvi dizer que O Exorcista é bom."]
|
||||
},
|
||||
"69": {
|
||||
"default": "Que tal algo picante?",
|
||||
"extra": [
|
||||
"Feliz dia 69 😘!"
|
||||
]
|
||||
"extra": ["Feliz dia 69 😘!"]
|
||||
},
|
||||
"420": {
|
||||
"default": "O que você gostaria de assistir neste 4/20?",
|
||||
"extra": [
|
||||
"Feliz 4/20 🥳!"
|
||||
]
|
||||
"extra": ["Feliz 4/20 🥳!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -201,9 +201,7 @@
|
|||
},
|
||||
"morning": {
|
||||
"default": "Что бы вы хотели посмотреть этим утром?",
|
||||
"extra": [
|
||||
"Слышали, что «Перед рассветом» – отличный фильм"
|
||||
]
|
||||
"extra": ["Слышали, что «Перед рассветом» – отличный фильм"]
|
||||
},
|
||||
"night": {
|
||||
"default": "Что бы вы хотели посмотреть этим вечером?",
|
||||
|
|
@ -213,9 +211,7 @@
|
|||
},
|
||||
"420": {
|
||||
"default": "20/4 - день веселья и травки! Что посмотрим?",
|
||||
"extra": [
|
||||
"С праздником 20/4! 🥳 Празднуем вместе!"
|
||||
]
|
||||
"extra": ["С праздником 20/4! 🥳 Празднуем вместе!"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -302,33 +302,23 @@
|
|||
"titles": {
|
||||
"day": {
|
||||
"default": "Vad vill du titta på i eftermiddag?",
|
||||
"extra": [
|
||||
"Viva la P-Stream!"
|
||||
]
|
||||
"extra": ["Viva la P-Stream!"]
|
||||
},
|
||||
"morning": {
|
||||
"default": "Vad vill du titta på den här morgonen?",
|
||||
"extra": [
|
||||
"Jag hör att Before Sunrise är bra"
|
||||
]
|
||||
"extra": ["Jag hör att Before Sunrise är bra"]
|
||||
},
|
||||
"night": {
|
||||
"default": "Vad vill du titta på ikväll?",
|
||||
"extra": [
|
||||
"Trött? Jag hör att The Exorcist är bra."
|
||||
]
|
||||
"extra": ["Trött? Jag hör att The Exorcist är bra."]
|
||||
},
|
||||
"420": {
|
||||
"default": "Vad vill du titta denna 4/20?",
|
||||
"extra": [
|
||||
"Glad 4/20 🥳!"
|
||||
]
|
||||
"extra": ["Glad 4/20 🥳!"]
|
||||
},
|
||||
"69": {
|
||||
"default": "Sugen på något hett?",
|
||||
"extra": [
|
||||
"Glad 69 dag 😘!"
|
||||
]
|
||||
"extra": ["Glad 69 dag 😘!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -284,33 +284,23 @@
|
|||
"titles": {
|
||||
"day": {
|
||||
"default": "What w-wouwd you wike t-to watch this aftewnyoon!!11",
|
||||
"extra": [
|
||||
" /ᐠ>ヮ<ᐟ\\ฅ"
|
||||
]
|
||||
"extra": [" /ᐠ>ヮ<ᐟ\\ฅ"]
|
||||
},
|
||||
"morning": {
|
||||
"default": "What w-wouwd you wike t-to watch this mownying?!! *walks away*",
|
||||
"extra": [
|
||||
"\"૮₍ ˶•⤙•˶ ₎ა"
|
||||
]
|
||||
"extra": ["\"૮₍ ˶•⤙•˶ ₎ა"]
|
||||
},
|
||||
"night": {
|
||||
"default": "What w-wouwd you wike t-to watch tonyight?!!",
|
||||
"extra": [
|
||||
"(づ ᴗ _ᴗ)づ♡"
|
||||
]
|
||||
"extra": ["(づ ᴗ _ᴗ)づ♡"]
|
||||
},
|
||||
"420": {
|
||||
"default": "What w-wouwd you wike t-to watch this 4/20!? *cries*",
|
||||
"extra": [
|
||||
"(づ ᴗ _ᴗ)づ♡ Weed!"
|
||||
]
|
||||
"extra": ["(づ ᴗ _ᴗ)づ♡ Weed!"]
|
||||
},
|
||||
"69": {
|
||||
"default": "up fow something spicy owo?",
|
||||
"extra": [
|
||||
"happy 69 day 😘 (* ^ ω ^)!"
|
||||
]
|
||||
"extra": ["happy 69 day 😘 (* ^ ω ^)!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -282,27 +282,19 @@
|
|||
},
|
||||
"morning": {
|
||||
"default": "Sáng nay bạn muốn coi gì?",
|
||||
"extra": [
|
||||
"Tôi nghe nói rằng bộ phim Before Sunrise hay đấy"
|
||||
]
|
||||
"extra": ["Tôi nghe nói rằng bộ phim Before Sunrise hay đấy"]
|
||||
},
|
||||
"night": {
|
||||
"default": "Đêm nay bạn muốn coi gì?",
|
||||
"extra": [
|
||||
"Cảm thấy mệt? Tôi nghe nói phim The Exorcist hay đấy."
|
||||
]
|
||||
"extra": ["Cảm thấy mệt? Tôi nghe nói phim The Exorcist hay đấy."]
|
||||
},
|
||||
"420": {
|
||||
"default": "Bạn muốn xem gì ngày 20/4 này?",
|
||||
"extra": [
|
||||
"Chúc mừng ngày 20/4 🥳 !"
|
||||
]
|
||||
"extra": ["Chúc mừng ngày 20/4 🥳 !"]
|
||||
},
|
||||
"69": {
|
||||
"default": "Muốn khuấy đảo tí cho đời thêm vui không?",
|
||||
"extra": [
|
||||
"Chúc mừng 69 ngày 😘!"
|
||||
]
|
||||
"extra": ["Chúc mừng 69 ngày 😘!"]
|
||||
}
|
||||
},
|
||||
"mediaCard": {
|
||||
|
|
|
|||
|
|
@ -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,9 +227,12 @@ 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) => {
|
||||
const batchPromise = Promise.all(
|
||||
batch.map(async (id) => {
|
||||
try {
|
||||
const details = await getMediaDetails(
|
||||
id.toString(),
|
||||
|
|
@ -240,14 +243,16 @@ export const getMovieDetailsForIds = async (
|
|||
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,
|
||||
}),
|
||||
).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">
|
||||
|
|
|
|||
|
|
@ -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,6 +563,7 @@ export function EpisodeCarousel({
|
|||
{/* Episodes Carousel */}
|
||||
<div className="relative">
|
||||
{/* Left scroll button */}
|
||||
{canScrollLeft && (
|
||||
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -539,6 +573,7 @@ export function EpisodeCarousel({
|
|||
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={carouselRef}
|
||||
|
|
@ -783,6 +818,7 @@ export function EpisodeCarousel({
|
|||
</div>
|
||||
|
||||
{/* Right scroll button */}
|
||||
{canScrollRight && (
|
||||
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -792,6 +828,7 @@ export function EpisodeCarousel({
|
|||
<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,6 +239,7 @@ 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}
|
||||
|
|
@ -211,6 +248,19 @@ export function CollectionOverlay({
|
|||
? 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
|
||||
|
||||
# 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
|
||||
|
||||
### `/internals`
|
||||
|
||||
Internal components that are always rendered on every player.
|
||||
|
||||
- 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
|
||||
|
||||
### `~/src/stores/player`
|
||||
|
||||
State for the video player.
|
||||
|
||||
- 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,6 +949,7 @@ export function EpisodesView({
|
|||
content = (
|
||||
<div className="relative">
|
||||
{/* Horizontal scroll buttons */}
|
||||
{canScrollLeft && (
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
|
||||
|
|
@ -929,6 +964,7 @@ export function EpisodesView({
|
|||
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={carouselRef}
|
||||
|
|
@ -995,6 +1031,7 @@ export function EpisodesView({
|
|||
</div>
|
||||
|
||||
{/* Right scroll button */}
|
||||
{canScrollRight && (
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
|
||||
|
|
@ -1009,6 +1046,7 @@ export function EpisodesView({
|
|||
<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,6 +500,26 @@ export function CaptionSettingsView({
|
|||
value={styling.backgroundOpacity * 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}
|
||||
|
|
@ -507,6 +530,7 @@ export function CaptionSettingsView({
|
|||
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,7 +393,32 @@ export function CaptionsView({
|
|||
// Render subtitle option
|
||||
const renderSubtitleOption = (
|
||||
v: CaptionListItem & { languageName: string },
|
||||
) => (
|
||||
) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CaptionOption
|
||||
key={v.id}
|
||||
countryCode={v.language}
|
||||
|
|
@ -327,6 +430,7 @@ export function CaptionsView({
|
|||
: undefined
|
||||
}
|
||||
onClick={() => startDownload(v.id)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
flag
|
||||
subtitleUrl={v.url}
|
||||
subtitleType={v.type}
|
||||
|
|
@ -337,6 +441,7 @@ export function CaptionsView({
|
|||
{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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{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,6 +26,7 @@ export function TopControls(props: {
|
|||
|
||||
return (
|
||||
<div className="w-full text-white">
|
||||
{backgroundBlurEnabled && (
|
||||
<Transition
|
||||
animation="fade"
|
||||
show={props.show}
|
||||
|
|
@ -30,6 +35,7 @@ export function TopControls(props: {
|
|||
}}
|
||||
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);
|
||||
|
||||
// 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: latestSkip.startTime,
|
||||
end_time: latestSkip.endTime,
|
||||
skip_duration: latestSkip.skipDuration,
|
||||
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,
|
||||
user_id: account?.userId,
|
||||
session_id: `session_${Date.now()}`,
|
||||
confidence: adjustedConfidence,
|
||||
turnstile_token: turnstileToken ?? "",
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send skip analytics:", error);
|
||||
}
|
||||
}, [latestSkip, meta, account]);
|
||||
},
|
||||
[meta, turnstileToken],
|
||||
);
|
||||
|
||||
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);
|
||||
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]];
|
||||
}
|
||||
lastSegmentAmount = segmentAmount;
|
||||
segmentSize /= 2;
|
||||
|
||||
// Convert shuffled indices to evenly distributed positions
|
||||
return indices.map((i) => i / (thumbnails - 1));
|
||||
}
|
||||
return output;
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 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) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
// 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,14 +337,11 @@ export function SettingsPage() {
|
|||
}
|
||||
|
||||
// Scroll to first highlighted element
|
||||
const firstHighlighted = document.querySelector(".search-highlight");
|
||||
if (firstHighlighted) {
|
||||
firstHighlighted.scrollIntoView({
|
||||
scrollToElement(".search-highlight", {
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSearchUnFocus = useCallback((newSearch?: string) => {
|
||||
|
|
@ -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,7 +738,13 @@ export function SettingsPage() {
|
|||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchUnFocus={handleSearchUnFocus}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
className="space-y-28"
|
||||
>
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-account") && (
|
||||
<div id="settings-account">
|
||||
<Heading1 border className="!mb-0">
|
||||
{t("settings.account.title")}
|
||||
|
|
@ -621,11 +756,15 @@ export function SettingsPage() {
|
|||
setDeviceName={state.deviceName.set}
|
||||
colorA={state.profile.state.colorA}
|
||||
setColorA={(v) => {
|
||||
state.profile.set((s) => (s ? { ...s, colorA: v } : undefined));
|
||||
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))
|
||||
state.profile.set((s) =>
|
||||
s ? { ...s, colorB: v } : undefined,
|
||||
)
|
||||
}
|
||||
userIcon={state.profile.state.icon as any}
|
||||
setUserIcon={(v) =>
|
||||
|
|
@ -636,7 +775,11 @@ export function SettingsPage() {
|
|||
<RegisterCalloutPart />
|
||||
)}
|
||||
</div>
|
||||
<div id="settings-preferences" className="mt-28">
|
||||
)}
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-preferences") && (
|
||||
<div id="settings-preferences">
|
||||
<PreferencesPart
|
||||
language={state.appLanguage.state}
|
||||
setLanguage={state.appLanguage.set}
|
||||
|
|
@ -650,6 +793,12 @@ export function SettingsPage() {
|
|||
setSourceOrder={state.sourceOrder.set}
|
||||
enableSourceOrder={state.enableSourceOrder.state}
|
||||
setenableSourceOrder={state.enableSourceOrder.set}
|
||||
enableLastSuccessfulSource={
|
||||
state.enableLastSuccessfulSource.state
|
||||
}
|
||||
setEnableLastSuccessfulSource={
|
||||
state.enableLastSuccessfulSource.set
|
||||
}
|
||||
disabledSources={state.disabledSources.state}
|
||||
setDisabledSources={state.disabledSources.set}
|
||||
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
|
||||
|
|
@ -662,7 +811,11 @@ export function SettingsPage() {
|
|||
setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-appearance" className="mt-28">
|
||||
)}
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-appearance") && (
|
||||
<div id="settings-appearance">
|
||||
<AppearancePart
|
||||
active={previewTheme ?? "default"}
|
||||
inUse={activeTheme ?? "default"}
|
||||
|
|
@ -684,13 +837,21 @@ export function SettingsPage() {
|
|||
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-captions" className="mt-28">
|
||||
)}
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-captions") && (
|
||||
<div id="settings-captions">
|
||||
<CaptionsPart
|
||||
styling={state.subtitleStyling.state}
|
||||
setStyling={state.subtitleStyling.set}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-connection" className="mt-28">
|
||||
)}
|
||||
{(searchQuery.trim() ||
|
||||
!selectedCategory ||
|
||||
selectedCategory === "settings-connection") && (
|
||||
<div id="settings-connection">
|
||||
<ConnectionsPart
|
||||
backendUrl={state.backendUrl.state}
|
||||
setBackendUrl={state.backendUrl.set}
|
||||
|
|
@ -704,6 +865,7 @@ export function SettingsPage() {
|
|||
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,7 +17,53 @@ export function CategoryButtons({
|
|||
isMobile,
|
||||
showAlwaysScroll,
|
||||
}: CategoryButtonsProps) {
|
||||
const renderScrollButton = (direction: "left" | "right") => (
|
||||
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"
|
||||
|
|
@ -33,16 +81,20 @@ export function CategoryButtons({
|
|||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT}
|
||||
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({
|
||||
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);
|
||||
}
|
||||
// Only fetch when enabled
|
||||
if (enabled) {
|
||||
fetchMedia();
|
||||
}, [fetchMedia, contentType, currentContentType, page, id]);
|
||||
}
|
||||
}, [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 (
|
||||
|
|
|
|||
123
src/pages/parts/settings/AppInfoPart.tsx
Normal file
123
src/pages/parts/settings/AppInfoPart.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAsync } from "react-use";
|
||||
|
||||
import { getBackendMeta } from "@/backend/accounts/meta";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SidebarSection } from "@/components/layout/Sidebar";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
function SecureBadge(props: { url: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const secure = props.url ? props.url.startsWith("https://") : false;
|
||||
return (
|
||||
<div className="flex items-center gap-1 -mx-1 ml-3 px-1 rounded bg-largeCard-background font-bold">
|
||||
<Icon icon={secure ? Icons.LOCK : Icons.UNLOCK} />
|
||||
{t(
|
||||
secure
|
||||
? "settings.sidebar.info.secure"
|
||||
: "settings.sidebar.info.insecure",
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppInfoPart() {
|
||||
const { t } = useTranslation();
|
||||
const { account } = useAuthStore();
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
const hostname = location.hostname;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const backendUrl = useBackendUrl();
|
||||
|
||||
const backendMeta = useAsync(async () => {
|
||||
if (!backendUrl) return;
|
||||
return getBackendMeta(backendUrl);
|
||||
}, [backendUrl]);
|
||||
|
||||
return (
|
||||
<SidebarSection
|
||||
className="text-sm"
|
||||
title={t("settings.sidebar.info.title")}
|
||||
>
|
||||
<div className="px-3 py-3.5 rounded-lg bg-largeCard-background bg-opacity-50 grid grid-cols-2 gap-4">
|
||||
{/* Hostname */}
|
||||
<div className="col-span-2 space-y-1">
|
||||
<p className="text-type-dimmed font-medium">
|
||||
{t("settings.sidebar.info.hostname")}
|
||||
</p>
|
||||
<p className="text-white">{hostname}</p>
|
||||
</div>
|
||||
|
||||
{/* Backend URL */}
|
||||
<div className="col-span-2 space-y-1">
|
||||
<div className="text-type-dimmed font-medium flex items-center">
|
||||
<p>{t("settings.sidebar.info.backendUrl")}</p>
|
||||
<SecureBadge url={backendUrl} />
|
||||
</div>
|
||||
<p className="text-white">
|
||||
{backendUrl?.replace(/https?:\/\//, "") ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User ID */}
|
||||
<div className="col-span-2 space-y-1">
|
||||
<p className="text-type-dimmed font-medium">
|
||||
{t("settings.sidebar.info.userId")}
|
||||
</p>
|
||||
<p className="text-white">
|
||||
{account?.userId ?? t("settings.sidebar.info.notLoggedIn")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* App version */}
|
||||
<div className="col-span-1 space-y-1">
|
||||
<p className="text-type-dimmed font-medium">
|
||||
{t("settings.sidebar.info.appVersion")}
|
||||
</p>
|
||||
<p className="text-type-dimmed px-2 py-1 rounded bg-settings-sidebar-badge inline-block">
|
||||
{conf().APP_VERSION}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Backend version */}
|
||||
<div className="col-span-1 space-y-1">
|
||||
<p className="text-type-dimmed font-medium">
|
||||
{t("settings.sidebar.info.backendVersion")}
|
||||
</p>
|
||||
<p className="text-type-dimmed px-2 py-1 rounded bg-settings-sidebar-badge inline-flex items-center gap-1">
|
||||
{backendMeta.error ? (
|
||||
<Icon
|
||||
icon={Icons.WARNING}
|
||||
className="text-type-danger text-base"
|
||||
/>
|
||||
) : null}
|
||||
{backendMeta.loading ? (
|
||||
<span className="block h-4 w-12 bg-type-dimmed/20 rounded" />
|
||||
) : (
|
||||
backendMeta?.value?.version ||
|
||||
t("settings.sidebar.info.unknownVersion")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1">
|
||||
<p className="text-type-dimmed font-medium">
|
||||
{t("settings.account.admin.title")}
|
||||
</p>
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={() => navigate("/admin")}
|
||||
className="w-full !p-2 text-xs"
|
||||
>
|
||||
{t("settings.account.admin.text")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,18 @@
|
|||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Toggle } from "@/components/buttons/Toggle";
|
||||
import { SortableList } from "@/components/form/SortableList";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useGroupOrderStore } from "@/stores/groupOrder";
|
||||
|
||||
const availableThemes = [
|
||||
{
|
||||
|
|
@ -243,6 +250,28 @@ export function AppearancePart(props: {
|
|||
const [isAtTop, setIsAtTop] = useState(true);
|
||||
const [isAtBottom, setIsAtBottom] = useState(false);
|
||||
|
||||
// Group order modal
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
|
||||
const editGroupOrderModal = useModal("bookmark-edit-order-settings");
|
||||
const backendUrl = useBackendUrl();
|
||||
const account = useAuthStore((s) => s.account);
|
||||
|
||||
// Check if there are groups
|
||||
const hasGroups = 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 groups.size > 1;
|
||||
}, [bookmarks]);
|
||||
|
||||
const {
|
||||
enableLowPerformanceMode,
|
||||
setEnableDiscover,
|
||||
|
|
@ -311,6 +340,26 @@ export function AppearancePart(props: {
|
|||
}
|
||||
}, [props.active]);
|
||||
|
||||
const handleEditGroupOrder = () => {
|
||||
editGroupOrderModal.show();
|
||||
};
|
||||
|
||||
const handleCancelGroupOrder = () => {
|
||||
editGroupOrderModal.hide();
|
||||
};
|
||||
|
||||
const handleSaveGroupOrder = (newOrder: string[]) => {
|
||||
setGroupOrder(newOrder);
|
||||
editGroupOrderModal.hide();
|
||||
|
||||
// Save to backend
|
||||
if (backendUrl && account) {
|
||||
useGroupOrderStore
|
||||
.getState()
|
||||
.saveGroupOrderToBackend(backendUrl, account);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<Heading1 border>{t("settings.appearance.title")}</Heading1>
|
||||
|
|
@ -500,6 +549,17 @@ export function AppearancePart(props: {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
{hasGroups && (
|
||||
<div className="mt-4 max-w-[25rem]">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={handleEditGroupOrder}
|
||||
className="w-full"
|
||||
>
|
||||
{t("settings.appearance.options.homeSectionOrderGroups")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -533,6 +593,14 @@ export function AppearancePart(props: {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Group Order Modal */}
|
||||
<EditGroupOrderModal
|
||||
id={editGroupOrderModal.id}
|
||||
isShown={editGroupOrderModal.isShown}
|
||||
onCancel={handleCancelGroupOrder}
|
||||
onSave={handleSaveGroupOrder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Transition } from "@/components/utils/Transition";
|
|||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { SubtitleStyling, useSubtitleStore } from "@/stores/subtitles";
|
||||
import { isFirefox } from "@/utils/detectFeatures";
|
||||
|
||||
export function CaptionPreview(props: {
|
||||
fullscreen?: boolean;
|
||||
|
|
@ -27,12 +28,29 @@ export function CaptionPreview(props: {
|
|||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { fullscreen, show, onToggle } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreen || !show) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [fullscreen, show, onToggle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"pointer-events-none overflow-hidden w-full rounded": true,
|
||||
"aspect-video relative": !props.fullscreen,
|
||||
"fixed inset-0 z-[60]": props.fullscreen,
|
||||
"fixed inset-0 z-[999]": props.fullscreen,
|
||||
})}
|
||||
>
|
||||
{props.fullscreen && props.show ? (
|
||||
|
|
@ -113,8 +131,9 @@ export function CaptionsPart(props: {
|
|||
backgroundOpacity: 0.5,
|
||||
size: 1,
|
||||
backgroundBlur: 0.5,
|
||||
backgroundBlurEnabled: !isFirefox,
|
||||
bold: false,
|
||||
verticalPosition: 3,
|
||||
verticalPosition: 1,
|
||||
fontStyle: "default",
|
||||
borderThickness: 1,
|
||||
});
|
||||
|
|
@ -158,6 +177,27 @@ export function CaptionsPart(props: {
|
|||
value={props.styling.backgroundOpacity * 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={props.styling.backgroundBlurEnabled}
|
||||
onClick={() =>
|
||||
handleStylingChange({
|
||||
...props.styling,
|
||||
backgroundBlurEnabled:
|
||||
!props.styling.backgroundBlurEnabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-type-secondary">
|
||||
{t("settings.subtitles.backgroundBlurEnabledDescription")}
|
||||
</span>
|
||||
{props.styling.backgroundBlurEnabled && (
|
||||
<CaptionSetting
|
||||
label={t("settings.subtitles.backgroundBlurLabel")}
|
||||
max={100}
|
||||
|
|
@ -171,6 +211,7 @@ export function CaptionsPart(props: {
|
|||
value={props.styling.backgroundBlur * 100}
|
||||
textTransformer={(s) => `${s}%`}
|
||||
/>
|
||||
)}
|
||||
<CaptionSetting
|
||||
label={t("settings.subtitles.textSizeLabel")}
|
||||
max={200}
|
||||
|
|
@ -303,23 +344,6 @@ export function CaptionsPart(props: {
|
|||
{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",
|
||||
props.styling.verticalPosition === 3
|
||||
? "bg-video-context-buttonFocus"
|
||||
: "bg-video-context-buttonFocus bg-opacity-0 hover:bg-opacity-50",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleStylingChange({
|
||||
...props.styling,
|
||||
verticalPosition: 3,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("settings.subtitles.default")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
|
|
@ -337,6 +361,23 @@ export function CaptionsPart(props: {
|
|||
>
|
||||
{t("settings.subtitles.low")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
"px-3 py-1 rounded transition-colors duration-100",
|
||||
props.styling.verticalPosition === 3
|
||||
? "bg-video-context-buttonFocus"
|
||||
: "bg-video-context-buttonFocus bg-opacity-0 hover:bg-opacity-50",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleStylingChange({
|
||||
...props.styling,
|
||||
verticalPosition: 3,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("settings.subtitles.high")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export function PreferencesPart(props: {
|
|||
setSourceOrder: (v: string[]) => void;
|
||||
enableSourceOrder: boolean;
|
||||
setenableSourceOrder: (v: boolean) => void;
|
||||
enableLastSuccessfulSource: boolean;
|
||||
setEnableLastSuccessfulSource: (v: boolean) => void;
|
||||
disabledSources: string[];
|
||||
setDisabledSources: (v: string[]) => void;
|
||||
enableLowPerformanceMode: boolean;
|
||||
|
|
@ -267,6 +269,30 @@ export function PreferencesPart(props: {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Successful Source Preference */}
|
||||
<div>
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.preferences.lastSuccessfulSource")}
|
||||
</p>
|
||||
<p className="max-w-[25rem] font-medium">
|
||||
{t("settings.preferences.lastSuccessfulSourceDescription")}
|
||||
</p>
|
||||
<div
|
||||
onClick={() =>
|
||||
props.setEnableLastSuccessfulSource(
|
||||
!props.enableLastSuccessfulSource,
|
||||
)
|
||||
}
|
||||
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
|
||||
>
|
||||
<Toggle enabled={props.enableLastSuccessfulSource} />
|
||||
<p className="flex-1 text-white font-bold">
|
||||
{t("settings.preferences.lastSuccessfulSourceEnableLabel")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-white font-bold">
|
||||
{t("settings.preferences.sourceOrder")}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function RegisterCalloutPart() {
|
|||
>
|
||||
<div>
|
||||
<Heading3>{t("settings.account.register.title")}</Heading3>
|
||||
<p className="text-type-text">
|
||||
<p className="text-type-text max-w-[30rem]">
|
||||
{t("settings.account.register.text")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue