Merge branch 'p-stream:production' into production

This commit is contained in:
zisra 2025-11-11 08:53:06 +08:00 committed by GitHub
commit 02c2dcb4c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
143 changed files with 24401 additions and 19266 deletions

View file

@ -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,
},
};

View file

@ -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

View file

@ -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"

View file

@ -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
View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -1,6 +1,3 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig"
]
"recommendations": ["dbaeumer.vscode-eslint", "editorconfig.editorconfig"]
}

View file

@ -1,8 +1,8 @@
# P-Stream
[![P-Stream Image](.github/P-Stream.png)](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)

View file

@ -1,7 +1,6 @@
version: "3.8"
services:
movieweb:
build:
context: .

View file

@ -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>

View file

@ -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": {

View file

@ -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);

View file

@ -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);
}
}
}))
})
]
}
},
},
})),
}),
];
};

File diff suppressed because it is too large Load diff

View file

@ -3,4 +3,4 @@ module.exports = {
tailwindcss: {},
autoprefixer: {},
},
}
};

View file

@ -1,4 +1,4 @@
module.exports = {
trailingComma: "all",
singleQuote: true
singleQuote: true,
};

View file

@ -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: "",
};

View file

@ -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>

View file

@ -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`

View file

@ -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;
}

View file

@ -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": {

View file

@ -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 😘!"]
}
}
},

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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",

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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."]
}
}
},

View file

@ -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ć?"
}
},

View file

@ -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": {

View file

@ -201,9 +201,7 @@
},
"morning": {
"default": "Что бы вы хотели посмотреть этим утром?",
"extra": [
"Слышали, что «Перед рассветом» отличный фильм"
]
"extra": ["Слышали, что «Перед рассветом» отличный фильм"]
},
"night": {
"default": "Что бы вы хотели посмотреть этим вечером?",
@ -213,9 +211,7 @@
},
"420": {
"default": "20/4 - день веселья и травки! Что посмотрим?",
"extra": [
"С праздником 20/4! 🥳 Празднуем вместе!"
]
"extra": ["С праздником 20/4! 🥳 Празднуем вместе!"]
}
}
},

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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;

View file

@ -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;
};

View file

@ -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) => {

View file

@ -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 />

View file

@ -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 }) =>

View file

@ -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">

View file

@ -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>
);

View file

@ -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}
/>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>

View file

@ -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>
);

View file

@ -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

View file

@ -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">

View file

@ -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

View file

@ -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>
);
}

View file

@ -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(() => {

View file

@ -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;

View file

@ -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

View file

@ -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 */}

View file

@ -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")}

View file

@ -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)}

View file

@ -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",

View file

@ -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>

View file

@ -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) {

View file

@ -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,
],
);

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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}

View file

@ -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);

View file

@ -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

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -27,7 +27,7 @@ export function useSearchQuery(): [
}
navigate(
generatePath("/browse/:query", {
query: inp,
query: encodeURIComponent(inp),
}),
{ replace: true },
);

View file

@ -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,

View file

@ -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 = {

View file

@ -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,

View file

@ -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"

View file

@ -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>

View file

@ -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);
}

View file

@ -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}
/>
</>
);
}

View file

@ -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>
);
}

View file

@ -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

View file

@ -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,

View file

@ -23,6 +23,7 @@ export interface UseDiscoverMediaProps {
providerName?: string;
mediaTitle?: string;
isCarouselView?: boolean;
enabled?: boolean;
}
export interface DiscoverMedia {

View file

@ -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>
</>
);

View file

@ -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

View file

@ -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}
/>
</>
);

View file

@ -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>
);

View file

@ -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}
>

View file

@ -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();

View file

@ -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} />

View file

@ -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 (

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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

View file

@ -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>

View file

@ -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