feat: initial commit

fix: fmt

Signed-off-by: pochoclin <hey@popcorntime.app>
This commit is contained in:
Pochoclin
2025-03-17 09:36:11 -04:00
committed by pochoclin
commit cb56278a9f
278 changed files with 65066 additions and 0 deletions

8
.cargo/config.toml Normal file
View 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
View File

@@ -0,0 +1,95 @@
## Popcorn Time Contributing Guide
Hi! Thanks for checking out Popcorn Time. Were 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 youd like to support the work, stay tuned for sponsorship options coming soon.

1
.github/FUNDING.yaml vendored Normal file
View File

@@ -0,0 +1 @@
github: popcorntime

View 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

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

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
lts/jod

3
.rustfmt.toml Normal file
View File

@@ -0,0 +1,3 @@
max_width = 100
tab_spaces = 2
edition = "2021"

1
.taurignore Normal file
View File

@@ -0,0 +1 @@
!crates/*

8
.vscode/extensions.json vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

62
Cargo.toml Normal file
View 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
View File

@@ -0,0 +1 @@
.react-router/

View 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"
}
}

View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1 @@
export { default } from "@popcorntime/ui/postcss.config";

View File

@@ -0,0 +1,5 @@
module.exports = {
singleQuote: true,
semi: false,
plugins: ["prettier-plugin-tailwindcss"],
};

Binary file not shown.

View 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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View 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

View 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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&apos;t find the page you&apos;re looking for.
</p>
<Link to="/" className={cn("mt-6", buttonVariants({ variant: "secondary" }))}>
Go back home
</Link>
</main>
);
}

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
export enum Code {
Unknown = "errors.unknown",
GraphqlServerError = "errors.graphql.server",
InvalidSession = "errors.session.invalid",
}

View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vitest/globals" />

View 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"
}
]
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["vite.config.ts"]
}

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

View 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
View 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" } }
}
}

View File

@@ -0,0 +1,9 @@
[package]
name = "popcorntime-error"
version.workspace = true
edition.workspace = true
authors.workspace = true
publish = false
[dependencies]
anyhow.workspace = true

View 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()),
})
}
}

View 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" }

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

View File

@@ -0,0 +1,13 @@
query Preferences {
preferences {
language
country
}
}
mutation UpdatePreferences($country: Country!, $language: Language!) {
updatePreferences(country: $country, language: $language) {
country
language
}
}

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

File diff suppressed because it is too large Load Diff

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

View 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

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

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

View 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