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
@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within

View file

@ -5,15 +5,16 @@ Thank you for investing your time in contributing to our project! Your contribut
Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectable.
## Contents
- [New Contributor Guide](#new-contributor-guide)
- [Requesting a feature or reporting a bug](#requesting-a-feature-or-reporting-a-bug)
- [Discord Server](#discord-server)
- [GitHub Issues](#github-issues)
- [Before you start](#before-you-start)
- [Contributing](#before-you-start)
- [Recommended Development Environment](#recommended-development-environment)
- [Tips](#tips)
- [Language Contributions](#language-contributions)
- [New Contributor Guide](#new-contributor-guide)
- [Requesting a feature or reporting a bug](#requesting-a-feature-or-reporting-a-bug)
- [Discord Server](#discord-server)
- [GitHub Issues](#github-issues)
- [Before you start](#before-you-start)
- [Contributing](#before-you-start)
- [Recommended Development Environment](#recommended-development-environment)
- [Tips](#tips)
- [Language Contributions](#language-contributions)
## New contributor guide
@ -24,18 +25,21 @@ To get an overview of the project, read the [README](README.md). Here are some r
- [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow)
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
## Requesting a feature or reporting a bug
There are two places where to request features or report bugs:
- The P-Stream Discord server
- GitHub Issues
- The P-Stream Discord server
- GitHub Issues
### Discord Server
If you do not have a GitHub account or want to discuss a feature or bug with us before making an issue, you can join our Discord server.
<a href="https://docs.pstream.mov/links/discord"><img src="https://discord.com/api/guilds/1267558147682205738/widget.png?style=banner2" alt="Discord Server"></a>
### GitHub Issues
To make a GitHub issue for P-Stream, please visit the [new issue page](https://github.com/p-stream/p-stream/issues/new/choose) where you can pick either the "Bug Report" or "Feature Request" template.
When filling out an issue template, please include as much detail as possible and any screenshots or console logs as appropriate.
@ -43,7 +47,8 @@ When filling out an issue template, please include as much detail as possible an
After an issue is created, it will be assigned either the https://github.com/p-stream/p-stream/labels/bug or https://github.com/p-stream/p-stream/labels/feature label, along with https://github.com/p-stream/p-stream/labels/awaiting-approval. One of our maintainers will review your issue and, if it's accepted, will set the https://github.com/p-stream/p-stream/labels/approved label.
## Before you start!
Before starting a contribution, please check your contribution is part of an open issue on [our issues page](https://github.com/p-stream/p-stream/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved).
Before starting a contribution, please check your contribution is part of an open issue on [our issues page](https://github.com/p-stream/p-stream/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved).
GitHub issues are how we track our bugs and feature requests that will be implemented into P-Stream - all contributions **must** have an issue and be approved by a maintainer before a pull request can be worked on.
@ -55,6 +60,7 @@ If a pull request is opened before an issue is created and accepted, you may ris
Also, make sure that the issue you would like to work on has been given the https://github.com/p-stream/p-stream/labels/approved label by a maintainer. Otherwise, if we reject the issue, it means your work will have gone to waste!
## Contributing
If you're here because you'd like to work on an issue, amazing! Thank you for even considering contributing to P-Stream; it means a lot :heart:
Firstly, make sure you've read the [Before you start!](#before-you-start) section!
@ -64,16 +70,19 @@ When you have found a GitHub issue you would like to work on, you can request to
If you are assigned to an issue but can't complete it for whatever reason, no problem! Just let us know, and we will open up the issue to have someone else assigned.
### Recommended Development Environment
Our recommended development environment to work on P-Stream is:
- [Visual Studio Code](https://code.visualstudio.com/)
- [ESLint Extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
- [EditorConfig Extension](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig)
When opening Visual Studio Code, you will be prompted to install our recommended extensions if they are not installed for you.
Our project is set up to enforce formatting and code style standards using ESLint.
Our project is set up to enforce formatting and code style standards using ESLint.
### Tips
Here are some tips to make sure that your pull requests are :pinched_fingers: first time:
- KISS - Keep It Simple Soldier! - Simple code makes readable and efficient code!
@ -83,11 +92,11 @@ Here are some tips to make sure that your pull requests are :pinched_fingers: fi
- Test, test, test! Make sure you thoroughly test the features you are contributing.
### Language Contributions
Language contributions help P-Stream massively, allowing people worldwide to use our app!
We use Weblate for crowdsourcing our translations. [Click here to go to our translation tool.](https://docs.pstream.mov/links/weblate)
1. First make sure you make an account. (click the link above)
2. Click the language you want to help translate, if it's not listed you can click the plus top left to add a new language.
3. In the top right of the screen, click "translate"

View file

@ -47,4 +47,3 @@ body:
Tip: You can attach files by clicking this textbox and dragging in files
validations:
required: false

3
.github/SECURITY.md vendored
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)
- Report the vulnerability in the [P-Stream Discord server](https://docs.pstream.mov/links/discord)

View file

@ -1,6 +1,6 @@
This pull request resolves #XXX
- [ ] I have read and agreed to the [code of conduct](https://github.com/p-stream/p-stream/blob/dev/.github/CODE_OF_CONDUCT.md).
- [ ] I have read and complied with the [contributing guidelines](https://github.com/p-stream/p-stream/blob/dev/.github/CONTRIBUTING.md).
- [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/p-stream/p-stream/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/p-stream/p-stream/projects).
- [ ] I have tested all of my changes.
- [ ] I have read and agreed to the [code of conduct](https://github.com/p-stream/p-stream/blob/dev/.github/CODE_OF_CONDUCT.md).
- [ ] I have read and complied with the [contributing guidelines](https://github.com/p-stream/p-stream/blob/dev/.github/CONTRIBUTING.md).
- [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/p-stream/p-stream/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/p-stream/p-stream/projects).
- [ ] I have tested all of my changes.

View file

@ -9,62 +9,62 @@ jobs:
build_pwa:
name: Build PWA
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install pnpm packages
run: pnpm install
- name: Build project
run: pnpm run build:pwa
- name: Upload production-ready build files
uses: actions/upload-artifact@v4
with:
name: pwa
path: ./dist
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install pnpm packages
run: pnpm install
- name: Build project
run: pnpm run build:pwa
- name: Upload production-ready build files
uses: actions/upload-artifact@v4
with:
name: pwa
path: ./dist
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm packages
run: pnpm install
- name: Build project
run: pnpm run build
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Upload production-ready build files
uses: actions/upload-artifact@v4
with:
name: normal
path: ./dist
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install pnpm packages
run: pnpm install
- name: Build project
run: pnpm run build
- name: Upload production-ready build files
uses: actions/upload-artifact@v4
with:
name: normal
path: ./dist
release:
name: Release
@ -72,61 +72,61 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Download PWA artifact
uses: actions/download-artifact@v4
with:
name: pwa
path: ./dist_pwa
- name: Download PWA artifact
uses: actions/download-artifact@v4
with:
name: pwa
path: ./dist_pwa
- name: Zip PWA files
run: cd dist_pwa && zip -r ../p-stream.pwa.zip .
- name: Zip PWA files
run: cd dist_pwa && zip -r ../p-stream.pwa.zip .
- name: Download normal artifact
uses: actions/download-artifact@v4
with:
name: normal
path: ./dist_normal
- name: Download normal artifact
uses: actions/download-artifact@v4
with:
name: normal
path: ./dist_normal
- name: Zip normal files
run: cd dist_normal && zip -r ../p-stream.zip .
- name: Zip normal files
run: cd dist_normal && zip -r ../p-stream.zip .
- name: Get version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
- name: Get version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.package-version.outputs.current-version }}
release_name: P-Stream v${{ steps.package-version.outputs.current-version }}
draft: false
prerelease: false
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.package-version.outputs.current-version }}
release_name: P-Stream v${{ steps.package-version.outputs.current-version }}
draft: false
prerelease: false
- name: Upload release (PWA)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./p-stream.pwa.zip
asset_name: p-stream.pwa.zip
asset_content_type: application/zip
- name: Upload release (PWA)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./p-stream.pwa.zip
asset_name: p-stream.pwa.zip
asset_content_type: application/zip
- name: Upload Release (Normal)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./p-stream.zip
asset_name: p-stream.zip
asset_content_type: application/zip
- name: Upload Release (Normal)
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./p-stream.zip
asset_name: p-stream.zip
asset_content_type: application/zip
registry:
name: Push to registry

View file

@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
@ -23,11 +23,11 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache: "pnpm"
- name: Install pnpm packages
run: pnpm install
- name: Run ESLint
run: pnpm run lint

View file

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

View file

@ -8,4 +8,4 @@
"[typescriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
}
}
}

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.**
[![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.**
## Quick Deploy
@ -12,21 +12,21 @@
**NOTE: To self-host, more setup is required. Check the [docs](https://docs.pstream.mov) to properly set up!!!!**
## Links And Resources
| Service | Link | Source Code |
|----------------|------------------------------------------------------------------|----------------------------------------------------------|
| P-Stream Docs | [docs](https://docs.pstream.mov) | [source code](https://github.com/p-stream/docs) |
| Extension | [extension](https://docs.pstream.mov/extension) | [source code](https://github.com/p-stream/browser-ext) |
| Proxy | [simple-proxy](https://docs.pstream.mov/proxy) | [source code](https://github.com/p-stream/sudo-proxy) |
| Backend | [backend](https://server.fifthwit.net) | [source code](https://github.com/p-stream/backend) |
| Frontend | [P-Stream](https://docs.pstream.mov/instances) | [source code](https://github.com/p-stream/p-stream) |
| Weblate | [weblate](https://weblate.pstream.mov) | |
***I provide these if you are not able to host yourself, though I do encourage hosting the frontend.***
| Service | Link | Source Code |
| ------------- | ----------------------------------------------- | ------------------------------------------------------ |
| P-Stream Docs | [docs](https://docs.pstream.mov) | [source code](https://github.com/p-stream/docs) |
| Extension | [extension](https://docs.pstream.mov/extension) | [source code](https://github.com/p-stream/browser-ext) |
| Proxy | [simple-proxy](https://docs.pstream.mov/proxy) | [source code](https://github.com/p-stream/sudo-proxy) |
| Backend | [backend](https://server.fifthwit.net) | [source code](https://github.com/p-stream/backend) |
| Frontend | [P-Stream](https://docs.pstream.mov/instances) | [source code](https://github.com/p-stream/p-stream) |
| Weblate | [weblate](https://weblate.pstream.mov) | |
**_I provide these if you are not able to host yourself, though I do encourage hosting the frontend._**
## Referrers
- [FMHY (Voted as #1 multi-server streaming site of 2024)](https://fmhy.net)
- [Piracy Subreddit Megathread](https://www.reddit.com/r/Piracy/s/iymSloEpXn)
- [Toon's Instances](https://erynith.github.io/movie-web-instances)
@ -34,9 +34,10 @@
- Search Engines: DuckDuckGo, Bing, Google
- Rentry.co
## Running Locally
Type the following commands into your terminal / command line to run P-Stream locally
```bash
git clone https://github.com/p-stream/p-stream.git
cd smov
@ -44,11 +45,13 @@ git pull
pnpm install
pnpm run dev
```
Then you can visit the local instance [here](http://localhost:5173) or, at local host on port 5173.
## Updating a P-Stream Instance
To update a P-Stream instance you can type the below commands into a terminal at the root of your project.
```bash
git remote add upstream https://github.com/p-stream/p-stream.git
git fetch upstream # Grab the contents of the new remote source
@ -60,6 +63,6 @@ git commit -m "Update p-stream instance (merge upstream/production)"
git push # Push to YOUR repository
```
## Contact Me / Discord
[Discord](https://discord.gg/7z6znYgrTG)

View file

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

View file

@ -1,183 +1,276 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" dir="ltr">
<!--https://www.youtube.com/watch?v=dQw4w9WgXcQ-->
<!--https://www.youtube.com/watch?v=dQw4w9WgXcQ-->
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico?v=2" />
<meta itemprop="image" content="/android-chrome-192x192.png?v=2" />
<!-- <meta property="og:image" content="/android-chrome-192x192.png?v=2"> -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
/>
<meta
name="description"
content="Watch your favorite shows and movies for free with no ads ever! (っ'ヮ'c)"
/>
<link rel="manifest" href="/manifest.json" />
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico?v=2" />
<meta itemprop="image" content="/android-chrome-192x192.png?v=2">
<!-- <meta property="og:image" content="/android-chrome-192x192.png?v=2"> -->
<meta name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="Watch your favorite shows and movies for free with no ads ever! (っ'ヮ'c)" />
<link rel="manifest" href="/manifest.json" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png?v=2"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png?v=2"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png?v=2"
/>
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
<meta name="msapplication-TileColor" content="#120f1d" />
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=2" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png?v=2" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png?v=2" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
<meta name="msapplication-TileColor" content="#120f1d" />
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- P-Stream Preview Embed -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="/embed-preview.png" />
<meta property="og:image" content="/embed-preview.png" />
<!-- P-Stream Preview Embed -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="/embed-preview.png" />
<meta property="og:image" content="/embed-preview.png">
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/iPhone_11__iPhone_XR_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/12.9__iPad_Pro_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/10.9__iPad_Air_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/10.5__iPad_Air_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/10.2__iPad_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/8.3__iPad_Mini_landscape.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/iPhone_11__iPhone_XR_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/12.9__iPad_Pro_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/10.9__iPad_Air_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/10.5__iPad_Air_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/10.2__iPad_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png"
/>
<link
rel="apple-touch-startup-image"
media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/8.3__iPad_Mini_portrait.png"
/>
<link rel="apple-touch-startup-image"
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/iPhone_11__iPhone_XR_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/12.9__iPad_Pro_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/10.9__iPad_Air_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/10.5__iPad_Air_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/10.2__iPad_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
href="/splash_screens/8.3__iPad_Mini_landscape.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/iPhone_11__iPhone_XR_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
href="/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/12.9__iPad_Pro_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/10.9__iPad_Air_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/10.5__iPad_Air_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/10.2__iPad_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
href="/splash_screens/8.3__iPad_Mini_portrait.png">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Open+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Open+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="/config.js"></script>
<script src="/config.js"></script>
{{#if env.VITE_TRACK_SCRIPT }}
{{{ env.VITE_TRACK_SCRIPT }}}
{{/if}}
{{#if env.VITE_TRACK_SCRIPT }} {{{ env.VITE_TRACK_SCRIPT }}} {{/if}}
<!-- prevent darkreader extension from messing with our already dark site -->
<meta name="darkreader-lock" />
<!-- prevent darkreader extension from messing with our already dark site -->
<meta name="darkreader-lock" />
<!-- disabling referrer can fix some provider problems -->
<!-- <meta name="referrer" content="no-referrer" /> -->
<meta name="referrer" content="always">
<!-- disabling referrer can fix some provider problems -->
<!-- <meta name="referrer" content="no-referrer" /> -->
<meta name="referrer" content="always" />
<title>P-Stream</title>
<title>P-Stream</title>
{{#if opensearchEnabled }}
<!-- OpenSearch -->
<link
rel="search"
type="application/opensearchdescription+xml"
title="P-Stream"
href="/opensearch.xml"
/>
{{#if opensearchEnabled }}
<!-- OpenSearch -->
<link rel="search" type="application/opensearchdescription+xml" title="P-Stream" href="/opensearch.xml">
<!-- Google Sitelinks -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"url": "{{ routeDomain }}",
"potentialAction": {
<!-- Google Sitelinks -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"url": "{{ routeDomain }}",
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "{{ routeDomain }}/browse/?q={search_term_string}"
},
"query-input": "required name=search_term_string"
}
}
</script>
{{/if}}
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
}
</script>
{{/if}}
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,444 +1,440 @@
{
"about": {
"description": "Minion-web is a banana application that searches the banana for bananas. The banana aims for a mostly banana approach to consuming banana.",
"faqTitle": "Banana questions",
"q1": {
"body": "Minion-web does not banana any banana. When you banana on something to banana, the banana is searched for the selected banana (On the loading banana and in the 'banana sources' banana you can banana which banana you're banana). Banana never gets banana by Minion-web, everything is banana this banana mechanism.",
"title": "Where does the banana come from?"
},
"q2": {
"body": "It's not banana to banana a banana or banana, Minion-web does not banana any banana. All banana is banana through bananas on the banana.",
"title": "Banana can I banana a banana or banana?",
"section": "banana search"
},
"q3": {
"body": "Our banana results are banana by The Banana Banana (TBMB) and banana regardless of whether our bananas actually have the banana.",
"title": "The banana results banana the banana or banana, banana can't I banana it?",
"section": "banana search"
},
"title": "About Minion-web",
"q9": {
"title": "This banana is missing / Can I request banana?"
},
"q11": {
"title": "Why am i seeing a banana screen?"
},
"q4": {
"title": "What about my banana and stuff?"
},
"q6": {
"title": "Is there a Banana app?"
}
"about": {
"description": "Minion-web is a banana application that searches the banana for bananas. The banana aims for a mostly banana approach to consuming banana.",
"faqTitle": "Banana questions",
"q1": {
"body": "Minion-web does not banana any banana. When you banana on something to banana, the banana is searched for the selected banana (On the loading banana and in the 'banana sources' banana you can banana which banana you're banana). Banana never gets banana by Minion-web, everything is banana this banana mechanism.",
"title": "Where does the banana come from?"
},
"actions": {
"copied": "Banana",
"copy": "Banana"
"q2": {
"body": "It's not banana to banana a banana or banana, Minion-web does not banana any banana. All banana is banana through bananas on the banana.",
"title": "Banana can I banana a banana or banana?",
"section": "banana search"
},
"auth": {
"createAccount": "Whaaaat? Don't have an account yet? <0>Create an account.</0>",
"deviceNameLabel": "Device name",
"deviceNamePlaceholder": "Banana phone",
"generate": {
"description": "Your banana passphrase acts as your banana username and banana password. Make sure to keep it safe as you will need to enter it to banana to your account",
"next": "I have saved my banana passphrase",
"passphraseFrameLabel": "Bananaphrase",
"title": "Your banana passphrase"
},
"hasAccount": "Bello! Already have an account? <0>Login here.</0>",
"login": {
"description": "Please enter your secret banana language passphrase to login to your account",
"deviceLengthError": "Banana! Please enter a device name",
"passphraseLabel": "12-Banana passphrase",
"passphrasePlaceholder": "Banana Passphrase",
"submit": "Bello! Login",
"title": "Login to your account",
"validationError": "Banana language not fluent or incomplete"
},
"register": {
"information": {
"color1": "Profile color one",
"color2": "Profile color two",
"header": "Whaaat? Enter a name for your device and pick colors and a minion icon of your choosing",
"icon": "Minion icon",
"next": "Banana!",
"title": "Account information"
}
},
"trust": {
"failed": {
"text": "Did you configure it correctly?",
"title": "Failed to reach server"
},
"host": "You are connecting to <0>{{hostname}}</0> - please confirm you trust it before making a banana account",
"no": "Go back, banana",
"title": "Do you trust this server?",
"yes": "I trust this server, banana!"
},
"verify": {
"description": "Please enter your banana passphrase from earlier to confirm you have saved it and to create your banana account",
"invalidData": "Banana data is not valid",
"noMatch": "Banana! Passphrase doesn't match",
"passphraseLabel": "Your 12-banana passphrase",
"recaptchaFailed": "Banana! ReCaptcha validation failed",
"register": "Create banana account",
"title": "Confirm your banana passphrase"
}
"q3": {
"body": "Our banana results are banana by The Banana Banana (TBMB) and banana regardless of whether our bananas actually have the banana.",
"title": "The banana results banana the banana or banana, banana can't I banana it?",
"section": "banana search"
},
"errors": {
"badge": "It broke",
"details": "Error banana details",
"reloadPage": "Reload the banana",
"showError": "Show banana details",
"title": "We encountered a banana!"
"title": "About Minion-web",
"q9": {
"title": "This banana is missing / Can I request banana?"
},
"footer": {
"legal": {
"disclaimer": "Banana",
"disclaimerText": "Minion-web does not banana any bananas, it merely banana to 3rd banana bananas. Banana issues should be banana up with the banana bananas and bananas. Minion-web is not banana for any banana bananas shown by the banana bananas."
},
"links": {
"discord": "Banana",
"legal": "Banana",
"github": "Banana"
},
"tagline": "Banana your favourite bananas and bananas with this open source banana app."
"q11": {
"title": "Why am i seeing a banana screen?"
},
"global": {
"name": "banana-web",
"pages": {
"about": "About banana",
"legal": "Legal / DMCA",
"login": "Banana Login",
"pagetitle": "{{title}} - banana-web",
"register": "Banana Register",
"settings": "Banana Settings"
}
"q4": {
"title": "What about my banana and stuff?"
},
"home": {
"bookmarks": {
"sectionTitle": "Banana"
},
"continueWatching": {
"sectionTitle": "Continue Banana"
},
"mediaList": {
"stopEditing": "Stop banana"
},
"search": {
"allResults": "Banana's all we banana!",
"failed": "Failed to banana banana, try again!",
"loading": "Loading...",
"noResults": "We couldn't banana anything!",
"placeholder": {
"default": "Banana do you want to banana?"
},
"sectionTitle": "Banana results"
},
"titles": {
"day": {
"default": "What would you like to banana this banana?",
"extra": [
"Feeling banana? Jurassic banana banana banana banana perfect banana."
]
},
"morning": {
"default": "What would you like to banana this banana?",
"extra": [
"Banana! I hear Banana Sunrise is banana"
]
},
"night": {
"default": "What would you like to banana banana?",
"extra": [
"Banana? I hear The Banana is banana."
]
}
}
},
"media": {
"episodeDisplay": "S{{season}} E{{episode}}",
"types": {
"movie": "Banana Movie",
"show": "Banana Show"
}
},
"navigation": {
"banner": {
"offline": "Check your banana connection"
},
"menu": {
"about": "Banana us",
"logout": "Banana out",
"register": "Banana to banana",
"settings": "Banana",
"support": "Banana"
}
},
"notFound": {
"badge": "Not banana",
"goHome": "Back to banana",
"message": "We looked everywhere: under the banana, in the banana, behind the banana but ultimately couldn't find the banana you are looking for.",
"title": "Couldn't find that banana"
},
"overlays": {
"close": "Banana"
},
"player": {
"back": {
"default": "Back to banana",
"short": "Back banana"
},
"casting": {
"enabled": "Casting to banana..."
},
"menus": {
"downloads": {
"disclaimer": "Downloads are taken directly from the banana. banana-web does not have banana over how the banana are banana.",
"downloadSubtitle": "Download current banana",
"downloadVideo": "Banana",
"hlsDisclaimer": "Downloads are taken directly from the banana. Banana-web does not have control over how the downloads are banana. please note that you are downloading Banana playlist, this is intended for minions familiar with advanced multimedia banana.",
"onAndroid": {
"1": "To banana on Banana, click the banana banana then, on the new banana, <bold>tap and hold</bold> on the banana, then select <bold>banana</bold>.",
"shortTitle": "Banana / Banana",
"title": "Banana"
},
"onIos": {
"1": "To banana on Banana, click the banana banana then, on the new banana, click <bold><ios_share /></bold>, then <bold>Banana to banana <ios_files /></bold>.",
"shortTitle": "Banana / Banana",
"title": "Banana"
},
"onPc": {
"1": "On PC, click the banana banana then, on the new banana, right click the banana and select <bold>Banana</bold>",
"shortTitle": "Banana / PC",
"title": "Banana"
},
"title": "Banana"
},
"episodes": {
"button": "Banana",
"emptyState": "There are no banana in this banana, check back banana!",
"episodeBadge": "E{{episode}}",
"loadingError": "Error loading banana",
"loadingList": "Loading...",
"loadingTitle": "Loading...",
"unairedEpisodes": "One or more banana in this banana have been banana because they haven't been aired yet."
},
"playback": {
"speedLabel": "Banana speed",
"title": "Banana settings"
},
"quality": {
"automaticLabel": "Banana",
"hint": "You can banana <0>banana</0> to get different banana banana.",
"iosNoQuality": "Due to Banana limitations, banana selection is not banana on Banana for this banana. You can banana <0>banana</0> to get different banana banana.",
"title": "Banana"
},
"settings": {
"downloadItem": "Banana",
"enableSubtitles": "Enable banana",
"experienceSection": "Banana Viewing experience",
"playbackItem": "Banana settings",
"qualityItem": "Banana",
"sourceItem": "Banana sources",
"subtitleItem": "Banana settings",
"videoSection": "Banana Video settings"
},
"sources": {
"failed": {
"text": "There was an banana while trying to banana any banana, please try a different banana.",
"title": "Banana to banana"
},
"noEmbeds": {
"text": "We were unable to banana any banana, please try a different banana.",
"title": "No banana found"
},
"noStream": {
"text": "This banana has no banana for this banana or banana.",
"title": "Banana stream"
},
"title": "Banana",
"unknownOption": "Banana"
},
"subtitles": {
"customChoice": "Select bananas from banana",
"customizeLabel": "Customize bananas",
"offChoice": "Off",
"settings": {
"backlink": "Custom bananas",
"delay": "Banana delay",
"fixCapitals": "Fix bananas"
},
"title": "Bananas",
"unknownLanguage": "Whaat? Unknown banana!"
}
},
"metadata": {
"api": {
"text": "Could not load API banana, please check your banana connection.",
"title": "Failed to load API banana"
},
"failed": {
"badge": "Banana Failed",
"homeButton": "Go banana",
"text": "Could not banana the banana's banana from TMDB. Please banana whether TMDB is down or banana on your banana connection.",
"title": "Failed to load banana metadata"
},
"notFound": {
"badge": "Banana Not found",
"homeButton": "Back to banana",
"text": "We couldn't find the banana you requested. Either it's been banana or you tampered with the banana.",
"title": "Couldn't find that banana."
}
},
"nextEpisode": {
"next": "Next banana"
},
"playbackError": {
"badge": "Banana Playback error",
"errors": {
"errorAborted": "The fetching of the banana was aborted by the user's banana.",
"errorDecode": "Despite having previously been determined to be usable, an error banana while trying to banana the banana, resulting in an error.",
"errorGenericMedia": "Unknown banana error occurred.",
"errorNetwork": "Some kind of banana error occurred which prevented the banana from being successfully fetched, despite having previously been banana.",
"errorNotSupported": "The banana or banana provider object is not banana."
},
"homeButton": "Go home",
"text": "There was an error trying to play the banana. Please try again.",
"title": "Failed to play banana video!"
},
"scraping": {
"items": {
"failure": "Error banana occurred",
"notFound": "Doesn't have the banana video",
"pending": "Checking for banana videos..."
},
"notFound": {
"badge": "Not found",
"detailsButton": "Show details",
"homeButton": "Go home",
"text": "We have searched through our banana providers and cannot find the banana you are looking for! We do not host the banana and have no control over what is available. Please click 'Show details' below for more details.",
"title": "We couldn't find that banana"
}
},
"time": {
"regular": "{{timeWatched}} / {{duration}}",
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}",
"shortRegular": "{{timeWatched}}",
"shortRemaining": "-{{timeLeft}}"
},
"turnstile": {
"description": "Please verify that you are banana by completing the banana on the right. This is to keep banana-web banana!",
"error": "Failed to verify your banananess. Please try banana.",
"title": "We banana to verify that you're banana",
"verifyingHumanity": "Verifying your banana..."
}
},
"screens": {
"legal": {
"title": "Banana"
},
"loadingApp": "Loading banana",
"loadingUser": "Loading your banana",
"loadingUserError": {
"logout": "Banana",
"reset": "Banana banana banana",
"text": "Failed to banana your banana",
"textWithReset": "Failed to banana your banana from your banana banana, banana to banana back to the banana banana?"
},
"migration": {
"failed": "Banana to banana your banana.",
"inProgress": "Please banana, we are banana your banana. This shouldn't banana long."
}
},
"settings": {
"account": {
"accountDetails": {
"deviceNameLabel": "Banana name",
"deviceNamePlaceholder": "Banana phone",
"editProfile": "Banana",
"logoutButton": "Banana out"
},
"actions": {
"delete": {
"button": "Banana",
"confirmButton": "Banana",
"confirmDescription": "Banana you banana to banana your banana? All your bananas will be banana!",
"confirmTitle": "Banana you banana?",
"text": "Whaaat? This banana is irreversible. All bananas will be banana and nothing can be banana.",
"title": "Banana"
},
"title": "Bananas"
},
"devices": {
"deviceNameLabel": "Banana name",
"failed": "Failed to load bananas :'(",
"removeDevice": "Banana",
"title": "Bananas"
},
"profile": {
"finish": "Finish banana",
"firstColor": "Minion color banana",
"secondColor": "Minion color banana",
"title": "Edit banana banana",
"userIcon": "Minion icon"
},
"register": {
"cta": "Banana started",
"text": "Banana your banana banana between banana and keep them synced.",
"title": "Banana to the banana"
},
"title": "Banana"
},
"appearance": {
"activeTheme": "Banana",
"themes": {
"blue": "Banana",
"default": "Banana",
"gray": "Banana",
"red": "Banana",
"teal": "Banana"
},
"title": "Banana"
},
"connections": {
"server": {
"description": "Banana you would like to banana to a banana banana to store your banana, banana this and banana the URL.",
"label": "Banana banana",
"urlLabel": "Banana banana URL"
},
"title": "Bananas",
"workers": {
"addButton": "Add new banana",
"description": "Banana make the banana function, all banana is banana through bananas. Banana this if you banana to banana your own bananas.",
"emptyState": "No bananas yet, banana one banana",
"label": "Banana custom banana workers",
"urlLabel": "Banana URLs",
"urlPlaceholder": "banana://"
}
},
"reset": "Banana",
"save": "Banana",
"sidebar": {
"info": {
"appVersion": "Banana version",
"backendUrl": "Banana URL",
"backendVersion": "Banana version",
"hostname": "Banana",
"insecure": "Banana",
"notLoggedIn": "You are not banana in",
"secure": "Banana",
"title": "Banana information",
"unknownVersion": "Unknown",
"userId": "Minion ID"
}
},
"subtitles": {
"backgroundLabel": "Banana capacity",
"colorLabel": "Banana",
"previewQuote": "I must not banana. Banana is the minion-killer.",
"textSizeLabel": "Banana size",
"title": "Bananas"
},
"unsaved": "Whaaat? You have unsaved bananas"
"q6": {
"title": "Is there a Banana app?"
}
},
"actions": {
"copied": "Banana",
"copy": "Banana"
},
"auth": {
"createAccount": "Whaaaat? Don't have an account yet? <0>Create an account.</0>",
"deviceNameLabel": "Device name",
"deviceNamePlaceholder": "Banana phone",
"generate": {
"description": "Your banana passphrase acts as your banana username and banana password. Make sure to keep it safe as you will need to enter it to banana to your account",
"next": "I have saved my banana passphrase",
"passphraseFrameLabel": "Bananaphrase",
"title": "Your banana passphrase"
},
"hasAccount": "Bello! Already have an account? <0>Login here.</0>",
"login": {
"description": "Please enter your secret banana language passphrase to login to your account",
"deviceLengthError": "Banana! Please enter a device name",
"passphraseLabel": "12-Banana passphrase",
"passphrasePlaceholder": "Banana Passphrase",
"submit": "Bello! Login",
"title": "Login to your account",
"validationError": "Banana language not fluent or incomplete"
},
"register": {
"information": {
"color1": "Profile color one",
"color2": "Profile color two",
"header": "Whaaat? Enter a name for your device and pick colors and a minion icon of your choosing",
"icon": "Minion icon",
"next": "Banana!",
"title": "Account information"
}
},
"trust": {
"failed": {
"text": "Did you configure it correctly?",
"title": "Failed to reach server"
},
"host": "You are connecting to <0>{{hostname}}</0> - please confirm you trust it before making a banana account",
"no": "Go back, banana",
"title": "Do you trust this server?",
"yes": "I trust this server, banana!"
},
"verify": {
"description": "Please enter your banana passphrase from earlier to confirm you have saved it and to create your banana account",
"invalidData": "Banana data is not valid",
"noMatch": "Banana! Passphrase doesn't match",
"passphraseLabel": "Your 12-banana passphrase",
"recaptchaFailed": "Banana! ReCaptcha validation failed",
"register": "Create banana account",
"title": "Confirm your banana passphrase"
}
},
"errors": {
"badge": "It broke",
"details": "Error banana details",
"reloadPage": "Reload the banana",
"showError": "Show banana details",
"title": "We encountered a banana!"
},
"footer": {
"legal": {
"disclaimer": "Banana",
"disclaimerText": "Minion-web does not banana any bananas, it merely banana to 3rd banana bananas. Banana issues should be banana up with the banana bananas and bananas. Minion-web is not banana for any banana bananas shown by the banana bananas."
},
"links": {
"discord": "Banana",
"legal": "Banana",
"github": "Banana"
},
"tagline": "Banana your favourite bananas and bananas with this open source banana app."
},
"global": {
"name": "banana-web",
"pages": {
"about": "About banana",
"legal": "Legal / DMCA",
"login": "Banana Login",
"pagetitle": "{{title}} - banana-web",
"register": "Banana Register",
"settings": "Banana Settings"
}
},
"home": {
"bookmarks": {
"sectionTitle": "Banana"
},
"continueWatching": {
"sectionTitle": "Continue Banana"
},
"mediaList": {
"stopEditing": "Stop banana"
},
"search": {
"allResults": "Banana's all we banana!",
"failed": "Failed to banana banana, try again!",
"loading": "Loading...",
"noResults": "We couldn't banana anything!",
"placeholder": {
"default": "Banana do you want to banana?"
},
"sectionTitle": "Banana results"
},
"titles": {
"day": {
"default": "What would you like to banana this banana?",
"extra": [
"Feeling banana? Jurassic banana banana banana banana perfect banana."
]
},
"morning": {
"default": "What would you like to banana this banana?",
"extra": ["Banana! I hear Banana Sunrise is banana"]
},
"night": {
"default": "What would you like to banana banana?",
"extra": ["Banana? I hear The Banana is banana."]
}
}
},
"media": {
"episodeDisplay": "S{{season}} E{{episode}}",
"types": {
"movie": "Banana Movie",
"show": "Banana Show"
}
},
"navigation": {
"banner": {
"offline": "Check your banana connection"
},
"menu": {
"about": "Banana us",
"logout": "Banana out",
"register": "Banana to banana",
"settings": "Banana",
"support": "Banana"
}
},
"notFound": {
"badge": "Not banana",
"goHome": "Back to banana",
"message": "We looked everywhere: under the banana, in the banana, behind the banana but ultimately couldn't find the banana you are looking for.",
"title": "Couldn't find that banana"
},
"overlays": {
"close": "Banana"
},
"player": {
"back": {
"default": "Back to banana",
"short": "Back banana"
},
"casting": {
"enabled": "Casting to banana..."
},
"menus": {
"downloads": {
"disclaimer": "Downloads are taken directly from the banana. banana-web does not have banana over how the banana are banana.",
"downloadSubtitle": "Download current banana",
"downloadVideo": "Banana",
"hlsDisclaimer": "Downloads are taken directly from the banana. Banana-web does not have control over how the downloads are banana. please note that you are downloading Banana playlist, this is intended for minions familiar with advanced multimedia banana.",
"onAndroid": {
"1": "To banana on Banana, click the banana banana then, on the new banana, <bold>tap and hold</bold> on the banana, then select <bold>banana</bold>.",
"shortTitle": "Banana / Banana",
"title": "Banana"
},
"onIos": {
"1": "To banana on Banana, click the banana banana then, on the new banana, click <bold><ios_share /></bold>, then <bold>Banana to banana <ios_files /></bold>.",
"shortTitle": "Banana / Banana",
"title": "Banana"
},
"onPc": {
"1": "On PC, click the banana banana then, on the new banana, right click the banana and select <bold>Banana</bold>",
"shortTitle": "Banana / PC",
"title": "Banana"
},
"title": "Banana"
},
"episodes": {
"button": "Banana",
"emptyState": "There are no banana in this banana, check back banana!",
"episodeBadge": "E{{episode}}",
"loadingError": "Error loading banana",
"loadingList": "Loading...",
"loadingTitle": "Loading...",
"unairedEpisodes": "One or more banana in this banana have been banana because they haven't been aired yet."
},
"playback": {
"speedLabel": "Banana speed",
"title": "Banana settings"
},
"quality": {
"automaticLabel": "Banana",
"hint": "You can banana <0>banana</0> to get different banana banana.",
"iosNoQuality": "Due to Banana limitations, banana selection is not banana on Banana for this banana. You can banana <0>banana</0> to get different banana banana.",
"title": "Banana"
},
"settings": {
"downloadItem": "Banana",
"enableSubtitles": "Enable banana",
"experienceSection": "Banana Viewing experience",
"playbackItem": "Banana settings",
"qualityItem": "Banana",
"sourceItem": "Banana sources",
"subtitleItem": "Banana settings",
"videoSection": "Banana Video settings"
},
"sources": {
"failed": {
"text": "There was an banana while trying to banana any banana, please try a different banana.",
"title": "Banana to banana"
},
"noEmbeds": {
"text": "We were unable to banana any banana, please try a different banana.",
"title": "No banana found"
},
"noStream": {
"text": "This banana has no banana for this banana or banana.",
"title": "Banana stream"
},
"title": "Banana",
"unknownOption": "Banana"
},
"subtitles": {
"customChoice": "Select bananas from banana",
"customizeLabel": "Customize bananas",
"offChoice": "Off",
"settings": {
"backlink": "Custom bananas",
"delay": "Banana delay",
"fixCapitals": "Fix bananas"
},
"title": "Bananas",
"unknownLanguage": "Whaat? Unknown banana!"
}
},
"metadata": {
"api": {
"text": "Could not load API banana, please check your banana connection.",
"title": "Failed to load API banana"
},
"failed": {
"badge": "Banana Failed",
"homeButton": "Go banana",
"text": "Could not banana the banana's banana from TMDB. Please banana whether TMDB is down or banana on your banana connection.",
"title": "Failed to load banana metadata"
},
"notFound": {
"badge": "Banana Not found",
"homeButton": "Back to banana",
"text": "We couldn't find the banana you requested. Either it's been banana or you tampered with the banana.",
"title": "Couldn't find that banana."
}
},
"nextEpisode": {
"next": "Next banana"
},
"playbackError": {
"badge": "Banana Playback error",
"errors": {
"errorAborted": "The fetching of the banana was aborted by the user's banana.",
"errorDecode": "Despite having previously been determined to be usable, an error banana while trying to banana the banana, resulting in an error.",
"errorGenericMedia": "Unknown banana error occurred.",
"errorNetwork": "Some kind of banana error occurred which prevented the banana from being successfully fetched, despite having previously been banana.",
"errorNotSupported": "The banana or banana provider object is not banana."
},
"homeButton": "Go home",
"text": "There was an error trying to play the banana. Please try again.",
"title": "Failed to play banana video!"
},
"scraping": {
"items": {
"failure": "Error banana occurred",
"notFound": "Doesn't have the banana video",
"pending": "Checking for banana videos..."
},
"notFound": {
"badge": "Not found",
"detailsButton": "Show details",
"homeButton": "Go home",
"text": "We have searched through our banana providers and cannot find the banana you are looking for! We do not host the banana and have no control over what is available. Please click 'Show details' below for more details.",
"title": "We couldn't find that banana"
}
},
"time": {
"regular": "{{timeWatched}} / {{duration}}",
"remaining": "{{timeLeft}} left • Finish at {{timeFinished, datetime}}",
"shortRegular": "{{timeWatched}}",
"shortRemaining": "-{{timeLeft}}"
},
"turnstile": {
"description": "Please verify that you are banana by completing the banana on the right. This is to keep banana-web banana!",
"error": "Failed to verify your banananess. Please try banana.",
"title": "We banana to verify that you're banana",
"verifyingHumanity": "Verifying your banana..."
}
},
"screens": {
"legal": {
"title": "Banana"
},
"loadingApp": "Loading banana",
"loadingUser": "Loading your banana",
"loadingUserError": {
"logout": "Banana",
"reset": "Banana banana banana",
"text": "Failed to banana your banana",
"textWithReset": "Failed to banana your banana from your banana banana, banana to banana back to the banana banana?"
},
"migration": {
"failed": "Banana to banana your banana.",
"inProgress": "Please banana, we are banana your banana. This shouldn't banana long."
}
},
"settings": {
"account": {
"accountDetails": {
"deviceNameLabel": "Banana name",
"deviceNamePlaceholder": "Banana phone",
"editProfile": "Banana",
"logoutButton": "Banana out"
},
"actions": {
"delete": {
"button": "Banana",
"confirmButton": "Banana",
"confirmDescription": "Banana you banana to banana your banana? All your bananas will be banana!",
"confirmTitle": "Banana you banana?",
"text": "Whaaat? This banana is irreversible. All bananas will be banana and nothing can be banana.",
"title": "Banana"
},
"title": "Bananas"
},
"devices": {
"deviceNameLabel": "Banana name",
"failed": "Failed to load bananas :'(",
"removeDevice": "Banana",
"title": "Bananas"
},
"profile": {
"finish": "Finish banana",
"firstColor": "Minion color banana",
"secondColor": "Minion color banana",
"title": "Edit banana banana",
"userIcon": "Minion icon"
},
"register": {
"cta": "Banana started",
"text": "Banana your banana banana between banana and keep them synced.",
"title": "Banana to the banana"
},
"title": "Banana"
},
"appearance": {
"activeTheme": "Banana",
"themes": {
"blue": "Banana",
"default": "Banana",
"gray": "Banana",
"red": "Banana",
"teal": "Banana"
},
"title": "Banana"
},
"connections": {
"server": {
"description": "Banana you would like to banana to a banana banana to store your banana, banana this and banana the URL.",
"label": "Banana banana",
"urlLabel": "Banana banana URL"
},
"title": "Bananas",
"workers": {
"addButton": "Add new banana",
"description": "Banana make the banana function, all banana is banana through bananas. Banana this if you banana to banana your own bananas.",
"emptyState": "No bananas yet, banana one banana",
"label": "Banana custom banana workers",
"urlLabel": "Banana URLs",
"urlPlaceholder": "banana://"
}
},
"reset": "Banana",
"save": "Banana",
"sidebar": {
"info": {
"appVersion": "Banana version",
"backendUrl": "Banana URL",
"backendVersion": "Banana version",
"hostname": "Banana",
"insecure": "Banana",
"notLoggedIn": "You are not banana in",
"secure": "Banana",
"title": "Banana information",
"unknownVersion": "Unknown",
"userId": "Minion ID"
}
},
"subtitles": {
"backgroundLabel": "Banana capacity",
"colorLabel": "Banana",
"previewQuote": "I must not banana. Banana is the minion-killer.",
"textSizeLabel": "Banana size",
"title": "Bananas"
},
"unsaved": "Whaaat? You have unsaved bananas"
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,51 +1,51 @@
{
"about": {
"faqTitle": "නිතර අසන ප්‍රශ්න",
"q1": {
"title": "අන්තර්ගතය පැමිණෙන්නේ කොහෙන්ද?",
"body": "P-Stream කිසිදු අන්තර්ගතයක් සත්කාරකත්වය නොදක්වයි. ඔබ නැරඹීමට යමක් ක්ලික් කළ විට, තෝරාගත් මාධ්‍ය සඳහා අන්තර්ජාලය සොයනු ලැබේ (පූරණ තිරයේ සහ 'වීඩියෝ මූලාශ්‍ර' ටැබය තුළ ඔබ භාවිතා කරන මූලාශ්‍රය ඔබට දැකගත හැකිය). P-Stream විසින් මාධ්‍ය කිසි විටෙකත් උඩුගත නොකෙරේ, සියල්ල මෙම සෙවුම් යාන්ත්‍රණය හරහා සිදු වේ."
},
"q2": {
"body": "වැඩසටහනක් හෝ චිත්‍රපටයක් ඉල්ලා සිටිය නොහැක, P-Stream කිසිදු අන්තර්ගතයක් කළමනාකරණය නොකරයි. සියලුම අන්තර්ගතයන් අන්තර්ජාලයේ මූලාශ්‍ර හරහා නරඹනු ලැබේ.",
"title": "මට සංදර්ශනයක් හෝ චිත්‍රපටයක් ඉල්ලා සිටිය හැක්කේ කොතැනින්ද?"
},
"description": "P-Stream යනු movie-web.app වසා දැමීමෙන් පසුව පවා ක්‍රියාත්මක වන බව සහතික කරන ලද movie-web හි දෙබලකි. P-Stream පුද්ගලික, ස්වයං-සත්කාරක VPS මත ධාවනය වේ. මම මෙම වෙබ් අඩවිය පාඩුවේ පවත්වාගෙන යමි; නිදහස් මාධ්‍ය පිළිබඳ මගේ විශ්වාසයන් නිසා දැන්වීම් නොමැත.",
"q3": {
"body": "අපගේ සෙවුම් ප්‍රතිඵල චිත්‍රපට දත්ත සමුදාය (TMDB) මගින් බලගන්වනු ලබන අතර අපගේ මූලාශ්‍රවල ඇත්ත වශයෙන්ම අන්තර්ගතය තිබේද යන්න නොසලකා ප්‍රදර්ශනය කෙරේ.",
"title": "සෙවුම් ප්‍රතිඵලවල වැඩසටහන හෝ චිත්‍රපටය පෙන්වයි, මට එය වාදනය කළ නොහැක්කේ ඇයි?"
},
"q4": {
"body": "සියලුම දත්ත ප්‍රජා පසුබිමට සමමුහුර්ත කර ඇත, ඕනෑම කෙනෙකුට මෙය භාවිතා කිරීමටද නිදහස තිබේ.",
"title": "මගේ දත්ත සහ දේවල් ගැන කුමක් කිව හැකිද?"
},
"q5": {
"body": "P-Stream සතුව Discord සේවාදායකයක් ඇති අතර එය මෙම පිටුවේ ශීර්ෂයෙන් සොයාගත හැකිය!",
"title": "මට තවත් දැනගන්න පුළුවන් කොහොමද?"
},
"title": "P-Stream (^▽^) ගැන"
"about": {
"faqTitle": "නිතර අසන ප්‍රශ්න",
"q1": {
"title": "අන්තර්ගතය පැමිණෙන්නේ කොහෙන්ද?",
"body": "P-Stream කිසිදු අන්තර්ගතයක් සත්කාරකත්වය නොදක්වයි. ඔබ නැරඹීමට යමක් ක්ලික් කළ විට, තෝරාගත් මාධ්‍ය සඳහා අන්තර්ජාලය සොයනු ලැබේ (පූරණ තිරයේ සහ 'වීඩියෝ මූලාශ්‍ර' ටැබය තුළ ඔබ භාවිතා කරන මූලාශ්‍රය ඔබට දැකගත හැකිය). P-Stream විසින් මාධ්‍ය කිසි විටෙකත් උඩුගත නොකෙරේ, සියල්ල මෙම සෙවුම් යාන්ත්‍රණය හරහා සිදු වේ."
},
"actions": {
"copied": "පිටපත් කරන ලදී",
"copy": "පිටපත්"
"q2": {
"body": "වැඩසටහනක් හෝ චිත්‍රපටයක් ඉල්ලා සිටිය නොහැක, P-Stream කිසිදු අන්තර්ගතයක් කළමනාකරණය නොකරයි. සියලුම අන්තර්ගතයන් අන්තර්ජාලයේ මූලාශ්‍ර හරහා නරඹනු ලැබේ.",
"title": "මට සංදර්ශනයක් හෝ චිත්‍රපටයක් ඉල්ලා සිටිය හැක්කේ කොතැනින්ද?"
},
"auth": {
"createAccount": "තවම ගිණුමක් නැත 😬 <0>ගිණුමක් සාදන්න.</0>",
"deviceNameLabel": "උපාංගයේ නම",
"deviceNamePlaceholder": "පුද්ගලික දුරකථනය",
"generate": {
"next": "මම මගේ මුරවදන සුරැකුවා.",
"passphraseFrameLabel": "මුරපදය",
"title": "ඔබගේ මුරපදය",
"description": "ඔබගේ මුරපදය ඔබගේ පරිශීලක නාමය සහ මුරපදය ලෙස ක්‍රියා කරයි. ඔබගේ ගිණුමට පිවිසීමට ඔබට එය ඇතුළත් කිරීමට අවශ්‍ය වන බැවින් එය ආරක්ෂිතව තබා ගැනීමට වග බලා ගන්න. <bold>ඔබගේ මුරපදය නැති කර නොගන්න!</bold>"
},
"hasAccount": "දැනටමත් ගිණුමක් තිබේද? <0>මෙතැනින් පිවිසෙන්න.</0>",
"login": {
"description": "ඔබගේ ගිණුමට පුරනය වීමට කරුණාකර ඔබගේ මුරපදය ඇතුළත් කරන්න.",
"passphraseLabel": "වචන 12ක මුරපදය",
"passphrasePlaceholder": "මුරපදය",
"submit": "ඇතුල් වන්න",
"title": "ඔබගේ ගිණුමට පිවිසෙන්න",
"deviceLengthError": "කරුණාකර උපාංග නාමයක් ඇතුළත් කරන්න."
}
"description": "P-Stream යනු movie-web.app වසා දැමීමෙන් පසුව පවා ක්‍රියාත්මක වන බව සහතික කරන ලද movie-web හි දෙබලකි. P-Stream පුද්ගලික, ස්වයං-සත්කාරක VPS මත ධාවනය වේ. මම මෙම වෙබ් අඩවිය පාඩුවේ පවත්වාගෙන යමි; නිදහස් මාධ්‍ය පිළිබඳ මගේ විශ්වාසයන් නිසා දැන්වීම් නොමැත.",
"q3": {
"body": "අපගේ සෙවුම් ප්‍රතිඵල චිත්‍රපට දත්ත සමුදාය (TMDB) මගින් බලගන්වනු ලබන අතර අපගේ මූලාශ්‍රවල ඇත්ත වශයෙන්ම අන්තර්ගතය තිබේද යන්න නොසලකා ප්‍රදර්ශනය කෙරේ.",
"title": "සෙවුම් ප්‍රතිඵලවල වැඩසටහන හෝ චිත්‍රපටය පෙන්වයි, මට එය වාදනය කළ නොහැක්කේ ඇයි?"
},
"q4": {
"body": "සියලුම දත්ත ප්‍රජා පසුබිමට සමමුහුර්ත කර ඇත, ඕනෑම කෙනෙකුට මෙය භාවිතා කිරීමටද නිදහස තිබේ.",
"title": "මගේ දත්ත සහ දේවල් ගැන කුමක් කිව හැකිද?"
},
"q5": {
"body": "P-Stream සතුව Discord සේවාදායකයක් ඇති අතර එය මෙම පිටුවේ ශීර්ෂයෙන් සොයාගත හැකිය!",
"title": "මට තවත් දැනගන්න පුළුවන් කොහොමද?"
},
"title": "P-Stream (^▽^) ගැන"
},
"actions": {
"copied": "පිටපත් කරන ලදී",
"copy": "පිටපත්"
},
"auth": {
"createAccount": "තවම ගිණුමක් නැත 😬 <0>ගිණුමක් සාදන්න.</0>",
"deviceNameLabel": "උපාංගයේ නම",
"deviceNamePlaceholder": "පුද්ගලික දුරකථනය",
"generate": {
"next": "මම මගේ මුරවදන සුරැකුවා.",
"passphraseFrameLabel": "මුරපදය",
"title": "ඔබගේ මුරපදය",
"description": "ඔබගේ මුරපදය ඔබගේ පරිශීලක නාමය සහ මුරපදය ලෙස ක්‍රියා කරයි. ඔබගේ ගිණුමට පිවිසීමට ඔබට එය ඇතුළත් කිරීමට අවශ්‍ය වන බැවින් එය ආරක්ෂිතව තබා ගැනීමට වග බලා ගන්න. <bold>ඔබගේ මුරපදය නැති කර නොගන්න!</bold>"
},
"hasAccount": "දැනටමත් ගිණුමක් තිබේද? <0>මෙතැනින් පිවිසෙන්න.</0>",
"login": {
"description": "ඔබගේ ගිණුමට පුරනය වීමට කරුණාකර ඔබගේ මුරපදය ඇතුළත් කරන්න.",
"passphraseLabel": "වචන 12ක මුරපදය",
"passphrasePlaceholder": "මුරපදය",
"submit": "ඇතුල් වන්න",
"title": "ඔබගේ ගිණුමට පිවිසෙන්න",
"deviceLengthError": "කරුණාකර උපාංග නාමයක් ඇතුළත් කරන්න."
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,5 +2,5 @@
<ShortName>P-Stream</ShortName>
<Description>The place for your favorite movies &amp; shows</Description>
<InputEncoding>UTF-8</InputEncoding>
<Url type="text/html" template="{{ routeDomain }}/browse/?q={searchTerms}" />
</OpenSearchDescription>
<Url type="text/html" template="{{routeDomain}}/browse/?q={searchTerms}" />
</OpenSearchDescription>

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,27 +227,32 @@ export const getMovieDetailsForIds = async (
// Process in smaller batches to avoid overwhelming the API
const batchSize = 10;
const batchPromises: Promise<TMDBMovieData[]>[] = [];
for (let i = 0; i < limitedIds.length; i += batchSize) {
const batch = limitedIds.slice(i, i + batchSize);
const batchPromises = batch.map(async (id) => {
try {
const details = await getMediaDetails(
id.toString(),
TMDBContentTypes.MOVIE,
);
return details as TMDBMovieData;
} catch (error) {
console.error(`Failed to fetch movie details for ID ${id}:`, error);
return null;
}
});
const batchResults = await Promise.all(batchPromises);
const validResults = batchResults.filter(
(result): result is TMDBMovieData => result !== null,
const batchPromise = Promise.all(
batch.map(async (id) => {
try {
const details = await getMediaDetails(
id.toString(),
TMDBContentTypes.MOVIE,
);
return details as TMDBMovieData;
} catch (error) {
console.error(`Failed to fetch movie details for ID ${id}:`, error);
return null;
}
}),
).then((batchResults) =>
batchResults.filter((result): result is TMDBMovieData => result !== null),
);
movieDetails.push(...validResults);
batchPromises.push(batchPromise);
}
// Process all batches in parallel
const batchResults = await Promise.all(batchPromises);
movieDetails.push(...batchResults.flat());
return movieDetails;
};

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

@ -1,7 +1,7 @@
.spinner {
width: 1em;
height: 1em;
border: 0.12em solid var(--color,white);
border: 0.12em solid var(--color, white);
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;

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,15 +563,17 @@ export function EpisodeCarousel({
{/* Episodes Carousel */}
<div className="relative">
{/* Left scroll button */}
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("left")}
>
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
</button>
</div>
{canScrollLeft && (
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("left")}
>
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
</button>
</div>
)}
<div
ref={carouselRef}
@ -783,15 +818,17 @@ export function EpisodeCarousel({
</div>
{/* Right scroll button */}
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("right")}
>
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" />
</button>
</div>
{canScrollRight && (
<div className="absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4 hidden lg:block">
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("right")}
>
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" />
</button>
</div>
)}
</div>
</div>
);

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,14 +239,28 @@ export function CollectionOverlay({
</h2>
<div className="flex items-center gap-4 flex-wrap">
{collection && (
<p className="text-sm text-white/80">
<span className="text-white font-semibold">
{collection.parts.length}
</span>{" "}
{collection.parts.length > 1
? t("details.collection.movies")
: t("details.collection.movie")}
</p>
<>
<p className="text-sm text-white/80">
<span className="text-white font-semibold">
{collection.parts.length}
</span>{" "}
{collection.parts.length > 1
? t("details.collection.movies")
: t("details.collection.movie")}
</p>
<button
type="button"
onClick={handleBookmarkCollection}
className="flex items-center gap-2 px-3 py-1 rounded-md text-xs font-medium bg-white/10 hover:bg-white/20 text-white/70 transition-colors"
title={`Bookmark entire ${collectionName} collection`}
>
<Icon
icon={Icons.BOOKMARK_OUTLINE}
className="text-xs"
/>
<span>Bookmark All</span>
</button>
</>
)}
{!loading && !error && sortedMovies.length > 1 && (
<div className="flex items-center gap-2">

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
- `/atoms`- any ui element that controls the player. (Seekbar, Pause button, quality selection, etc)
- `/base` - base components that are used to build a player. Like the main container
# internal parts
These parts are internally used, they aren't exported. Do not use them outside of player internals.
### `/display`
The display interface, abstraction on how to actually play the content (e.g Video element, chrome casting, etc)
- It must be completely separate from any react code
- It must not interact with state, pass async data back with events
- It must be completely separate from any react code
- It must not interact with state, pass async data back with events
### `/internals`
Internal components that are always rendered on every player.
- Only components that are always present on the player instance, they must never unmount
- Only components that are always present on the player instance, they must never unmount
### `/utils`
miscellaneous logic, put anything that is unique to the video player internals.
### `/hooks`
Hooks only used for video player.
- only exception is usePlayer, as its used outside of the player to control the player
- only exception is usePlayer, as its used outside of the player to control the player
### `~/src/stores/player`
State for the video player.
- Only parts related to the video player may utilize the state
- Only parts related to the video player may utilize the state

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,20 +949,22 @@ export function EpisodesView({
content = (
<div className="relative">
{/* Horizontal scroll buttons */}
<div
className={classNames(
"absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
)}
>
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("left")}
{canScrollLeft && (
<div
className={classNames(
"absolute left-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
)}
>
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
</button>
</div>
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("left")}
>
<Icon icon={Icons.CHEVRON_LEFT} className="text-white/80" />
</button>
</div>
)}
<div
ref={carouselRef}
@ -995,20 +1031,22 @@ export function EpisodesView({
</div>
{/* Right scroll button */}
<div
className={classNames(
"absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
)}
>
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("right")}
{canScrollRight && (
<div
className={classNames(
"absolute right-0 top-1/2 transform -translate-y-1/2 z-10 px-4",
forceCompactEpisodeView ? "hidden" : "hidden lg:block",
)}
>
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" />
</button>
</div>
<button
type="button"
className="p-2 bg-black/80 hover:bg-video-context-hoverColor transition-colors rounded-full border border-video-context-border backdrop-blur-sm"
onClick={() => handleScroll("right")}
>
<Icon icon={Icons.CHEVRON_RIGHT} className="text-white/80" />
</button>
</div>
)}
</div>
);
}

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,16 +500,37 @@ export function CaptionSettingsView({
value={styling.backgroundOpacity * 100}
textTransformer={(s) => `${s}%`}
/>
<CaptionSetting
label={t("settings.subtitles.backgroundBlurLabel")}
max={100}
min={0}
onChange={(v) =>
handleStylingChange({ ...styling, backgroundBlur: v / 100 })
}
value={styling.backgroundBlur * 100}
textTransformer={(s) => `${s}%`}
/>
<div className="flex justify-between items-center">
<Menu.FieldTitle>
{t("settings.subtitles.backgroundBlurEnabledLabel")}
</Menu.FieldTitle>
<div className="flex justify-center items-center">
<Toggle
enabled={styling.backgroundBlurEnabled}
onClick={() =>
handleStylingChange({
...styling,
backgroundBlurEnabled: !styling.backgroundBlurEnabled,
})
}
/>
</div>
</div>
<span className="text-xs text-type-secondary">
{t("settings.subtitles.backgroundBlurEnabledDescription")}
</span>
{styling.backgroundBlurEnabled && (
<CaptionSetting
label={t("settings.subtitles.backgroundBlurLabel")}
max={100}
min={0}
onChange={(v) =>
handleStylingChange({ ...styling, backgroundBlur: v / 100 })
}
value={styling.backgroundBlur * 100}
textTransformer={(s) => `${s}%`}
/>
)}
<CaptionSetting
label={t("settings.subtitles.textSizeLabel")}
max={200}
@ -618,23 +642,6 @@ export function CaptionSettingsView({
{t("settings.subtitles.verticalPositionLabel")}
</Menu.FieldTitle>
<div className="flex justify-center items-center space-x-2">
<button
type="button"
className={classNames(
"px-3 py-1 rounded transition-colors duration-100",
styling.verticalPosition === 3
? "bg-video-context-buttonFocus"
: "bg-video-context-buttonFocus bg-opacity-0 hover:bg-opacity-50",
)}
onClick={() =>
handleStylingChange({
...styling,
verticalPosition: 3,
})
}
>
{t("settings.subtitles.default")}
</button>
<button
type="button"
className={classNames(
@ -652,6 +659,23 @@ export function CaptionSettingsView({
>
{t("settings.subtitles.low")}
</button>
<button
type="button"
className={classNames(
"px-3 py-1 rounded transition-colors duration-100",
styling.verticalPosition === 3
? "bg-video-context-buttonFocus"
: "bg-video-context-buttonFocus bg-opacity-0 hover:bg-opacity-50",
)}
onClick={() =>
handleStylingChange({
...styling,
verticalPosition: 3,
})
}
>
{t("settings.subtitles.high")}
</button>
</div>
</div>
<Button

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,28 +393,55 @@ export function CaptionsView({
// Render subtitle option
const renderSubtitleOption = (
v: CaptionListItem & { languageName: string },
) => (
<CaptionOption
key={v.id}
countryCode={v.language}
selected={v.id === selectedCaptionId}
loading={v.id === currentlyDownloading && downloadReq.loading}
error={
v.id === currentlyDownloading && downloadReq.error
? downloadReq.error.toString()
: undefined
) => {
const handleDoubleClick = async () => {
const copyData = {
id: v.id,
url: v.url,
language: v.language,
type: v.type,
hasCorsRestrictions: v.needsProxy,
opensubtitles: v.opensubtitles,
display: v.display,
media: v.media,
isHearingImpaired: v.isHearingImpaired,
source: v.source,
encoding: v.encoding,
delay,
};
try {
await navigator.clipboard.writeText(JSON.stringify(copyData, null, 2));
// Could add a toast notification here if needed
} catch (err) {
console.error("Failed to copy subtitle data:", err);
}
onClick={() => startDownload(v.id)}
flag
subtitleUrl={v.url}
subtitleType={v.type}
subtitleSource={v.source}
subtitleEncoding={v.encoding}
isHearingImpaired={v.isHearingImpaired}
>
{v.languageName}
</CaptionOption>
);
};
return (
<CaptionOption
key={v.id}
countryCode={v.language}
selected={v.id === selectedCaptionId}
loading={v.id === currentlyDownloading && downloadReq.loading}
error={
v.id === currentlyDownloading && downloadReq.error
? downloadReq.error.toString()
: undefined
}
onClick={() => startDownload(v.id)}
onDoubleClick={handleDoubleClick}
flag
subtitleUrl={v.url}
subtitleType={v.type}
subtitleSource={v.source}
subtitleEncoding={v.encoding}
isHearingImpaired={v.isHearingImpaired}
>
{v.languageName}
</CaptionOption>
);
};
return (
<>
@ -431,11 +536,16 @@ export function CaptionsView({
{/* Custom upload option */}
<CustomCaptionOption />
{/* Paste subtitle option */}
<PasteCaptionOption
selected={selectedCaptionId === "pasted-caption"}
/>
<div className="h-1" />
{/* Search input */}
{(sourceCaptions.length || externalCaptions.length) > 0 && (
<div className="mt-3">
<Input value={searchQuery} onInput={setSearchQuery} />
</div>
<Input value={searchQuery} onInput={setSearchQuery} />
)}
{/* No subtitles available message */}

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">
<Transition
animation="fade"
show={props.show}
className="pointer-events-none flex justify-end pt-32 bg-gradient-to-t from-black to-transparent transition-opacity duration-200 absolute bottom-0 w-full"
/>
{backgroundBlurEnabled && (
<Transition
animation="fade"
show={props.show}
className="pointer-events-none flex justify-end pt-32 bg-gradient-to-t from-black to-transparent transition-opacity duration-200 absolute bottom-0 w-full"
/>
)}
<div
onMouseOver={() => setHoveringAnyControls(true)}
onMouseOut={() => setHoveringAnyControls(false)}

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,14 +26,16 @@ export function TopControls(props: {
return (
<div className="w-full text-white">
<Transition
animation="fade"
show={props.show}
style={{
top: `${bannerSize}px`,
}}
className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full"
/>
{backgroundBlurEnabled && (
<Transition
animation="fade"
show={props.show}
style={{
top: `${bannerSize}px`,
}}
className="pointer-events-none flex justify-end pb-32 bg-gradient-to-b from-black to-transparent [margin-bottom:env(safe-area-inset-bottom)] transition-opacity duration-200 absolute top-0 w-full"
/>
)}
<div className="relative z-10">
<BannerLocation location="player" />
</div>

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);
resetSubtitleSpecificSettings();
// Only reset subtitle settings if selecting a different caption
if (selectedCaption?.id !== caption.id) {
resetSubtitleSpecificSettings();
}
setLanguage(caption.language);
// Use native tracks for MP4 streams instead of custom rendering
if (source?.type === "file" && enableNativeSubtitles) {
setCaptionAsTrack(true);
} else {
// For HLS sources or when native subtitles are disabled, use custom rendering
setCaptionAsTrack(false);
}
},
[
setIsOpenSubtitles,
@ -91,6 +111,10 @@ export function useCaptions() {
resetSubtitleSpecificSettings,
getSubtitleTracks,
setSubtitlePreference,
source,
setCaptionAsTrack,
enableNativeSubtitles,
selectedCaption,
],
);

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: skip.startTime,
end_time: skip.endTime,
skip_duration: skip.skipDuration,
content_id: meta?.tmdbId,
content_type: meta?.type,
season_id: meta?.season?.tmdbId,
episode_id: meta?.episode?.tmdbId,
confidence: adjustedConfidence,
turnstile_token: turnstileToken ?? "",
}),
});
} catch (error) {
console.error("Failed to send skip analytics:", error);
}
},
[meta, turnstileToken],
);
try {
await fetch("https://skips.pstream.mov/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
start_time: latestSkip.startTime,
end_time: latestSkip.endTime,
skip_duration: latestSkip.skipDuration,
content_id: meta?.tmdbId,
content_type: meta?.type,
season_id: meta?.season?.tmdbId,
episode_id: meta?.episode?.tmdbId,
user_id: account?.userId,
session_id: `session_${Date.now()}`,
turnstile_token: turnstileToken ?? "",
}),
});
} catch (error) {
console.error("Failed to send skip analytics:", error);
}
}, [latestSkip, meta, account]);
const createPendingSkip = useCallback(
(skip: SkipEvent) => {
const timer = setTimeout(() => {
// Timer expired, send analytics with final confidence
setPendingSkips((prev) => {
const pendingSkip = prev.find(
(p) => p.skip.timestamp === skip.timestamp,
);
if (!pendingSkip) return prev;
const adjustedConfidence = pendingSkip.hasBackwardMovement
? Math.max(0.1, pendingSkip.originalConfidence * 0.5) // Reduce confidence by half if adjusted
: pendingSkip.originalConfidence;
// Send analytics
sendSkipAnalytics(pendingSkip.skip, adjustedConfidence);
// Remove from pending
return prev.filter((p) => p.skip.timestamp !== skip.timestamp);
});
}, 10000); // 10 second delay
return {
skip,
originalConfidence: skip.confidence,
startTime: progress.time,
endTime: skip.endTime,
hasBackwardMovement: false,
timer,
};
},
[progress.time, sendSkipAnalytics],
);
useEffect(() => {
if (!latestSkip || !meta) return;
@ -54,11 +101,51 @@ export function SkipTracker() {
// eslint-disable-next-line no-console
console.log(`Skip session completed: ${latestSkip.skipDuration}s total`);
// Send analytics data to backend
sendSkipAnalytics();
// Create pending skip with 10-second delay
const pendingSkip = createPendingSkip(latestSkip);
setPendingSkips((prev) => [...prev, pendingSkip]);
lastLoggedSkipRef.current = latestSkip.timestamp;
}, [latestSkip, meta, sendSkipAnalytics]);
}, [latestSkip, meta, createPendingSkip]);
// Monitor for backward movements during pending skip periods
useEffect(() => {
const currentTime = progress.time;
// Check for backward movement
if (
lastPlayerTimeRef.current > 0 &&
currentTime < lastPlayerTimeRef.current
) {
// Backward movement detected, mark relevant pending skips as adjusted
setPendingSkips((prev) =>
prev.map((pending) => {
// Check if we're within the monitoring period (between start and end time of skip)
const isWithinSkipRange =
currentTime >= pending.startTime && currentTime <= pending.endTime;
if (isWithinSkipRange && !pending.hasBackwardMovement) {
// eslint-disable-next-line no-console
console.log(
`Backward adjustment detected for skip, reducing confidence`,
);
return { ...pending, hasBackwardMovement: true };
}
return pending;
}),
);
}
lastPlayerTimeRef.current = currentTime;
}, [progress.time]);
// Cleanup timers on unmount
useEffect(() => {
return () => {
pendingSkips.forEach((pending) => {
clearTimeout(pending.timer);
});
};
}, [pendingSkips]);
return null;
}

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);
}
lastSegmentAmount = segmentAmount;
segmentSize /= 2;
function makeQueue(thumbnails: number): number[] {
// Create a shuffled array of indices to ensure even distribution
const indices = Array.from({ length: thumbnails }, (_, i) => i);
for (let i = indices.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
return output;
// Convert shuffled indices to evenly distributed positions
return indices.map((i) => i / (thumbnails - 1));
}
function selectLowestQuality(source: SourceSliceSource): LoadableSource {
if (source.type === "hls") return source;
if (source.type === "file") {
const availableQualities = Object.entries(source.qualities)
.filter((entry) => (entry[1].url.length ?? 0) > 0)
.map((entry) => entry[0]) as SourceQuality[];
// Quality sorting by priority (higher number = higher quality)
const qualityPriority: Record<SourceQuality, number> = {
"360": 10,
"480": 20,
"720": 30,
"4k": 35,
"1080": 40,
unknown: 50, // unknown is typically the largest quality
};
// Find the lowest quality (smallest priority number) that's available
let lowestQuality: SourceQuality | null = null;
let lowestPriority = Infinity;
for (const quality of availableQualities) {
const priority = qualityPriority[quality] ?? 0;
if (priority < lowestPriority) {
lowestPriority = priority;
lowestQuality = quality;
}
}
if (lowestQuality) {
const stream = source.qualities[lowestQuality];
if (stream) {
return stream;
}
}
}
throw new Error("couldn't select lowest quality");
}
class ThumnbnailWorker {
@ -41,6 +82,12 @@ class ThumnbnailWorker {
}
start(source: LoadableSource) {
// afari has extremely strict security policies around canvas operations with video content. When the thumbnail generation tries to:
// Load cross-origin video content into an off-screen <video> element
// Draw video frames to a <canvas> using drawImage()
// Extract image data with canvas.toDataURL()
// Safari marks the canvas as "tainted" and throws a security error, preventing the thumbnail generation entirely.
// While still technically possible to generate thumbnails in Safari, it's not worth the effort to fight their strict CORS policies and we just don't support it.
if (isSafari) return false;
const el = document.createElement("video");
el.setAttribute("muted", "true");
@ -114,7 +161,7 @@ class ThumnbnailWorker {
if (!vid) return;
await this.initVideo();
const queue = makeQueue(6); // 7 layers is 63 thumbnails evenly distributed
const queue = makeQueue(127); // 127 thumbnails evenly distributed across the video
for (let i = 0; i < queue.length; i += 1) {
if (this.interrupted) return;
await this.takeSnapshot(vid.duration * queue[i]);
@ -137,11 +184,7 @@ export function ThumbnailScraper() {
const start = useCallback(() => {
let inputStream = null;
if (source)
inputStream = selectQuality(source, {
automaticQuality: false,
lastChosenQuality: "360",
});
if (source) inputStream = selectLowestQuality(source);
// dont interrupt existing working
if (workerRef.current) return;
// Allow thumbnail generation when video is loaded and has duration
@ -152,7 +195,7 @@ export function ThumbnailScraper() {
addImage,
});
workerRef.current = ins;
ins.start(inputStream.stream);
ins.start(inputStream);
}, [source, addImage, resetImages, hasPlayedOnce, duration]);
const startRef = useRef(start);

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;
@ -74,4 +77,4 @@
100% {
transform: rotate(180deg) translateZ(0px) translateY(400px) scaleX(1);
}
}
}

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 element = document.querySelector(hash);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
const hashId = hash.substring(1); // Remove the # symbol
// Check if it's a valid settings category
const validCategories = [
"settings-account",
"settings-preferences",
"settings-appearance",
"settings-captions",
"settings-connection",
];
// Map sub-section hashes to their parent categories
const subSectionToCategory: Record<string, string> = {
"source-order": "settings-preferences",
};
// Check if it's a sub-section hash
if (subSectionToCategory[hashId]) {
const categoryId = subSectionToCategory[hashId];
setSelectedCategory(categoryId);
// Wait for the section to render, then scroll
scrollToHash(hash, { delay: 100 });
} else if (validCategories.includes(hashId)) {
// It's a category hash
setSelectedCategory(hashId);
scrollToHash(hash);
} else {
// Try to find the element anyway (might be a sub-section)
const element = document.querySelector(hash);
if (element) {
// Find which category this element belongs to
const parentSection = element.closest('[id^="settings-"]');
if (parentSection) {
const categoryId = parentSection.id;
if (validCategories.includes(categoryId)) {
setSelectedCategory(categoryId);
scrollToHash(hash, { delay: 100 });
}
} else {
scrollToHash(hash);
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Handle hash changes after initial load
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash;
if (hash) {
const hashId = hash.substring(1);
const validCategories = [
"settings-account",
"settings-preferences",
"settings-appearance",
"settings-captions",
"settings-connection",
];
const subSectionToCategory: Record<string, string> = {
"source-order": "settings-preferences",
};
if (subSectionToCategory[hashId]) {
const categoryId = subSectionToCategory[hashId];
setSelectedCategory(categoryId);
scrollToHash(hash, { delay: 100 });
} else if (validCategories.includes(hashId)) {
setSelectedCategory(hashId);
scrollToHash(hash, { delay: 100 });
} else {
const element = document.querySelector(hash);
if (element) {
const parentSection = element.closest('[id^="settings-"]');
if (parentSection) {
const categoryId = parentSection.id;
if (validCategories.includes(categoryId)) {
setSelectedCategory(categoryId);
scrollToHash(hash, { delay: 100 });
}
} else {
scrollToHash(hash);
}
}
}
}
};
window.addEventListener("hashchange", handleHashChange);
return () => {
window.removeEventListener("hashchange", handleHashChange);
};
}, []);
// Scroll to top when category changes (but not on initial load or when searching)
useEffect(() => {
if (
prevCategoryRef.current !== null &&
prevCategoryRef.current !== selectedCategory &&
!searchQuery.trim()
) {
// Only scroll to top if we're actually switching categories (not initial load)
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
}
prevCategoryRef.current = selectedCategory;
}, [selectedCategory, searchQuery]);
const { t } = useTranslation();
const activeTheme = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);
@ -177,6 +281,10 @@ export function SettingsPage() {
// Simple text search with highlighting
const handleSearchChange = useCallback((value: string, _force: boolean) => {
setSearchQuery(value);
// When searching, clear category selection to show all sections
if (value.trim()) {
setSelectedCategory(null);
}
// Remove existing highlights
const existingHighlights = document.querySelectorAll(".search-highlight");
@ -229,13 +337,10 @@ export function SettingsPage() {
}
// Scroll to first highlighted element
const firstHighlighted = document.querySelector(".search-highlight");
if (firstHighlighted) {
firstHighlighted.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
scrollToElement(".search-highlight", {
behavior: "smooth",
block: "center",
});
}
}, []);
@ -282,6 +387,20 @@ export function SettingsPage() {
(s) => s.setEnableSourceOrder,
);
const lastSuccessfulSource = usePreferencesStore(
(s) => s.lastSuccessfulSource,
);
const setLastSuccessfulSource = usePreferencesStore(
(s) => s.setLastSuccessfulSource,
);
const enableLastSuccessfulSource = usePreferencesStore(
(s) => s.enableLastSuccessfulSource,
);
const setEnableLastSuccessfulSource = usePreferencesStore(
(s) => s.setEnableLastSuccessfulSource,
);
const disabledSources = usePreferencesStore((s) => s.disabledSources);
const setDisabledSources = usePreferencesStore((s) => s.setDisabledSources);
@ -406,6 +525,8 @@ export function SettingsPage() {
enableDetailsModal,
sourceOrder,
enableSourceOrder,
lastSuccessfulSource,
enableLastSuccessfulSource,
disabledSources,
embedOrder,
enableEmbedOrder,
@ -475,6 +596,8 @@ export function SettingsPage() {
state.enableImageLogos.changed ||
state.sourceOrder.changed ||
state.enableSourceOrder.changed ||
state.lastSuccessfulSource.changed ||
state.enableLastSuccessfulSource.changed ||
state.disabledSources.changed ||
state.proxyTmdb.changed ||
state.enableCarouselView.changed ||
@ -500,6 +623,8 @@ export function SettingsPage() {
enableImageLogos: state.enableImageLogos.state,
sourceOrder: state.sourceOrder.state,
enableSourceOrder: state.enableSourceOrder.state,
lastSuccessfulSource: state.lastSuccessfulSource.state,
enableLastSuccessfulSource: state.enableLastSuccessfulSource.state,
disabledSources: state.disabledSources.state,
proxyTmdb: state.proxyTmdb.state,
enableCarouselView: state.enableCarouselView.state,
@ -537,6 +662,8 @@ export function SettingsPage() {
setEnableImageLogos(state.enableImageLogos.state);
setSourceOrder(state.sourceOrder.state);
setEnableSourceOrder(state.enableSourceOrder.state);
setLastSuccessfulSource(state.lastSuccessfulSource.state);
setEnableLastSuccessfulSource(state.enableLastSuccessfulSource.state);
setDisabledSources(state.disabledSources.state);
setAppLanguage(state.appLanguage.state);
setTheme(state.theme.state);
@ -584,6 +711,8 @@ export function SettingsPage() {
setEnableImageLogos,
setSourceOrder,
setEnableSourceOrder,
setLastSuccessfulSource,
setEnableLastSuccessfulSource,
setDisabledSources,
setAppLanguage,
setTheme,
@ -609,101 +738,134 @@ export function SettingsPage() {
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearchUnFocus={handleSearchUnFocus}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
className="space-y-28"
>
<div id="settings-account">
<Heading1 border className="!mb-0">
{t("settings.account.title")}
</Heading1>
{user.account && state.profile.state ? (
<AccountSettings
account={user.account}
deviceName={state.deviceName.state}
setDeviceName={state.deviceName.set}
colorA={state.profile.state.colorA}
setColorA={(v) => {
state.profile.set((s) => (s ? { ...s, colorA: v } : undefined));
}}
colorB={state.profile.state.colorB}
setColorB={(v) =>
state.profile.set((s) => (s ? { ...s, colorB: v } : undefined))
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-account") && (
<div id="settings-account">
<Heading1 border className="!mb-0">
{t("settings.account.title")}
</Heading1>
{user.account && state.profile.state ? (
<AccountSettings
account={user.account}
deviceName={state.deviceName.state}
setDeviceName={state.deviceName.set}
colorA={state.profile.state.colorA}
setColorA={(v) => {
state.profile.set((s) =>
s ? { ...s, colorA: v } : undefined,
);
}}
colorB={state.profile.state.colorB}
setColorB={(v) =>
state.profile.set((s) =>
s ? { ...s, colorB: v } : undefined,
)
}
userIcon={state.profile.state.icon as any}
setUserIcon={(v) =>
state.profile.set((s) => (s ? { ...s, icon: v } : undefined))
}
/>
) : (
<RegisterCalloutPart />
)}
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-preferences") && (
<div id="settings-preferences">
<PreferencesPart
language={state.appLanguage.state}
setLanguage={state.appLanguage.set}
enableThumbnails={state.enableThumbnails.state}
setEnableThumbnails={state.enableThumbnails.set}
enableAutoplay={state.enableAutoplay.state}
setEnableAutoplay={state.enableAutoplay.set}
enableSkipCredits={state.enableSkipCredits.state}
setEnableSkipCredits={state.enableSkipCredits.set}
sourceOrder={availableSources}
setSourceOrder={state.sourceOrder.set}
enableSourceOrder={state.enableSourceOrder.state}
setenableSourceOrder={state.enableSourceOrder.set}
enableLastSuccessfulSource={
state.enableLastSuccessfulSource.state
}
userIcon={state.profile.state.icon as any}
setUserIcon={(v) =>
state.profile.set((s) => (s ? { ...s, icon: v } : undefined))
setEnableLastSuccessfulSource={
state.enableLastSuccessfulSource.set
}
disabledSources={state.disabledSources.state}
setDisabledSources={state.disabledSources.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
setEnableLowPerformanceMode={state.enableLowPerformanceMode.set}
enableHoldToBoost={state.enableHoldToBoost.state}
setEnableHoldToBoost={state.enableHoldToBoost.set}
manualSourceSelection={state.manualSourceSelection.state}
setManualSourceSelection={state.manualSourceSelection.set}
enableDoubleClickToSeek={state.enableDoubleClickToSeek.state}
setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set}
/>
) : (
<RegisterCalloutPart />
)}
</div>
<div id="settings-preferences" className="mt-28">
<PreferencesPart
language={state.appLanguage.state}
setLanguage={state.appLanguage.set}
enableThumbnails={state.enableThumbnails.state}
setEnableThumbnails={state.enableThumbnails.set}
enableAutoplay={state.enableAutoplay.state}
setEnableAutoplay={state.enableAutoplay.set}
enableSkipCredits={state.enableSkipCredits.state}
setEnableSkipCredits={state.enableSkipCredits.set}
sourceOrder={availableSources}
setSourceOrder={state.sourceOrder.set}
enableSourceOrder={state.enableSourceOrder.state}
setenableSourceOrder={state.enableSourceOrder.set}
disabledSources={state.disabledSources.state}
setDisabledSources={state.disabledSources.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
setEnableLowPerformanceMode={state.enableLowPerformanceMode.set}
enableHoldToBoost={state.enableHoldToBoost.state}
setEnableHoldToBoost={state.enableHoldToBoost.set}
manualSourceSelection={state.manualSourceSelection.state}
setManualSourceSelection={state.manualSourceSelection.set}
enableDoubleClickToSeek={state.enableDoubleClickToSeek.state}
setEnableDoubleClickToSeek={state.enableDoubleClickToSeek.set}
/>
</div>
<div id="settings-appearance" className="mt-28">
<AppearancePart
active={previewTheme ?? "default"}
inUse={activeTheme ?? "default"}
setTheme={setThemeWithPreview}
enableDiscover={state.enableDiscover.state}
setEnableDiscover={state.enableDiscover.set}
enableFeatured={state.enableFeatured.state}
setEnableFeatured={state.enableFeatured.set}
enableDetailsModal={state.enableDetailsModal.state}
setEnableDetailsModal={state.enableDetailsModal.set}
enableImageLogos={state.enableImageLogos.state}
setEnableImageLogos={state.enableImageLogos.set}
enableCarouselView={state.enableCarouselView.state}
setEnableCarouselView={state.enableCarouselView.set}
forceCompactEpisodeView={state.forceCompactEpisodeView.state}
setForceCompactEpisodeView={state.forceCompactEpisodeView.set}
homeSectionOrder={state.homeSectionOrder.state}
setHomeSectionOrder={state.homeSectionOrder.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
/>
</div>
<div id="settings-captions" className="mt-28">
<CaptionsPart
styling={state.subtitleStyling.state}
setStyling={state.subtitleStyling.set}
/>
</div>
<div id="settings-connection" className="mt-28">
<ConnectionsPart
backendUrl={state.backendUrl.state}
setBackendUrl={state.backendUrl.set}
proxyUrls={state.proxyUrls.state}
setProxyUrls={state.proxyUrls.set}
febboxKey={state.febboxKey.state}
setFebboxKey={state.febboxKey.set}
realDebridKey={state.realDebridKey.state}
setRealDebridKey={state.realDebridKey.set}
proxyTmdb={state.proxyTmdb.state}
setProxyTmdb={state.proxyTmdb.set}
/>
</div>
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-appearance") && (
<div id="settings-appearance">
<AppearancePart
active={previewTheme ?? "default"}
inUse={activeTheme ?? "default"}
setTheme={setThemeWithPreview}
enableDiscover={state.enableDiscover.state}
setEnableDiscover={state.enableDiscover.set}
enableFeatured={state.enableFeatured.state}
setEnableFeatured={state.enableFeatured.set}
enableDetailsModal={state.enableDetailsModal.state}
setEnableDetailsModal={state.enableDetailsModal.set}
enableImageLogos={state.enableImageLogos.state}
setEnableImageLogos={state.enableImageLogos.set}
enableCarouselView={state.enableCarouselView.state}
setEnableCarouselView={state.enableCarouselView.set}
forceCompactEpisodeView={state.forceCompactEpisodeView.state}
setForceCompactEpisodeView={state.forceCompactEpisodeView.set}
homeSectionOrder={state.homeSectionOrder.state}
setHomeSectionOrder={state.homeSectionOrder.set}
enableLowPerformanceMode={state.enableLowPerformanceMode.state}
/>
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-captions") && (
<div id="settings-captions">
<CaptionsPart
styling={state.subtitleStyling.state}
setStyling={state.subtitleStyling.set}
/>
</div>
)}
{(searchQuery.trim() ||
!selectedCategory ||
selectedCategory === "settings-connection") && (
<div id="settings-connection">
<ConnectionsPart
backendUrl={state.backendUrl.state}
setBackendUrl={state.backendUrl.set}
proxyUrls={state.proxyUrls.state}
setProxyUrls={state.proxyUrls.set}
febboxKey={state.febboxKey.state}
setFebboxKey={state.febboxKey.set}
realDebridKey={state.realDebridKey.state}
setRealDebridKey={state.realDebridKey.set}
proxyTmdb={state.proxyTmdb.state}
setProxyTmdb={state.proxyTmdb.set}
/>
</div>
)}
</SettingsLayout>
<Transition
animation="fade"

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,34 +17,84 @@ export function CategoryButtons({
isMobile,
showAlwaysScroll,
}: CategoryButtonsProps) {
const renderScrollButton = (direction: "left" | "right") => (
<div>
<button
type="button"
className="flex items-center rounded-full px-4 text-white py-3"
onClick={() => {
const element = document.getElementById(
`button-carousel-${categoryType}`,
);
if (element) {
element.scrollBy({
left: direction === "left" ? -200 : 200,
behavior: "smooth",
});
}
}}
>
<Icon
icon={direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT}
className="text-2xl rtl:-scale-x-100"
/>
</button>
</div>
);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const element = document.getElementById(`button-carousel-${categoryType}`);
if (!element) {
setCanScrollLeft(false);
setCanScrollRight(false);
return;
}
const { scrollLeft, scrollWidth, clientWidth } = element;
const isAtStart = scrollLeft <= 1;
const isAtEnd = scrollLeft + clientWidth >= scrollWidth - 1;
setCanScrollLeft(!isAtStart);
setCanScrollRight(!isAtEnd);
}, [categoryType]);
useEffect(() => {
const element = document.getElementById(`button-carousel-${categoryType}`);
if (!element) return;
updateScrollState();
element.addEventListener("scroll", updateScrollState);
window.addEventListener("resize", updateScrollState);
return () => {
element.removeEventListener("scroll", updateScrollState);
window.removeEventListener("resize", updateScrollState);
};
}, [categoryType, updateScrollState]);
useEffect(() => {
const timeoutId = setTimeout(() => {
updateScrollState();
}, 0);
return () => clearTimeout(timeoutId);
}, [categories, categoryType, updateScrollState]);
const renderScrollButton = (direction: "left" | "right") => {
const shouldShow = direction === "left" ? canScrollLeft : canScrollRight;
if (!shouldShow && !showAlwaysScroll && !isMobile) return null;
return (
<div>
<button
type="button"
className="flex items-center rounded-full px-4 text-white py-3"
onClick={() => {
const element = document.getElementById(
`button-carousel-${categoryType}`,
);
if (element) {
element.scrollBy({
left: direction === "left" ? -200 : 200,
behavior: "smooth",
});
}
}}
>
<Icon
icon={
direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT
}
className="text-2xl rtl:-scale-x-100"
/>
</button>
</div>
);
};
return (
<div className="flex overflow-x-auto">
{(showAlwaysScroll || isMobile) && renderScrollButton("left")}
{(showAlwaysScroll || isMobile || canScrollLeft) &&
renderScrollButton("left")}
<div
id={`button-carousel-${categoryType}`}
@ -62,7 +114,8 @@ export function CategoryButtons({
</div>
</div>
{(showAlwaysScroll || isMobile) && renderScrollButton("right")}
{(showAlwaysScroll || isMobile || canScrollRight) &&
renderScrollButton("right")}
</div>
);
}

View file

@ -115,9 +115,11 @@ export function MediaCarousel({
}));
// Set up intersection observer for lazy loading
const { targetRef, isIntersecting } = useIntersectionObserver({
rootMargin: "300px",
});
const { targetRef, isIntersecting, hasIntersected } = useIntersectionObserver(
{
rootMargin: "300px",
},
);
// Handle provider/genre selection
const handleProviderChange = React.useCallback((id: string, name: string) => {
@ -195,7 +197,7 @@ export function MediaCarousel({
content.type,
]);
// Fetch media using our hook
// Fetch media using our hook - only when carousel has been visible
const { media, sectionTitle } = useDiscoverMedia({
contentType,
mediaType,
@ -205,6 +207,7 @@ export function MediaCarousel({
providerName: selectedProviderName,
mediaTitle: selectedRecommendationTitle,
isCarouselView: true,
enabled: hasIntersected,
});
// Find active button

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);
}
fetchMedia();
}, [fetchMedia, contentType, currentContentType, page, id]);
// Only fetch when enabled
if (enabled) {
fetchMedia();
}
}, [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 (

Some files were not shown because too many files have changed in this diff Show more