feat: initial commit
fix: fmt Signed-off-by: pochoclin <hey@popcorntime.app>
This commit is contained in:
8
.cargo/config.toml
Normal file
8
.cargo/config.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[env]
|
||||
GRAPHQL_SERVER = "https://api.popcorntime.app"
|
||||
AUTH_SERVER = "https://accounts.popcorntime.app"
|
||||
CLIENT_ID = "dc855eea-1421-466b-9cd0-166da403d004" # Public DEV client ID
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
target-dir = "target"
|
||||
95
.github/CONTRIBUTING.md
vendored
Normal file
95
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
## Popcorn Time Contributing Guide
|
||||
|
||||
Hi! Thanks for checking out Popcorn Time. We’re excited you want to contribute. Please take a minute to read through our Code of Conduct and this guide before diving in.
|
||||
|
||||
- [Issue Reporting Guidelines](#issue-reporting-guidelines)
|
||||
- [Pull Request Guidelines](#pull-request-guidelines)
|
||||
- [Development Guide](#development-guide)
|
||||
|
||||
---
|
||||
|
||||
### Issue Reporting Guidelines
|
||||
|
||||
- The issue list of this repo is **exclusively** for bug reports and feature requests. Non-conforming issues will be closed immediately.
|
||||
|
||||
- If you have a question, you can get quick answers from the [Tauri Discord chat](https://discord.gg/SpmNs4S).
|
||||
|
||||
- Try to search for your issue, it may have already been answered or even fixed in the development branch (`dev`).
|
||||
|
||||
- Check if the issue is reproducible with the latest stable version of Popcorn Time. If you are using a nightly, please indicate the specific version you are using.
|
||||
|
||||
- It is **required** that you clearly describe the steps necessary to reproduce the issue you are running into. Although we would love to help our users as much as possible, diagnosing issues without clear reproduction steps is extremely time-consuming and simply not sustainable.
|
||||
|
||||
- Issues with no clear repro steps will not be triaged. If an issue labeled "need repro" receives no further input from the issue author for more than 5 days, it will be closed.
|
||||
|
||||
---
|
||||
|
||||
### Pull Request Guidelines
|
||||
|
||||
- Open PRs against the dev branch.
|
||||
- Keep PRs small and focused.
|
||||
- Squash merges are used, so multiple commits in a PR are fine.
|
||||
- For new features:
|
||||
- Open an issue first so the idea can be discussed.
|
||||
- For bug fixes:
|
||||
- Reference issues with fix: … (fix #1234) in your PR title.
|
||||
- Explain the bug and how your fix solves it.
|
||||
- CI must pass before merging (TS type-check, tests, Rust fmt/clippy).
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- Builds locally (`pnpm dev`)
|
||||
- pnpm type-check passes
|
||||
- pnpm test passes (if applicable)
|
||||
- cargo fmt + cargo clippy clean (if Rust touched)
|
||||
- Screenshots/GIFs for UI changes
|
||||
|
||||
---
|
||||
|
||||
### Development Guide
|
||||
|
||||
_Requirements:_
|
||||
|
||||
- Node.js 20+, pnpm 10+
|
||||
- Rust stable
|
||||
- Tauri prerequisites (see https://tauri.app/start/prerequisites/)
|
||||
|
||||
To set up your machine for development, follow the [Tauri setup guide](https://v2.tauri.app/start/prerequisites/) to get all the tools you need to develop Tauri apps. The only additional tool you may need is [PNPM](https://pnpm.io/).
|
||||
|
||||
Next, [fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) and clone [this repository](https://github.com/popcorntime/popcorntime).
|
||||
|
||||
The development process varies depending on what part of Popcorn Time you are contributing.
|
||||
|
||||
```
|
||||
git clone https://github.com/popcorntime/popcorntime.git
|
||||
cd popcorntime
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Repo Layout
|
||||
|
||||
- `apps/desktop` - Tauri desktop app built with react-router)
|
||||
- `packages/*` — Shared TypeScript packages (UI, configs, i18n, etc.)
|
||||
- `crates/*` — Rust crates (error handling, GraphQL, tauri bindings, etc.)
|
||||
|
||||
---
|
||||
|
||||
### Code Style
|
||||
|
||||
- Use clear commit messages (feat:, fix:, refactor: are welcome but not enforced).
|
||||
- Keep code typed, documented, and formatted.
|
||||
- Avoid duplication — prefer shared packages/crates.
|
||||
|
||||
---
|
||||
|
||||
### Questions?
|
||||
|
||||
- Open a Discussion or Draft PR.
|
||||
- Faster feedback is better than a “perfect” PR!
|
||||
|
||||
---
|
||||
|
||||
### Financial Contribution
|
||||
|
||||
Popcorn Time is MIT-licensed and community-driven. If you’d like to support the work, stay tuned for sponsorship options coming soon.
|
||||
1
.github/FUNDING.yaml
vendored
Normal file
1
.github/FUNDING.yaml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: popcorntime
|
||||
10
.github/actions/init-env-linux/action.yaml
vendored
Normal file
10
.github/actions/init-env-linux/action.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
name: init-env-linux
|
||||
description: Prepare Linux environment for tauri
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends libgtk-3-dev webkit2gtk-4.1 libayatana-appindicator3-dev
|
||||
13
.github/actions/init-env-node/action.yaml
vendored
Normal file
13
.github/actions/init-env-node/action.yaml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
name: init-env-node
|
||||
description: Prepare Node.js environment
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
cache: pnpm
|
||||
node-version-file: ".nvmrc"
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: pnpm install
|
||||
48
.github/workflows/lint-rust.yaml
vendored
Normal file
48
.github/workflows/lint-rust.yaml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Lint Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/lint-rust.yml"
|
||||
- "crates/**"
|
||||
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/init-env-linux
|
||||
|
||||
- name: install rust stable and clippy
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
rustfmt:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: install Rust stable and rustfmt
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: run cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
31
.github/workflows/lint-ts.yaml
vendored
Normal file
31
.github/workflows/lint-ts.yaml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Lint TS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/lint-ts.yml"
|
||||
- "apps/**"
|
||||
- "packages/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/init-env-node
|
||||
- run: pnpm type-check
|
||||
|
||||
format-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/init-env-node
|
||||
- run: pnpm format-check
|
||||
12
.github/workflows/publish.include.txt
vendored
Normal file
12
.github/workflows/publish.include.txt
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
*/
|
||||
*.msi
|
||||
*.dmg
|
||||
*.tar.gz
|
||||
*.deb
|
||||
*.rpm
|
||||
*.sig
|
||||
*.exe
|
||||
*.app
|
||||
*.nsis
|
||||
*.zip
|
||||
*.AppImage
|
||||
192
.github/workflows/publish.yaml
vendored
Normal file
192
.github/workflows/publish.yaml
vendored
Normal file
@@ -0,0 +1,192 @@
|
||||
name: "Publish"
|
||||
on:
|
||||
# schedule:
|
||||
# every day at 3am
|
||||
#- cron: "0 3 * * *"
|
||||
workflow_run:
|
||||
workflows: ["Nightly build"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
type: choice
|
||||
required: true
|
||||
description: channel
|
||||
default: nightly
|
||||
options:
|
||||
- nightly
|
||||
bump:
|
||||
type: choice
|
||||
required: true
|
||||
description: update type
|
||||
default: patch
|
||||
options:
|
||||
- undefined
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
jobs:
|
||||
build-tauri:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- macos-13 # [macOs, x64]
|
||||
- macos-latest # [macOs, ARM64]
|
||||
- ubuntu-24.04 # [linux, x64]
|
||||
- windows-latest # [windows, x64]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
outputs:
|
||||
platform: ${{ matrix.platform }}
|
||||
channel: ${{ env.channel }}
|
||||
version: ${{ env.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT_POCHOCLIN }} # custom token here so that we can push tags later
|
||||
- uses: ./.github/actions/init-env-node
|
||||
- uses: ./.github/actions/init-env-linux
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Consume input variables
|
||||
shell: bash
|
||||
if: ${{ !github.event.workflow_run }}
|
||||
run: |
|
||||
echo "channel=${{ github.event.inputs.channel || 'nightly' }}" >> $GITHUB_ENV
|
||||
echo "bump=${{ github.event.inputs.bump || 'patch' }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Calculate next version
|
||||
shell: bash
|
||||
run: |
|
||||
CURRENT_VERSION="$(curl --silent "https://updates.popcorntime.app/${{ env.channel }}" | jq -r '.version')"
|
||||
NEXT_VERSION=$(./scripts/next.sh "${CURRENT_VERSION}" "${{ env.bump }}")
|
||||
echo "version=$NEXT_VERSION" >> $GITHUB_ENV
|
||||
mkdir -p release && echo "$NEXT_VERSION" > release/version
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
run: |
|
||||
./scripts/release.sh \
|
||||
--sign \
|
||||
--channel "${{ env.channel }}" \
|
||||
--dist "./release" \
|
||||
--version "${{ env.version }}"
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
CLIENT_ID: ${{ secrets.CLIENT_ID }}
|
||||
AUTH_SERVER: ${{ secrets.AUTH_SERVER }}
|
||||
GRAPHQL_SERVER: ${{ secrets.GRAPHQL_SERVER }}
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: "${{ env.channel }}-${{ matrix.platform }}-${{ github.run_number }}"
|
||||
path: release/
|
||||
if-no-files-found: error
|
||||
|
||||
create-git-tag:
|
||||
needs: [build-tauri]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT_POCHOCLIN }} # custom token here so that we can push tags later
|
||||
- name: Create git tag
|
||||
shell: bash
|
||||
env:
|
||||
TAG_NAME: "${{ needs.build-tauri.outputs.channel }}/${{ needs.build-tauri.outputs.version }}"
|
||||
run: |
|
||||
function tag_exists() {
|
||||
git tag --list | grep -q "^$1$"
|
||||
}
|
||||
function fetch_tag() {
|
||||
git fetch origin "refs/tags/$1:refs/tags/$1"
|
||||
}
|
||||
function delete_tag() {
|
||||
git push --delete origin "$1"
|
||||
}
|
||||
function create_tag() {
|
||||
git tag --force "$1"
|
||||
git push --tags
|
||||
}
|
||||
|
||||
fetch_tag "$TAG_NAME" || true
|
||||
if tag_exists "$TAG_NAME"; then
|
||||
delete_tag "$TAG_NAME"
|
||||
fi
|
||||
create_tag "$TAG_NAME"
|
||||
|
||||
upload-releases:
|
||||
needs: [build-tauri]
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ env.version }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-private-repositories
|
||||
platform:
|
||||
- macos-13 # [macOs, x64]
|
||||
- macos-latest # [macOs, ARM64]
|
||||
- ubuntu-24.04 # [linux, x64]
|
||||
- windows-latest # [windows, x64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT_POCHOCLIN }} # custom token here so that we can push tags later
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: "${{ needs.build-tauri.outputs.channel }}-${{ matrix.platform }}-${{ github.run_number }}"
|
||||
path: release
|
||||
- name: Extract version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="$(cat release/version)"
|
||||
echo "version=$VERSION" >> $GITHUB_ENV
|
||||
- name: Prepare R2 payload
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf release-r2
|
||||
mkdir -p release-r2
|
||||
rsync -avE --prune-empty-dirs --include-from='.github/workflows/publish.include.txt' --exclude='*' release/ release-r2/
|
||||
bash scripts/normalize-spaces.sh ./release-r2
|
||||
- uses: ryand56/r2-upload-action@latest
|
||||
name: Upload To R2
|
||||
id: R2
|
||||
with:
|
||||
r2-account-id: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
r2-bucket: ${{ secrets.R2_BUCKET }}
|
||||
source-dir: "release-r2/"
|
||||
destination-dir: "${{ needs.build-tauri.outputs.channel }}/${{ env.version }}-${{ github.run_number }}"
|
||||
|
||||
promote-tauri:
|
||||
needs: [build-tauri, upload-releases]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify updater API of new release
|
||||
shell: bash
|
||||
run: |
|
||||
curl 'https://updates.popcorntime.app' \
|
||||
--fail \
|
||||
--request POST \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header 'Authorization: Bearer ${{ secrets.UPDATER_AUTH_TOKEN }}' \
|
||||
--data '{"channel":"${{ needs.build-tauri.outputs.channel }}","version":"${{ needs.build-tauri.outputs.version }}","index": ${{ github.run_number }}}'
|
||||
24
.github/workflows/test-ts.yaml
vendored
Normal file
24
.github/workflows/test-ts.yaml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Test TS
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/test-ts.yml"
|
||||
- "apps/**"
|
||||
- "packages/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
vitest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/init-env-node
|
||||
- run: pnpm test
|
||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
/target/
|
||||
release/
|
||||
.rust-analyzer/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.turbo
|
||||
.env
|
||||
.vite
|
||||
3
.rustfmt.toml
Normal file
3
.rustfmt.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
max_width = 100
|
||||
tab_spaces = 2
|
||||
edition = "2021"
|
||||
1
.taurignore
Normal file
1
.taurignore
Normal file
@@ -0,0 +1 @@
|
||||
!crates/*
|
||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer",
|
||||
"biomejs.biome",
|
||||
"lokalise.i18n-ally"
|
||||
]
|
||||
}
|
||||
7256
Cargo.lock
generated
Normal file
7256
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
Normal file
62
Cargo.toml
Normal file
@@ -0,0 +1,62 @@
|
||||
[workspace]
|
||||
members = ["crates/popcorntime-*", "crates/popcorntime-graphql-client/macros"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
authors = ["Popcorn Time <hello@popcorntime.app>"]
|
||||
edition = "2021"
|
||||
version = "0.0.1"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.99"
|
||||
thiserror = "2.0.16"
|
||||
serde = { version = "1.0.223", features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
tokio = { version = "1.47.1", default-features = false }
|
||||
tokio-util = "0.7.16"
|
||||
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = [
|
||||
"time",
|
||||
"json",
|
||||
"env-filter",
|
||||
] }
|
||||
|
||||
reqwest = "0.12.23"
|
||||
url = "2.5.7"
|
||||
time = "0.3.43"
|
||||
jsonwebtoken = "9.3.1"
|
||||
|
||||
tauri = "2.8.5"
|
||||
tauri-build = "2.4.1"
|
||||
tauri-plugin-opener = "2.5.0"
|
||||
tauri-plugin-log = "2.7.0"
|
||||
tauri-plugin-shell = "2.3.1"
|
||||
tauri-plugin-fs = "2.4.2"
|
||||
tauri-plugin-deep-link = "2.4.3"
|
||||
tauri-plugin-updater = "2.9.0"
|
||||
tauri-plugin-process = "2.3.0"
|
||||
tauri-plugin-single-instance = "2.3.4"
|
||||
|
||||
futures-util = "0.3"
|
||||
toml = "0.9.5"
|
||||
async-graphql = { version = "7.0.11", features = [
|
||||
"chrono",
|
||||
"time",
|
||||
"dataloader",
|
||||
] }
|
||||
async-graphql-poem = "7.0.11"
|
||||
poem = "3.1.12"
|
||||
uuid = "1.18.1"
|
||||
convert_case = "0.8.0"
|
||||
|
||||
objc2 = "0.6.2"
|
||||
objc2-foundation = "0.3.1"
|
||||
objc2-app-kit = { version = "0.3.1", features = ["NSView"] }
|
||||
|
||||
popcorntime-session = { path = "crates/popcorntime-session" }
|
||||
popcorntime-error = { path = "crates/popcorntime-error" }
|
||||
popcorntime-graphql-client = { path = "crates/popcorntime-graphql-client" }
|
||||
popcorntime-graphql-macros = { path = "crates/popcorntime-graphql-client/macros" }
|
||||
popcorntime-tauri-trafficlights = { path = "crates/popcorntime-tauri-trafficlights" }
|
||||
popcorntime-tauri-splash = { path = "crates/popcorntime-tauri-splash" }
|
||||
1
apps/desktop/.gitignore
vendored
Normal file
1
apps/desktop/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.react-router/
|
||||
20
apps/desktop/components.json
Normal file
20
apps/desktop/components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/popcorntime-ui/src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib",
|
||||
"utils": "@popcorntime/ui/lib/utils",
|
||||
"ui": "@popcorntime/ui/components"
|
||||
}
|
||||
}
|
||||
4
apps/desktop/eslint.config.js
Normal file
4
apps/desktop/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { config } from "@popcorntime/eslint-config/react-internal";
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default config;
|
||||
13
apps/desktop/index.html
Normal file
13
apps/desktop/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full antialiased">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Popcorn Time</title>
|
||||
</head>
|
||||
|
||||
<body class="flex min-h-full">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
72
apps/desktop/package.json
Normal file
72
apps/desktop/package.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "@popcorntime/desktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit",
|
||||
"redux-devtools": "redux-devtools --hostname=localhost --port=8000 --remote --open",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "catalog:",
|
||||
"@popcorntime/graphql": "workspace:*",
|
||||
"@popcorntime/ui": "workspace:*",
|
||||
"@tauri-apps/api": "catalog:",
|
||||
"@tauri-apps/plugin-fs": "catalog:",
|
||||
"@tauri-apps/plugin-log": "catalog:",
|
||||
"@tauri-apps/plugin-opener": "catalog:",
|
||||
"@tauri-apps/plugin-process": "catalog:",
|
||||
"@tauri-apps/plugin-shell": "catalog:",
|
||||
"@tauri-apps/plugin-updater": "catalog:",
|
||||
"flag-icons": "catalog:",
|
||||
"fuse.js": "catalog:",
|
||||
"i18next": "catalog:",
|
||||
"i18next-resources-to-backend": "catalog:",
|
||||
"immer": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-fast-compare": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"react-i18next": "catalog:",
|
||||
"react-infinite-scroll-hook": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
"rtl-detect": "catalog:",
|
||||
"sonner": "catalog:",
|
||||
"typewriter-effect": "^2.22.0",
|
||||
"use-debounce": "^10.0.6",
|
||||
"vault66-crt-effect": "^1.4.0",
|
||||
"zod": "catalog:",
|
||||
"zustand": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@popcorntime/i18n": "workspace:*",
|
||||
"@popcorntime/typescript-config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/rtl-detect": "catalog:",
|
||||
"@types/socketcluster-client": "^19.1.0",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"postcss": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@redux-devtools/cli": "^4.0.3",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"socketcluster-client": "^19.2.7",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
}
|
||||
}
|
||||
1
apps/desktop/postcss.config.mjs
Normal file
1
apps/desktop/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@popcorntime/ui/postcss.config";
|
||||
5
apps/desktop/prettier.config.cjs
Normal file
5
apps/desktop/prettier.config.cjs
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
BIN
apps/desktop/public/audios/intro.m4a
Normal file
BIN
apps/desktop/public/audios/intro.m4a
Normal file
Binary file not shown.
142
apps/desktop/public/audios/intro.vtt
Normal file
142
apps/desktop/public/audios/intro.vtt
Normal file
@@ -0,0 +1,142 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.797 --> 00:02.473
|
||||
In 2013
|
||||
|
||||
00:02.473 --> 00:03.750
|
||||
Streaming was broken
|
||||
|
||||
00:04.627 --> 00:05.744
|
||||
Choices were limited
|
||||
|
||||
00:05.744 --> 00:07.340
|
||||
Access was restricted
|
||||
|
||||
00:08.297 --> 00:09.335
|
||||
Popcorn Time
|
||||
|
||||
00:09.335 --> 00:11.888
|
||||
Was born out of that frustration
|
||||
|
||||
00:12.765 --> 00:14.840
|
||||
A revolutionary platform
|
||||
|
||||
00:14.840 --> 00:17.154
|
||||
That made entertainment accessible
|
||||
|
||||
00:17.154 --> 00:18.670
|
||||
To everyone
|
||||
|
||||
00:19.867 --> 00:22.260
|
||||
We disrupted the industry
|
||||
|
||||
00:22.260 --> 00:23.457
|
||||
Forcing it to evolve
|
||||
|
||||
00:24.494 --> 00:26.648
|
||||
Today in 2025
|
||||
|
||||
00:26.648 --> 00:29.202
|
||||
The world is over flowing with content
|
||||
|
||||
00:30.079 --> 00:30.797
|
||||
Netflix
|
||||
|
||||
00:30.797 --> 00:31.356
|
||||
Prime
|
||||
|
||||
00:31.356 --> 00:32.154
|
||||
Disney Plus
|
||||
|
||||
00:32.154 --> 00:33.111
|
||||
Crunchyroll
|
||||
|
||||
00:33.111 --> 00:33.750
|
||||
MUBI
|
||||
|
||||
00:33.750 --> 00:34.547
|
||||
Crave
|
||||
|
||||
00:34.547 --> 00:35.665
|
||||
Endless choices
|
||||
|
||||
00:36.941 --> 00:37.815
|
||||
But with choice
|
||||
|
||||
00:37.819 --> 00:40.132
|
||||
Comes a new problem
|
||||
|
||||
00:40.132 --> 00:41.250
|
||||
Endless scrolling
|
||||
|
||||
00:42.047 --> 00:44.749
|
||||
We returns not as pirates
|
||||
|
||||
00:44.749 --> 00:45.957
|
||||
But as pioneers
|
||||
|
||||
00:47.792 --> 00:50.984
|
||||
A complete rewrite from scratch
|
||||
|
||||
00:51.781 --> 00:52.260
|
||||
Safer
|
||||
|
||||
00:52.260 --> 00:53.058
|
||||
Simpler
|
||||
|
||||
00:53.058 --> 00:53.297
|
||||
Smarter
|
||||
|
||||
00:54.813 --> 00:57.446
|
||||
We index the streaming universe
|
||||
|
||||
00:58.723 --> 01:00.398
|
||||
We rank what matters
|
||||
|
||||
01:02.074 --> 01:03.829
|
||||
We bring global support
|
||||
|
||||
01:03.829 --> 01:05.345
|
||||
With local relevance
|
||||
|
||||
01:06.941 --> 01:10.053
|
||||
We include a full media suite
|
||||
|
||||
01:10.053 --> 01:12.127
|
||||
Automation powered by AI
|
||||
|
||||
01:13.803 --> 01:15.638
|
||||
Play locally
|
||||
|
||||
01:15.638 --> 01:17.473
|
||||
Cast to your devices
|
||||
|
||||
01:18.271 --> 01:21.223
|
||||
Download for offline viewing
|
||||
|
||||
01:23.138 --> 01:24.255
|
||||
Everything open source
|
||||
|
||||
01:26.648 --> 01:30.079
|
||||
Our mission is the same as day one
|
||||
|
||||
01:31.436 --> 01:32.553
|
||||
Make entertainment
|
||||
|
||||
01:32.553 --> 01:35.664
|
||||
Accessible to everyone
|
||||
|
||||
01:35.664 --> 01:36.622
|
||||
Everywhere
|
||||
|
||||
01:37.659 --> 01:38.696
|
||||
Welcome back
|
||||
|
||||
01:38.696 --> 01:39.893
|
||||
Popcorn Time
|
||||
|
||||
01:41.888 --> 01:42.925
|
||||
The revolution
|
||||
|
||||
01:42.925 --> 01:45.585
|
||||
Has only begun
|
||||
45
apps/desktop/src/__mocks__/zustand.ts
Normal file
45
apps/desktop/src/__mocks__/zustand.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { act } from "@testing-library/react";
|
||||
import type * as ZustandExportedTypes from "zustand";
|
||||
|
||||
export * from "zustand";
|
||||
|
||||
const { create: actualCreate, createStore: actualCreateStore } =
|
||||
await vi.importActual<typeof ZustandExportedTypes>("zustand");
|
||||
|
||||
export const storeResetFns = new Set<() => void>();
|
||||
|
||||
const createUncurried = <T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
|
||||
const store = actualCreate(stateCreator);
|
||||
const initialState = store.getInitialState();
|
||||
storeResetFns.add(() => {
|
||||
store.setState(initialState, true);
|
||||
});
|
||||
return store;
|
||||
};
|
||||
|
||||
export const create = (<T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
|
||||
return typeof stateCreator === "function" ? createUncurried(stateCreator) : createUncurried;
|
||||
}) as typeof ZustandExportedTypes.create;
|
||||
|
||||
const createStoreUncurried = <T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
|
||||
const store = actualCreateStore(stateCreator);
|
||||
const initialState = store.getInitialState();
|
||||
storeResetFns.add(() => {
|
||||
store.setState(initialState, true);
|
||||
});
|
||||
return store;
|
||||
};
|
||||
|
||||
export const createStore = (<T>(stateCreator: ZustandExportedTypes.StateCreator<T>) => {
|
||||
return typeof stateCreator === "function"
|
||||
? createStoreUncurried(stateCreator)
|
||||
: createStoreUncurried;
|
||||
}) as typeof ZustandExportedTypes.createStore;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
storeResetFns.forEach(resetFn => {
|
||||
resetFn();
|
||||
});
|
||||
});
|
||||
});
|
||||
49
apps/desktop/src/app.tsx
Normal file
49
apps/desktop/src/app.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router";
|
||||
import { useErrorHandler } from "@/hooks/useErrorHandler";
|
||||
import { initReactI18n } from "@/i18n";
|
||||
import { BrowseLayout, DefaultLayout } from "@/layout";
|
||||
import { Providers } from "@/providers";
|
||||
import { BrowseRoute } from "@/routes/browse";
|
||||
import { LoginRoute } from "@/routes/login";
|
||||
import { MaintenanceRoute } from "@/routes/maintenance";
|
||||
import { NotFoundRoute } from "@/routes/not-found";
|
||||
import {
|
||||
OnboardingManifestRoute,
|
||||
OnboardingTimelineRoute,
|
||||
OnboardingWelcomeRoute,
|
||||
} from "@/routes/onboarding";
|
||||
import { SplashRoute } from "@/routes/splash";
|
||||
|
||||
import "@/css/styles.css";
|
||||
import "@popcorntime/ui/styles.css";
|
||||
import "flag-icons/css/flag-icons.min.css";
|
||||
|
||||
initReactI18n();
|
||||
|
||||
export function App() {
|
||||
useErrorHandler();
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<Providers>
|
||||
<Routes>
|
||||
<Route element={<DefaultLayout />}>
|
||||
<Route index element={<SplashRoute />} />
|
||||
<Route path="/onboarding">
|
||||
<Route index element={<OnboardingWelcomeRoute />} />
|
||||
<Route path="/onboarding/manifest" element={<OnboardingManifestRoute />} />
|
||||
<Route path="/onboarding/timeline" element={<OnboardingTimelineRoute />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginRoute />} />
|
||||
<Route path="/maintenance" element={<MaintenanceRoute />} />
|
||||
<Route path="*" element={<NotFoundRoute />} />
|
||||
</Route>
|
||||
<Route path="/browse/:country/:kind" element={<BrowseLayout />}>
|
||||
<Route index element={<BrowseRoute />} />
|
||||
</Route>
|
||||
<Route path="/browse/:country" element={<Navigate to="movie" replace />} />
|
||||
</Routes>
|
||||
</Providers>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
BIN
apps/desktop/src/assets/logo.png
Normal file
BIN
apps/desktop/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/desktop/src/assets/logo_grayscale.png
Normal file
BIN
apps/desktop/src/assets/logo_grayscale.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
3
apps/desktop/src/assets/placeholder.svg
Normal file
3
apps/desktop/src/assets/placeholder.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#1e293b" className="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 509 B |
3
apps/desktop/src/assets/provider.svg
Normal file
3
apps/desktop/src/assets/provider.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#fff" className="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0 1 18 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0 1 18 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 0 1 6 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
25
apps/desktop/src/components/app-sidebar.tsx
Normal file
25
apps/desktop/src/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@popcorntime/ui/components/sidebar";
|
||||
import * as React from "react";
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { groups, header, footer, open } = useSidebar();
|
||||
return open ? (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
{header}
|
||||
<SidebarContent>
|
||||
{groups.map((group, idx) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static group
|
||||
<React.Fragment key={idx}>{group}</React.Fragment>
|
||||
))}
|
||||
</SidebarContent>
|
||||
{footer}
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
) : null;
|
||||
}
|
||||
294
apps/desktop/src/components/browse/sidebar.tsx
Normal file
294
apps/desktop/src/components/browse/sidebar.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { Genre, MediaKind, WatchPriceType } from "@popcorntime/graphql/types";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import { Checkbox } from "@popcorntime/ui/components/checkbox";
|
||||
import { Label } from "@popcorntime/ui/components/label";
|
||||
import {
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
useSidebarFooter,
|
||||
useSidebarHeader,
|
||||
} from "@popcorntime/ui/components/sidebar";
|
||||
import { defaultFilters, type Filters } from "@popcorntime/ui/lib/medias";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Film, Podcast, Tv, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
function compareArrayDiff<T>(a: T[], b: T[]) {
|
||||
return a.filter(v => !b.includes(v)).length + b.filter(v => !a.includes(v)).length;
|
||||
}
|
||||
|
||||
export function BrowseSidebarGroup() {
|
||||
const { t } = useTranslation();
|
||||
const providers = useGlobalStore(state => state.providers.providers);
|
||||
const args = useGlobalStore(useShallow(state => state.browse.args));
|
||||
const setArgs = useGlobalStore(useShallow(state => state.browse.setArgs));
|
||||
const [filters, setFilters] = useState<Filters>(defaultFilters);
|
||||
const initialFilters = useRef<Filters | null>(null);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters(defaultFilters);
|
||||
}, []);
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
const initial = initialFilters.current;
|
||||
if (!initial) return 0;
|
||||
|
||||
let count = 0;
|
||||
|
||||
count += compareArrayDiff(filters.genres, initial.genres);
|
||||
count += compareArrayDiff(filters.providers, initial.providers);
|
||||
count += compareArrayDiff(filters.kind, initial.kind);
|
||||
count += compareArrayDiff(filters.prices, initial.prices);
|
||||
|
||||
return count;
|
||||
}, [filters]);
|
||||
|
||||
const defaultFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
|
||||
count += compareArrayDiff(filters.genres, defaultFilters.genres);
|
||||
count += compareArrayDiff(filters.providers, defaultFilters.providers);
|
||||
count += compareArrayDiff(filters.kind, defaultFilters.kind);
|
||||
count += compareArrayDiff(filters.prices, defaultFilters.prices);
|
||||
|
||||
return count;
|
||||
}, [filters]);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(kind: MediaKind) => {
|
||||
const newType = filters.kind.includes(kind)
|
||||
? filters.kind.filter(t => t !== kind)
|
||||
: [...filters.kind, kind];
|
||||
|
||||
if (newType.length === 0) {
|
||||
toast.error("Please select at least one content type");
|
||||
return;
|
||||
}
|
||||
|
||||
setFilters({
|
||||
...filters,
|
||||
kind: newType,
|
||||
});
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
const handleGenreChange = useCallback(
|
||||
(genre: Genre) => {
|
||||
const newGenres = filters.genres.includes(genre)
|
||||
? filters.genres.filter(g => g !== genre)
|
||||
: [...filters.genres, genre];
|
||||
|
||||
setFilters({
|
||||
...filters,
|
||||
genres: newGenres,
|
||||
});
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
const handleProvidersChange = useCallback(
|
||||
(provider: string) => {
|
||||
const newProviders = filters.providers.includes(provider)
|
||||
? filters.providers.filter(p => p !== provider)
|
||||
: [...filters.providers, provider];
|
||||
|
||||
setFilters({
|
||||
...filters,
|
||||
providers: newProviders,
|
||||
});
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
const handlePriceChange = useCallback(
|
||||
(priceType: WatchPriceType) => {
|
||||
const newPriceTypes = filters.prices.includes(priceType)
|
||||
? filters.prices.filter(p => p !== priceType)
|
||||
: [...filters.prices, priceType];
|
||||
|
||||
setFilters({
|
||||
...filters,
|
||||
prices: newPriceTypes,
|
||||
});
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const parsedFilters = {
|
||||
genres: args?.genres ?? [],
|
||||
kind: args?.kind ? [args.kind] : defaultFilters.kind,
|
||||
prices: args?.priceTypes ?? [],
|
||||
providers: args?.providers ?? [],
|
||||
};
|
||||
setFilters(parsedFilters);
|
||||
initialFilters.current = parsedFilters;
|
||||
}, [args]);
|
||||
|
||||
const applyFilters = useCallback(() => {
|
||||
setArgs({
|
||||
...args,
|
||||
genres: filters.genres,
|
||||
kind: filters.kind.length === 1 ? filters.kind[0] : undefined,
|
||||
priceTypes: filters.prices,
|
||||
providers: filters.providers,
|
||||
});
|
||||
}, [args, filters, setArgs]);
|
||||
|
||||
useSidebarHeader(
|
||||
useMemo(
|
||||
() => (
|
||||
<SidebarHeader className="border-b group-data-[collapsible=icon]:hidden">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h2 className="text-lg font-semibold">Filters</h2>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetFilters}
|
||||
className={cn("text-muted-foreground", defaultFilterCount === 0 && "opacity-0")}
|
||||
>
|
||||
<X />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
),
|
||||
[resetFilters, defaultFilterCount]
|
||||
)
|
||||
);
|
||||
|
||||
useSidebarFooter(
|
||||
useMemo(
|
||||
() => (
|
||||
<SidebarFooter className="border-t p-4 group-data-[collapsible=icon]:hidden">
|
||||
<Button onClick={applyFilters} disabled={activeFilterCount === 0} className="w-full">
|
||||
Apply Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-primary-foreground text-primary ml-2 rounded-full px-2 py-0.5 text-xs">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</SidebarFooter>
|
||||
),
|
||||
[activeFilterCount, applyFilters]
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarMenu>
|
||||
<SidebarGroupLabel>Content Type</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenuItem className="space-y-2">
|
||||
<div className="flex items-center space-x-2 px-2">
|
||||
<Checkbox
|
||||
id={MediaKind.MOVIE}
|
||||
checked={filters.kind.includes(MediaKind.MOVIE)}
|
||||
onCheckedChange={() => handleTypeChange(MediaKind.MOVIE)}
|
||||
/>
|
||||
<Label htmlFor={MediaKind.MOVIE} className="flex items-center gap-2">
|
||||
<Film className="h-4 w-4" /> Movies
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 px-2">
|
||||
<Checkbox
|
||||
id={MediaKind.TV_SHOW}
|
||||
checked={filters.kind.includes(MediaKind.TV_SHOW)}
|
||||
onCheckedChange={() => handleTypeChange(MediaKind.TV_SHOW)}
|
||||
/>
|
||||
<Label htmlFor={MediaKind.TV_SHOW} className="flex items-center gap-2">
|
||||
<Tv className="h-4 w-4" /> TV Shows
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 px-2">
|
||||
<Checkbox disabled id="podcast" />
|
||||
<Label htmlFor="podcast" className="flex items-center gap-2">
|
||||
<Podcast className="h-4 w-4" /> Podcast <small>(soon)</small>
|
||||
</Label>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
</SidebarGroupContent>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarMenu>
|
||||
<SidebarGroupLabel>Genres</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="grid grid-cols-2 gap-2 px-2">
|
||||
{Object.values(Genre).map(genre => (
|
||||
<div key={genre} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`genre-${genre.toLowerCase()}`}
|
||||
checked={filters.genres.includes(genre)}
|
||||
onCheckedChange={() => handleGenreChange(genre)}
|
||||
/>
|
||||
<Label htmlFor={`genre-${genre.toLowerCase()}`} className="truncate">
|
||||
<span className="truncate">{t(`genres.${genre}`)}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarMenu>
|
||||
<SidebarGroupLabel>Prices</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="grid grid-cols-2 gap-2 px-2">
|
||||
{Object.values(WatchPriceType).map(priceType => (
|
||||
<div key={priceType} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`priceType-${priceType.toLowerCase()}`}
|
||||
checked={filters.prices.includes(priceType)}
|
||||
onCheckedChange={() => handlePriceChange(priceType)}
|
||||
/>
|
||||
<Label htmlFor={`priceType-${priceType.toLowerCase()}`} className="truncate">
|
||||
<span className="truncate">{t(`priceType.${priceType.toLowerCase()}`)}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarMenu>
|
||||
<SidebarGroupLabel>Providers</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="grid grid-cols-2 gap-2 px-2">
|
||||
{providers.map(provider => (
|
||||
<div key={provider.key} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={provider.key}
|
||||
checked={filters.providers.includes(provider.key)}
|
||||
onCheckedChange={() => handleProvidersChange(provider.key)}
|
||||
/>
|
||||
<Label htmlFor={provider.key} className="truncate">
|
||||
<span className="truncate">{provider.name}</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
409
apps/desktop/src/components/command-center.tsx
Normal file
409
apps/desktop/src/components/command-center.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { MediaKind } from "@popcorntime/graphql/types";
|
||||
import { countries } from "@popcorntime/i18n";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from "@popcorntime/ui/components/command";
|
||||
import { MediaPosterAsPicture } from "@popcorntime/ui/components/poster";
|
||||
import { Skeleton } from "@popcorntime/ui/components/skeleton";
|
||||
import Fuse from "fuse.js";
|
||||
import { Film, MoveLeft, MoveRight, SearchIcon, TrendingUp, Tv, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCountry } from "@/hooks/useCountry";
|
||||
import { useSearch } from "@/hooks/useSearch";
|
||||
import {
|
||||
type Command,
|
||||
type View as CommandCenterView,
|
||||
defaultCommands,
|
||||
type CommandGroup as ICommandGroup,
|
||||
useCommandCenterStore,
|
||||
} from "@/stores/command-center";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
function CommandCenterViewCountrySelection() {
|
||||
const { t } = useTranslation();
|
||||
const toggle = useCommandCenterStore(state => state.toggle);
|
||||
const { country } = useCountry();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sortedCountries = useMemo(() => {
|
||||
return countries
|
||||
.filter(c => c !== country)
|
||||
.sort((a, b) => {
|
||||
const countryA = t(`country.${a}`);
|
||||
const countryB = t(`country.${b}`);
|
||||
return countryA.localeCompare(countryB);
|
||||
});
|
||||
}, [t, country]);
|
||||
|
||||
const handleNavigation = useCallback(
|
||||
(path: string) => {
|
||||
navigate(path);
|
||||
toggle();
|
||||
},
|
||||
[navigate, toggle]
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandGroup heading="Quick Region">
|
||||
{sortedCountries.map(country => (
|
||||
<CommandItem
|
||||
key={country}
|
||||
onSelect={() => handleNavigation(`/browse/${country}`)}
|
||||
className="group gap-2"
|
||||
>
|
||||
<span className="flex w-6 justify-center">
|
||||
<span
|
||||
className={`fi fis fi-${country} h-4 w-4 rounded grayscale group-hover:grayscale-0`}
|
||||
/>
|
||||
</span>
|
||||
{t(`country.${country}`)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandCenterCommand({ command }: { command: Command }) {
|
||||
const { t } = useTranslation();
|
||||
const { country } = useCountry();
|
||||
const navigate = useNavigate();
|
||||
const toggle = useCommandCenterStore(state => state.toggle);
|
||||
const setQuery = useCommandCenterStore(state => state.setQuery);
|
||||
const goto = useCommandCenterStore(state => state.goto);
|
||||
|
||||
const handleNavigation = useCallback(
|
||||
(path: string) => {
|
||||
navigate(path);
|
||||
toggle();
|
||||
},
|
||||
[toggle, navigate]
|
||||
);
|
||||
|
||||
const handleOpenView = useCallback(
|
||||
(view: CommandCenterView) => {
|
||||
setQuery(undefined);
|
||||
goto(view);
|
||||
},
|
||||
[goto, setQuery]
|
||||
);
|
||||
|
||||
switch (command.id) {
|
||||
case "main":
|
||||
return (
|
||||
<CommandItem key={command.id} value={command.id} onSelect={() => goto("main")}>
|
||||
<MoveLeft />
|
||||
<span>{t("commandCenter.home")}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
case "populars":
|
||||
return (
|
||||
<CommandItem
|
||||
key={command.id}
|
||||
value={command.id}
|
||||
onSelect={() => {
|
||||
handleNavigation(`/browse/${country}`);
|
||||
}}
|
||||
>
|
||||
<TrendingUp />
|
||||
<span>{t("browse.populars")}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
case "movies":
|
||||
return (
|
||||
<CommandItem
|
||||
key={command.id}
|
||||
value={command.id}
|
||||
onSelect={() => {
|
||||
handleNavigation(`/browse/${country}?kind=${MediaKind.MOVIE}`);
|
||||
}}
|
||||
>
|
||||
<Film />
|
||||
<span>{t("browse.movies")}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
case "tv-shows":
|
||||
return (
|
||||
<CommandItem
|
||||
key={command.id}
|
||||
value={command.id}
|
||||
onSelect={() => {
|
||||
handleNavigation(`/browse/${country}?kind=${MediaKind.TV_SHOW}`);
|
||||
}}
|
||||
>
|
||||
<Tv />
|
||||
<span>{t("browse.tv-shows")}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
case "change-region":
|
||||
return (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
handleOpenView("country-selection");
|
||||
}}
|
||||
className="group gap-2"
|
||||
>
|
||||
<span
|
||||
className={`fi fis fi-${country} h-4 w-4 rounded grayscale group-hover:grayscale-0`}
|
||||
/>
|
||||
{t(`country.${country}`)}
|
||||
<CommandShortcut className="ml-auto">
|
||||
<MoveRight />
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function CommandCenterCommands() {
|
||||
const query = useCommandCenterStore(state => state.query);
|
||||
const view = useCommandCenterStore(state => state.view);
|
||||
|
||||
const filterCommandGroups = useCallback(
|
||||
(groups: ICommandGroup[], search?: string): ICommandGroup[] => {
|
||||
const preFilteredGroups = groups
|
||||
.reduce((acc: ICommandGroup[], group) => {
|
||||
const filteredCommands = group.commands.filter(c => c.view !== view);
|
||||
|
||||
if (filteredCommands.length) {
|
||||
acc.push({ label: group.label, commands: filteredCommands });
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.filter(group => group.commands.length > 0);
|
||||
|
||||
if (!search?.trim()) return preFilteredGroups;
|
||||
|
||||
// fuse search
|
||||
return preFilteredGroups
|
||||
.reduce((acc: ICommandGroup[], group) => {
|
||||
// fuse this group
|
||||
const fuse = new Fuse(
|
||||
group.commands.filter(c => c.view !== view),
|
||||
{
|
||||
keys: ["label", "keywords"],
|
||||
threshold: 0.3,
|
||||
}
|
||||
);
|
||||
const filteredCommands = fuse.search(search).map(result => result.item);
|
||||
if (filteredCommands.length) {
|
||||
acc.push({ label: group.label, commands: filteredCommands });
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.filter(group => group.commands.length);
|
||||
},
|
||||
[view]
|
||||
);
|
||||
|
||||
const commands = useMemo(() => {
|
||||
return filterCommandGroups(defaultCommands, query);
|
||||
}, [query, filterCommandGroups]);
|
||||
|
||||
if (!commands || commands.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{commands.map((command, index) => (
|
||||
<CommandGroup
|
||||
key={`command-group-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static
|
||||
index
|
||||
}`}
|
||||
heading={command.label}
|
||||
>
|
||||
{command.commands.map(c => (
|
||||
<CommandCenterCommand key={c.id} command={c} />
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandCenterViewSearchResults() {
|
||||
const { t } = useTranslation();
|
||||
const query = useCommandCenterStore(state => state.query);
|
||||
const open = useGlobalStore(state => state.dialogs.media.open);
|
||||
const locale = useGlobalStore(state => state.i18n.locale);
|
||||
const sortKey = useGlobalStore(state => state.browse.sortKey);
|
||||
const { country } = useCountry();
|
||||
|
||||
const { data, isLoading } = useSearch({
|
||||
country,
|
||||
query,
|
||||
limit: 20,
|
||||
cursor: undefined,
|
||||
language: locale,
|
||||
sortKey,
|
||||
enabled: !!query,
|
||||
});
|
||||
|
||||
return (
|
||||
<CommandGroup>
|
||||
{isLoading &&
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<CommandItem
|
||||
key={`loading-i-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static
|
||||
i
|
||||
}`}
|
||||
className="m-2 cursor-pointer"
|
||||
>
|
||||
<Skeleton className="h-12 w-8 rounded-md" />
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Skeleton className="h-4 w-[180px] rounded-md" />
|
||||
<Skeleton className="h-4 w-[80px] rounded-md" />
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data.nodes.map(media => (
|
||||
<CommandItem
|
||||
className="m-2 cursor-pointer"
|
||||
onSelect={() => {
|
||||
open(media.slug);
|
||||
}}
|
||||
key={media.id}
|
||||
value={media.id.toString()}
|
||||
>
|
||||
<MediaPosterAsPicture
|
||||
loading="lazy"
|
||||
posterId={media.poster?.match(/\/([^/.]+)\./)?.[1]}
|
||||
title={media.title}
|
||||
className="w-8 rounded-md group-hover:scale-110 group-focus:scale-110"
|
||||
/>
|
||||
|
||||
<div className="w-2/3">
|
||||
<div className="hover:text-accent-foreground truncate font-semibold whitespace-nowrap">
|
||||
{media.title}
|
||||
</div>
|
||||
<div className="hover:text-accent-foreground flex">
|
||||
<span>
|
||||
{media.kind === MediaKind.MOVIE ? t("media.movie") : t("media.tv-show")}
|
||||
</span>
|
||||
{media.year && <span>, {media.year}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
{!isLoading && <CommandEmpty>{t("search.noResults")}</CommandEmpty>}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandCenter() {
|
||||
const isOpen = useCommandCenterStore(state => state.isOpen);
|
||||
const toggle = useCommandCenterStore(state => state.toggle);
|
||||
const query = useCommandCenterStore(state => state.query);
|
||||
const setQuery = useCommandCenterStore(state => state.setQuery);
|
||||
const view = useCommandCenterStore(state => state.view);
|
||||
const goto = useCommandCenterStore(state => state.goto);
|
||||
const reset = useCommandCenterStore(state => state.reset);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, [toggle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
reset();
|
||||
}
|
||||
}, [isOpen, reset]);
|
||||
|
||||
const onClickClose = useCallback(() => {
|
||||
// if on search result, clear results
|
||||
if (query) {
|
||||
setQuery(undefined);
|
||||
} else {
|
||||
toggle();
|
||||
}
|
||||
}, [query, toggle, setQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (query && view !== "search-result") {
|
||||
goto("search-result");
|
||||
} else if (!query?.trim() && view === "search-result") {
|
||||
goto("main");
|
||||
}
|
||||
}, [query, view, goto]);
|
||||
|
||||
const viewComponent = useMemo(() => {
|
||||
switch (view) {
|
||||
case "main":
|
||||
return undefined;
|
||||
case "search-result":
|
||||
return <CommandCenterViewSearchResults />;
|
||||
case "country-selection":
|
||||
return <CommandCenterViewCountrySelection />;
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:bg-muted z-[200] px-2"
|
||||
onClick={() => toggle()}
|
||||
>
|
||||
<SearchIcon className="size-8" />
|
||||
</Button>
|
||||
<CommandDialog
|
||||
modal
|
||||
open={isOpen}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Escape" || (e.key === "Backspace" && !query)) {
|
||||
e.preventDefault();
|
||||
onClickClose();
|
||||
}
|
||||
}}
|
||||
onOpenChange={toggle}
|
||||
>
|
||||
<div>
|
||||
<CommandInput
|
||||
placeholder={t("search.search")}
|
||||
value={query ?? ""}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClickClose}
|
||||
className="ring-offset-background absolute top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none ltr:right-4 rtl:left-4"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CommandList>
|
||||
{viewComponent}
|
||||
<CommandCenterCommands />
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
177
apps/desktop/src/components/header.tsx
Normal file
177
apps/desktop/src/components/header.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { MediaKind } from "@popcorntime/graphql/types";
|
||||
import { Button, buttonVariants } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger,
|
||||
} from "@popcorntime/ui/components/menubar";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@popcorntime/ui/components/tooltip";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { appLogDir } from "@tauri-apps/api/path";
|
||||
import { openPath } from "@tauri-apps/plugin-opener";
|
||||
import {
|
||||
Bug,
|
||||
CircleUserIcon,
|
||||
Clapperboard,
|
||||
FileText,
|
||||
Film,
|
||||
Globe,
|
||||
Headphones,
|
||||
LogOut,
|
||||
StarsIcon,
|
||||
Tv,
|
||||
} from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { useCountry } from "@/hooks/useCountry";
|
||||
import { useSession } from "@/hooks/useSession";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
export function Header() {
|
||||
const favorites = useGlobalStore(state => state.providers.favorites);
|
||||
const { logout } = useSession();
|
||||
const preferFavorites = useGlobalStore(state => state.browse.preferFavorites);
|
||||
const togglePreferFavorites = useGlobalStore(state => state.browse.togglePreferFavorites);
|
||||
const openPreferences = useGlobalStore(state => state.dialogs.preferences.toggle);
|
||||
const openWatchPreferences = useGlobalStore(state => state.dialogs.watchPreferences.toggle);
|
||||
const direction = useGlobalStore(state => state.i18n.direction);
|
||||
|
||||
const { country } = useCountry();
|
||||
const { t } = useTranslation();
|
||||
const { kind } = useParams<{
|
||||
kind: Lowercase<MediaKind>;
|
||||
}>();
|
||||
|
||||
const openLogsDir = useCallback(async () => {
|
||||
const appLogDirPath = await appLogDir();
|
||||
console.log(appLogDirPath);
|
||||
openPath(appLogDirPath);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="border-border bg-background/95 supports-[backdrop-filter]:bg-background/90 fixed top-0 z-50 h-14 w-full overscroll-none border-b backdrop-blur select-none">
|
||||
<div className="macos:ml-20 mr-4 ml-4 flex h-full items-center">
|
||||
<div className="mx-auto grid w-full grid-cols-[auto_1fr_auto] items-center gap-2">
|
||||
<div className="z-[200] flex items-center gap-1">
|
||||
{/**
|
||||
* Disabled for now, as it adds complexity
|
||||
* <SidebarTrigger className={cn(kind === 'tv_show' && 'invisible')} />
|
||||
*/}
|
||||
|
||||
{favorites.length > 0 && kind && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="link"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-muted hover:text-muted-foreground p-2",
|
||||
preferFavorites && "text-accent-foreground hover:text-accent-foreground"
|
||||
)}
|
||||
onClick={togglePreferFavorites}
|
||||
>
|
||||
<StarsIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="flex items-center gap-2 py-1 text-xs ">
|
||||
<Clapperboard className="h-3 w-3" />
|
||||
<span>For You feed</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<nav className="z-[50] flex gap-4 justify-self-center rtl:space-x-reverse">
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
kind === "movie" && "text-accent-foreground bg-accent",
|
||||
"flex gap-2"
|
||||
)}
|
||||
to={`/browse/${country}/movie`}
|
||||
>
|
||||
<Film className="h-4 w-4" />
|
||||
<span>{t("browse.movies")}</span>
|
||||
</Link>
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
kind === "tv_show" && "text-accent-foreground bg-accent",
|
||||
"flex gap-2"
|
||||
)}
|
||||
to={`/browse/${country}/tv_show`}
|
||||
>
|
||||
<Tv className="h-4 w-4" />
|
||||
<span>{t("browse.tv-shows")}</span>
|
||||
</Link>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button variant="link" disabled className="flex gap-2">
|
||||
<Headphones className="h-4 w-4" />
|
||||
<span>Podcasts</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs" side="bottom">
|
||||
{t("header.soon")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-1 justify-self-end">
|
||||
<CommandCenter />
|
||||
|
||||
<Menubar className="z-[200]">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger asChild>
|
||||
<Button
|
||||
title={t("preferences.preferences")}
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<CircleUserIcon className="size-8" />
|
||||
</Button>
|
||||
</MenubarTrigger>
|
||||
|
||||
<MenubarContent align={direction === "ltr" ? "end" : "start"} className="z-[400]">
|
||||
<MenubarItem onClick={openPreferences} className="flex gap-2">
|
||||
<Globe className="size-4 shrink-0" />
|
||||
<span>{t("menu.preferences")}</span>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={openWatchPreferences} className="flex gap-2">
|
||||
<Clapperboard className="size-4 shrink-0" />
|
||||
<span>{t("menu.watchPreferences")}</span>
|
||||
</MenubarItem>
|
||||
<MenubarItem asChild>
|
||||
<Link to="/onboarding/manifest?next=/" className="flex gap-2">
|
||||
<FileText className="size-4 shrink-0" />
|
||||
<span>{t("menu.manifest")}</span>
|
||||
</Link>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={openLogsDir} className="flex gap-2">
|
||||
<Bug className="size-4 shrink-0" />
|
||||
<span>{t("menu.logs")}</span>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={logout} className="flex gap-2">
|
||||
<LogOut className="size-4 shrink-0" />
|
||||
<span>{t("menu.signOut")}</span>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 z-40 h-14 w-full" data-tauri-drag-region></div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
443
apps/desktop/src/components/modal/media.tsx
Normal file
443
apps/desktop/src/components/modal/media.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { type Movie, RatingSource, type TVShow, WatchPriceType } from "@popcorntime/graphql/types";
|
||||
import { type Country, getLocalesForCountry, type Locale } from "@popcorntime/i18n";
|
||||
import { Badge } from "@popcorntime/ui/components/badge";
|
||||
import { Button, buttonVariants } from "@popcorntime/ui/components/button";
|
||||
import { Dialog, DialogContent } from "@popcorntime/ui/components/dialog";
|
||||
import { MediaPosterAsPicture } from "@popcorntime/ui/components/poster";
|
||||
import { ScrollArea } from "@popcorntime/ui/components/scroll-area";
|
||||
import { Spinner } from "@popcorntime/ui/components/spinner";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@popcorntime/ui/components/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@popcorntime/ui/components/tabs";
|
||||
import { timeDisplay } from "@popcorntime/ui/lib/time";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Calendar, Clock, ExternalLink, Star, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import placeholderImg from "@/assets/placeholder.svg";
|
||||
import { ProviderIcon, ProviderText } from "@/components/provider";
|
||||
import { useCountry } from "@/hooks/useCountry";
|
||||
import { useTauri } from "@/hooks/useTauri";
|
||||
import { NotFoundRoute } from "@/routes/not-found";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
function MediaContentSkeleton() {
|
||||
return (
|
||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaContent() {
|
||||
const locale = useGlobalStore(state => state.i18n.locale);
|
||||
const slug = useGlobalStore(state => state.dialogs.media.slug);
|
||||
const toggle = useGlobalStore(state => state.dialogs.media.toggle);
|
||||
const { country } = useCountry();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { invoke } = useTauri();
|
||||
const [media, setMedia] = useState<Movie | TVShow | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const officialLocales = useMemo(() => [...getLocalesForCountry(country)], [country]);
|
||||
|
||||
const fetch = useCallback(
|
||||
async (slug: string) => {
|
||||
setIsLoading(true);
|
||||
const results = await invoke<Movie | TVShow>("media", {
|
||||
params: {
|
||||
country: country.toUpperCase() as Country,
|
||||
slug,
|
||||
language: locale,
|
||||
},
|
||||
});
|
||||
setMedia(results);
|
||||
setIsLoading(false);
|
||||
},
|
||||
[country, locale, invoke]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
void fetch(slug);
|
||||
}
|
||||
}, [fetch, slug]);
|
||||
|
||||
const posterId = useMemo(() => {
|
||||
// `/ID.jpg` is the original poster
|
||||
if (media?.poster) {
|
||||
return media.poster.match(/\/([^/.]+)\./)?.[1];
|
||||
}
|
||||
}, [media?.poster]);
|
||||
|
||||
const backdropId = useMemo(() => {
|
||||
// `/ID.jpg` is the original backdrop
|
||||
if (media?.backdrop) {
|
||||
return media.backdrop.match(/\/([^/.]+)\./)?.[1];
|
||||
}
|
||||
}, [media?.backdrop]);
|
||||
|
||||
const bestProviders = useMemo(() => {
|
||||
if (!media?.availabilities) {
|
||||
return [];
|
||||
}
|
||||
const bestProvider = [...media.availabilities];
|
||||
|
||||
// sort bestProvider with free provider first, then subscription then others by weight
|
||||
bestProvider.sort((a, b) => {
|
||||
if (a.pricesType?.includes(WatchPriceType.FREE)) {
|
||||
return -1;
|
||||
}
|
||||
if (b.pricesType?.includes(WatchPriceType.FREE)) {
|
||||
return 1;
|
||||
}
|
||||
if (a.pricesType?.includes(WatchPriceType.FLATRATE)) {
|
||||
return -1;
|
||||
}
|
||||
if (b.pricesType?.includes(WatchPriceType.FLATRATE)) {
|
||||
return 1;
|
||||
}
|
||||
// default
|
||||
return a.providerName.localeCompare(b.providerName);
|
||||
});
|
||||
|
||||
if (bestProvider.length >= 3) {
|
||||
return bestProvider.slice(0, 3);
|
||||
}
|
||||
|
||||
if (bestProvider.length > 0 && media.availabilities.length < 3) {
|
||||
const missing = 3 - bestProvider.length;
|
||||
const rest = media.availabilities.filter(
|
||||
availability =>
|
||||
!bestProvider.find(filtered => filtered.providerId === availability.providerId)
|
||||
);
|
||||
return [...bestProvider, ...rest.slice(0, missing)];
|
||||
}
|
||||
|
||||
return media.availabilities.slice(0, 3);
|
||||
}, [media?.availabilities]);
|
||||
|
||||
const bestProvider = useMemo(() => {
|
||||
if (bestProviders && bestProviders.length > 0) {
|
||||
return bestProviders[0];
|
||||
}
|
||||
}, [bestProviders]);
|
||||
|
||||
const imdbRating = useMemo(() => {
|
||||
return media?.ratings?.find(rating => rating.source === RatingSource.IMDB && rating.rating > 0);
|
||||
}, [media?.ratings]);
|
||||
|
||||
const allLanguages = useMemo(() => {
|
||||
if (!media?.availabilities) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(
|
||||
media.availabilities.reduce((acc, availability) => {
|
||||
availability.audioLanguages
|
||||
?.filter(l => officialLocales.includes(l))
|
||||
.forEach(lang => {
|
||||
acc.add(lang);
|
||||
});
|
||||
return acc;
|
||||
}, new Set<Locale>())
|
||||
);
|
||||
}, [media?.availabilities, officialLocales]);
|
||||
|
||||
const allSubtitles = useMemo(() => {
|
||||
if (!media?.availabilities) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(
|
||||
media.availabilities.reduce((acc, availability) => {
|
||||
availability.subtitleLanguages
|
||||
?.filter(l => officialLocales.includes(l))
|
||||
.filter((a: Locale) => !allLanguages.includes(a))
|
||||
.forEach(lang => {
|
||||
acc.add(lang);
|
||||
});
|
||||
return acc;
|
||||
}, new Set<Locale>())
|
||||
);
|
||||
}, [media?.availabilities, officialLocales, allLanguages]);
|
||||
|
||||
if (isLoading) return <MediaContentSkeleton />;
|
||||
if (!media) return <NotFoundRoute />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="relative h-80 overflow-hidden rounded-md">
|
||||
<div className="absolute inset-0 flex items-end">
|
||||
{backdropId && (
|
||||
<>
|
||||
<picture
|
||||
className={cn(
|
||||
"h-full w-full transition-opacity duration-500",
|
||||
"opacity-100 portrait:mix-blend-multiply"
|
||||
)}
|
||||
>
|
||||
<source
|
||||
srcSet={`https://img.popcorntime.app/o/${backdropId}.webp`}
|
||||
media="(min-width: 960px)"
|
||||
type="image/webp"
|
||||
/>
|
||||
<source
|
||||
srcSet={`https://img.popcorntime.app/o/${backdropId}@960.webp`}
|
||||
type="image/webp"
|
||||
/>
|
||||
<source
|
||||
srcSet={`https://img.popcorntime.app/o/${backdropId}.jpg`}
|
||||
media="(min-width: 960px)"
|
||||
type="image/jpeg"
|
||||
/>
|
||||
<source
|
||||
srcSet={`https://img.popcorntime.app/o/${backdropId}@960.jpg`}
|
||||
type="image/jpeg"
|
||||
/>
|
||||
<img
|
||||
src={`https://img.popcorntime.app/o/${backdropId}.jpg`}
|
||||
alt={media.title}
|
||||
className="aspect-[16/9] h-full w-full object-cover"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
/>
|
||||
</picture>
|
||||
<div className="from-background via-background/60 absolute inset-0 bg-gradient-to-t to-transparent" />
|
||||
<div className="from-background/80 to-background/40 absolute inset-0 bg-gradient-to-r via-transparent" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
className="absolute top-4 right-4 z-[500] text-white/80 backdrop-blur-sm hover:bg-black/30 hover:text-white"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 left-0 px-8">
|
||||
<div className="flex h-full items-stretch gap-6">
|
||||
<MediaPosterAsPicture
|
||||
loading="lazy"
|
||||
title={media.title}
|
||||
posterId={posterId}
|
||||
placeholder={placeholderImg}
|
||||
className="w-32 rounded-md"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col pb-4">
|
||||
<h1 className="mb-3 line-clamp-1 text-4xl leading-tight font-bold">{media.title}</h1>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 fill-current text-yellow-400" />
|
||||
{media.ranking?.score && (
|
||||
<span className="text-lg font-semibold">{media.ranking?.score}</span>
|
||||
)}
|
||||
{imdbRating && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({imdbRating.rating.toFixed(2)} IMDb)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{media.year}</span>
|
||||
</div>
|
||||
{media.__typename === "Movie" && media.runtime && Number(media.runtime) > 0 && (
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="">{timeDisplay(media.runtime)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Badge variant="default" className="font-medium capitalize backdrop-blur-sm">
|
||||
{media.__typename === "Movie" ? t("media.movie") : t("media.tv-show")}
|
||||
</Badge>
|
||||
|
||||
{media.genres.map(genre => (
|
||||
<Badge
|
||||
key={genre}
|
||||
variant="outline"
|
||||
className="border-white/30 bg-black/20 backdrop-blur-sm"
|
||||
>
|
||||
{t(`genres.${genre}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{bestProvider && (
|
||||
<div className="mt-auto flex flex-col space-y-2 space-x-0 sm:flex-row sm:space-y-0 sm:space-x-2 rtl:space-x-reverse">
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "default", size: "xl" }),
|
||||
"group bg-primary/40 dark:bg-primary/20 hover:bg-primary/90 hover:text-secondary flex w-full items-center justify-center gap-x-2 px-4 font-extrabold sm:w-auto sm:max-w-sm"
|
||||
)}
|
||||
to={`https://go.popcorntime.app/${bestProvider.urlHash}?country=${country?.toUpperCase()}`}
|
||||
target="_blank"
|
||||
>
|
||||
<ProviderIcon
|
||||
alt={bestProvider.providerName}
|
||||
className="w-6 rounded-md"
|
||||
icon={bestProvider.logo}
|
||||
/>
|
||||
<div className="max-w-sm truncate text-lg">
|
||||
<ProviderText availability={bestProvider} />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="border-border/30 min-h-0 flex-1 overflow-hidden border-t px-8 pt-3">
|
||||
<Tabs defaultValue="links" className="flex h-full min-h-0 flex-col">
|
||||
<TabsList>
|
||||
<TabsTrigger value="links">{t("mediaTabs.links")}</TabsTrigger>
|
||||
<TabsTrigger value="overview">{t("mediaTabs.overview")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="mb-8 min-h-0 flex-1">
|
||||
<TabsContent asChild value="links">
|
||||
<ScrollArea className="h-full py-4">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{media.availabilities.map(availability => {
|
||||
return (
|
||||
<TableRow
|
||||
key={availability.providerId}
|
||||
className="border-gray-700/30 transition-colors hover:bg-gray-800/40"
|
||||
>
|
||||
<TableCell className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700/50 text-lg">
|
||||
<ProviderIcon className="rounded-md" icon={availability.logo} />
|
||||
</div>
|
||||
<span className="font-medium">{availability.providerName}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[20vw] align-middle">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{availability.pricesType?.map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="outline"
|
||||
className="border-current/30 bg-current/10"
|
||||
>
|
||||
{t(`priceType.${type.toLowerCase()}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<Link
|
||||
to={`https://go.popcorntime.app/${availability.urlHash}?country=${country?.toUpperCase()}`}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
buttonVariants({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
<span>{t("mediaActions.open")}</span>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent value="overview" asChild>
|
||||
<ScrollArea className="flex h-full space-y-12">
|
||||
{media.overview && <p className="text-pretty">{media.overview}</p>}
|
||||
|
||||
<div className={cn("grid grid-cols-2 gap-8", media.overview && "mt-4")}>
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold">Details</h4>
|
||||
<div className="space-y-3 text-sm">
|
||||
{allLanguages && allLanguages.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Audios:</span>
|
||||
<span>
|
||||
{allLanguages.map(locale => t(`language.${locale}`)).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allSubtitles && allSubtitles.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Subtitles:</span>
|
||||
<span>
|
||||
{allSubtitles.map(locale => t(`language.${locale}`)).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{media.country && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Country:</span>
|
||||
<span>{t(`country.${media.country.toLowerCase()}`)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold">Ratings</h4>
|
||||
<div className="space-y-3">
|
||||
{media.ranking?.score && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Popcorn Time Score</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 fill-current text-yellow-400" />
|
||||
<span className="font-semibold">{media.ranking.score}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imdbRating && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">IMDb</span>
|
||||
<span className="font-medium">{imdbRating.rating.toFixed(2)}/10</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaDialog() {
|
||||
const isOpen = useGlobalStore(state => state.dialogs.media.isOpen);
|
||||
const toggle = useGlobalStore(state => state.dialogs.media.toggle);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={toggle} modal>
|
||||
<DialogContent className="z-[300] h-full w-full max-w-2xl border-0 p-0 outline-none md:max-h-[90vh] lg:max-w-4xl">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[calc(100vh-(12rem))] flex-col transition-all duration-300",
|
||||
"translate-y-0 opacity-100",
|
||||
"ease-[cubic-bezier(0.25, 1, 0.5, 1)]"
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-0 left-0 z-400 h-14 w-full" data-tauri-drag-region></div>
|
||||
<MediaContent />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
167
apps/desktop/src/components/modal/preferences.tsx
Normal file
167
apps/desktop/src/components/modal/preferences.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { type Country, i18n, type Locale } from "@popcorntime/i18n";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@popcorntime/ui/components/dialog";
|
||||
import { Form, FormField, FormItem, FormLabel, FormMessage } from "@popcorntime/ui/components/form";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { CountryPopover } from "@/components/popover/country";
|
||||
import { LanguagePopover } from "@/components/popover/language";
|
||||
import { useCountry } from "@/hooks/useCountry";
|
||||
import { useSession } from "@/hooks/useSession";
|
||||
import { useUpdater } from "@/hooks/useUpdater";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
const accountFormSchema = z.object({
|
||||
country: z.enum([...i18n.countries] as [Country, ...Country[]]),
|
||||
language: z.enum([...i18n.locales] as [Locale, ...Locale[]]),
|
||||
});
|
||||
type AccountFormValues = z.infer<typeof accountFormSchema>;
|
||||
|
||||
export function PreferencesDialog() {
|
||||
const shouldOpen = useGlobalStore(state => state.dialogs.preferences.isOpen);
|
||||
const toggle = useGlobalStore(state => state.dialogs.preferences.toggle);
|
||||
const preferences = useGlobalStore(useShallow(state => state.preferences));
|
||||
const initialized = useGlobalStore(state => state.session.initialized);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const { updatePreferences } = useSession();
|
||||
const { t } = useTranslation();
|
||||
const { country } = useCountry();
|
||||
const navigate = useNavigate();
|
||||
const { hide } = useUpdater();
|
||||
|
||||
const form = useForm<AccountFormValues>({
|
||||
resolver: zodResolver(accountFormSchema),
|
||||
defaultValues: {
|
||||
country: i18n.defaultCountry,
|
||||
language: i18n.defaultLocale,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const withUpdateAvailable = hide(shouldOpen);
|
||||
return () => {
|
||||
if (withUpdateAvailable) {
|
||||
hide(false);
|
||||
}
|
||||
};
|
||||
}, [shouldOpen, hide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
if (preferences.country) {
|
||||
form.setValue("country", preferences.country.toLowerCase() as Country);
|
||||
}
|
||||
if (preferences.language) {
|
||||
form.setValue("language", preferences.language);
|
||||
}
|
||||
}
|
||||
}, [form, preferences, initialized]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: AccountFormValues) => {
|
||||
if (submitted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
updatePreferences({
|
||||
country: values.country.toUpperCase() as Country,
|
||||
language: values.language,
|
||||
})
|
||||
.then(() =>
|
||||
toast.success(t("preferences.toast"), {
|
||||
closeButton: true,
|
||||
dismissible: true,
|
||||
})
|
||||
)
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
setSubmitted(false);
|
||||
if (country !== values.country) {
|
||||
navigate(`/browse/${values.country}`, { flushSync: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
[submitted, updatePreferences, t, country, navigate]
|
||||
);
|
||||
|
||||
if (!initialized || !open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={shouldOpen} onOpenChange={toggle}>
|
||||
<DialogContent className="max-w-md">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("preferences.preferences")}</DialogTitle>
|
||||
<DialogDescription>{t("preferences.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-4 items-center gap-4">
|
||||
<FormLabel className="text-right">{t("preferences.language")}</FormLabel>
|
||||
|
||||
<LanguagePopover
|
||||
current={field.value}
|
||||
onSelect={locale => {
|
||||
form.setValue("language", locale);
|
||||
}}
|
||||
contentClassName="w-[200px] h-[30vh]"
|
||||
className="w-[200px] justify-between"
|
||||
/>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="country"
|
||||
render={({ field }) => (
|
||||
<FormItem className="grid grid-cols-4 items-center gap-4">
|
||||
<FormLabel className="text-right">{t("preferences.country")}</FormLabel>
|
||||
|
||||
<CountryPopover
|
||||
current={field.value}
|
||||
onSelect={country => {
|
||||
form.setValue("country", country);
|
||||
}}
|
||||
contentClassName="w-[200px] h-[30vh]"
|
||||
className="w-[200px] justify-between"
|
||||
/>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{t("preferences.update")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
109
apps/desktop/src/components/modal/watch-preferences.tsx
Normal file
109
apps/desktop/src/components/modal/watch-preferences.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Badge } from "@popcorntime/ui/components/badge";
|
||||
import { Button } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@popcorntime/ui/components/dialog";
|
||||
import { ScrollArea } from "@popcorntime/ui/components/scroll-area";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@popcorntime/ui/components/table";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { BookmarkMinusIcon, BookmarkPlus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useProviders } from "@/hooks/useProviders";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import { ProviderIcon } from "../provider";
|
||||
|
||||
export function WatchPreferencesDialog() {
|
||||
const providers = useGlobalStore(state => state.providers.providers);
|
||||
const favorites = useGlobalStore(state => state.providers.favorites);
|
||||
const isOpen = useGlobalStore(state => state.dialogs.watchPreferences.isOpen);
|
||||
const toggle = useGlobalStore(state => state.dialogs.watchPreferences.toggle);
|
||||
const { addToFavorites, removeFromFavorites } = useProviders();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={toggle}>
|
||||
<DialogContent className="z-[300] h-full w-full max-w-2xl border-0 outline-none md:max-h-[90vh] lg:max-w-4xl">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[calc(100vh-(12rem))] flex-col transition-all duration-300",
|
||||
"translate-y-0 opacity-100",
|
||||
"ease-[cubic-bezier(0.25, 1, 0.5, 1)]"
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("watchPreferences.label")}</DialogTitle>
|
||||
<DialogDescription>{t("watchPreferences.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="grid h-full min-h-0 flex-1 gap-4 overflow-hidden rounded py-4">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{providers.map(provider => {
|
||||
const isFavorite = favorites.find(f => f.key === provider.key);
|
||||
return (
|
||||
<TableRow
|
||||
key={provider.key}
|
||||
className={cn(
|
||||
"border-gray-700/30 transition-colors hover:bg-gray-800/40",
|
||||
isFavorite && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<TableCell className="py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon className="flex size-10 rounded-lg" icon={provider.logo} />
|
||||
|
||||
<span className="font-medium">{provider.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[20vw] align-middle">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{provider.priceTypes?.map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="outline"
|
||||
className="border-current/30 bg-current/10"
|
||||
>
|
||||
{t(`priceType.${type.toLowerCase()}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
{isFavorite ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
removeFromFavorites(provider.key);
|
||||
}}
|
||||
>
|
||||
<BookmarkMinusIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
addToFavorites(provider.key);
|
||||
}}
|
||||
>
|
||||
<BookmarkPlus />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
337
apps/desktop/src/components/onboarding/manifest.tsx
Normal file
337
apps/desktop/src/components/onboarding/manifest.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import CRTEffect from "vault66-crt-effect";
|
||||
import "@/css/crt.css";
|
||||
import { buttonVariants } from "@popcorntime/ui/components/button";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { MoveRightIcon, PlayCircle } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { useUpdater } from "@/hooks/useUpdater";
|
||||
|
||||
const DEFAULT_COUNTDOWN = 4;
|
||||
interface KaraokeLineProps {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
getTime: () => number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function splitTokensPreserve(text: string) {
|
||||
const re = /([\p{L}\p{N}]+)|(\s+|[^\p{L}\p{N}\s]+)/gu;
|
||||
const out: { t: string; len: number; isWord: boolean }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
m = re.exec(text);
|
||||
while (m) {
|
||||
const word = m[1];
|
||||
const other = m[2];
|
||||
if (word) {
|
||||
out.push({ t: word, len: word.length, isWord: true });
|
||||
} else if (other) {
|
||||
out.push({ t: other, len: other.length, isWord: false });
|
||||
}
|
||||
m = re.exec(text);
|
||||
}
|
||||
if (out.length === 0) {
|
||||
out.push({ t: text, len: text.length, isWord: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function KaraokeLine({ text, start, end, getTime, className }: KaraokeLineProps) {
|
||||
const tokens = useMemo(() => splitTokensPreserve(text), [text]);
|
||||
const [activeIdx, setActiveIdx] = useState<number>(-1);
|
||||
|
||||
const spans = useMemo(() => {
|
||||
// distribute cue duration ~ proportional to token length (only for words)
|
||||
const totalWordChars = tokens.reduce((s, tk) => s + (tk.isWord ? tk.len : 0), 0) || 1;
|
||||
const dur = Math.max(0, end - start);
|
||||
let t = start;
|
||||
const res: { from: number; to: number }[] = [];
|
||||
for (const tk of tokens) {
|
||||
const frac = tk.isWord ? tk.len / totalWordChars : 0;
|
||||
const wDur = dur * frac;
|
||||
res.push({ from: t, to: t + wDur });
|
||||
t += wDur;
|
||||
}
|
||||
return res;
|
||||
}, [tokens, start, end]);
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
const now = getTime();
|
||||
let idx = -1;
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
if (now >= (spans[i]?.from || 0) && now <= (spans[i]?.to || 0)) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
if (now >= (spans[i]?.from || 0)) idx = i;
|
||||
}
|
||||
if (idx !== activeIdx) setActiveIdx(idx);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [spans, getTime, activeIdx]);
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto w-full text-center font-bold leading-tight", className)}>
|
||||
<div className="inline-block min-h-[2lh] space-x-1 tracking-tighter">
|
||||
{tokens.map((tk, i) => {
|
||||
const isActive =
|
||||
i === activeIdx && tk.isWord && (spans[i]?.to || 0) > (spans[i]?.from || 0);
|
||||
return (
|
||||
<span
|
||||
key={`${tk.t.trim()}-${i}`}
|
||||
className={cn(
|
||||
"select-none cursor-default uppercase transition-[color,transform] duration-200",
|
||||
tk.isWord
|
||||
? cn(
|
||||
"inline-block align-baseline transform-gpu origin-center",
|
||||
isActive ? "text-foreground scale-110" : "text-muted-foreground/80 scale-100"
|
||||
)
|
||||
: "inline whitespace-pre text-muted-foreground/80"
|
||||
)}
|
||||
>
|
||||
{tk.t}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AudioPlayerProps {
|
||||
audioUrl?: string;
|
||||
vttUrl?: string;
|
||||
onFinished?: () => void;
|
||||
}
|
||||
function AudioPlayer({ onFinished }: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const trackRef = useRef<HTMLTrackElement | null>(null);
|
||||
const [cueText, setCueText] = useState("");
|
||||
const [cueStart, setCueStart] = useState(0);
|
||||
const [cueEnd, setCueEnd] = useState(0);
|
||||
const finishedOnce = useRef(false);
|
||||
const [shouldInteract, setShouldInteract] = useState(false);
|
||||
const onFinishedRef = useRef(onFinished);
|
||||
const setShouldInteractRef = useRef(setShouldInteract);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return;
|
||||
const audio = audioRef.current;
|
||||
const finish = () => {
|
||||
if (!finishedOnce.current) {
|
||||
finishedOnce.current = true;
|
||||
onFinishedRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
const wire = () => {
|
||||
const track = audio.textTracks?.[0];
|
||||
if (!track) return;
|
||||
track.mode = "hidden";
|
||||
const onCueChange = () => {
|
||||
const active = track.activeCues?.[0] as VTTCue | undefined;
|
||||
if (active) {
|
||||
setCueText(active.text);
|
||||
setCueStart(active.startTime);
|
||||
setCueEnd(active.endTime);
|
||||
} else {
|
||||
setCueStart(0);
|
||||
setCueEnd(0);
|
||||
}
|
||||
};
|
||||
track.addEventListener("cuechange", onCueChange);
|
||||
onCueChange();
|
||||
return () => track.removeEventListener("cuechange", onCueChange);
|
||||
};
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
const u = wire();
|
||||
if (u) cleanups.push(u);
|
||||
|
||||
const onLoadedMeta = () => {
|
||||
const u2 = wire();
|
||||
if (u2) cleanups.push(u2);
|
||||
};
|
||||
audio.addEventListener("loadedmetadata", onLoadedMeta);
|
||||
audio.addEventListener("ended", finish);
|
||||
audio.play().catch(e => {
|
||||
if (e?.name === "NotAllowedError") {
|
||||
setShouldInteractRef.current(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cleanups.forEach(fn => {
|
||||
fn();
|
||||
});
|
||||
audio.removeEventListener("loadedmetadata", onLoadedMeta);
|
||||
audio.removeEventListener("ended", finish);
|
||||
audio.pause();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cueSize = useMemo(() => {
|
||||
if (cueText.length < 20) return "text-6xl xl:text-7xl";
|
||||
if (cueText.length < 50) return "text-5xl xl:text-6xl";
|
||||
if (cueText.length < 100) return "text-4xl xl:text-5xl";
|
||||
return "text-3xl xl:text-4xl";
|
||||
}, [cueText]);
|
||||
|
||||
const getTime = () => audioRef.current?.currentTime ?? 0;
|
||||
|
||||
return (
|
||||
<div className="w-[50vw] text-center">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="/audios/intro.m4a"
|
||||
crossOrigin="anonymous"
|
||||
preload="auto"
|
||||
aria-label="Popcorn Time Manifesto"
|
||||
className="hidden"
|
||||
>
|
||||
<track
|
||||
ref={trackRef}
|
||||
kind="captions"
|
||||
label="English"
|
||||
srcLang="en"
|
||||
src="/audios/intro.vtt"
|
||||
default
|
||||
/>
|
||||
</audio>
|
||||
|
||||
{shouldInteract && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
audioRef.current?.play().catch(() => {
|
||||
// cant play, lets skip for now
|
||||
onFinished?.();
|
||||
});
|
||||
setShouldInteract(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 text-5xl uppercase font-bold tracking-tighter text-muted-foreground/80">
|
||||
<PlayCircle className="size-15" />
|
||||
<span>Play</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<KaraokeLine
|
||||
key={cueStart}
|
||||
text={cueText}
|
||||
start={cueStart}
|
||||
end={cueEnd}
|
||||
getTime={getTime}
|
||||
className={cn(cueSize, "tracking-wide")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingManifest() {
|
||||
const navigate = useNavigate();
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [countdown, setCountdown] = useState(DEFAULT_COUNTDOWN);
|
||||
const { hide } = useUpdater();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
// if linked from somewhere, go there next
|
||||
const nextUrl = useMemo(() => {
|
||||
return params.get("next") ?? "/onboarding/timeline";
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setIsCompleted(false);
|
||||
setCountdown(DEFAULT_COUNTDOWN);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCompleted) return;
|
||||
setCountdown(DEFAULT_COUNTDOWN);
|
||||
const interval = setInterval(() => {
|
||||
setCountdown(c => c - 1);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompleted && countdown <= 0) {
|
||||
navigate(nextUrl);
|
||||
}
|
||||
}, [isCompleted, countdown, navigate, nextUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
navigate(nextUrl);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [navigate, nextUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
hide(true);
|
||||
return () => {
|
||||
hide(false);
|
||||
};
|
||||
}, [hide]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CRTEffect
|
||||
enabled={true}
|
||||
enableSweep={true}
|
||||
sweepThickness={0.1}
|
||||
sweepDuration={0.5}
|
||||
enableFlicker={true}
|
||||
glitchMode={true}
|
||||
enableVignette={true}
|
||||
glitchIntensity="low"
|
||||
flickerIntensity="low"
|
||||
>
|
||||
<div className="flex min-h-svh w-screen flex-col items-center justify-center gap-12">
|
||||
<div className="flex flex-col items-center space-y-6 text-5xl">
|
||||
{!isCompleted && <AudioPlayer onFinished={() => setIsCompleted(true)} />}
|
||||
{isCompleted && (
|
||||
<Link to={nextUrl} className="text-muted-foreground font-bold hover:no-underline">
|
||||
<div className="flex items-center gap-2 [font-variant:small-caps]">
|
||||
<span>Continue</span>
|
||||
<MoveRightIcon className="size-12" />
|
||||
</div>
|
||||
<div className="text-muted-foregroun/75 mt-2 text-center text-sm font-normal">
|
||||
Redirecting in {countdown}s...
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CRTEffect>
|
||||
{!isCompleted && (
|
||||
<Link
|
||||
to={nextUrl}
|
||||
replace
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"dark:text-muted-foreground/50 absolute top-0 right-0 z-[600] m-3 flex gap-2 hover:no-underline"
|
||||
)}
|
||||
>
|
||||
<kbd className="border-border bg-muted rounded-md border px-2 py-0.5 font-mono text-xs shadow-sm">
|
||||
Esc
|
||||
</kbd>
|
||||
<MoveRightIcon />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
apps/desktop/src/components/onboarding/timeline.tsx
Normal file
137
apps/desktop/src/components/onboarding/timeline.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Button, buttonVariants } from "@popcorntime/ui/components/button";
|
||||
import { ScrollArea } from "@popcorntime/ui/components/scroll-area";
|
||||
import { TimelineLayout } from "@popcorntime/ui/components/timeline-layout";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Check, FileVideoIcon, MoveRightIcon, TabletSmartphone, TvMinimalPlay } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useTauri } from "@/hooks/useTauri";
|
||||
import { useUpdater } from "@/hooks/useUpdater";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
function GithubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" className={cn("size-4", className)}>
|
||||
<title>Github</title>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OnboardingTimeline() {
|
||||
const navigate = useNavigate();
|
||||
const { hide } = useUpdater();
|
||||
const { invoke } = useTauri();
|
||||
const [isWorking, setIsWorking] = useState(false);
|
||||
const setOnboarded = useGlobalStore(state => state.settings.setOnboarded);
|
||||
|
||||
useEffect(() => {
|
||||
hide(true);
|
||||
return () => {
|
||||
hide(false);
|
||||
};
|
||||
}, [hide]);
|
||||
|
||||
const startBrowsing = useCallback(() => {
|
||||
setIsWorking(true);
|
||||
invoke("set_onboarded", undefined).then(() => {
|
||||
setIsWorking(false);
|
||||
setOnboarded(true);
|
||||
navigate("/");
|
||||
});
|
||||
}, [invoke, navigate, setOnboarded]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh w-full flex-col justify-center gap-12">
|
||||
<ScrollArea className="text-muted-foreground h-full w-full cursor-default overflow-hidden text-lg leading-7 font-bold select-none">
|
||||
<div className="mx-auto mt-15 h-[55vh] w-[70vw]">
|
||||
<h1 className="scroll-m-20 text-left text-4xl font-extrabold tracking-tight text-balance">
|
||||
What to expect.
|
||||
</h1>
|
||||
<p className="leading-7 [&:not(:first-child)]:mt-6">
|
||||
Today marks the release of our first version, laying the foundation for a new adventure.
|
||||
</p>
|
||||
<TimelineLayout
|
||||
animate
|
||||
className={cn("text-muted-foreground flex w-full items-start")}
|
||||
iconColor="primary"
|
||||
items={[
|
||||
{
|
||||
color: "muted",
|
||||
date: "Planned",
|
||||
description:
|
||||
"Bringing Popcorn Time to your pocket with native apps for iOS and Android.",
|
||||
icon: <TabletSmartphone />,
|
||||
id: 1,
|
||||
status: "pending",
|
||||
title: "Mobile Applications",
|
||||
},
|
||||
{
|
||||
color: "muted",
|
||||
date: "Planned",
|
||||
description:
|
||||
"Expanding to your living room with native apps for major smart TV platforms.",
|
||||
icon: <TvMinimalPlay />,
|
||||
id: 2,
|
||||
status: "pending",
|
||||
title: "Smart TV Applications",
|
||||
},
|
||||
{
|
||||
color: "muted",
|
||||
date: "In Progress",
|
||||
description:
|
||||
"Next up: play your own files, cast to devices, and enjoy offline viewing, built right into Popcorn Time.",
|
||||
icon: <FileVideoIcon />,
|
||||
id: 3,
|
||||
status: "in-progress",
|
||||
title: "Local Backend",
|
||||
},
|
||||
{
|
||||
date: "Completed",
|
||||
description:
|
||||
"Built the Popcorn Time scraper and index streaming links, backed by a solid database and API foundation that will be open-sourced soon.",
|
||||
icon: <Check />,
|
||||
id: 4,
|
||||
color: "primary",
|
||||
status: "completed",
|
||||
title: "Core Data & API",
|
||||
},
|
||||
{
|
||||
date: "Completed",
|
||||
description:
|
||||
"First public desktop release: stable core, secure updater, solid foundation.",
|
||||
icon: <Check />,
|
||||
id: 5,
|
||||
color: "primary",
|
||||
status: "completed",
|
||||
title: "Alpha Desktop",
|
||||
},
|
||||
]}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="mx-auto flex w-[70vw] flex-col gap-4">
|
||||
<Link
|
||||
to="https://github.com/popcorntime"
|
||||
target="_blank"
|
||||
className={cn(buttonVariants({ variant: "outline" }), "flex w-full items-center gap-2")}
|
||||
>
|
||||
<span>Follow us on Github</span>
|
||||
<GithubIcon className="text-primary" />
|
||||
</Link>
|
||||
<Button
|
||||
disabled={isWorking}
|
||||
onClick={startBrowsing}
|
||||
className="flex w-full items-center gap-2"
|
||||
>
|
||||
<span>{isWorking ? "Please wait..." : "Finish"}</span>
|
||||
<MoveRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
apps/desktop/src/components/onboarding/welcome.tsx
Normal file
98
apps/desktop/src/components/onboarding/welcome.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import Typewriter from "typewriter-effect";
|
||||
import CRTEffect from "vault66-crt-effect";
|
||||
import logo from "@/assets/logo_grayscale.png";
|
||||
import "@/css/crt.css";
|
||||
import { buttonVariants } from "@popcorntime/ui/components/button";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { MoveRightIcon, Volume2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useUpdater } from "@/hooks/useUpdater";
|
||||
|
||||
export function OnboardingWelcome() {
|
||||
const navigate = useNavigate();
|
||||
const { hide } = useUpdater();
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
navigate("/onboarding/manifest", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
hide(true);
|
||||
return () => {
|
||||
hide(false);
|
||||
};
|
||||
}, [hide]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CRTEffect
|
||||
enabled={true}
|
||||
enableSweep={true}
|
||||
sweepThickness={0.1}
|
||||
sweepDuration={0.5}
|
||||
enableFlicker={true}
|
||||
glitchMode={true}
|
||||
enableVignette={true}
|
||||
glitchIntensity="low"
|
||||
flickerIntensity="high"
|
||||
>
|
||||
<div className="flex min-h-svh w-screen flex-col items-center justify-center gap-12">
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
<img src={logo} alt="Popcorn Time" className="z-[800] size-18 dark:opacity-80" />
|
||||
<div className="text-muted-foreground z-[800] text-4xl font-semibold">
|
||||
<Typewriter
|
||||
onInit={typewriter => {
|
||||
typewriter
|
||||
.typeString("Hello, World.")
|
||||
.pauseFor(1500)
|
||||
.deleteAll()
|
||||
.typeString("We're back.")
|
||||
.pauseFor(1500)
|
||||
.callFunction(() => {
|
||||
navigate("/onboarding/manifest", {
|
||||
replace: true,
|
||||
});
|
||||
})
|
||||
.start();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CRTEffect>
|
||||
<Link
|
||||
to="/onboarding/manifest"
|
||||
replace
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link" }),
|
||||
"dark:text-muted-foreground/50 absolute top-0 right-0 z-[600] m-3 flex gap-2"
|
||||
)}
|
||||
>
|
||||
<kbd className="border-border bg-muted rounded-md border px-2 hover:no-underline py-0.5 font-mono text-xs shadow-sm">
|
||||
Esc
|
||||
</kbd>
|
||||
<MoveRightIcon />
|
||||
</Link>
|
||||
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="pointer-events-auto fixed bottom-12 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-full bg-muted/40 px-3 py-1.5 text-sm text-foreground shadow backdrop-blur-md border-border">
|
||||
<Volume2 className="h-4 w-4 animate-pulse" aria-hidden="true" />
|
||||
<span className="font-medium">Turn your sound on</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/desktop/src/components/popover/country.tsx
Normal file
92
apps/desktop/src/components/popover/country.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { type Country, i18n } from "@popcorntime/i18n";
|
||||
import { Button, type ButtonProps } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@popcorntime/ui/components/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@popcorntime/ui/components/popover";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { capitalize } from "@/utils/text";
|
||||
|
||||
export function CountryPopover({
|
||||
size = "default",
|
||||
className,
|
||||
contentClassName,
|
||||
current,
|
||||
onSelect,
|
||||
countries = i18n.countries,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
current: Country;
|
||||
size?: ButtonProps["size"];
|
||||
onSelect: ((value: Country) => void) | undefined;
|
||||
countries?: Country[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sortedCountries = useMemo(() => {
|
||||
return countries.sort((a, b) => {
|
||||
const countryA = t(`country.${a.toLowerCase()}`);
|
||||
const countryB = t(`country.${b.toLowerCase()}`);
|
||||
return countryA.localeCompare(countryB);
|
||||
});
|
||||
}, [t, countries]);
|
||||
|
||||
return (
|
||||
<Popover modal open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
role="combobox"
|
||||
size={size}
|
||||
className={cn(className, !current && "text-muted-foreground")}
|
||||
title={current ? capitalize(t(`country.${current.toLowerCase()}`)) : "Select country"}
|
||||
>
|
||||
{current ? capitalize(t(`country.${current.toLowerCase()}`)) : "Select country"}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn("pointer-events-auto p-0", contentClassName)}>
|
||||
<Command
|
||||
filter={(country, search) => {
|
||||
if (t(`country.${country.toLowerCase()}`).toLowerCase().includes(search.toLowerCase()))
|
||||
return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder={t("search.search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("search.noResults")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sortedCountries.map(country => (
|
||||
<CommandItem
|
||||
value={country}
|
||||
content={t(`country.${country.toLowerCase()}`)}
|
||||
key={country}
|
||||
onSelect={country => {
|
||||
onSelect?.(country as Country);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2", country === current ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
{capitalize(t(`country.${country.toLowerCase()}`))}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
86
apps/desktop/src/components/popover/language.tsx
Normal file
86
apps/desktop/src/components/popover/language.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { i18n, type Locale } from "@popcorntime/i18n";
|
||||
import { Button, type ButtonProps } from "@popcorntime/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@popcorntime/ui/components/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@popcorntime/ui/components/popover";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { capitalize } from "@/utils/text";
|
||||
|
||||
export function LanguagePopover({
|
||||
size = "default",
|
||||
className,
|
||||
contentClassName,
|
||||
current,
|
||||
onSelect,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
current: Locale;
|
||||
size?: ButtonProps["size"];
|
||||
onSelect: ((value: Locale) => void) | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sortedLocales = useMemo(() => {
|
||||
return i18n.locales.sort((a, b) => {
|
||||
const localeA = t(`language.${a}`);
|
||||
const localeB = t(`language.${b}`);
|
||||
return localeA.localeCompare(localeB);
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<Popover modal open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
size={size}
|
||||
className={cn(className, !current && "text-muted-foreground")}
|
||||
>
|
||||
{current ? capitalize(t(`language.${current}`)) : "Select language"}
|
||||
<ChevronsUpDown className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn("pointer-events-auto p-0", contentClassName)}>
|
||||
<Command
|
||||
filter={(locale, search) => {
|
||||
if (t(`language.${locale}`).toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder={t("search.search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("search.noResults")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sortedLocales.map(locale => (
|
||||
<CommandItem
|
||||
value={locale}
|
||||
content={t(`language.${locale}`)}
|
||||
key={locale}
|
||||
onSelect={locale => {
|
||||
onSelect?.(locale as Locale);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2", locale === current ? "opacity-100" : "opacity-0")} />
|
||||
{capitalize(t(`language.${locale}`))}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
85
apps/desktop/src/components/provider.tsx
Normal file
85
apps/desktop/src/components/provider.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { type Availability, WatchPriceType } from "@popcorntime/graphql/types";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import emptyProvider from "@/assets/provider.svg";
|
||||
|
||||
export function ProviderIcon({
|
||||
icon,
|
||||
className,
|
||||
alt,
|
||||
}: {
|
||||
alt?: string;
|
||||
icon?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const posterId = useMemo(() => {
|
||||
// `/ID.jpg` is the original poster
|
||||
if (icon) {
|
||||
return icon.match(/\/([^/.]+)\./)?.[1];
|
||||
}
|
||||
}, [icon]);
|
||||
|
||||
if (!posterId) {
|
||||
return (
|
||||
<img
|
||||
src={emptyProvider}
|
||||
alt={alt || ""}
|
||||
width={64}
|
||||
height={64}
|
||||
className={cn(
|
||||
"relative bg-gradient-to-b from-slate-700/20 to-slate-900 p-4 shadow-md transition-transform duration-200 ease-in-out hover:scale-105 hover:shadow-lg hover:shadow-slate-700/20",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<picture>
|
||||
<source srcSet={`https://img.popcorntime.app/o/${posterId}.webp`} type="image/webp" />
|
||||
<img
|
||||
alt={alt}
|
||||
src={`https://img.popcorntime.app/o/${posterId}.jpg`}
|
||||
className={cn(
|
||||
"relative bg-gradient-to-b from-slate-700/20 to-slate-900 shadow-md transition-transform duration-200 ease-in-out hover:scale-105 hover:shadow-lg hover:shadow-slate-700/20",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderText({ availability }: { availability: Availability }) {
|
||||
const { t } = useTranslation();
|
||||
const translateKey = useMemo(() => {
|
||||
if (
|
||||
availability.pricesType?.includes(WatchPriceType.BUY) &&
|
||||
availability.pricesType?.includes(WatchPriceType.RENT)
|
||||
) {
|
||||
return "buy-rent-on";
|
||||
}
|
||||
|
||||
if (availability.pricesType?.includes(WatchPriceType.BUY)) {
|
||||
return "buy-on";
|
||||
}
|
||||
|
||||
if (availability.pricesType?.includes(WatchPriceType.FREE)) {
|
||||
return "free-on";
|
||||
}
|
||||
|
||||
if (availability.pricesType?.includes(WatchPriceType.RENT)) {
|
||||
return "rent-on";
|
||||
}
|
||||
|
||||
return "watch-on";
|
||||
}, [availability.pricesType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{t(`mediaQuickActions.${translateKey}`, {
|
||||
platform: availability.providerName,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
apps/desktop/src/components/splash-screen.tsx
Normal file
44
apps/desktop/src/components/splash-screen.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import Typewriter from "typewriter-effect";
|
||||
import logo from "@/assets/logo_grayscale.png";
|
||||
|
||||
const bootingWords = [
|
||||
"Démarrage...",
|
||||
"Iniciando...",
|
||||
"Startvorgang...",
|
||||
"Avvio...",
|
||||
"Inicializando...",
|
||||
"Загрузка",
|
||||
"جارٍ التشغيل",
|
||||
];
|
||||
|
||||
function shuffle<T>(array: T[]): T[] {
|
||||
const a: T[] = [...array];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
const tmp = a[i];
|
||||
if (tmp && a[j]) {
|
||||
a[i] = a[j];
|
||||
a[j] = tmp;
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
export function SplashScreen() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-6">
|
||||
<img src={logo} alt="Popcorn Time" className="size-18 dark:opacity-80" />
|
||||
<div className="text-muted-foreground text-4xl font-semibold">
|
||||
<Typewriter
|
||||
onInit={typewriter => {
|
||||
// no translation, to prevent flickering
|
||||
typewriter = typewriter.changeDelay(0.01);
|
||||
shuffle(bootingWords).forEach(word => {
|
||||
typewriter = typewriter.changeDelay(0.01).typeString(word).pauseFor(500).deleteAll();
|
||||
});
|
||||
typewriter.start();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
apps/desktop/src/css/crt.css
Normal file
147
apps/desktop/src/css/crt.css
Normal file
@@ -0,0 +1,147 @@
|
||||
.crt-effect-wrapper.sweep-on::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: -30%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: var(--sweep-thickness, 10px);
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
oklch(var(--scanline-base) / 40%),
|
||||
oklch(var(--scanline-base) / 60%),
|
||||
oklch(var(--scanline-base2) / 80%),
|
||||
transparent 100%
|
||||
);
|
||||
animation: sweep-line var(--sweep-duration, 7s) linear infinite;
|
||||
z-index: 9999;
|
||||
filter: blur(1.5px);
|
||||
}
|
||||
|
||||
@keyframes sweep-line {
|
||||
0% {
|
||||
top: -30%;
|
||||
}
|
||||
100% {
|
||||
top: 130%;
|
||||
}
|
||||
}
|
||||
|
||||
.crt-effect-wrapper.scanlines-on::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: repeating-linear-gradient(
|
||||
var(--scanline-gradient-direction, to bottom),
|
||||
var(--border) 0px,
|
||||
var(--border)
|
||||
rgba(var(--scanline-color-rgb, 91, 179, 135), calc(var(--scanline-opacity, 0.035)))
|
||||
var(--scanline-thickness, 2px),
|
||||
transparent var(--scanline-thickness, 2px),
|
||||
transparent calc(var(--scanline-thickness, 2px) + var(--scanline-gap, 3px))
|
||||
);
|
||||
z-index: 449;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.crt-effect-wrapper.edge-glow-on {
|
||||
box-shadow: inset 0 0 var(--edge-glow-size, 30px) var(--edge-glow-color, rgba(0, 255, 128, 0.2));
|
||||
}
|
||||
|
||||
.crt-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.crt-effect-wrapper.sweep-soft::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: -30%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: var(--sweep-thickness, 10px);
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
oklch(var(--scanline-base) / 40%),
|
||||
oklch(var(--scanline-base) / 60%),
|
||||
oklch(var(--scanline-base2) / 80%),
|
||||
transparent 100%
|
||||
);
|
||||
animation: sweep-line var(--sweep-duration, 7s) linear infinite;
|
||||
z-index: 450;
|
||||
filter: blur(5px);
|
||||
}
|
||||
|
||||
.crt-effect-wrapper.flicker-on {
|
||||
animation: crt-flicker var(--flicker-speed, 0.8s) infinite;
|
||||
}
|
||||
|
||||
@keyframes crt-flicker {
|
||||
0%,
|
||||
60%,
|
||||
90%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
10% {
|
||||
opacity: 0.96;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.99;
|
||||
}
|
||||
30% {
|
||||
opacity: 0.97;
|
||||
}
|
||||
40% {
|
||||
opacity: 0.98;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.97;
|
||||
}
|
||||
70% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
80% {
|
||||
opacity: 0.98;
|
||||
}
|
||||
}
|
||||
|
||||
.crt-inner.glitch-on {
|
||||
animation: glitch-fuzz var(--glitch-speed, 0.6s) steps(2, end) infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes glitch-fuzz {
|
||||
0% {
|
||||
transform: translate(0, 0) skew(0deg, 0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translate(-4px, 2px) skew(-1deg, 1deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(3px, -2px) skew(1deg, -1deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-2px, 2px) skew(-0.5deg, 0.5deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) skew(0deg, 0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.crt-vignette {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
var(--border),
|
||||
rgba(0, 0, 0, var(--vignette-intensity, 0.4)) 100%
|
||||
);
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 500;
|
||||
}
|
||||
20
apps/desktop/src/css/styles.css
Normal file
20
apps/desktop/src/css/styles.css
Normal file
@@ -0,0 +1,20 @@
|
||||
/* biome-ignore-all lint/suspicious/noUnknownAtRules: tailwindcss apply */
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply overflow-x-hidden overscroll-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
#root {
|
||||
@apply h-[100vh] w-[100vw] overflow-auto;
|
||||
}
|
||||
.Typewriter {
|
||||
display: inline;
|
||||
}
|
||||
.Typewriter__wrapper,
|
||||
.Typewriter__cursor {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
67
apps/desktop/src/hooks/useCountry.test.tsx
Normal file
67
apps/desktop/src/hooks/useCountry.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { MemoryRouter, Outlet, Route, Routes } from "react-router";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CountryProvider, useCountry } from "@/hooks/useCountry";
|
||||
import { resetGlobalStore, useGlobalStore } from "@/stores/global";
|
||||
|
||||
function CountryProbe() {
|
||||
const { country } = useCountry();
|
||||
return <div data-testid="country">{country}</div>;
|
||||
}
|
||||
|
||||
function LayoutWithCountry() {
|
||||
return (
|
||||
<CountryProvider>
|
||||
<CountryProbe />
|
||||
<Outlet />
|
||||
</CountryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalStore();
|
||||
});
|
||||
|
||||
const renderWithProvider = (initialEntry: string = "/") =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<LayoutWithCountry />}>
|
||||
<Route index element={<div data-testid="default" />} />
|
||||
</Route>
|
||||
<Route path="/browse/:country/:kind" element={<LayoutWithCountry />}>
|
||||
<Route index element={<div data-testid="browse" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("useCountry", () => {
|
||||
it("make sure params country is priorized", async () => {
|
||||
useGlobalStore.setState(s => {
|
||||
s.preferences.country = "US";
|
||||
s.preferences.language = "fr";
|
||||
});
|
||||
|
||||
const r = renderWithProvider("/browse/ca/movies");
|
||||
await act(async () => {});
|
||||
|
||||
expect(r.getByTestId("country").textContent).toBe("CA");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("make sure preferences are respected", async () => {
|
||||
useGlobalStore.setState(s => {
|
||||
s.preferences.country = "NL";
|
||||
s.preferences.language = "nl";
|
||||
});
|
||||
|
||||
const r = renderWithProvider();
|
||||
await act(async () => {});
|
||||
|
||||
expect(r.getByTestId("country").textContent).toBe("NL");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
});
|
||||
39
apps/desktop/src/hooks/useCountry.tsx
Normal file
39
apps/desktop/src/hooks/useCountry.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { type Country, i18n } from "@popcorntime/i18n";
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
export type Context = {
|
||||
country: Country;
|
||||
};
|
||||
|
||||
const CountryContext = createContext<Context>({
|
||||
country: i18n.defaultCountry,
|
||||
});
|
||||
|
||||
export const CountryProvider = ({ children }: { children: ReactNode }) => {
|
||||
const preferedCountry = useGlobalStore(state => state.preferences?.country);
|
||||
const params = useParams<{
|
||||
country: Country;
|
||||
}>();
|
||||
|
||||
const country = useMemo(() => {
|
||||
return (params.country?.toUpperCase() ??
|
||||
preferedCountry?.toUpperCase() ??
|
||||
i18n.defaultCountry.toUpperCase()) as Country;
|
||||
}, [params.country, preferedCountry]);
|
||||
|
||||
return (
|
||||
<CountryContext.Provider
|
||||
value={{
|
||||
country,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CountryContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCountry = () => {
|
||||
return useContext(CountryContext);
|
||||
};
|
||||
36
apps/desktop/src/hooks/useErrorHandler.tsx
Normal file
36
apps/desktop/src/hooks/useErrorHandler.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { error as errorTauri } from "@tauri-apps/plugin-log";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useErrorHandler() {
|
||||
useEffect(() => {
|
||||
const logError = (error: unknown) => {
|
||||
try {
|
||||
errorTauri(String(error));
|
||||
} catch (err) {
|
||||
console.error("Error while trying to log error.", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWindowError = (
|
||||
event: Event | string,
|
||||
_source?: string,
|
||||
_lineno?: number,
|
||||
_colno?: number,
|
||||
error?: Error
|
||||
) => {
|
||||
logError(error || event);
|
||||
};
|
||||
|
||||
const handlePromiseRejection = (event: PromiseRejectionEvent) => {
|
||||
logError(event.reason);
|
||||
};
|
||||
|
||||
window.onerror = handleWindowError;
|
||||
window.onunhandledrejection = handlePromiseRejection;
|
||||
|
||||
return () => {
|
||||
window.onerror = null;
|
||||
window.onunhandledrejection = null;
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
197
apps/desktop/src/hooks/useProviders.test.tsx
Normal file
197
apps/desktop/src/hooks/useProviders.test.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { type ProviderSearchForCountry, WatchPriceType } from "@popcorntime/graphql/types";
|
||||
import type { Country } from "@popcorntime/i18n/types";
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resetGlobalStore, useGlobalStore } from "@/stores/global";
|
||||
|
||||
const ALL = {
|
||||
CA: [
|
||||
{
|
||||
id: "1",
|
||||
key: "netflix",
|
||||
name: "Netflix",
|
||||
priceTypes: [WatchPriceType.FLATRATE],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
key: "hulu",
|
||||
name: "Hulu",
|
||||
priceTypes: [WatchPriceType.FLATRATE],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
key: "disney_plus",
|
||||
name: "Disney+",
|
||||
priceTypes: [WatchPriceType.FLATRATE],
|
||||
},
|
||||
],
|
||||
} satisfies Partial<Record<Country, ProviderSearchForCountry[]>>;
|
||||
|
||||
const initialFavs = [
|
||||
{
|
||||
id: "1",
|
||||
key: "netflix",
|
||||
name: "Netflix",
|
||||
priceTypes: [WatchPriceType.FLATRATE],
|
||||
},
|
||||
];
|
||||
|
||||
let FAVORITES = {
|
||||
CA: initialFavs,
|
||||
} satisfies Partial<Record<Country, ProviderSearchForCountry[]>>;
|
||||
|
||||
import { CountryProvider } from "@/hooks/useCountry";
|
||||
import { type InvokeParams, useProviders } from "@/hooks/useProviders";
|
||||
|
||||
function Harness() {
|
||||
const { getProviders, addToFavorites, removeFromFavorites } = useProviders();
|
||||
const isLoading = useGlobalStore(st => st.providers.isLoading);
|
||||
const providers = useGlobalStore(st => st.providers.providers);
|
||||
const favorites = useGlobalStore(st => st.providers.favorites);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => getProviders("CA")}>
|
||||
load
|
||||
</button>
|
||||
<button type="button" onClick={() => addToFavorites("disney_plus")}>
|
||||
addFav
|
||||
</button>
|
||||
<button type="button" onClick={() => removeFromFavorites("netflix")}>
|
||||
rmFav
|
||||
</button>
|
||||
|
||||
<div data-testid="loading">{String(isLoading)}</div>
|
||||
<div data-testid="all">{JSON.stringify(providers ?? [])}</div>
|
||||
<div data-testid="favs">{JSON.stringify(favorites ?? [])}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithHarness() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<CountryProvider>
|
||||
<Harness />
|
||||
</CountryProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
useGlobalStore.getState().preferences.setPreferences({
|
||||
country: "CA",
|
||||
language: "en",
|
||||
});
|
||||
|
||||
FAVORITES = {
|
||||
CA: initialFavs,
|
||||
};
|
||||
|
||||
mockIPC((cmd, args: unknown) => {
|
||||
if (cmd === "providers") {
|
||||
const { params } = args as { params: InvokeParams };
|
||||
if (params.country !== "CA") return [];
|
||||
const { favorites, country } = params;
|
||||
return favorites ? FAVORITES[country] : ALL[country];
|
||||
}
|
||||
|
||||
if (cmd === "add_favorites_provider") {
|
||||
const { params } = args as {
|
||||
params: { country: Country; providerKey: string };
|
||||
};
|
||||
|
||||
if (params.country !== "CA") return [];
|
||||
|
||||
const base = FAVORITES[params.country] ?? [];
|
||||
const toAdd = (ALL[params.country] ?? []).find(p => p.key === params.providerKey);
|
||||
const exists = base.some(p => p.key === params.providerKey);
|
||||
|
||||
const next = exists || !toAdd ? base.slice() : [...base, toAdd];
|
||||
FAVORITES = { ...FAVORITES, [params.country]: next };
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "remove_favorites_provider") {
|
||||
const { params } = args as {
|
||||
params: { country: Country; providerKey: string };
|
||||
};
|
||||
if (params.country !== "CA") return [];
|
||||
|
||||
const base = FAVORITES[params.country] ?? [];
|
||||
const next = base.filter(p => p.key !== params.providerKey);
|
||||
FAVORITES = { ...FAVORITES, [params.country]: next };
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
resetGlobalStore();
|
||||
});
|
||||
|
||||
describe("useProviders", () => {
|
||||
it("getProviders populates store", async () => {
|
||||
const r = renderWithHarness();
|
||||
await act(async () => {});
|
||||
|
||||
await userEvent.click(screen.getByText("load"));
|
||||
await waitFor(() => {
|
||||
const all = JSON.parse(screen.getByTestId("all").textContent || "[]");
|
||||
const favs = JSON.parse(screen.getByTestId("favs").textContent || "[]");
|
||||
|
||||
expect(all.map((p: ProviderSearchForCountry) => p.key)).toEqual([
|
||||
"netflix",
|
||||
"hulu",
|
||||
"disney_plus",
|
||||
]);
|
||||
expect(favs.map((p: ProviderSearchForCountry) => p.key)).toEqual(["netflix"]);
|
||||
});
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("add favorite", async () => {
|
||||
const r = renderWithHarness();
|
||||
await act(async () => {});
|
||||
|
||||
await userEvent.click(screen.getByText("load"));
|
||||
await waitFor(() => {
|
||||
const favs = JSON.parse(screen.getByTestId("favs").textContent || "[]");
|
||||
expect(favs.map((p: ProviderSearchForCountry) => p.key)).toEqual(["netflix"]);
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("addFav"));
|
||||
await waitFor(() => {
|
||||
const favs = JSON.parse(screen.getByTestId("favs").textContent || "[]") || [];
|
||||
expect(favs.map((p: ProviderSearchForCountry) => p.key)).toEqual(["netflix", "disney_plus"]);
|
||||
});
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("remove favorite", async () => {
|
||||
const r = renderWithHarness();
|
||||
await act(async () => {});
|
||||
|
||||
await userEvent.click(screen.getByText("load"));
|
||||
await waitFor(() => {
|
||||
const favs = JSON.parse(screen.getByTestId("favs").textContent || "[]");
|
||||
expect(favs.map((p: ProviderSearchForCountry) => p.key)).toEqual(["netflix"]);
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByText("rmFav"));
|
||||
await waitFor(() => {
|
||||
const favs = JSON.parse(screen.getByTestId("favs").textContent || "[]") || [];
|
||||
expect(favs.map((p: ProviderSearchForCountry) => p.key)).toEqual([]);
|
||||
});
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
});
|
||||
94
apps/desktop/src/hooks/useProviders.tsx
Normal file
94
apps/desktop/src/hooks/useProviders.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { ProviderSearchForCountry } from "@popcorntime/graphql/types";
|
||||
import type { Country } from "@popcorntime/i18n/types";
|
||||
import { useCallback } from "react";
|
||||
import { useCountry } from "@/hooks/useCountry";
|
||||
import { useTauri } from "@/hooks/useTauri";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
export interface InvokeParams {
|
||||
country: Country;
|
||||
favorites: boolean;
|
||||
}
|
||||
|
||||
export const useProviders = () => {
|
||||
const setInitialized = useGlobalStore(state => state.providers.setInitialized);
|
||||
const setIsLoading = useGlobalStore(state => state.providers.setIsLoading);
|
||||
const setProviders = useGlobalStore(state => state.providers.setProviders);
|
||||
const setFavoriteProviders = useGlobalStore(state => state.providers.setFavorites);
|
||||
const { country } = useCountry();
|
||||
const { invoke } = useTauri();
|
||||
|
||||
const loadProviders = useCallback(
|
||||
async (favorites: boolean, country: Country): Promise<ProviderSearchForCountry[]> => {
|
||||
try {
|
||||
return invoke<ProviderSearchForCountry[]>(
|
||||
"providers",
|
||||
{
|
||||
params: { country: country, favorites },
|
||||
},
|
||||
{
|
||||
hideToast: true,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("failed to load providers", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[invoke]
|
||||
);
|
||||
|
||||
const getProviders = useCallback(
|
||||
async (country: Country) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [fav, all] = await Promise.all([
|
||||
loadProviders(true, country),
|
||||
loadProviders(false, country),
|
||||
]);
|
||||
setFavoriteProviders(fav);
|
||||
setProviders(all);
|
||||
} catch (error) {
|
||||
console.error("failed to load providers", error);
|
||||
} finally {
|
||||
setInitialized();
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[setIsLoading, setFavoriteProviders, setProviders, loadProviders, setInitialized]
|
||||
);
|
||||
|
||||
const addToFavorites = useCallback(
|
||||
async (providerKey: string) => {
|
||||
setIsLoading(true);
|
||||
await invoke<boolean>("add_favorites_provider", {
|
||||
params: { country: country.toUpperCase(), providerKey },
|
||||
});
|
||||
setIsLoading(false);
|
||||
// update favs
|
||||
const favs = await loadProviders(true, country.toUpperCase() as Country);
|
||||
setFavoriteProviders(favs);
|
||||
},
|
||||
[country, loadProviders, invoke, setFavoriteProviders, setIsLoading]
|
||||
);
|
||||
|
||||
const removeFromFavorites = useCallback(
|
||||
async (providerKey: string) => {
|
||||
setIsLoading(true);
|
||||
await invoke<boolean>("remove_favorites_provider", {
|
||||
params: { country: country.toUpperCase(), providerKey },
|
||||
});
|
||||
setIsLoading(false);
|
||||
// update favs
|
||||
const favs = await loadProviders(true, country.toUpperCase() as Country);
|
||||
setFavoriteProviders(favs);
|
||||
},
|
||||
[country, loadProviders, invoke, setFavoriteProviders, setIsLoading]
|
||||
);
|
||||
|
||||
return {
|
||||
getProviders,
|
||||
addToFavorites,
|
||||
removeFromFavorites,
|
||||
};
|
||||
};
|
||||
90
apps/desktop/src/hooks/useSearch.tsx
Normal file
90
apps/desktop/src/hooks/useSearch.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
type MediaSearch,
|
||||
type PageInfo,
|
||||
type SearchArguments,
|
||||
SortKey,
|
||||
} from "@popcorntime/graphql/types";
|
||||
import type { Country, Locale } from "@popcorntime/i18n";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import isEqual from "react-fast-compare";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useTauri } from "@/hooks/useTauri";
|
||||
|
||||
export type SearchParams = {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
country?: Country;
|
||||
language?: Locale;
|
||||
query?: string;
|
||||
arguments?: SearchArguments;
|
||||
sortKey?: SortKey;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type SearchResults = {
|
||||
nodes: Array<MediaSearch>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
// reverted pagination for 'updated at'
|
||||
function toInput(p: SearchParams) {
|
||||
const limit = p.limit ?? 24;
|
||||
const sort = p.sortKey;
|
||||
const reverse = sort === SortKey.UPDATED_AT;
|
||||
return {
|
||||
...p,
|
||||
...(reverse
|
||||
? { last: limit, before: p.cursor || undefined }
|
||||
: { first: limit, after: p.cursor || undefined }),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearch(params: SearchParams, onChange?: (params: SearchParams) => void) {
|
||||
const { invoke } = useTauri();
|
||||
const [data, setData] = useState<null | SearchResults>(null);
|
||||
const [debouncedParams] = useDebounce(params, 300);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const prevParams = useRef<SearchParams | undefined>(undefined);
|
||||
const enabled = params.enabled !== false;
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (
|
||||
!isEqual(debouncedParams.arguments, prevParams.current?.arguments) ||
|
||||
debouncedParams.query !== prevParams.current?.query ||
|
||||
debouncedParams.country !== prevParams.current?.country ||
|
||||
debouncedParams.language !== prevParams.current?.language ||
|
||||
debouncedParams.sortKey !== prevParams.current?.sortKey
|
||||
) {
|
||||
onChange?.(debouncedParams);
|
||||
}
|
||||
|
||||
// sort updated AT by `
|
||||
prevParams.current = debouncedParams;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const results = await invoke<SearchResults>("search_medias", {
|
||||
params: toInput(debouncedParams),
|
||||
});
|
||||
setData(results ?? null);
|
||||
setIsLoading(false);
|
||||
}, [debouncedParams, invoke, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedParams || !enabled) return;
|
||||
if (isEqual(debouncedParams, prevParams.current)) return;
|
||||
if (!debouncedParams.country) return;
|
||||
|
||||
fetch();
|
||||
}, [debouncedParams, fetch, enabled]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
data,
|
||||
fetch,
|
||||
reset: () => {
|
||||
setData(null);
|
||||
setIsLoading(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
128
apps/desktop/src/hooks/useSession.test.tsx
Normal file
128
apps/desktop/src/hooks/useSession.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation } from "react-router";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SessionProvider } from "@/hooks/useSession";
|
||||
import { TauriError } from "@/hooks/useTauri";
|
||||
import { resetGlobalStore, useGlobalStore } from "@/stores/global";
|
||||
import { Code } from "@/utils/error";
|
||||
|
||||
function LocationProbe() {
|
||||
const { pathname } = useLocation();
|
||||
return <div data-testid="loc">{pathname}</div>;
|
||||
}
|
||||
|
||||
const renderWithProvider = (initialIndex: number) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/", "/browse/us", "/login"]} initialIndex={initialIndex}>
|
||||
<SessionProvider>
|
||||
<div data-testid="root" />
|
||||
<LocationProbe />
|
||||
</SessionProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
resetGlobalStore();
|
||||
});
|
||||
|
||||
describe("SessionProvider with mockIPC", () => {
|
||||
it("should not be onboarded and stay on splash", async () => {
|
||||
mockIPC((cmd, _args) => {
|
||||
if (cmd === "validate")
|
||||
throw new TauriError("Invalid session", Code.InvalidSession, undefined);
|
||||
});
|
||||
|
||||
const s = useGlobalStore.getState();
|
||||
s.settings.setOnboarded(true);
|
||||
expect(useGlobalStore.getState().settings.initialized).toBe(true);
|
||||
|
||||
const r = renderWithProvider(0);
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/");
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("should redirect to login (private page)", async () => {
|
||||
mockIPC((cmd, _args) => {
|
||||
if (cmd === "validate")
|
||||
throw new TauriError("Invalid session", Code.InvalidSession, undefined);
|
||||
});
|
||||
|
||||
const s = useGlobalStore.getState();
|
||||
s.settings.setOnboarded(true);
|
||||
expect(useGlobalStore.getState().settings.initialized).toBe(true);
|
||||
|
||||
const r = renderWithProvider(1);
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/login");
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("should initialize app with session", async () => {
|
||||
mockIPC((cmd, _args) => {
|
||||
if (cmd === "validate") null;
|
||||
if (cmd === "user_preferences") return { country: "US", language: "fr" };
|
||||
});
|
||||
|
||||
const s = useGlobalStore.getState();
|
||||
s.settings.setOnboarded(true);
|
||||
expect(useGlobalStore.getState().settings.initialized).toBe(true);
|
||||
expect(useGlobalStore.getState().app.initialized).toBe(false);
|
||||
expect(useGlobalStore.getState().preferences.initialized).toBe(false);
|
||||
expect(useGlobalStore.getState().session.initialized).toBe(false);
|
||||
expect(useGlobalStore.getState().providers.initialized).toBe(false);
|
||||
|
||||
const r = renderWithProvider(1);
|
||||
await act(async () => {});
|
||||
|
||||
expect(useGlobalStore.getState().session.initialized).toBe(true);
|
||||
expect(useGlobalStore.getState().session.isActive).toBe(true);
|
||||
expect(useGlobalStore.getState().providers.initialized).toBe(true);
|
||||
expect(useGlobalStore.getState().preferences.initialized).toBe(true);
|
||||
expect(useGlobalStore.getState().preferences.country).toBe("US");
|
||||
expect(useGlobalStore.getState().preferences.language).toBe("fr");
|
||||
expect(useGlobalStore.getState().app.initialized).toBe(true);
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("should not initialize the app without preferences", async () => {
|
||||
mockIPC((cmd, _args) => {
|
||||
if (cmd === "validate") null;
|
||||
if (cmd === "user_preferences") return null;
|
||||
});
|
||||
|
||||
const s = useGlobalStore.getState();
|
||||
s.settings.setOnboarded(true);
|
||||
expect(useGlobalStore.getState().settings.initialized).toBe(true);
|
||||
expect(useGlobalStore.getState().app.initialized).toBe(false);
|
||||
expect(useGlobalStore.getState().preferences.initialized).toBe(false);
|
||||
expect(useGlobalStore.getState().session.initialized).toBe(false);
|
||||
expect(useGlobalStore.getState().providers.initialized).toBe(false);
|
||||
|
||||
const r = renderWithProvider(1);
|
||||
await act(async () => {});
|
||||
|
||||
expect(useGlobalStore.getState().session.initialized).toBe(true);
|
||||
expect(useGlobalStore.getState().session.isActive).toBe(true);
|
||||
expect(useGlobalStore.getState().preferences.initialized).toBe(true);
|
||||
expect(useGlobalStore.getState().providers.initialized).toBe(false);
|
||||
expect(useGlobalStore.getState().app.initialized).toBe(false);
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(true);
|
||||
|
||||
await act(async () => s.preferences.setPreferences({ country: "US", language: "fr" }));
|
||||
|
||||
expect(useGlobalStore.getState().preferences.country).toBe("US");
|
||||
expect(useGlobalStore.getState().preferences.language).toBe("fr");
|
||||
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(false);
|
||||
expect(useGlobalStore.getState().providers.initialized).toBe(true);
|
||||
expect(useGlobalStore.getState().app.initialized).toBe(true);
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
});
|
||||
147
apps/desktop/src/hooks/useSession.tsx
Normal file
147
apps/desktop/src/hooks/useSession.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { UserPreferences } from "@popcorntime/graphql/types";
|
||||
import type { Country, Locale } from "@popcorntime/i18n";
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useProviders } from "@/hooks/useProviders";
|
||||
import { type TauriError, useTauri } from "@/hooks/useTauri";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import { Code } from "@/utils/error";
|
||||
|
||||
type UpdatePreferencesParams = {
|
||||
country: Country;
|
||||
language: Locale;
|
||||
};
|
||||
|
||||
const PUBLIC_ROUTES = [/^\/$/, /^\/login$/, /^\/onboarding(\/.*)?$/];
|
||||
|
||||
function isPublicRoute(pathname: string) {
|
||||
return PUBLIC_ROUTES.some(rx => rx.test(pathname));
|
||||
}
|
||||
|
||||
type Context = {
|
||||
logout: () => Promise<void>;
|
||||
revalidate: () => Promise<void>;
|
||||
updatePreferences: (params: UpdatePreferencesParams) => Promise<void>;
|
||||
};
|
||||
const SessionContext = createContext<Context>({
|
||||
logout: async () => {},
|
||||
revalidate: async () => {},
|
||||
updatePreferences: async () => {},
|
||||
});
|
||||
|
||||
export const SessionProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { getProviders } = useProviders();
|
||||
const { t } = useTranslation();
|
||||
const setSessionInitialized = useGlobalStore(state => state.session.setInitialized);
|
||||
const setPreferencesInitialized = useGlobalStore(state => state.preferences.setInitialized);
|
||||
const setLoading = useGlobalStore(state => state.session.setIsLoading);
|
||||
const setActive = useGlobalStore(state => state.session.setIsActive);
|
||||
const { country } = useGlobalStore(useShallow(state => state.preferences));
|
||||
const setPreferences = useGlobalStore(state => state.preferences.setPreferences);
|
||||
const isActive = useGlobalStore(state => state.session.isActive);
|
||||
|
||||
const { invoke, listen } = useTauri();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = useRef(navigate);
|
||||
const pathRef = useRef(pathname);
|
||||
useEffect(() => {
|
||||
pathRef.current = pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const revalidate = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await invoke("validate", undefined, {
|
||||
hideConsoleError: true,
|
||||
hideToast: true,
|
||||
});
|
||||
setActive(true);
|
||||
} catch (e) {
|
||||
const err = e as TauriError;
|
||||
setActive(false);
|
||||
if (err.code === Code.InvalidSession && !isPublicRoute(pathRef.current)) {
|
||||
navigateRef.current("/login", { replace: true });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSessionInitialized();
|
||||
}
|
||||
}, [invoke, setActive, setLoading, setSessionInitialized]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await invoke("logout", undefined, { hideConsoleError: true });
|
||||
setActive(false);
|
||||
if (pathRef.current !== "/login") navigate("/", { replace: true });
|
||||
}, [invoke, navigate, setActive]);
|
||||
|
||||
useEffect(() => {
|
||||
// emitted when the session might have changed
|
||||
return listen("popcorntime://session_update", () => {
|
||||
void revalidate();
|
||||
});
|
||||
}, [listen, revalidate]);
|
||||
|
||||
useEffect(() => {
|
||||
void revalidate();
|
||||
}, [revalidate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
invoke<UserPreferences | null>("user_preferences", undefined, {
|
||||
hideConsoleError: true,
|
||||
hideToast: true,
|
||||
})
|
||||
.then(prefs => {
|
||||
setPreferences(prefs ?? undefined);
|
||||
})
|
||||
// fallback to default preferences on error
|
||||
.catch(console.error)
|
||||
.finally(setPreferencesInitialized);
|
||||
}, [isActive, invoke, setPreferences, setPreferencesInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !country) {
|
||||
return;
|
||||
}
|
||||
getProviders(country);
|
||||
}, [isActive, country, getProviders]);
|
||||
|
||||
const updatePreferences = useCallback(
|
||||
async (params: UpdatePreferencesParams) => {
|
||||
try {
|
||||
const preferences = await invoke<Pick<UserPreferences, "country" | "language">>(
|
||||
"update_user_preferences",
|
||||
{ params }
|
||||
);
|
||||
setPreferences(preferences);
|
||||
} catch (err) {
|
||||
toast.error(t("preferences.error"), {
|
||||
dismissible: true,
|
||||
closeButton: true,
|
||||
duration: 5000,
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
[invoke, setPreferences, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{ logout, revalidate, updatePreferences }}>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSession = () => {
|
||||
return useContext(SessionContext);
|
||||
};
|
||||
48
apps/desktop/src/hooks/useSettings.test.tsx
Normal file
48
apps/desktop/src/hooks/useSettings.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SettingsProvider } from "@/hooks/useSettings";
|
||||
import { resetGlobalStore, useGlobalStore } from "@/stores/global";
|
||||
|
||||
const renderWithProvider = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<div data-testid="root" />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
resetGlobalStore();
|
||||
});
|
||||
|
||||
describe("useSettings", () => {
|
||||
it("should not be onboarded", async () => {
|
||||
mockIPC((cmd, _args) => {
|
||||
if (cmd === "is_onboarded") return false;
|
||||
});
|
||||
|
||||
const r = renderWithProvider();
|
||||
await act(async () => {});
|
||||
|
||||
expect(useGlobalStore.getState().settings.onboarded).toBe(false);
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("should be onboarded", async () => {
|
||||
mockIPC((cmd, _args) => {
|
||||
if (cmd === "is_onboarded") return true;
|
||||
});
|
||||
|
||||
const r = renderWithProvider();
|
||||
await act(async () => {});
|
||||
|
||||
expect(useGlobalStore.getState().settings.onboarded).toBe(true);
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
});
|
||||
19
apps/desktop/src/hooks/useSettings.tsx
Normal file
19
apps/desktop/src/hooks/useSettings.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createContext, type ReactNode, useEffect } from "react";
|
||||
import { useTauri } from "@/hooks/useTauri";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
const SettingsContext = createContext(undefined);
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const setOnboarded = useGlobalStore(state => state.settings.setOnboarded);
|
||||
const isActive = useGlobalStore(state => state.session.isActive);
|
||||
const { invoke } = useTauri();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
invoke<boolean>("is_onboarded", undefined).then(setOnboarded);
|
||||
}
|
||||
}, [invoke, setOnboarded, isActive]);
|
||||
|
||||
return <SettingsContext.Provider value={undefined}>{children}</SettingsContext.Provider>;
|
||||
};
|
||||
97
apps/desktop/src/hooks/useTauri.tsx
Normal file
97
apps/desktop/src/hooks/useTauri.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { type InvokeArgs, invoke as invokeTauri } from "@tauri-apps/api/core";
|
||||
import { type EventCallback, type EventName, listen as listenTauri } from "@tauri-apps/api/event";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Code } from "@/utils/error";
|
||||
import { capitalize } from "@/utils/text";
|
||||
|
||||
export class TauriError extends Error {
|
||||
code!: Code;
|
||||
cause: Error | undefined;
|
||||
|
||||
constructor(message: string, code: Code, cause: Error | undefined) {
|
||||
super(message);
|
||||
this.cause = cause;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
static fromError(error: unknown): TauriError {
|
||||
if (error instanceof TauriError) return error;
|
||||
|
||||
let code: Code = Code.Unknown;
|
||||
let message = "Unknown error";
|
||||
let cause: Error | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
cause = error;
|
||||
message = error.message;
|
||||
if ("code" in error && error.code) {
|
||||
code = error.code as Code;
|
||||
}
|
||||
} else if (typeof error === "string") {
|
||||
message = error;
|
||||
} else if (typeof error === "object" && error !== null) {
|
||||
if ("message" in error && typeof error.message === "string") {
|
||||
message = String(error.message);
|
||||
}
|
||||
if ("code" in error) {
|
||||
code = (error.code as Code) ?? Code.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
return new TauriError(capitalize(message), code, cause);
|
||||
}
|
||||
}
|
||||
|
||||
type Options = {
|
||||
hideConsoleError?: boolean;
|
||||
hideToast?: boolean;
|
||||
};
|
||||
|
||||
export function useTauri() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const navRef = useRef(navigate);
|
||||
const tRef = useRef(t);
|
||||
|
||||
const invoke = useCallback(async <T,>(command: string, args?: InvokeArgs, opts?: Options) => {
|
||||
try {
|
||||
return await invokeTauri<T>(command, args);
|
||||
} catch (err) {
|
||||
const tauriError = TauriError.fromError(err);
|
||||
if (opts?.hideConsoleError !== true) {
|
||||
console.error(`tauri->${command}: ${JSON.stringify(args ?? {})}`, tauriError, err);
|
||||
}
|
||||
|
||||
if (tauriError.code === Code.GraphqlServerError) {
|
||||
navRef.current("/maintenance");
|
||||
}
|
||||
|
||||
if (!opts?.hideToast) {
|
||||
toast.error(tRef.current(tauriError.message), {
|
||||
dismissible: true,
|
||||
closeButton: true,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
throw tauriError;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const listen = useCallback(<T,>(event: EventName, handle: EventCallback<T>) => {
|
||||
const unlistenProm = listenTauri(event, handle);
|
||||
return () => {
|
||||
unlistenProm.then(unlisten => {
|
||||
unlisten();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
invoke,
|
||||
listen,
|
||||
};
|
||||
}
|
||||
57
apps/desktop/src/hooks/useUpdater.test.tsx
Normal file
57
apps/desktop/src/hooks/useUpdater.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { UpdaterProvider } from "@/hooks/useUpdater";
|
||||
import { resetGlobalStore, useGlobalStore } from "@/stores/global";
|
||||
import { toast } from "@/test/mock";
|
||||
|
||||
const renderWithProvider = () =>
|
||||
render(
|
||||
<UpdaterProvider>
|
||||
<div data-testid="root" />
|
||||
</UpdaterProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockIPC((cmd, _args) => {
|
||||
if (cmd === "plugin:app|name") return "Popcorn Time";
|
||||
if (cmd === "plugin:app|version") return "1.0.0";
|
||||
if (cmd === "plugin:updater|check") {
|
||||
return {
|
||||
available: true,
|
||||
version: "9.9.9",
|
||||
download: async (
|
||||
_onEvt: (ev: { event: "Started" | "Progress" | "Finished" }) => void
|
||||
) => {},
|
||||
install: async () => {},
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
resetGlobalStore();
|
||||
});
|
||||
|
||||
describe("UpdaterProvider with mockIPC", () => {
|
||||
it("mount check + sets available update", async () => {
|
||||
const r = renderWithProvider();
|
||||
await act(async () => {});
|
||||
|
||||
const s = useGlobalStore.getState();
|
||||
expect(s.updater.status).toBe("available");
|
||||
expect(s.updater.availableUpdate?.version).toBe("9.9.9");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("shows toast on update available", async () => {
|
||||
const r = renderWithProvider();
|
||||
await act(async () => {});
|
||||
|
||||
expect(toast).toHaveBeenCalled();
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
});
|
||||
225
apps/desktop/src/hooks/useUpdater.tsx
Normal file
225
apps/desktop/src/hooks/useUpdater.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { getName, getVersion } from "@tauri-apps/api/app";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { check as checkUpdate, type DownloadEvent } from "@tauri-apps/plugin-updater";
|
||||
import { createContext, useCallback, useContext, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
// 1 hour
|
||||
const UPDATE_INTERVAL_MS = 3600000;
|
||||
export enum UpdateStatus {
|
||||
Available = "available",
|
||||
Manual = "manual",
|
||||
NoUpdate = "no-update",
|
||||
}
|
||||
|
||||
export enum UpdateProgress {
|
||||
Downloading = "downloading",
|
||||
Downloaded = "downloaded",
|
||||
Installing = "installing",
|
||||
Installed = "installed",
|
||||
}
|
||||
|
||||
const downloadStatusMap: { [K in DownloadEvent["event"]]: UpdateProgress } = {
|
||||
Started: UpdateProgress.Downloading,
|
||||
Progress: UpdateProgress.Downloading,
|
||||
Finished: UpdateProgress.Downloaded,
|
||||
};
|
||||
|
||||
export type Context = {
|
||||
check: () => void;
|
||||
downloadAndInstall: () => void;
|
||||
relaunch: () => void;
|
||||
hide: (hide: boolean) => boolean;
|
||||
};
|
||||
|
||||
const UpdaterContext = createContext<Context>({
|
||||
check: () => {},
|
||||
downloadAndInstall: () => {},
|
||||
hide: () => false,
|
||||
relaunch: () => undefined,
|
||||
});
|
||||
|
||||
export const UpdaterProvider = ({ children }: React.PropsWithChildren) => {
|
||||
const { t } = useTranslation();
|
||||
const setLastChecked = useGlobalStore(state => state.updater.setLastChecked);
|
||||
const setStatus = useGlobalStore(state => state.updater.setStatus);
|
||||
const setAvailableUpdate = useGlobalStore(state => state.updater.setAvailableUpdate);
|
||||
const setProgress = useGlobalStore(state => state.updater.setProgress);
|
||||
const setVersion = useGlobalStore(state => state.app.setVersion);
|
||||
const setNightly = useGlobalStore(state => state.app.setNightly);
|
||||
|
||||
const progress = useGlobalStore(state => state.updater.progress);
|
||||
const status = useGlobalStore(state => state.updater.status);
|
||||
const availableUpdate = useGlobalStore(state => state.updater.availableUpdate);
|
||||
const lastChecked = useGlobalStore(state => state.updater.lastChecked);
|
||||
const nightly = useGlobalStore(state => state.app.nightly);
|
||||
|
||||
const check = useCallback(() => {
|
||||
setLastChecked(new Date());
|
||||
checkUpdate().then(update => {
|
||||
if (update?.available) {
|
||||
setStatus(UpdateStatus.Available);
|
||||
} else {
|
||||
setStatus(UpdateStatus.NoUpdate);
|
||||
}
|
||||
setAvailableUpdate(update ?? undefined);
|
||||
});
|
||||
}, [setAvailableUpdate, setLastChecked, setStatus]);
|
||||
|
||||
const installUpdate = useCallback(async () => {
|
||||
if (availableUpdate) {
|
||||
setProgress(UpdateProgress.Installing);
|
||||
await availableUpdate.install();
|
||||
setProgress(UpdateProgress.Installed);
|
||||
}
|
||||
}, [availableUpdate, setProgress]);
|
||||
|
||||
const downloadUpdate = useCallback(async () => {
|
||||
if (availableUpdate) {
|
||||
setProgress(UpdateProgress.Downloading);
|
||||
await availableUpdate.download((progress: DownloadEvent) => {
|
||||
setProgress(downloadStatusMap[progress.event]);
|
||||
});
|
||||
setProgress(UpdateProgress.Downloaded);
|
||||
}
|
||||
}, [availableUpdate, setProgress]);
|
||||
|
||||
const downloadAndInstall = useCallback(() => {
|
||||
if (availableUpdate) {
|
||||
downloadUpdate()
|
||||
.then(installUpdate)
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
toast.error(t("update.error"));
|
||||
});
|
||||
}
|
||||
}, [downloadUpdate, installUpdate, t, availableUpdate]);
|
||||
|
||||
const hide = useCallback(
|
||||
(hide: boolean) => {
|
||||
if (availableUpdate && hide) {
|
||||
toast.dismiss(`update-available-${availableUpdate?.version}`);
|
||||
} else if (availableUpdate && !hide) {
|
||||
toast(t("update.available", { version: availableUpdate?.version }), {
|
||||
id: `update-available-${availableUpdate?.version}`,
|
||||
closeButton: import.meta.env.DEV,
|
||||
dismissible: import.meta.env.DEV,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: t("update.install"),
|
||||
onClick: () => {
|
||||
toast.dismiss(`update-available-${availableUpdate?.version}`);
|
||||
downloadAndInstall();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return availableUpdate !== undefined;
|
||||
},
|
||||
[downloadAndInstall, availableUpdate, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastChecked || Date.now() - lastChecked.getTime() > UPDATE_INTERVAL_MS) {
|
||||
check();
|
||||
}
|
||||
}, [lastChecked, check]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
check();
|
||||
}, UPDATE_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [check]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case UpdateStatus.Available:
|
||||
toast(t("update.available", { version: availableUpdate?.version }), {
|
||||
id: `update-available-${availableUpdate?.version}`,
|
||||
closeButton: import.meta.env.DEV,
|
||||
dismissible: import.meta.env.DEV,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: t("update.install"),
|
||||
onClick: () => {
|
||||
toast.dismiss(`update-available-${availableUpdate?.version}`);
|
||||
downloadAndInstall();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [status, downloadAndInstall, availableUpdate, t]);
|
||||
|
||||
useEffect(() => {
|
||||
switch (progress) {
|
||||
case UpdateProgress.Downloading:
|
||||
toast.loading(t("update.downloading"), {
|
||||
id: `update-progress-${availableUpdate?.version}`,
|
||||
});
|
||||
break;
|
||||
case UpdateProgress.Downloaded:
|
||||
toast.loading(t("update.downloaded"), {
|
||||
id: `update-progress-${availableUpdate?.version}`,
|
||||
});
|
||||
break;
|
||||
case UpdateProgress.Installing:
|
||||
toast.loading(t("update.installing"), {
|
||||
id: `update-progress-${availableUpdate?.version}`,
|
||||
});
|
||||
break;
|
||||
case UpdateProgress.Installed:
|
||||
toast.dismiss(`update-progress-${availableUpdate?.version}`);
|
||||
toast(t("update.installed"), {
|
||||
id: `update-complete-${availableUpdate?.version}`,
|
||||
dismissible: false,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: t("update.relaunch"),
|
||||
onClick: relaunch,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [progress, t, availableUpdate?.version]);
|
||||
|
||||
useEffect(() => {
|
||||
getName().then(name => {
|
||||
setNightly(name.toLowerCase().includes("nightly"));
|
||||
});
|
||||
|
||||
getVersion().then(version => {
|
||||
let suffix = "";
|
||||
if (import.meta.env.DEV) {
|
||||
suffix = "-dev";
|
||||
} else if (nightly) {
|
||||
suffix = "-nightly";
|
||||
}
|
||||
setVersion(`${version}${suffix}`);
|
||||
});
|
||||
}, [nightly, setVersion, setNightly]);
|
||||
|
||||
return (
|
||||
<UpdaterContext.Provider
|
||||
value={{
|
||||
hide,
|
||||
check,
|
||||
downloadAndInstall,
|
||||
relaunch,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UpdaterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdater = () => {
|
||||
return useContext(UpdaterContext);
|
||||
};
|
||||
29
apps/desktop/src/i18n/index.ts
Normal file
29
apps/desktop/src/i18n/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type Locale, locales } from "@popcorntime/i18n/types";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import i18n from "i18next";
|
||||
import resourcesToBackend from "i18next-resources-to-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
async function loader(language: Locale) {
|
||||
if (!locales.includes(language)) {
|
||||
return;
|
||||
}
|
||||
const file_path = await resolveResource(`dictionaries/${language}.json`);
|
||||
const resources = await readTextFile(file_path);
|
||||
return JSON.parse(resources);
|
||||
}
|
||||
|
||||
export function initReactI18n() {
|
||||
return i18n
|
||||
.use(resourcesToBackend(loader))
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
debug: false,
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
// not needed for react as it escapes by default
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
41
apps/desktop/src/layout.tsx
Normal file
41
apps/desktop/src/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { SidebarInset } from "@popcorntime/ui/components/sidebar";
|
||||
import { Outlet } from "react-router";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { Header } from "@/components/header";
|
||||
import { MediaDialog } from "@/components/modal/media";
|
||||
import { PreferencesDialog } from "@/components/modal/preferences";
|
||||
import { WatchPreferencesDialog } from "@/components/modal/watch-preferences";
|
||||
import { CountryProvider } from "@/hooks/useCountry";
|
||||
|
||||
export function DefaultLayout() {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute top-0 left-0 isolate z-40 h-14 w-full" data-tauri-drag-region></div>
|
||||
<Outlet />
|
||||
<PreferencesDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrowseLayout() {
|
||||
return (
|
||||
<CountryProvider>
|
||||
<div className="isolate flex w-full flex-col overscroll-none">
|
||||
<Header />
|
||||
|
||||
<div className="group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-full w-full">
|
||||
<AppSidebar variant="sidebar" />
|
||||
<SidebarInset className="pt-14">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PreferencesDialog />
|
||||
<WatchPreferencesDialog />
|
||||
<MediaDialog />
|
||||
</CountryProvider>
|
||||
);
|
||||
}
|
||||
9
apps/desktop/src/main.tsx
Normal file
9
apps/desktop/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "@/app";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
31
apps/desktop/src/providers.tsx
Normal file
31
apps/desktop/src/providers.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { SidebarProvider } from "@popcorntime/ui/components/sidebar";
|
||||
import { Toaster } from "@popcorntime/ui/components/sonner";
|
||||
import { TooltipProvider } from "@popcorntime/ui/components/tooltip";
|
||||
import { CheckCircle } from "lucide-react";
|
||||
import { SessionProvider } from "@/hooks/useSession";
|
||||
import { UpdaterProvider } from "@/hooks/useUpdater";
|
||||
import { SettingsProvider } from "./hooks/useSettings";
|
||||
|
||||
export function Providers({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<SessionProvider>
|
||||
<UpdaterProvider>
|
||||
<TooltipProvider>
|
||||
<SidebarProvider className="h-full" defaultOpen={false}>
|
||||
{children}
|
||||
<Toaster
|
||||
duration={2000}
|
||||
expand={false}
|
||||
icons={{
|
||||
success: <CheckCircle className="ml-4 size-4" />,
|
||||
}}
|
||||
className="-z-10"
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
</UpdaterProvider>
|
||||
</SessionProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
}
|
||||
163
apps/desktop/src/routes/browse.tsx
Normal file
163
apps/desktop/src/routes/browse.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { type MediaKind, type MediaSearch, SortKey } from "@popcorntime/graphql/types";
|
||||
import { BrowseMedias } from "@popcorntime/ui/blocks/browse";
|
||||
import { useSidebar, useSidebarGroup } from "@popcorntime/ui/components/sidebar";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useInfiniteScroll from "react-infinite-scroll-hook";
|
||||
import { useLocation, useParams } from "react-router";
|
||||
import placeholderImg from "@/assets/placeholder.svg";
|
||||
import { BrowseSidebarGroup } from "@/components/browse/sidebar";
|
||||
import { useCountry } from "@/hooks/useCountry";
|
||||
import { type SearchParams, useSearch } from "@/hooks/useSearch";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
const SORTS = [
|
||||
{ key: SortKey.POSITION, label: "popularity" },
|
||||
{ key: SortKey.UPDATED_AT, label: "updated" },
|
||||
] as const;
|
||||
|
||||
export function BrowseRoute() {
|
||||
const { country } = useCountry();
|
||||
const initialized = useGlobalStore(state => state.app.initialized);
|
||||
const globalArgs = useGlobalStore(state => state.browse.args);
|
||||
const sortKey = useGlobalStore(state => state.browse.sortKey);
|
||||
const query = useGlobalStore(state => state.browse.query);
|
||||
const openMediaDialog = useGlobalStore(state => state.dialogs.media.open);
|
||||
const { t } = useTranslation();
|
||||
const [dataAccumulator, setDataAccumulator] = useState<MediaSearch[]>([]);
|
||||
const { setOpen: setOpenSidebar } = useSidebar();
|
||||
const { pathname } = useLocation();
|
||||
const { kind } = useParams<{ kind: "movie" | "tv_show" }>();
|
||||
const setSortKey = useGlobalStore(state => state.browse.setSortKey);
|
||||
|
||||
const args = useMemo(() => {
|
||||
return {
|
||||
...globalArgs,
|
||||
kind: kind?.toUpperCase() as MediaKind | undefined,
|
||||
};
|
||||
}, [globalArgs, kind]);
|
||||
const prevQuery = useRef([query, args, sortKey]);
|
||||
const prevPathname = useRef(pathname);
|
||||
|
||||
const sortKeys = useMemo(
|
||||
() =>
|
||||
SORTS.map(sort => {
|
||||
return {
|
||||
key: sort.key,
|
||||
label: t(`sortBy.${sort.label}`),
|
||||
current: sort.key === sortKey,
|
||||
};
|
||||
}),
|
||||
[sortKey, t]
|
||||
);
|
||||
|
||||
// Register the sidebar group for this route
|
||||
useSidebarGroup(useMemo(() => <BrowseSidebarGroup />, []));
|
||||
|
||||
const [browseParams, setBrowseParams] = useState<SearchParams>({
|
||||
limit: 50,
|
||||
country: country,
|
||||
sortKey: sortKey,
|
||||
arguments: args,
|
||||
query: query,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useSearch(browseParams, () => {
|
||||
setDataAccumulator([]);
|
||||
});
|
||||
|
||||
const hasNextPage = useMemo(
|
||||
() => data?.pageInfo.hasNextPage ?? false,
|
||||
[data?.pageInfo.hasNextPage]
|
||||
);
|
||||
|
||||
const cursor = useMemo(() => data?.pageInfo.endCursor ?? null, [data?.pageInfo.endCursor]);
|
||||
|
||||
useEffect(() => {
|
||||
setBrowseParams(prev => {
|
||||
return {
|
||||
...prev,
|
||||
country,
|
||||
};
|
||||
});
|
||||
}, [country]);
|
||||
|
||||
const onLoadMore = useCallback(async () => {
|
||||
if (!hasNextPage || !cursor) return;
|
||||
setBrowseParams(prev => {
|
||||
return {
|
||||
...prev,
|
||||
cursor,
|
||||
};
|
||||
});
|
||||
}, [hasNextPage, cursor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDataAccumulator(prev => {
|
||||
const newData = data.nodes.filter(node => !prev.some(existing => existing.id === node.id));
|
||||
return [...prev, ...newData];
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevQuery.current[0] === query &&
|
||||
prevQuery.current[1] === args &&
|
||||
prevQuery.current[2] === sortKey
|
||||
)
|
||||
return;
|
||||
prevQuery.current = [query, args, sortKey];
|
||||
setBrowseParams(prev => {
|
||||
return {
|
||||
...prev,
|
||||
query: query,
|
||||
arguments: args,
|
||||
sortKey: sortKey,
|
||||
// reset cursor when query changes
|
||||
cursor: undefined,
|
||||
};
|
||||
});
|
||||
}, [query, args, sortKey]);
|
||||
|
||||
// FIXME: allow filter for TV SHOW
|
||||
useEffect(() => {
|
||||
if (prevPathname.current === pathname) return;
|
||||
prevPathname.current = pathname;
|
||||
// always close the sidebar when browsing
|
||||
// as tv show currently doesn't support it
|
||||
setOpenSidebar(false);
|
||||
}, [setOpenSidebar, pathname]);
|
||||
|
||||
const [sentryRef] = useInfiniteScroll({
|
||||
loading: isLoading,
|
||||
hasNextPage,
|
||||
onLoadMore,
|
||||
rootMargin: "0px 0px 500px 0px",
|
||||
});
|
||||
|
||||
return (
|
||||
<BrowseMedias
|
||||
sentryRef={sentryRef}
|
||||
medias={dataAccumulator}
|
||||
onOpen={openMediaDialog}
|
||||
placeholder={placeholderImg}
|
||||
isReady={!isLoading && initialized && dataAccumulator.length > 0}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={onLoadMore}
|
||||
onSortChange={setSortKey}
|
||||
sortKeys={sortKeys}
|
||||
translations={{
|
||||
free: t("media.free"),
|
||||
kind: {
|
||||
movie: t("media.movie"),
|
||||
tvShow: t("media.tv-show"),
|
||||
},
|
||||
loading: t("browse.loading"),
|
||||
loadMore: t("browse.load-more"),
|
||||
sortBy: t("sortBy.label"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
100
apps/desktop/src/routes/login.test.tsx
Normal file
100
apps/desktop/src/routes/login.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { mockIPC } from "@tauri-apps/api/mocks";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { LoginRoute } from "@/routes/login";
|
||||
import { resetGlobalStore, useGlobalStore } from "@/stores/global";
|
||||
import { pluginShellOpen } from "@/test/mock";
|
||||
|
||||
const AUTH_URL = "https://popcorntime.app/authorize";
|
||||
|
||||
function LocationProbe() {
|
||||
const { pathname } = useLocation();
|
||||
return <div data-testid="loc">{pathname}</div>;
|
||||
}
|
||||
|
||||
function renderWithRouter() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<div data-testid="login">
|
||||
<LoginRoute />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<div data-testid="splash" />} />
|
||||
</Routes>
|
||||
<LocationProbe />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockIPC(
|
||||
(cmd, _args) => {
|
||||
if (cmd === "initialize_session_authorization") {
|
||||
emit("popcorntime://session_server_ready", {
|
||||
authorizeUrl: AUTH_URL,
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ shouldMockEvents: true }
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalStore();
|
||||
});
|
||||
|
||||
describe("LoginRoute", () => {
|
||||
it("shows login", async () => {
|
||||
const r = renderWithRouter();
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("login")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/login");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("open oauth2 window", async () => {
|
||||
const r = renderWithRouter();
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("login")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/login");
|
||||
|
||||
const btn = screen.getByRole("button", { name: "Login" });
|
||||
await act(async () => {
|
||||
btn.click();
|
||||
});
|
||||
|
||||
expect(pluginShellOpen).toHaveBeenCalledWith(AUTH_URL);
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("redirect back to splash", async () => {
|
||||
const r = renderWithRouter();
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("login")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/login");
|
||||
|
||||
await act(async () =>
|
||||
useGlobalStore.setState(s => {
|
||||
s.app.initialized = true;
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("splash")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
});
|
||||
59
apps/desktop/src/routes/login.tsx
Normal file
59
apps/desktop/src/routes/login.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Button, buttonVariants } from "@popcorntime/ui/components/button";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import logo from "@/assets/logo.png";
|
||||
import { useTauri } from "@/hooks/useTauri";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
type Event = {
|
||||
authorizeUrl: string;
|
||||
};
|
||||
|
||||
export function LoginRoute() {
|
||||
const { invoke, listen } = useTauri();
|
||||
const appInitialized = useGlobalStore(state => state.app.initialized);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function initialize_session_authorization() {
|
||||
await invoke("initialize_session_authorization");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return listen<Event>("popcorntime://session_server_ready", event => {
|
||||
open(event.payload.authorizeUrl);
|
||||
});
|
||||
}, [listen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appInitialized) return;
|
||||
navigate("/");
|
||||
}, [appInitialized, navigate]);
|
||||
|
||||
return (
|
||||
<main className="flex h-full">
|
||||
<div className="m-auto flex w-xs flex-col items-center justify-center gap-4">
|
||||
<img src={logo} alt="Popcorn Time" className="size-12 xl:size-14 dark:opacity-80" />
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
initialize_session_authorization();
|
||||
}}
|
||||
>
|
||||
<Button size="xl" type="submit">
|
||||
Login
|
||||
</Button>
|
||||
<Link
|
||||
className={buttonVariants({ variant: "link", size: "xl" })}
|
||||
to="https://watch.popcorntime.app/signup"
|
||||
target="_blank"
|
||||
>
|
||||
No account? Signup
|
||||
</Link>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
20
apps/desktop/src/routes/maintenance.tsx
Normal file
20
apps/desktop/src/routes/maintenance.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { buttonVariants } from "@popcorntime/ui/components/button";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export function MaintenanceRoute() {
|
||||
return (
|
||||
<main className="relative isolate flex h-full flex-col items-center justify-center py-20 text-center sm:py-32">
|
||||
<p className="text-sm font-semibold text-gray-300">503</p>
|
||||
<h1 className="mt-2 text-3xl font-medium tracking-tight text-gray-300">
|
||||
Service unavailable
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-gray-600">
|
||||
The server is currently unavailable (because it is overloaded or down for maintenance).
|
||||
</p>
|
||||
<Link to="/" className={cn("mt-6", buttonVariants({ variant: "secondary" }))}>
|
||||
Refresh
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
18
apps/desktop/src/routes/not-found.tsx
Normal file
18
apps/desktop/src/routes/not-found.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { buttonVariants } from "@popcorntime/ui/components/button";
|
||||
import { cn } from "@popcorntime/ui/lib/utils";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export function NotFoundRoute() {
|
||||
return (
|
||||
<main className="relative isolate flex h-full flex-col items-center justify-center py-20 text-center sm:py-32">
|
||||
<p className="text-sm font-semibold text-gray-300">404</p>
|
||||
<h1 className="mt-2 text-3xl font-medium tracking-tight text-gray-300">Page not found</h1>
|
||||
<p className="mt-2 text-lg text-gray-600">
|
||||
Sorry, we couldn't find the page you're looking for.
|
||||
</p>
|
||||
<Link to="/" className={cn("mt-6", buttonVariants({ variant: "secondary" }))}>
|
||||
Go back home
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
15
apps/desktop/src/routes/onboarding.tsx
Normal file
15
apps/desktop/src/routes/onboarding.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { OnboardingManifest } from "@/components/onboarding/manifest";
|
||||
import { OnboardingTimeline } from "@/components/onboarding/timeline";
|
||||
import { OnboardingWelcome } from "@/components/onboarding/welcome";
|
||||
|
||||
export function OnboardingWelcomeRoute() {
|
||||
return <OnboardingWelcome />;
|
||||
}
|
||||
|
||||
export function OnboardingTimelineRoute() {
|
||||
return <OnboardingTimeline />;
|
||||
}
|
||||
|
||||
export function OnboardingManifestRoute() {
|
||||
return <OnboardingManifest />;
|
||||
}
|
||||
146
apps/desktop/src/routes/splash.test.tsx
Normal file
146
apps/desktop/src/routes/splash.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { SplashRoute } from "@/routes/splash";
|
||||
import { resetGlobalStore, useGlobalStore } from "@/stores/global";
|
||||
|
||||
function LocationProbe() {
|
||||
const { pathname } = useLocation();
|
||||
return <div data-testid="loc">{pathname}</div>;
|
||||
}
|
||||
|
||||
function renderWithRouter(initialPath = "/") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<div data-testid="splash">
|
||||
<SplashRoute />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Route path="/onboarding" element={<div data-testid="onboarding" />} />
|
||||
<Route path="/login" element={<div data-testid="login" />} />
|
||||
<Route path="/browse/:country" element={<div data-testid="browse" />} />
|
||||
</Routes>
|
||||
<LocationProbe />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalStore();
|
||||
});
|
||||
|
||||
describe("SplashRoute", () => {
|
||||
it("shows Splash while boot is not initialized", async () => {
|
||||
useGlobalStore.setState(s => {
|
||||
s.app.initialized = false;
|
||||
s.app.bootInitialized = false;
|
||||
s.settings.onboarded = false;
|
||||
s.session.isActive = false;
|
||||
});
|
||||
|
||||
const r = renderWithRouter("/");
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("splash")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("redirects to onboarding when not onboarded", async () => {
|
||||
useGlobalStore.setState(s => {
|
||||
s.app.bootInitialized = true;
|
||||
});
|
||||
|
||||
const r = renderWithRouter("/");
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("onboarding")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/onboarding");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("redirects to login when onboarded but session is not active", async () => {
|
||||
useGlobalStore.setState(s => {
|
||||
s.app.initialized = true;
|
||||
s.app.bootInitialized = true;
|
||||
s.settings.onboarded = true;
|
||||
});
|
||||
|
||||
const r = renderWithRouter("/");
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("login")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/login");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("shows splash when active but app not initialized", async () => {
|
||||
useGlobalStore.setState(s => {
|
||||
s.app.bootInitialized = true;
|
||||
s.settings.onboarded = true;
|
||||
s.session.isActive = true;
|
||||
// missing providers
|
||||
});
|
||||
|
||||
const r = renderWithRouter("/");
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("splash")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("redirects to browser when active and app initialized", async () => {
|
||||
useGlobalStore.setState(s => {
|
||||
s.app.initialized = true;
|
||||
s.app.bootInitialized = true;
|
||||
// prevent onboarding
|
||||
s.settings.onboarded = true;
|
||||
// prevent login
|
||||
s.session.isActive = true;
|
||||
s.preferences.country = "CA";
|
||||
});
|
||||
|
||||
const r = renderWithRouter("/");
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("browse")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/browse/ca");
|
||||
r.unmount();
|
||||
});
|
||||
|
||||
it("reacts when app initialization flips", async () => {
|
||||
useGlobalStore.setState(s => {
|
||||
s.app.bootInitialized = false;
|
||||
s.settings.onboarded = true;
|
||||
});
|
||||
|
||||
const r = renderWithRouter("/");
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId("splash")).toBeInTheDocument();
|
||||
|
||||
await act(async () =>
|
||||
useGlobalStore.setState(s => {
|
||||
s.app.bootInitialized = true;
|
||||
s.app.initialized = true;
|
||||
s.session.isActive = true;
|
||||
s.preferences.country = "FR";
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("browse")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loc")).toHaveTextContent("/browse/fr");
|
||||
|
||||
r.unmount();
|
||||
});
|
||||
});
|
||||
23
apps/desktop/src/routes/splash.tsx
Normal file
23
apps/desktop/src/routes/splash.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { i18n } from "@popcorntime/i18n/types";
|
||||
import { Navigate } from "react-router";
|
||||
import { SplashScreen } from "@/components/splash-screen";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
|
||||
export function SplashRoute() {
|
||||
const isActive = useGlobalStore(s => s.session.isActive);
|
||||
const onboarded = useGlobalStore(s => s.settings.onboarded);
|
||||
const appInitialized = useGlobalStore(s => s.app.initialized);
|
||||
const bootInitialized = useGlobalStore(s => s.app.bootInitialized);
|
||||
const country = useGlobalStore(s => s.preferences.country);
|
||||
|
||||
if (!bootInitialized) return <SplashScreen />;
|
||||
if (!onboarded) return <Navigate to="/onboarding" replace />;
|
||||
|
||||
if (isActive) {
|
||||
if (!appInitialized) return <SplashScreen />;
|
||||
const goto = (country ?? i18n.defaultCountry).toLowerCase();
|
||||
return <Navigate to={`/browse/${goto}`} replace />;
|
||||
}
|
||||
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
68
apps/desktop/src/stores/command-center.test.ts
Normal file
68
apps/desktop/src/stores/command-center.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resetCommandCenterStore, useCommandCenterStore } from "@/stores/command-center";
|
||||
|
||||
beforeEach(() => {
|
||||
resetCommandCenterStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetCommandCenterStore();
|
||||
});
|
||||
|
||||
describe("useCommandCenterStore", () => {
|
||||
it("defaults", () => {
|
||||
const s = useCommandCenterStore.getState();
|
||||
expect(s.isOpen).toBe(false);
|
||||
expect(s.isLoading).toBe(false);
|
||||
expect(s.query).toBeUndefined();
|
||||
expect(s.view).toBe("main");
|
||||
});
|
||||
|
||||
it("toggle open/close", () => {
|
||||
const s = useCommandCenterStore.getState();
|
||||
s.toggle();
|
||||
expect(useCommandCenterStore.getState().isOpen).toBe(true);
|
||||
s.toggle();
|
||||
expect(useCommandCenterStore.getState().isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("set loading", () => {
|
||||
const s = useCommandCenterStore.getState();
|
||||
s.setIsLoading(true);
|
||||
expect(useCommandCenterStore.getState().isLoading).toBe(true);
|
||||
s.setIsLoading(false);
|
||||
expect(useCommandCenterStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("set query", () => {
|
||||
const s = useCommandCenterStore.getState();
|
||||
s.setQuery("batman");
|
||||
expect(useCommandCenterStore.getState().query).toBe("batman");
|
||||
s.setQuery(undefined);
|
||||
expect(useCommandCenterStore.getState().query).toBeUndefined();
|
||||
});
|
||||
|
||||
it("goto switches view", () => {
|
||||
const s = useCommandCenterStore.getState();
|
||||
s.goto("country-selection");
|
||||
expect(useCommandCenterStore.getState().view).toBe("country-selection");
|
||||
s.goto("search-result");
|
||||
expect(useCommandCenterStore.getState().view).toBe("search-result");
|
||||
});
|
||||
|
||||
it("reset returns to defaults", () => {
|
||||
const s = useCommandCenterStore.getState();
|
||||
s.toggle();
|
||||
s.setIsLoading(true);
|
||||
s.setQuery("hello");
|
||||
s.goto("country-selection");
|
||||
|
||||
s.reset();
|
||||
|
||||
const st = useCommandCenterStore.getState();
|
||||
expect(st.isOpen).toBe(false);
|
||||
expect(st.isLoading).toBe(false);
|
||||
expect(st.query).toBeUndefined();
|
||||
expect(st.view).toBe("main");
|
||||
});
|
||||
});
|
||||
114
apps/desktop/src/stores/command-center.ts
Normal file
114
apps/desktop/src/stores/command-center.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { create } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { devtools } from "@/stores/devtools";
|
||||
|
||||
export type CommandId = "main" | "movies" | "tv-shows" | "change-region" | "populars";
|
||||
export type View = "main" | "search-result" | "country-selection";
|
||||
|
||||
export interface CommandGroup {
|
||||
label: string;
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: CommandId;
|
||||
label: string;
|
||||
keywords: string[];
|
||||
view?: View;
|
||||
}
|
||||
|
||||
export interface CommandCenterState {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
query?: string;
|
||||
setQuery: (query?: string) => void;
|
||||
view: View;
|
||||
goto: (view: View) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const defaultCommands: CommandGroup[] = [
|
||||
{
|
||||
label: "Navigation",
|
||||
commands: [
|
||||
{
|
||||
id: "main",
|
||||
label: "Home",
|
||||
keywords: ["home", "start"],
|
||||
view: "main",
|
||||
},
|
||||
{
|
||||
id: "populars",
|
||||
label: "Populars",
|
||||
keywords: ["populars", "trending"],
|
||||
},
|
||||
{
|
||||
id: "movies",
|
||||
label: "Movies",
|
||||
keywords: ["movies", "films"],
|
||||
},
|
||||
{
|
||||
id: "tv-shows",
|
||||
label: "TV Shows",
|
||||
keywords: ["tv", "shows", "series", "episodes"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Change Region",
|
||||
commands: [
|
||||
{
|
||||
id: "change-region",
|
||||
label: "Change Region",
|
||||
keywords: ["region", "country", "location"],
|
||||
view: "country-selection",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const useCommandCenterStore = create<CommandCenterState>()(
|
||||
devtools(
|
||||
immer(set => ({
|
||||
isOpen: false,
|
||||
toggle: () =>
|
||||
set(state => {
|
||||
state.isOpen = !state.isOpen;
|
||||
}),
|
||||
isLoading: false,
|
||||
setIsLoading: loading =>
|
||||
set(state => {
|
||||
state.isLoading = loading;
|
||||
}),
|
||||
query: undefined,
|
||||
setQuery: query =>
|
||||
set(state => {
|
||||
state.query = query;
|
||||
}),
|
||||
view: "main",
|
||||
goto: view =>
|
||||
set(state => {
|
||||
state.view = view;
|
||||
}),
|
||||
reset: () =>
|
||||
set(state => {
|
||||
state.isOpen = false;
|
||||
state.query = undefined;
|
||||
state.isLoading = false;
|
||||
state.view = "main";
|
||||
}),
|
||||
})),
|
||||
{
|
||||
name: "Command Center",
|
||||
port: 8000,
|
||||
realtime: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const resetCommandCenterStore = () => {
|
||||
const initial = useCommandCenterStore.getInitialState();
|
||||
useCommandCenterStore.setState(initial, true);
|
||||
};
|
||||
139
apps/desktop/src/stores/devtools.ts
Normal file
139
apps/desktop/src/stores/devtools.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { create as createSocket } from "socketcluster-client";
|
||||
import type { StateCreator, StoreMutatorIdentifier } from "zustand";
|
||||
|
||||
const generateArray = (length: number) => Array.from({ length }, (_, i) => i);
|
||||
|
||||
const ACTION_TYPES = {
|
||||
INIT: "@@INIT",
|
||||
NEW_STATE: "@@NEW_STATE",
|
||||
PAUSED: "@@PAUSED",
|
||||
RESUMED: "@@RESUMED",
|
||||
};
|
||||
|
||||
interface Config {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
realtime?: boolean;
|
||||
secure?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const devtools = <
|
||||
T,
|
||||
Mps extends [StoreMutatorIdentifier, unknown][] = [],
|
||||
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
|
||||
>(
|
||||
fn: StateCreator<T, Mps, Mcs>,
|
||||
options: Config = {}
|
||||
): StateCreator<T, Mps, Mcs> => {
|
||||
return (set, get, api) => {
|
||||
const {
|
||||
hostname,
|
||||
port,
|
||||
secure,
|
||||
name: instanceId,
|
||||
} = {
|
||||
name: "Popcorn Time",
|
||||
hostname: "localhost",
|
||||
port: 8000,
|
||||
secure: false,
|
||||
...options,
|
||||
} satisfies Config;
|
||||
|
||||
if (!import.meta.env.DEV || import.meta.env.VITEST) {
|
||||
return fn(set, get, api);
|
||||
}
|
||||
|
||||
const socket = createSocket({ hostname, port, secure });
|
||||
|
||||
let nextActionId = 0;
|
||||
let initialState: T;
|
||||
const actionsById: Record<number, unknown> = {};
|
||||
const computedStates: Array<{ state: T }> = [];
|
||||
|
||||
let isPaused = false;
|
||||
|
||||
const pushNewState = (state: T, actionType: string) => {
|
||||
actionsById[nextActionId++] = {
|
||||
type: "PERFORM_ACTION",
|
||||
action: { type: actionType },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
computedStates.push({ state });
|
||||
};
|
||||
|
||||
const sendMessage = (type: string, payload: unknown, forceSend?: boolean) => {
|
||||
if (forceSend || !isPaused) {
|
||||
socket.transmit("log", {
|
||||
type,
|
||||
...(type === "ACTION" ? { action: { type: ACTION_TYPES.NEW_STATE } } : {}),
|
||||
payload,
|
||||
instanceId,
|
||||
id: socket.id,
|
||||
nextActionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sendActualState = () => {
|
||||
sendMessage(
|
||||
"STATE",
|
||||
{
|
||||
monitorState: {},
|
||||
actionsById,
|
||||
nextActionId,
|
||||
stagedActionIds: generateArray(nextActionId),
|
||||
skippedActionIds: [],
|
||||
committedState: initialState,
|
||||
currentStateIndex: nextActionId,
|
||||
computedStates,
|
||||
isLocked: false,
|
||||
isPaused,
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
const handleInit = () => {
|
||||
initialState = get();
|
||||
pushNewState(initialState, "@@INIT");
|
||||
sendMessage("INIT", initialState);
|
||||
};
|
||||
|
||||
socket.invoke("login", "master").then(async (channelName: string) => {
|
||||
handleInit();
|
||||
for await (const { type, action } of socket.subscribe(channelName)) {
|
||||
switch (type) {
|
||||
case "DISPATCH":
|
||||
switch (action.type) {
|
||||
case "PAUSE_RECORDING":
|
||||
isPaused = action.status;
|
||||
pushNewState(get(), isPaused ? ACTION_TYPES.PAUSED : ACTION_TYPES.RESUMED);
|
||||
sendActualState();
|
||||
break;
|
||||
default:
|
||||
console.log("Unsupported dispatch type:", action.type);
|
||||
}
|
||||
break;
|
||||
case "START":
|
||||
sendActualState();
|
||||
break;
|
||||
default:
|
||||
console.log("Unsupported type:", type);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return fn(
|
||||
((state: T, replace: true) => {
|
||||
(set as (s: T, r: boolean) => unknown)(state, replace);
|
||||
const newState = get();
|
||||
pushNewState(newState, ACTION_TYPES.NEW_STATE);
|
||||
sendMessage("ACTION", newState);
|
||||
}) as typeof set,
|
||||
get,
|
||||
api
|
||||
);
|
||||
};
|
||||
};
|
||||
200
apps/desktop/src/stores/global.test.ts
Normal file
200
apps/desktop/src/stores/global.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { type ProviderSearchForCountry, SortKey } from "@popcorntime/graphql/types";
|
||||
import i18next from "i18next";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetGlobalStore, useGlobalStore } from "@/stores/global";
|
||||
|
||||
const mkProv = (key: string) => ({ key }) as ProviderSearchForCountry;
|
||||
const setAllReady = () => {
|
||||
const s = useGlobalStore.getState();
|
||||
s.providers.setInitialized();
|
||||
s.session.setInitialized();
|
||||
s.preferences.setInitialized();
|
||||
s.settings.setOnboarded(true);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetGlobalStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalStore();
|
||||
});
|
||||
|
||||
describe("i18n", () => {
|
||||
it("updates locale and direction & calls i18next.changeLanguage", () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mock
|
||||
const spy = vi.spyOn(i18next, "changeLanguage").mockResolvedValue({} as any);
|
||||
expect(useGlobalStore.getState().i18n.locale).toBe("en");
|
||||
expect(document.documentElement.getAttribute("dir")).toBe(null);
|
||||
|
||||
// action
|
||||
useGlobalStore.getState().i18n.setLocale("ar");
|
||||
|
||||
// effect
|
||||
expect(spy).toHaveBeenCalledWith("ar");
|
||||
expect(useGlobalStore.getState().i18n.direction).toBe("rtl");
|
||||
expect(document.documentElement.getAttribute("dir")).toBe("rtl");
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("preferences.setPreferences(language) also updates i18n.locale", () => {
|
||||
useGlobalStore.getState().preferences.setPreferences({ language: "fr", country: "CA" });
|
||||
expect(useGlobalStore.getState().i18n.locale).toBe("fr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("boot & app flags", () => {
|
||||
it("sets app.initialized when all dependencies are ready", () => {
|
||||
expect(useGlobalStore.getState().app.initialized).toBe(false);
|
||||
setAllReady();
|
||||
expect(useGlobalStore.getState().app.initialized).toBe(true);
|
||||
});
|
||||
|
||||
it("sets app.bootInitialized when session + settings are ready", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
expect(s.app.bootInitialized).toBe(false);
|
||||
s.session.setInitialized();
|
||||
s.settings.setOnboarded(true);
|
||||
expect(useGlobalStore.getState().app.bootInitialized).toBe(true);
|
||||
});
|
||||
|
||||
it("sets version & nightly", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
s.app.setVersion("1.2.3");
|
||||
s.app.setNightly(true);
|
||||
expect(useGlobalStore.getState().app.version).toBe("1.2.3");
|
||||
expect(useGlobalStore.getState().app.nightly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("favorites sync → browse.args.providers", () => {
|
||||
it("writes providers keys when providers.initialized && preferFavorites", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
s.providers.setInitialized();
|
||||
// preferFavorites defaults to true in your store
|
||||
s.providers.setFavorites([mkProv("netflix"), mkProv("hulu"), mkProv("netflix")]);
|
||||
|
||||
const args = useGlobalStore.getState().browse.args;
|
||||
expect(args?.providers).toEqual(["netflix", "hulu"]);
|
||||
});
|
||||
|
||||
it("removes providers filter when preferFavorites toggles off", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
s.providers.setInitialized();
|
||||
s.providers.setFavorites([mkProv("netflix")]);
|
||||
expect(useGlobalStore.getState().browse.args?.providers).toEqual(["netflix"]);
|
||||
|
||||
s.browse.togglePreferFavorites(); // -> false
|
||||
expect(useGlobalStore.getState().browse.args?.providers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("browse setters", () => {
|
||||
it("sets query/cursor/args/sortKey", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
s.browse.setQuery("batman");
|
||||
s.browse.setCursor("abc123");
|
||||
s.browse.setArgs({ year: 2024 });
|
||||
s.browse.setSortKey(SortKey.CREATED_AT);
|
||||
|
||||
const st = useGlobalStore.getState().browse;
|
||||
expect(st.query).toBe("batman");
|
||||
expect(st.cursor).toBe("abc123");
|
||||
expect(st.args).toEqual({ year: 2024 });
|
||||
expect(st.sortKey).toBe(SortKey.CREATED_AT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dialogs behavior", () => {
|
||||
it("media.open sets slug and isOpen, toggle clears slug and flips isOpen", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
|
||||
expect(s.dialogs.media.isOpen).toBe(false);
|
||||
expect(s.dialogs.media.slug).toBeUndefined();
|
||||
|
||||
s.dialogs.media.open("some-slug");
|
||||
expect(useGlobalStore.getState().dialogs.media.isOpen).toBe(true);
|
||||
expect(useGlobalStore.getState().dialogs.media.slug).toBe("some-slug");
|
||||
|
||||
s.dialogs.media.toggle();
|
||||
expect(useGlobalStore.getState().dialogs.media.isOpen).toBe(false);
|
||||
expect(useGlobalStore.getState().dialogs.media.slug).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preferences.toggle is prevented when prefs not set, but allowed otherwise", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
|
||||
// not initialized initially; open -> allowed
|
||||
s.dialogs.preferences.toggle();
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(true);
|
||||
|
||||
// mark preferences initialized with nothing set -> prevent closing
|
||||
s.preferences.setInitialized();
|
||||
s.dialogs.preferences.toggle();
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(true);
|
||||
|
||||
// set preferences -> auto closing
|
||||
s.preferences.setPreferences({ country: "CA", language: "en" });
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-opens preferences when session active & app initialized but missing prefs", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
setAllReady();
|
||||
|
||||
expect(useGlobalStore.getState().app.initialized).toBe(true);
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(false);
|
||||
|
||||
s.session.setIsActive(true);
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(true);
|
||||
|
||||
s.preferences.setPreferences({ country: "CA", language: "en" });
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(false);
|
||||
|
||||
// should be closable
|
||||
s.dialogs.preferences.toggle();
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(true);
|
||||
s.dialogs.preferences.toggle();
|
||||
expect(useGlobalStore.getState().dialogs.preferences.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/logout reset", () => {
|
||||
it("resetGlobalStore is called on logout (isActive=false) and clears state", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
setAllReady();
|
||||
|
||||
// mutate some state
|
||||
s.app.setVersion("9.9.9");
|
||||
s.browse.setQuery("x");
|
||||
s.session.setIsActive(true);
|
||||
|
||||
// now simulate logout
|
||||
s.session.setIsActive(false);
|
||||
|
||||
const st = useGlobalStore.getState();
|
||||
expect(st.app.version).toBeUndefined();
|
||||
expect(st.browse.query).toBeUndefined();
|
||||
expect(st.providers.initialized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updater setters", () => {
|
||||
it("sets status/progress/availableUpdate/lastChecked", () => {
|
||||
const s = useGlobalStore.getState();
|
||||
|
||||
s.updater.setStatus("available");
|
||||
s.updater.setProgress("downloading");
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test store reactvitiy
|
||||
s.updater.setAvailableUpdate({ version: "1.0.0" } as any);
|
||||
const when = new Date();
|
||||
s.updater.setLastChecked(when);
|
||||
|
||||
const u = useGlobalStore.getState().updater;
|
||||
expect(u.status).toBe("available");
|
||||
expect(u.progress).toBe("downloading");
|
||||
expect(u.availableUpdate?.version).toBe("1.0.0");
|
||||
expect(u.lastChecked?.getTime()).toBe(when.getTime());
|
||||
});
|
||||
});
|
||||
427
apps/desktop/src/stores/global.ts
Normal file
427
apps/desktop/src/stores/global.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
type ProviderSearchForCountry,
|
||||
type SearchArguments,
|
||||
SortKey,
|
||||
} from "@popcorntime/graphql/types";
|
||||
import { i18n } from "@popcorntime/i18n";
|
||||
import type { Country, Locale } from "@popcorntime/i18n/types";
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
import i18next from "i18next";
|
||||
import { getLangDir } from "rtl-detect";
|
||||
import { create } from "zustand";
|
||||
import { subscribeWithSelector } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { devtools } from "@/stores/devtools";
|
||||
|
||||
type UpdateStatus = "available" | "manual" | "no-update";
|
||||
type UpdateProgress = "downloading" | "downloaded" | "installing" | "installed";
|
||||
|
||||
export interface GlobalState {
|
||||
i18n: {
|
||||
/** Application locale */
|
||||
locale: Locale;
|
||||
direction: "ltr" | "rtl";
|
||||
/**
|
||||
* Update application locale
|
||||
* @param locale Locale
|
||||
*/
|
||||
setLocale: (locale: Locale) => void;
|
||||
};
|
||||
session: {
|
||||
/** Session initialized */
|
||||
initialized: boolean;
|
||||
setInitialized: () => void;
|
||||
|
||||
/** Session active */
|
||||
isActive: boolean;
|
||||
setIsActive: (isActive: boolean) => void;
|
||||
/** Session is currenly validating */
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
};
|
||||
preferences: {
|
||||
/** Preferences initialized */
|
||||
initialized: boolean;
|
||||
setInitialized: () => void;
|
||||
country?: Country;
|
||||
language?: Locale;
|
||||
setPreferences: (preferences?: { country: Country; language: Locale }) => void;
|
||||
};
|
||||
settings: {
|
||||
/** Session initialized */
|
||||
initialized: boolean;
|
||||
|
||||
/** Whether onboarding flow has been completed */
|
||||
onboarded: boolean;
|
||||
setOnboarded: (onboarded: boolean) => void;
|
||||
};
|
||||
app: {
|
||||
/** Application is ready and browsing can start */
|
||||
initialized: boolean;
|
||||
/** Depedency are ready */
|
||||
bootInitialized: boolean;
|
||||
/** Current application version */
|
||||
version: string | undefined;
|
||||
setVersion: (version: string) => void;
|
||||
/** Determine if we are running nightly version */
|
||||
nightly: boolean;
|
||||
setNightly: (nightly: boolean) => void;
|
||||
};
|
||||
updater: {
|
||||
status: UpdateStatus;
|
||||
setStatus: (status: UpdateStatus) => void;
|
||||
progress?: UpdateProgress;
|
||||
setProgress: (progress?: UpdateProgress) => void;
|
||||
availableUpdate?: Update;
|
||||
setAvailableUpdate: (update?: Update) => void;
|
||||
lastChecked?: Date;
|
||||
setLastChecked: (date?: Date) => void;
|
||||
};
|
||||
providers: {
|
||||
initialized: boolean;
|
||||
setInitialized: () => void;
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
providers: ProviderSearchForCountry[];
|
||||
setProviders: (providers: ProviderSearchForCountry[]) => void;
|
||||
favorites: ProviderSearchForCountry[];
|
||||
setFavorites: (favorites: ProviderSearchForCountry[]) => void;
|
||||
};
|
||||
browse: {
|
||||
/** Search query */
|
||||
query?: string;
|
||||
setQuery: (query?: string) => void;
|
||||
/** Current browsing cursor */
|
||||
cursor?: string;
|
||||
setCursor: (cursor?: string) => void;
|
||||
/** Current browsing args */
|
||||
args?: SearchArguments;
|
||||
setArgs: (args?: SearchArguments) => void;
|
||||
/** Current browsing sort key */
|
||||
sortKey: SortKey;
|
||||
setSortKey: (sortKey: SortKey) => void;
|
||||
/** Whether to show only favorite providers content */
|
||||
preferFavorites: boolean;
|
||||
togglePreferFavorites: () => void;
|
||||
};
|
||||
dialogs: {
|
||||
media: {
|
||||
/** Slug the dialog should load */
|
||||
slug?: string;
|
||||
open: (slug?: string) => void;
|
||||
/** Dialog is open */
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
};
|
||||
preferences: {
|
||||
/** Dialog is open */
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
};
|
||||
watchPreferences: {
|
||||
/** Dialog is open */
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const useGlobalStore = create<GlobalState>()(
|
||||
devtools(
|
||||
subscribeWithSelector(
|
||||
immer(set => ({
|
||||
i18n: {
|
||||
locale: i18n.defaultLocale,
|
||||
direction: getLangDir(i18n.defaultLocale),
|
||||
setLocale: (locale: Locale) =>
|
||||
set(state => {
|
||||
state.i18n.locale = locale;
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
initialized: false,
|
||||
isActive: false,
|
||||
isLoading: false,
|
||||
setInitialized: () =>
|
||||
set(state => {
|
||||
state.session.initialized = true;
|
||||
}),
|
||||
setIsActive: (isActive: boolean) =>
|
||||
set(state => {
|
||||
state.session.isActive = isActive;
|
||||
}),
|
||||
setIsLoading: (isLoading: boolean) =>
|
||||
set(state => {
|
||||
state.session.isLoading = isLoading;
|
||||
}),
|
||||
},
|
||||
settings: {
|
||||
initialized: false,
|
||||
onboarded: false,
|
||||
setOnboarded: (onboarded: boolean) =>
|
||||
set(state => {
|
||||
state.settings.onboarded = onboarded;
|
||||
state.settings.initialized = true;
|
||||
}),
|
||||
},
|
||||
preferences: {
|
||||
initialized: false,
|
||||
setInitialized: () =>
|
||||
set(state => {
|
||||
state.preferences.initialized = true;
|
||||
}),
|
||||
setPreferences: preferences =>
|
||||
set(state => {
|
||||
// FIXME: would worth moving into a subscription?
|
||||
if (preferences?.language) {
|
||||
state.i18n.locale = preferences.language;
|
||||
}
|
||||
state.preferences.country = preferences?.country;
|
||||
state.preferences.language = preferences?.language;
|
||||
state.dialogs.preferences.isOpen = false;
|
||||
}),
|
||||
},
|
||||
app: {
|
||||
initialized: false,
|
||||
bootInitialized: false,
|
||||
version: undefined,
|
||||
setVersion: (version: string) =>
|
||||
set(state => {
|
||||
state.app.version = version;
|
||||
}),
|
||||
nightly: false,
|
||||
setNightly: (nightly: boolean) =>
|
||||
set(state => {
|
||||
state.app.nightly = nightly;
|
||||
}),
|
||||
},
|
||||
updater: {
|
||||
status: "no-update",
|
||||
setStatus: (status: UpdateStatus) =>
|
||||
set(state => {
|
||||
state.updater.status = status;
|
||||
}),
|
||||
setProgress: (progress?: UpdateProgress) =>
|
||||
set(state => {
|
||||
state.updater.progress = progress;
|
||||
}),
|
||||
setAvailableUpdate: (update?: Update) =>
|
||||
set(state => {
|
||||
state.updater.availableUpdate = update;
|
||||
}),
|
||||
setLastChecked: (date?: Date) =>
|
||||
set(state => {
|
||||
state.updater.lastChecked = date;
|
||||
}),
|
||||
},
|
||||
providers: {
|
||||
initialized: false,
|
||||
isLoading: false,
|
||||
providers: [],
|
||||
favorites: [],
|
||||
setInitialized: () =>
|
||||
set(state => {
|
||||
state.providers.initialized = true;
|
||||
}),
|
||||
setIsLoading: (isLoading: boolean) =>
|
||||
set(state => {
|
||||
state.providers.isLoading = isLoading;
|
||||
}),
|
||||
setProviders: (providers: ProviderSearchForCountry[]) =>
|
||||
set(state => {
|
||||
state.providers.providers = providers;
|
||||
}),
|
||||
setFavorites: (favorites: ProviderSearchForCountry[]) =>
|
||||
set(state => {
|
||||
state.providers.favorites = favorites;
|
||||
}),
|
||||
},
|
||||
browse: {
|
||||
query: undefined,
|
||||
cursor: undefined,
|
||||
args: undefined,
|
||||
sortKey: SortKey.POSITION,
|
||||
preferFavorites: true,
|
||||
setQuery: (query?: string) =>
|
||||
set(state => {
|
||||
state.browse.query = query;
|
||||
}),
|
||||
setCursor: (cursor?: string) =>
|
||||
set(state => {
|
||||
state.browse.cursor = cursor;
|
||||
}),
|
||||
setArgs: (args?: SearchArguments) =>
|
||||
set(state => {
|
||||
state.browse.args = args;
|
||||
}),
|
||||
setSortKey: (sortKey: SortKey) =>
|
||||
set(state => {
|
||||
state.browse.sortKey = sortKey;
|
||||
}),
|
||||
togglePreferFavorites: () =>
|
||||
set(state => {
|
||||
state.browse.preferFavorites = !state.browse.preferFavorites;
|
||||
}),
|
||||
},
|
||||
dialogs: {
|
||||
media: {
|
||||
slug: undefined,
|
||||
isOpen: false,
|
||||
open: (slug?: string) =>
|
||||
set(state => {
|
||||
state.dialogs.media.isOpen = true;
|
||||
state.dialogs.media.slug = slug;
|
||||
}),
|
||||
toggle: () =>
|
||||
set(state => {
|
||||
state.dialogs.media.slug = undefined;
|
||||
state.dialogs.media.isOpen = !state.dialogs.media.isOpen;
|
||||
}),
|
||||
},
|
||||
preferences: {
|
||||
isOpen: false,
|
||||
toggle: () =>
|
||||
set(state => {
|
||||
if (
|
||||
state.dialogs.preferences.isOpen &&
|
||||
state.preferences.initialized &&
|
||||
state.preferences.country === undefined &&
|
||||
state.preferences.language === undefined
|
||||
) {
|
||||
// prevent closing preferences if not set
|
||||
return;
|
||||
}
|
||||
state.dialogs.preferences.isOpen = !state.dialogs.preferences.isOpen;
|
||||
}),
|
||||
},
|
||||
watchPreferences: {
|
||||
isOpen: false,
|
||||
toggle: () =>
|
||||
set(state => {
|
||||
state.dialogs.watchPreferences.isOpen = !state.dialogs.watchPreferences.isOpen;
|
||||
}),
|
||||
},
|
||||
},
|
||||
}))
|
||||
),
|
||||
{
|
||||
name: "Global",
|
||||
port: 8000,
|
||||
realtime: true,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const resetGlobalStore = () => {
|
||||
const initial = useGlobalStore.getInitialState();
|
||||
useGlobalStore.setState(initial, true);
|
||||
};
|
||||
|
||||
function syncFavorites(favorites: ProviderSearchForCountry[]) {
|
||||
const {
|
||||
providers,
|
||||
browse: { preferFavorites },
|
||||
} = useGlobalStore.getState();
|
||||
if (!providers.initialized) return;
|
||||
if (!preferFavorites) return;
|
||||
const keys = Array.from(new Set(favorites.map(p => p.key)));
|
||||
useGlobalStore.setState(state => {
|
||||
state.browse.args ||= {};
|
||||
state.browse.args.providers = keys;
|
||||
});
|
||||
}
|
||||
|
||||
// i18n can be updated by preferences update as well
|
||||
// we keep it as a subscription to avoid circular updates
|
||||
useGlobalStore.subscribe(
|
||||
state => state.i18n.locale,
|
||||
locale => {
|
||||
i18next.changeLanguage(locale);
|
||||
const dir = getLangDir(locale);
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.setAttribute("dir", dir);
|
||||
}
|
||||
useGlobalStore.setState(state => {
|
||||
state.i18n.direction = dir;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
useGlobalStore.subscribe(
|
||||
state => state.providers.favorites,
|
||||
favorites => syncFavorites(favorites)
|
||||
);
|
||||
|
||||
useGlobalStore.subscribe(
|
||||
state => state.browse.preferFavorites,
|
||||
preferFavorites => {
|
||||
if (preferFavorites) {
|
||||
syncFavorites(useGlobalStore.getState().providers.favorites);
|
||||
} else {
|
||||
useGlobalStore.setState(state => {
|
||||
if (!state.browse.args) return;
|
||||
delete state.browse.args.providers;
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// all dependencies are ready
|
||||
useGlobalStore.subscribe(
|
||||
state =>
|
||||
state.providers.initialized &&
|
||||
state.session.initialized &&
|
||||
state.preferences.initialized &&
|
||||
state.settings.initialized,
|
||||
ready => {
|
||||
if (ready && !useGlobalStore.getState().app.initialized) {
|
||||
useGlobalStore.setState(state => {
|
||||
state.app.initialized = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
{ equalityFn: Object.is }
|
||||
);
|
||||
|
||||
// we dont include providers & preferences as they are
|
||||
// not required for boot, only for browsing
|
||||
useGlobalStore.subscribe(
|
||||
state => state.session.initialized && state.settings.initialized,
|
||||
ready => {
|
||||
if (ready && !useGlobalStore.getState().app.bootInitialized) {
|
||||
useGlobalStore.setState(state => {
|
||||
state.app.bootInitialized = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
{ equalityFn: Object.is }
|
||||
);
|
||||
|
||||
useGlobalStore.subscribe(
|
||||
state => state.session.isActive,
|
||||
isActive => {
|
||||
if (!isActive) {
|
||||
// reset initial state on logout
|
||||
// we could have a more elegant way to do that
|
||||
resetGlobalStore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// FIXME: should we open?
|
||||
useGlobalStore.subscribe(
|
||||
state =>
|
||||
state.app.bootInitialized &&
|
||||
state.session.isActive &&
|
||||
state.settings.onboarded &&
|
||||
state.preferences.initialized &&
|
||||
(!state.preferences.country || !state.preferences.language) &&
|
||||
!state.dialogs.preferences.isOpen,
|
||||
ready => {
|
||||
if (!ready) return;
|
||||
useGlobalStore.setState(state => {
|
||||
state.dialogs.preferences.isOpen = true;
|
||||
});
|
||||
},
|
||||
{ equalityFn: Object.is }
|
||||
);
|
||||
11
apps/desktop/src/test/mock.ts
Normal file
11
apps/desktop/src/test/mock.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: mock */
|
||||
const base = vi.fn<(message: unknown, data?: unknown) => string | number>();
|
||||
(base as any).success = vi.fn();
|
||||
(base as any).info = vi.fn();
|
||||
(base as any).warning = vi.fn();
|
||||
(base as any).error = vi.fn();
|
||||
(base as any).loading = vi.fn();
|
||||
(base as any).dismiss = vi.fn();
|
||||
|
||||
export const toast = base as unknown as typeof import("sonner").toast;
|
||||
export const pluginShellOpen = vi.fn();
|
||||
43
apps/desktop/src/test/setup.ts
Normal file
43
apps/desktop/src/test/setup.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import i18n from "i18next";
|
||||
import resourcesToBackend from "i18next-resources-to-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { pluginShellOpen, toast } from "@/test/mock";
|
||||
import { Code } from "@/utils/error";
|
||||
|
||||
const dicts = import.meta.glob("../../crates/popcorntime-tauri/dictionaries/*.json", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
}) as Record<string, Record<string, unknown>>;
|
||||
|
||||
i18n
|
||||
.use(
|
||||
resourcesToBackend((lng: string) => {
|
||||
const key = Object.keys(dicts).find(p => p.endsWith(`${lng}.json`));
|
||||
return key ? dicts[key] : {};
|
||||
})
|
||||
)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
debug: false,
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
vi.mock("sonner", () => ({ toast }));
|
||||
vi.mock("zustand");
|
||||
|
||||
vi.mock("@tauri-apps/plugin-shell", async () => {
|
||||
const actual = await vi.importActual("@tauri-apps/plugin-shell");
|
||||
return {
|
||||
...actual,
|
||||
open: pluginShellOpen,
|
||||
};
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", err => {
|
||||
if ((err as { code?: string })?.code === Code.InvalidSession) return;
|
||||
throw err;
|
||||
});
|
||||
5
apps/desktop/src/utils/error.ts
Normal file
5
apps/desktop/src/utils/error.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum Code {
|
||||
Unknown = "errors.unknown",
|
||||
GraphqlServerError = "errors.graphql.server",
|
||||
InvalidSession = "errors.session.invalid",
|
||||
}
|
||||
6
apps/desktop/src/utils/text.tsx
Normal file
6
apps/desktop/src/utils/text.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export function capitalize(str: string): string {
|
||||
if (str.length === 0) {
|
||||
return str;
|
||||
}
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
2
apps/desktop/src/vite-env.d.ts
vendored
Normal file
2
apps/desktop/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vitest/globals" />
|
||||
33
apps/desktop/tsconfig.json
Normal file
33
apps/desktop/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "@popcorntime/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@popcorntime/ui/*": ["../../packages/popcorntime-ui/src/*"],
|
||||
"@popcorntime/i18n/*": ["../../packages/popcorntime-i18n/src/*"],
|
||||
"@popcorntime/i18n/dictionaries/*": ["../../packages/popcorntime-i18n/dictionaries/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
apps/desktop/tsconfig.node.json
Normal file
13
apps/desktop/tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
33
apps/desktop/vite.config.ts
Normal file
33
apps/desktop/vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import path, { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [tailwindcss(), react()],
|
||||
clearScreen: false,
|
||||
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: { manualChunks: {} },
|
||||
target: "modules",
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
||||
},
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
fs: {
|
||||
strict: false,
|
||||
},
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
21
apps/desktop/vitest.config.ts
Normal file
21
apps/desktop/vitest.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "./vite.config.js";
|
||||
|
||||
export default defineConfig(configEnv =>
|
||||
mergeConfig(
|
||||
viteConfig(configEnv),
|
||||
defineConfig({
|
||||
root: "./src",
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
43
biome.json
Normal file
43
biome.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["apps/**", "!**/dist"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "tab",
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 100,
|
||||
"indentWidth": 2,
|
||||
"attributePosition": "auto",
|
||||
"bracketSameLine": false,
|
||||
"expand": "auto",
|
||||
"useEditorconfig": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "es5",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "asNeeded",
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "double",
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
},
|
||||
"html": { "formatter": { "selfCloseVoidElements": "always" } },
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": { "source": { "organizeImports": "on" } }
|
||||
}
|
||||
}
|
||||
9
crates/popcorntime-error/Cargo.toml
Normal file
9
crates/popcorntime-error/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "popcorntime-error"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
103
crates/popcorntime-error/src/lib.rs
Normal file
103
crates/popcorntime-error/src/lib.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use std::{borrow::Cow, fmt::Debug};
|
||||
|
||||
#[derive(Debug, Default, Copy, Clone, PartialOrd, PartialEq)]
|
||||
pub enum Code {
|
||||
#[default]
|
||||
Unknown,
|
||||
GraphqlServerError,
|
||||
DatabaseNotAvailable,
|
||||
InvalidSession,
|
||||
GraphqlNoData,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Code {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let code = match self {
|
||||
Code::Unknown => "errors.unknown",
|
||||
Code::GraphqlServerError => "errors.graphql.server",
|
||||
Code::InvalidSession => "errors.session.invalid",
|
||||
Code::DatabaseNotAvailable => "errors.database.not_available",
|
||||
Code::GraphqlNoData => "errors.graphql.no_data",
|
||||
};
|
||||
f.write_str(code)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct Context {
|
||||
pub code: Code,
|
||||
pub message: Option<Cow<'static, str>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Context {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.message.as_deref().unwrap_or("Something went wrong"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Code> for Context {
|
||||
fn from(code: Code) -> Self {
|
||||
Context {
|
||||
code,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// Create a new instance with `code` and an owned `message`.
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Context {
|
||||
code: Code::Unknown,
|
||||
message: Some(Cow::Owned(message.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new instance with `code` and a statically known `message`.
|
||||
pub const fn new_static(code: Code, message: &'static str) -> Self {
|
||||
Context {
|
||||
code,
|
||||
message: Some(Cow::Borrowed(message)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust the `code` of this instance to the given one.
|
||||
pub fn with_code(mut self, code: Code) -> Self {
|
||||
self.code = code;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
mod private {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
|
||||
/// A way to obtain attached Code or context information from `anyhow` contexts, so that
|
||||
/// the more complete information is preferred.
|
||||
pub trait AnyhowContextExt: private::Sealed {
|
||||
/// Return our custom context that might be attached to this instance.
|
||||
///
|
||||
/// Note that it could not be named `context()` as this method already exists.
|
||||
fn custom_context(&self) -> Option<Context>;
|
||||
|
||||
/// Return our custom context or default it to the root-cause of the error.
|
||||
fn custom_context_or_root_cause(&self) -> Context;
|
||||
}
|
||||
|
||||
impl private::Sealed for anyhow::Error {}
|
||||
impl AnyhowContextExt for anyhow::Error {
|
||||
fn custom_context(&self) -> Option<Context> {
|
||||
if let Some(ctx) = self.downcast_ref::<Context>() {
|
||||
Some(ctx.clone())
|
||||
} else {
|
||||
self.downcast_ref::<Code>().map(|code| (*code).into())
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_context_or_root_cause(&self) -> Context {
|
||||
self.custom_context().unwrap_or_else(|| Context {
|
||||
code: Code::Unknown,
|
||||
message: Some(self.root_cause().to_string().into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
16
crates/popcorntime-graphql-client/Cargo.toml
Normal file
16
crates/popcorntime-graphql-client/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "popcorntime-graphql-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
graphql_client = "0.14.0"
|
||||
popcorntime-error = { workspace = true }
|
||||
popcorntime-graphql-macros = { path = "macros" }
|
||||
105
crates/popcorntime-graphql-client/gql/media.graphql
Normal file
105
crates/popcorntime-graphql-client/gql/media.graphql
Normal file
@@ -0,0 +1,105 @@
|
||||
query Media($slug: String!, $country: Country!, $language: Language) {
|
||||
media(by: { slug: $slug }, country: $country, language: $language) {
|
||||
id
|
||||
__typename
|
||||
title
|
||||
slug
|
||||
overview
|
||||
tagline
|
||||
languages
|
||||
poster
|
||||
backdrop
|
||||
released
|
||||
year
|
||||
country
|
||||
tags
|
||||
trailers
|
||||
genres
|
||||
classification
|
||||
countries
|
||||
kind
|
||||
|
||||
videos {
|
||||
source
|
||||
videoId
|
||||
}
|
||||
|
||||
ratings {
|
||||
rating
|
||||
source
|
||||
}
|
||||
|
||||
ranking {
|
||||
score
|
||||
position
|
||||
points
|
||||
}
|
||||
|
||||
pochoclinReview(language: $language) {
|
||||
review
|
||||
excerpt
|
||||
}
|
||||
|
||||
similars(country: $country, language: $language) {
|
||||
title
|
||||
overview
|
||||
kind
|
||||
slug
|
||||
poster
|
||||
year
|
||||
}
|
||||
|
||||
similarsFree: similars(
|
||||
country: $country
|
||||
arguments: { country: $country, priceTypes: FREE }
|
||||
) {
|
||||
title
|
||||
overview
|
||||
kind
|
||||
slug
|
||||
poster
|
||||
year
|
||||
}
|
||||
|
||||
charts(country: $country, language: $language) {
|
||||
title
|
||||
kind
|
||||
slug
|
||||
poster
|
||||
year
|
||||
rank {
|
||||
position
|
||||
change
|
||||
points
|
||||
previousPoints
|
||||
}
|
||||
}
|
||||
|
||||
availabilities(country: $country) {
|
||||
providerId
|
||||
providerName
|
||||
logo
|
||||
availableTo
|
||||
urlHash
|
||||
audioLanguages
|
||||
subtitleLanguages
|
||||
pricesType
|
||||
}
|
||||
|
||||
talents {
|
||||
id
|
||||
rank
|
||||
name
|
||||
role
|
||||
roleType
|
||||
}
|
||||
|
||||
... on Movie {
|
||||
runtime
|
||||
}
|
||||
|
||||
... on TVShow {
|
||||
inProduction
|
||||
}
|
||||
}
|
||||
}
|
||||
13
crates/popcorntime-graphql-client/gql/preferences.graphql
Normal file
13
crates/popcorntime-graphql-client/gql/preferences.graphql
Normal file
@@ -0,0 +1,13 @@
|
||||
query Preferences {
|
||||
preferences {
|
||||
language
|
||||
country
|
||||
}
|
||||
}
|
||||
|
||||
mutation UpdatePreferences($country: Country!, $language: Language!) {
|
||||
updatePreferences(country: $country, language: $language) {
|
||||
country
|
||||
language
|
||||
}
|
||||
}
|
||||
18
crates/popcorntime-graphql-client/gql/providers.graphql
Normal file
18
crates/popcorntime-graphql-client/gql/providers.graphql
Normal file
@@ -0,0 +1,18 @@
|
||||
query Providers($country: Country!, $query: String, $favorites: Boolean) {
|
||||
providers(country: $country, query: $query, favorites: $favorites) {
|
||||
key
|
||||
name
|
||||
logo
|
||||
weight
|
||||
priceTypes
|
||||
parentKey
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddFavoriteProvider($country: Country!, $providerKey: String!) {
|
||||
addFavoriteProvider(country: $country, providerKey: $providerKey)
|
||||
}
|
||||
|
||||
mutation RemoveFavoriteProvider($country: Country!, $providerKey: String!) {
|
||||
removeFavoriteProvider(country: $country, providerKey: $providerKey)
|
||||
}
|
||||
5824
crates/popcorntime-graphql-client/gql/schema.json
Normal file
5824
crates/popcorntime-graphql-client/gql/schema.json
Normal file
File diff suppressed because it is too large
Load Diff
44
crates/popcorntime-graphql-client/gql/search.graphql
Normal file
44
crates/popcorntime-graphql-client/gql/search.graphql
Normal file
@@ -0,0 +1,44 @@
|
||||
query Search(
|
||||
$after: String
|
||||
$before: String
|
||||
$first: Int
|
||||
$last: Int
|
||||
$sortKey: SortKey
|
||||
$country: Country!
|
||||
$language: Language
|
||||
$query: String
|
||||
$arguments: SearchArguments
|
||||
) {
|
||||
search(
|
||||
after: $after
|
||||
before: $before
|
||||
first: $first
|
||||
last: $last
|
||||
country: $country
|
||||
language: $language
|
||||
sortKey: $sortKey
|
||||
query: $query
|
||||
arguments: $arguments
|
||||
) {
|
||||
nodes {
|
||||
id
|
||||
slug
|
||||
kind
|
||||
title
|
||||
overview
|
||||
poster
|
||||
backdrop
|
||||
released
|
||||
updatedAt
|
||||
providers {
|
||||
providerId
|
||||
priceTypes
|
||||
}
|
||||
year
|
||||
}
|
||||
pageInfo {
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
16
crates/popcorntime-graphql-client/macros/Cargo.toml
Normal file
16
crates/popcorntime-graphql-client/macros/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "popcorntime-graphql-macros"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
syn = { version = "2", features = ["full"] }
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
anyhow.workspace = true
|
||||
convert_case.workspace = true
|
||||
52
crates/popcorntime-graphql-client/macros/src/lib.rs
Normal file
52
crates/popcorntime-graphql-client/macros/src/lib.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use convert_case::{Case, Casing};
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{
|
||||
parse::{Parse, ParseStream},
|
||||
parse_macro_input, Ident, LitStr, Token,
|
||||
};
|
||||
|
||||
struct Args {
|
||||
name: Ident,
|
||||
_c1: Token![,],
|
||||
query: LitStr,
|
||||
}
|
||||
|
||||
impl Parse for Args {
|
||||
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||
Ok(Self {
|
||||
name: input.parse()?,
|
||||
_c1: input.parse()?,
|
||||
query: input.parse()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn define_graphql_query(input: TokenStream) -> TokenStream {
|
||||
let Args { name, query, .. } = parse_macro_input!(input as Args);
|
||||
let module = Ident::new(&name.to_string().to_case(Case::Snake), name.span());
|
||||
|
||||
TokenStream::from(quote! {
|
||||
#[derive(graphql_client::GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "gql/schema.json",
|
||||
query_path = #query,
|
||||
variables_derives = "Clone, Debug, Deserialize",
|
||||
response_derives = "Debug, Serialize, Deserialize"
|
||||
)]
|
||||
pub struct #name;
|
||||
|
||||
impl ApiClient {
|
||||
pub async fn #module(
|
||||
&self,
|
||||
vars: &#module::Variables,
|
||||
) -> Result<Option<#module::ResponseData>> {
|
||||
let body = #name::build_query(vars.clone());
|
||||
let resp: graphql_client::Response<#module::ResponseData> =
|
||||
self.query(&body, false).await.context(Code::GraphqlServerError)?;
|
||||
Ok(resp.data)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
83
crates/popcorntime-graphql-client/src/client.rs
Normal file
83
crates/popcorntime-graphql-client/src/client.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use crate::consts::GRAPHQL_SERVER;
|
||||
use anyhow::Result;
|
||||
use graphql_client::{QueryBody, Response};
|
||||
use reqwest::header;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::{fmt::Debug, sync::Arc, time::Duration};
|
||||
use tokio::{runtime::Handle, sync::Mutex};
|
||||
|
||||
static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApiClient {
|
||||
client: Arc<Mutex<reqwest::Client>>,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl Debug for ApiClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "ApiClient")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_client(access_token: Option<String>) -> Result<reqwest::Client> {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
|
||||
if let Some(access_token) = access_token {
|
||||
let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", access_token))?;
|
||||
auth_value.set_sensitive(true);
|
||||
headers.insert(header::AUTHORIZATION, auth_value);
|
||||
}
|
||||
|
||||
reqwest::ClientBuilder::new()
|
||||
.default_headers(headers)
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(access_token: Option<String>) -> Result<Self> {
|
||||
let client = build_client(access_token)?;
|
||||
Ok(Self {
|
||||
url: GRAPHQL_SERVER.to_string(),
|
||||
client: Arc::new(Mutex::new(client)),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn query<T: Serialize, R: DeserializeOwned>(
|
||||
&self,
|
||||
params: &QueryBody<T>,
|
||||
disable_cache: bool,
|
||||
) -> anyhow::Result<Response<R>> {
|
||||
let res = self.post(disable_cache).await.json(params).send().await?;
|
||||
res.json().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn post(&self, disable_cache: bool) -> reqwest::RequestBuilder {
|
||||
if disable_cache {
|
||||
self
|
||||
.client
|
||||
.lock()
|
||||
.await
|
||||
.post(&self.url)
|
||||
.header("Cache-Control", "no-cache")
|
||||
} else {
|
||||
self.client.lock().await.post(&self.url)
|
||||
}
|
||||
}
|
||||
|
||||
// this run in the `watch_config_in_background` thread
|
||||
pub fn update_access_token(&self, access_token: Option<String>) -> Result<()> {
|
||||
tokio::task::block_in_place(move || {
|
||||
Handle::current().block_on(async move {
|
||||
let mut client = self.client.lock().await;
|
||||
if let Ok(new_client) = build_client(access_token) {
|
||||
*client = new_client;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
1
crates/popcorntime-graphql-client/src/consts.rs
Normal file
1
crates/popcorntime-graphql-client/src/consts.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub const GRAPHQL_SERVER: &str = env!("GRAPHQL_SERVER");
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user