Compare commits
8 Commits
fix/save-a
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
613ad1effc | ||
|
|
2df63eb63c | ||
|
|
ab42e8a576 | ||
|
|
0e93cd5385 | ||
|
|
96b4121c1f | ||
|
|
27c400a54a | ||
|
|
261f7cc0cd | ||
|
|
d06daef933 |
@@ -45,4 +45,4 @@ This file previously contained facts about the codebase learned from past sessio
|
|||||||
|
|
||||||
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
||||||
|
|
||||||
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
|
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
|
||||||
|
|||||||
29
.gitattributes
vendored
@@ -1,28 +1 @@
|
|||||||
# Normalise line endings to LF for everyone. Files are stored as LF in git and
|
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
||||||
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
|
|
||||||
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
|
|
||||||
* text=auto eol=lf
|
|
||||||
|
|
||||||
# Windows-only scripts must stay CRLF
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
|
|
||||||
# Binary assets — never touched / never normalised
|
|
||||||
*.png binary
|
|
||||||
*.jpg binary
|
|
||||||
*.jpeg binary
|
|
||||||
*.gif binary
|
|
||||||
*.webp binary
|
|
||||||
*.ico binary
|
|
||||||
*.icns binary
|
|
||||||
*.ttf binary
|
|
||||||
*.otf binary
|
|
||||||
*.woff binary
|
|
||||||
*.woff2 binary
|
|
||||||
*.mp3 binary
|
|
||||||
*.mp4 binary
|
|
||||||
*.mov binary
|
|
||||||
*.pdf binary
|
|
||||||
*.keystore binary
|
|
||||||
*.jks binary
|
|
||||||
*.p12 binary
|
|
||||||
|
|||||||
22
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: "🐛 Bug Report"
|
name: "🐛 Bug Report"
|
||||||
description: Create a report to help Streamyfin improve
|
description: Create a report to help us improve
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels:
|
labels:
|
||||||
- "🐛 bug"
|
- "🐛 bug"
|
||||||
@@ -36,7 +36,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: What happened?
|
label: What happened?
|
||||||
description: A clear and concise description of what the bug is.
|
description: A clear and concise description of what the bug is.
|
||||||
placeholder: Describe what happened in detail, the more precise the better.
|
placeholder: Describe what happened in detail.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Which device and operating system are you using?
|
label: Which device and operating system are you using?
|
||||||
description: Please provide your device model and OS version
|
description: Please provide your device model and OS version
|
||||||
placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5
|
placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
@@ -75,11 +75,11 @@ body:
|
|||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you using?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
- 0.54.1
|
- 0.47.1
|
||||||
- 0.51.0
|
- 0.30.2
|
||||||
- Older
|
- older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -90,9 +90,9 @@ body:
|
|||||||
label: Jellyfin Server Information
|
label: Jellyfin Server Information
|
||||||
description: Please provide details about your Jellyfin server
|
description: Please provide details about your Jellyfin server
|
||||||
placeholder: |
|
placeholder: |
|
||||||
- Jellyfin Server Version: e.g. 10.11.10
|
- Jellyfin Server Version: e.g. 10.10.7
|
||||||
- Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox
|
- Server OS: e.g. Ubuntu 22.04, Windows 11, Docker
|
||||||
- Connection: e.g. Local network, remote via domain, VPN
|
- Connection: e.g. Local network, Remote via domain, VPN
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: screenshots
|
id: screenshots
|
||||||
@@ -104,7 +104,7 @@ body:
|
|||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Relevant logs (if available)
|
label: Relevant logs (if available)
|
||||||
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.**
|
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.**
|
||||||
render: shell
|
render: shell
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
102
.github/workflows/crowdin.yml
vendored
@@ -1,51 +1,51 @@
|
|||||||
name: 🌐 Translation Sync
|
name: 🌐 Translation Sync
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [develop]
|
branches: [develop]
|
||||||
paths:
|
paths:
|
||||||
- "translations/**"
|
- "translations/**"
|
||||||
- "crowdin.yml"
|
- "crowdin.yml"
|
||||||
- "i18n.ts"
|
- "i18n.ts"
|
||||||
- ".github/workflows/crowdin.yml"
|
- ".github/workflows/crowdin.yml"
|
||||||
# Run weekly to pull new translations
|
# Run weekly to pull new translations
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-translations:
|
sync-translations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
download_translations: true
|
download_translations: true
|
||||||
localization_branch_name: I10n_crowdin_translations
|
localization_branch_name: I10n_crowdin_translations
|
||||||
create_pull_request: true
|
create_pull_request: true
|
||||||
pull_request_title: "feat: New Crowdin Translations"
|
pull_request_title: "feat: New Crowdin Translations"
|
||||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||||
pull_request_base_branch_name: "develop"
|
pull_request_base_branch_name: "develop"
|
||||||
pull_request_labels: "🌐 translation"
|
pull_request_labels: "🌐 translation"
|
||||||
# Quality control options
|
# Quality control options
|
||||||
skip_untranslated_strings: false
|
skip_untranslated_strings: false
|
||||||
skip_untranslated_files: false
|
skip_untranslated_files: false
|
||||||
export_only_approved: false
|
export_only_approved: false
|
||||||
# Commit customization
|
# Commit customization
|
||||||
commit_message: "feat(i18n): update translations from Crowdin"
|
commit_message: "feat(i18n): update translations from Crowdin"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
|
|||||||
38
.github/workflows/detect-duplicate.yml
vendored
@@ -1,38 +0,0 @@
|
|||||||
name: 🔁 Detect Duplicate Issues
|
|
||||||
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [opened]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: detect-duplicate-${{ github.event.issue.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
detect:
|
|
||||||
name: 🔍 Find similar issues
|
|
||||||
if: github.actor != 'github-actions[bot]'
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: 🔍 Detect duplicate issues
|
|
||||||
run: bun scripts/detect-duplicate-issue.mjs
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
||||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
|
||||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
|
||||||
1
.github/workflows/linting.yml
vendored
@@ -97,7 +97,6 @@ jobs:
|
|||||||
- "check"
|
- "check"
|
||||||
- "format"
|
- "format"
|
||||||
- "typecheck"
|
- "typecheck"
|
||||||
- "i18n:check"
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
|
|||||||
60
.github/workflows/trivy-scan.yml
vendored
@@ -1,60 +0,0 @@
|
|||||||
name: 🛡️ Trivy Security Scan
|
|
||||||
|
|
||||||
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
|
|
||||||
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
|
|
||||||
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
|
|
||||||
# upload needs a write token that fork PRs don't get).
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [develop, master]
|
|
||||||
schedule:
|
|
||||||
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: trivy-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
trivy:
|
|
||||||
name: 🔎 Filesystem scan
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
security-events: write # upload SARIF to code scanning
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout repository
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
|
|
||||||
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
|
|
||||||
# instead of a fresh immutable entry per run, still refreshing the DB every week.
|
|
||||||
- name: 🗓️ Compute weekly Trivy cache key
|
|
||||||
id: trivy-cache-key
|
|
||||||
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: 💾 Cache Trivy vulnerability DB
|
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: ~/.cache/trivy
|
|
||||||
key: ${{ steps.trivy-cache-key.outputs.value }}
|
|
||||||
restore-keys: trivy-db-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: 🔎 Run Trivy filesystem scan
|
|
||||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
||||||
with:
|
|
||||||
scan-type: fs
|
|
||||||
scan-ref: .
|
|
||||||
scanners: vuln,secret,misconfig
|
|
||||||
ignore-unfixed: true
|
|
||||||
severity: CRITICAL,HIGH
|
|
||||||
format: sarif
|
|
||||||
output: trivy-results.sarif
|
|
||||||
|
|
||||||
- name: 📤 Upload results to code scanning
|
|
||||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
|
||||||
with:
|
|
||||||
sarif_file: trivy-results.sarif
|
|
||||||
category: trivy-fs
|
|
||||||
11
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Dependencies and Package Managers
|
# Dependencies and Package Managers
|
||||||
node_modules/
|
node_modules/
|
||||||
|
bun.lock
|
||||||
bun.lockb
|
bun.lockb
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
@@ -20,8 +21,10 @@ web-build/
|
|||||||
# Gradle caches (top-level + per-module native projects)
|
# Gradle caches (top-level + per-module native projects)
|
||||||
**/.gradle/
|
**/.gradle/
|
||||||
|
|
||||||
# Native module build outputs (any module)
|
# Module-specific Builds
|
||||||
modules/*/android/build/
|
modules/mpv-player/android/build
|
||||||
|
modules/player/android
|
||||||
|
modules/hls-downloader/android/build
|
||||||
|
|
||||||
# Generated Applications
|
# Generated Applications
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
@@ -66,6 +69,10 @@ certs/
|
|||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
|
/modules/sf-player/android/build
|
||||||
|
/modules/music-controls/android/build
|
||||||
|
modules/background-downloader/android/build/*
|
||||||
|
/modules/mpv-player/android/build
|
||||||
|
|
||||||
# ios:unsigned-build Artifacts
|
# ios:unsigned-build Artifacts
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
/>
|
/>
|
||||||
{!itemType ? (
|
{!itemType ? (
|
||||||
<View className='flex-1 items-center justify-center px-6'>
|
<View className='flex-1 items-center justify-center px-6'>
|
||||||
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
|
<Text className='text-neutral-500'>
|
||||||
|
{t("favorites.noData", { defaultValue: "No items found." })}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<View className='justify-center items-center h-full'>
|
<View className='justify-center items-center h-full'>
|
||||||
@@ -192,7 +194,7 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex flex-col items-center justify-center h-full py-12'>
|
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
{t("home.no_items")}
|
{t("home.no_items", { defaultValue: "No items" })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
|
||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
const SyncPlayButtonComponent = Platform.isTV
|
||||||
|
? null
|
||||||
|
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
@@ -33,6 +36,7 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
|
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ export default function DownloadsPage() {
|
|||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
t("home.downloads.toasts.deleted_all_series_successfully"),
|
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.catch((reason) => {
|
.catch((reason) => {
|
||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const deleteOtherMedia = () =>
|
const deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
@@ -207,7 +207,7 @@ export default function DownloadsPage() {
|
|||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.series")}
|
{t("home.downloads.tvseries")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
<Text className='text-xs font-bold'>
|
<Text className='text-xs font-bold'>
|
||||||
@@ -288,7 +288,7 @@ export default function DownloadsPage() {
|
|||||||
{t("home.downloads.delete_all_movies_button")}
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color='purple' onPress={deleteShows}>
|
<Button color='purple' onPress={deleteShows}>
|
||||||
{t("home.downloads.delete_all_series_button")}
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
</Button>
|
</Button>
|
||||||
{otherMedia.length > 0 && (
|
{otherMedia.length > 0 && (
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
<Button color='purple' onPress={deleteOtherMedia}>
|
||||||
|
|||||||
@@ -179,15 +179,18 @@ export default function SettingsTV() {
|
|||||||
// Handle clearing all cache in the entire app
|
// Handle clearing all cache in the entire app
|
||||||
const handleClearCache = async () => {
|
const handleClearCache = async () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.storage.clear_all_cache_confirm"),
|
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
|
||||||
t("home.settings.storage.clear_all_cache_confirm_desc"),
|
t(
|
||||||
|
"home.settings.storage.clear_all_cache_confirm_desc",
|
||||||
|
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||||
|
),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: t("common.cancel"),
|
text: t("common.cancel", "Cancel"),
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t("common.ok"),
|
text: t("common.ok", "OK"),
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
// 1. Clear React Query Cache (memory & MMKV)
|
// 1. Clear React Query Cache (memory & MMKV)
|
||||||
@@ -240,8 +243,11 @@ export default function SettingsTV() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to clear cache:", error);
|
console.error("Failed to clear cache:", error);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.toasts.error_deleting_files"),
|
t("home.settings.toasts.error_deleting_files", "Error"),
|
||||||
t("home.settings.storage.clear_all_cache_error_desc"),
|
t(
|
||||||
|
"home.settings.storage.clear_all_cache_error_desc",
|
||||||
|
"An error occurred while clearing the cache.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export default function TabLayout() {
|
|||||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/list.star.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: (_e) => ({ sfSymbol: "list.star" }),
|
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -112,7 +112,7 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.library"),
|
title: t("tabs.library"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/rectangle.stack.fill.png")
|
? (_e) => require("@/assets/icons/server.rack.png")
|
||||||
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -123,8 +123,8 @@ export default function TabLayout() {
|
|||||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/link.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: (_e) => ({ sfSymbol: "link" }),
|
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -134,7 +134,7 @@ export default function TabLayout() {
|
|||||||
tabBarItemHidden: !Platform.isTV,
|
tabBarItemHidden: !Platform.isTV,
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/gearshape.fill.png")
|
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
|
||||||
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ import {
|
|||||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
|
||||||
|
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import {
|
import {
|
||||||
@@ -49,10 +51,10 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useInactivity } from "@/providers/InactivityProvider";
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import type { PlayerControls } from "@/providers/SyncPlay/types";
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
@@ -77,6 +79,11 @@ export default function DirectPlayerPage() {
|
|||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
const [isPipMode, setIsPipMode] = useState(false);
|
const [isPipMode, setIsPipMode] = useState(false);
|
||||||
|
|
||||||
|
// Keep the global WebSocket open while in PiP so SyncPlay commands
|
||||||
|
// (and any other server pushes) keep flowing while iOS treats the
|
||||||
|
// app as backgrounded. See `WebSocketProvider.acquireKeepAlive`.
|
||||||
|
useKeepWebSocketAlive(isPipMode);
|
||||||
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
|
||||||
"default",
|
"default",
|
||||||
);
|
);
|
||||||
@@ -128,6 +135,7 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
offline: offlineStr,
|
||||||
playbackPosition: playbackPositionFromUrl,
|
playbackPosition: playbackPositionFromUrl,
|
||||||
|
syncPlay: syncPlayStr,
|
||||||
} = useLocalSearchParams<{
|
} = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
@@ -137,9 +145,23 @@ export default function DirectPlayerPage() {
|
|||||||
offline: string;
|
offline: string;
|
||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
|
/** Whether playback was initiated by SyncPlay */
|
||||||
|
syncPlay?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
|
||||||
|
const openedViaSyncPlay = syncPlayStr === "true";
|
||||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
|
// SyncPlay integration
|
||||||
|
const syncPlay = useSyncPlay();
|
||||||
|
const {
|
||||||
|
isEnabled: isSyncPlayEnabled,
|
||||||
|
controller: syncPlayController,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyBuffering,
|
||||||
|
} = syncPlay;
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
|
|
||||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||||
@@ -415,8 +437,72 @@ export default function DirectPlayerPage() {
|
|||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream, api, offline]);
|
}, [stream, api, offline]);
|
||||||
|
|
||||||
|
// SyncPlay: Connect player controls when video is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVideoLoaded || !videoRef.current || offline) {
|
||||||
|
setPlayerControls(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls: PlayerControls = {
|
||||||
|
play: () => videoRef.current?.play(),
|
||||||
|
pause: () => videoRef.current?.pause(),
|
||||||
|
seekTo: (positionMs: number) => {
|
||||||
|
const positionSec = positionMs / 1000;
|
||||||
|
console.log(
|
||||||
|
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
|
||||||
|
);
|
||||||
|
videoRef.current?.seekTo(positionSec);
|
||||||
|
},
|
||||||
|
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
|
||||||
|
getSpeed: () => currentPlaybackSpeed,
|
||||||
|
getCurrentPosition: () => progress.get(),
|
||||||
|
isPlaying: () => isPlaying,
|
||||||
|
isBuffering: () => isBuffering,
|
||||||
|
};
|
||||||
|
|
||||||
|
setPlayerControls(controls);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setPlayerControls(null);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isVideoLoaded,
|
||||||
|
offline,
|
||||||
|
isPlaying,
|
||||||
|
isBuffering,
|
||||||
|
currentPlaybackSpeed,
|
||||||
|
progress,
|
||||||
|
setPlayerControls,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// SyncPlay: Report buffering/ready state to server.
|
||||||
|
//
|
||||||
|
// CRITICAL: We must report `buffering` to the server *during* initial
|
||||||
|
// load (before `isVideoLoaded`), otherwise the server treats us as ready
|
||||||
|
// and proceeds without waiting for us. jellyfin-web reports this for
|
||||||
|
// free via the HTML5 video element's `waiting` event; for us, the
|
||||||
|
// initial load itself is the buffering window.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSyncPlayEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocallyReady = isVideoLoaded && !isBuffering;
|
||||||
|
// notifyBuffering routes through the debouncer in PlaybackCore so
|
||||||
|
// re-renders during a stall don't spam the server.
|
||||||
|
notifyBuffering(!isLocallyReady);
|
||||||
|
}, [isSyncPlayEnabled, isVideoLoaded, isBuffering, notifyBuffering]);
|
||||||
|
|
||||||
const togglePlay = async () => {
|
const togglePlay = async () => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Route through SyncPlay when active
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.playPause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsPlaying(!isPlaying);
|
setIsPlaying(!isPlaying);
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
@@ -645,10 +731,12 @@ export default function DirectPlayerPage() {
|
|||||||
const startPos = ticksToSeconds(startTicks);
|
const startPos = ticksToSeconds(startTicks);
|
||||||
|
|
||||||
// Build source config - headers only needed for online streaming
|
// Build source config - headers only needed for online streaming
|
||||||
|
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
|
||||||
|
const shouldAutoplay = !openedViaSyncPlay;
|
||||||
const source: MpvVideoSource = {
|
const source: MpvVideoSource = {
|
||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: shouldAutoplay,
|
||||||
initialSubtitleId,
|
initialSubtitleId,
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
@@ -843,6 +931,41 @@ export default function DirectPlayerPage() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// PiP playback controls. When SyncPlay is active, the native side
|
||||||
|
// is told to *delegate* these via `syncPlayDelegated`, so the OS
|
||||||
|
// play/pause/skip buttons emit these events instead of poking MPV
|
||||||
|
// directly. We route them through the SyncPlay controller so the
|
||||||
|
// server broadcasts a command to every group member (including us).
|
||||||
|
const _onPipPlayRequest = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
console.log("SyncPlay: PiP play → controller.playPause()");
|
||||||
|
syncPlayController.playPause();
|
||||||
|
}
|
||||||
|
}, [isSyncPlayEnabled, syncPlayController]);
|
||||||
|
|
||||||
|
const _onPipPauseRequest = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
console.log("SyncPlay: PiP pause → controller.playPause()");
|
||||||
|
syncPlayController.playPause();
|
||||||
|
}
|
||||||
|
}, [isSyncPlayEnabled, syncPlayController]);
|
||||||
|
|
||||||
|
const _onPipSkipRequest = useCallback(
|
||||||
|
(e: {
|
||||||
|
nativeEvent: { targetSeconds: number; intervalSeconds: number };
|
||||||
|
}) => {
|
||||||
|
if (!isSyncPlayEnabled || !syncPlayController) return;
|
||||||
|
const { targetSeconds } = e.nativeEvent;
|
||||||
|
// SyncPlay seek takes ticks (1 s = 10_000_000 ticks).
|
||||||
|
const ticks = Math.max(0, Math.round(targetSeconds * 10_000_000));
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: PiP skip → controller.seek(${targetSeconds}s = ${ticks} ticks)`,
|
||||||
|
);
|
||||||
|
syncPlayController.seek(ticks);
|
||||||
|
},
|
||||||
|
[isSyncPlayEnabled, syncPlayController],
|
||||||
|
);
|
||||||
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
// Add useEffect to handle mounting
|
// Add useEffect to handle mounting
|
||||||
@@ -867,10 +990,21 @@ export default function DirectPlayerPage() {
|
|||||||
videoRef.current?.pause?.();
|
videoRef.current?.pause?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const seek = useCallback((position: number) => {
|
const seek = useCallback(
|
||||||
// MPV expects seconds, convert from ms
|
(position: number) => {
|
||||||
videoRef.current?.seekTo?.(position / 1000);
|
// Route through SyncPlay when active. `position` is in ms; the
|
||||||
}, []);
|
// controller takes ticks (1 ms = 10000 ticks).
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
console.log("SyncPlay: seek requested via SyncPlay", position);
|
||||||
|
syncPlayController.seek(Math.round(position * 10000));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MPV expects seconds, convert from ms
|
||||||
|
videoRef.current?.seekTo?.(position / 1000);
|
||||||
|
},
|
||||||
|
[isSyncPlayEnabled, syncPlayController],
|
||||||
|
);
|
||||||
|
|
||||||
// TV audio track change handler
|
// TV audio track change handler
|
||||||
const handleAudioIndexChange = useCallback(
|
const handleAudioIndexChange = useCallback(
|
||||||
@@ -1010,44 +1144,6 @@ export default function DirectPlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
|
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
|
||||||
|
|
||||||
// TV: Navigate to previous item
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
|
||||||
if (!previousItem || !settings) return;
|
|
||||||
|
|
||||||
const {
|
|
||||||
mediaSource: newMediaSource,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
|
||||||
} = getDefaultPlaySettings(previousItem, settings, {
|
|
||||||
indexes: {
|
|
||||||
// Use the live selection, not the stale URL params (see goToNextItem).
|
|
||||||
subtitleIndex: currentSubtitleIndex,
|
|
||||||
audioIndex: currentAudioIndex,
|
|
||||||
},
|
|
||||||
source: stream?.mediaSource ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: previousItem.Id ?? "",
|
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: newMediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString() ?? "",
|
|
||||||
playbackPosition:
|
|
||||||
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
|
||||||
}, [
|
|
||||||
previousItem,
|
|
||||||
settings,
|
|
||||||
currentSubtitleIndex,
|
|
||||||
currentAudioIndex,
|
|
||||||
stream?.mediaSource,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||||
const addSubtitleFile = useCallback(async (path: string) => {
|
const addSubtitleFile = useCallback(async (path: string) => {
|
||||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||||
@@ -1077,45 +1173,25 @@ export default function DirectPlayerPage() {
|
|||||||
return [];
|
return [];
|
||||||
}, [isMounted]);
|
}, [isMounted]);
|
||||||
|
|
||||||
// TV: Navigate to next item
|
/*
|
||||||
const goToNextItem = useCallback(() => {
|
* Item-level navigation (next / previous). Wraps SyncPlay dispatch,
|
||||||
if (!nextItem || !settings || isPlaybackStopped) return;
|
* platform-appropriate local navigation (replace on TV), and offline
|
||||||
|
* param injection in a single hook so the in-player buttons and any
|
||||||
const {
|
* future entry points (autoplay overlay, episode picker, etc.) share
|
||||||
mediaSource: newMediaSource,
|
* one implementation.
|
||||||
audioIndex: defaultAudioIndex,
|
*/
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
const {
|
||||||
} = getDefaultPlaySettings(nextItem, settings, {
|
goToNextItem: dispatchNextItem,
|
||||||
indexes: {
|
goToPreviousItem: dispatchPreviousItem,
|
||||||
// Use the live selection (updated when the user changes tracks
|
} = usePlayerItemNavigation({
|
||||||
// mid-playback), not the stale URL params the episode started with.
|
|
||||||
subtitleIndex: currentSubtitleIndex,
|
|
||||||
audioIndex: currentAudioIndex,
|
|
||||||
},
|
|
||||||
source: stream?.mediaSource ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: nextItem.Id ?? "",
|
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: newMediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString() ?? "",
|
|
||||||
playbackPosition:
|
|
||||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
|
||||||
}, [
|
|
||||||
nextItem,
|
nextItem,
|
||||||
settings,
|
previousItem,
|
||||||
currentSubtitleIndex,
|
mediaSource: stream?.mediaSource,
|
||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
stream?.mediaSource,
|
currentSubtitleIndex,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
isDisabled: isPlaybackStopped,
|
||||||
isPlaybackStopped,
|
});
|
||||||
]);
|
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1262,6 +1338,10 @@ export default function DirectPlayerPage() {
|
|||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
onPictureInPictureChange={_onPictureInPictureChange}
|
onPictureInPictureChange={_onPictureInPictureChange}
|
||||||
|
syncPlayDelegated={isSyncPlayEnabled}
|
||||||
|
onPipPlayRequest={_onPipPlayRequest}
|
||||||
|
onPipPauseRequest={_onPipPauseRequest}
|
||||||
|
onPipSkipRequest={_onPipSkipRequest}
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
@@ -1316,8 +1396,8 @@ export default function DirectPlayerPage() {
|
|||||||
onSubtitleIndexChange={handleSubtitleIndexChange}
|
onSubtitleIndexChange={handleSubtitleIndexChange}
|
||||||
previousItem={previousItem}
|
previousItem={previousItem}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
goToPreviousItem={goToPreviousItem}
|
goToPreviousItem={dispatchPreviousItem}
|
||||||
goToNextItem={goToNextItem}
|
goToNextItem={dispatchNextItem}
|
||||||
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
||||||
addSubtitleFile={addSubtitleFile}
|
addSubtitleFile={addSubtitleFile}
|
||||||
showTechnicalInfo={showTechnicalInfo}
|
showTechnicalInfo={showTechnicalInfo}
|
||||||
|
|||||||
254
app/_layout.tsx
@@ -25,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
|||||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
|
import { SyncPlayProvider } from "@/providers/SyncPlay";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
@@ -409,133 +410,136 @@ function Layout() {
|
|||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
<SyncPlayProvider>
|
||||||
<MusicPlayerProvider>
|
<DownloadProvider>
|
||||||
<GlobalModalProvider>
|
<MusicPlayerProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<IntroSheetProvider>
|
<BottomSheetModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<IntroSheetProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<SystemBars style='light' hidden={false} />
|
||||||
<Stack.Screen
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
name='(auth)/(tabs)'
|
<Stack.Screen
|
||||||
options={{
|
name='(auth)/(tabs)'
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
header: () => null,
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/player'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/now-playing'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "modal",
|
||||||
|
gestureEnabled: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent:
|
||||||
|
Platform.OS === "ios",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-option-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-subtitle-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-request-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-season-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-series-season-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-action-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='tv-account-select-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/tv-user-switch-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
|
closeButton
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
{!Platform.isTV && <GlobalModal />}
|
||||||
name='(auth)/player'
|
</ThemeProvider>
|
||||||
options={{
|
</IntroSheetProvider>
|
||||||
headerShown: false,
|
</BottomSheetModalProvider>
|
||||||
title: "",
|
</GlobalModalProvider>
|
||||||
header: () => null,
|
</MusicPlayerProvider>
|
||||||
}}
|
</DownloadProvider>
|
||||||
/>
|
</SyncPlayProvider>
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/now-playing'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "modal",
|
|
||||||
gestureEnabled: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-option-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-subtitle-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-request-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-season-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-series-season-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-action-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='tv-account-select-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='(auth)/tv-user-switch-modal'
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "transparentModal",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
{!Platform.isTV && <GlobalModal />}
|
|
||||||
</ThemeProvider>
|
|
||||||
</IntroSheetProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</GlobalModalProvider>
|
|
||||||
</MusicPlayerProvider>
|
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
|
|||||||
BIN
assets/icons/gear.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/heart.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 112 KiB |
118
assets/icons/jellyseerr-logo.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB |
BIN
assets/icons/list.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><path fill="url(#paint0_linear)" fill-rule="evenodd" d="M48 96C74.5097 96 96 74.5097 96 48C96 21.4903 74.5097 0 48 0C21.4903 0 0 21.4903 0 48C0 74.5097 21.4903 96 48 96ZM80.0001 52C80.0001 67.464 67.4641 80 52.0001 80C36.5361 80 24.0001 67.464 24.0001 52C24.0001 49.1303 24.4318 46.3615 25.2338 43.7548C27.4288 48.6165 32.3194 52 38.0001 52C45.7321 52 52.0001 45.732 52.0001 38C52.0001 32.3192 48.6166 27.4287 43.755 25.2337C46.3616 24.4317 49.1304 24 52.0001 24C67.4641 24 80.0001 36.536 80.0001 52Z" clip-rule="evenodd"/><path fill="#131928" fill-rule="evenodd" d="M80.0002 52C80.0002 67.464 67.4642 80 52.0002 80C36.864 80 24.5329 67.9897 24.017 52.9791C24.0057 53.318 24 53.6583 24 54C24 70.5685 37.4315 84 54 84C70.5685 84 84 70.5685 84 54C84 37.4315 70.5685 24 54 24C53.6597 24 53.3207 24.0057 52.9831 24.0169C67.9919 24.5347 80.0002 36.865 80.0002 52Z" clip-rule="evenodd" opacity=".2"/><path fill="url(#paint1_linear)" fill-rule="evenodd" d="M48 12C28.1177 12 12 28.1177 12 48C12 50.2091 10.2091 52 8 52C5.79086 52 4 50.2091 4 48C4 23.6995 23.6995 4 48 4C50.2091 4 52 5.79086 52 8C52 10.2091 50.2091 12 48 12Z" clip-rule="evenodd"/><defs><linearGradient id="paint0_linear" x1="48" x2="117.5" y1="0" y2="69.5" gradientUnits="userSpaceOnUse"><stop stop-color="#C395FC"/><stop offset="1" stop-color="#4F65F5"/></linearGradient><linearGradient id="paint1_linear" x1="28" x2="28" y1="8" y2="48" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".4"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
BIN
assets/icons/server.rack.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
65
assets/images/not-rotten-tomatoes.svg
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<svg
|
||||||
|
type="certified"
|
||||||
|
viewBox="0 0 80 80"
|
||||||
|
preserveAspectRatio="xMidYMid"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
>
|
||||||
|
<g transform="translate(2.29, 0)">
|
||||||
|
<path
|
||||||
|
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
|
||||||
|
id="Fill-2"
|
||||||
|
fill="#00912D"
|
||||||
|
></path>
|
||||||
|
<mask id="mask-2" fill="white">
|
||||||
|
<polygon
|
||||||
|
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
|
||||||
|
></polygon>
|
||||||
|
</mask>
|
||||||
|
<path
|
||||||
|
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
|
||||||
|
fill="#FFD700"
|
||||||
|
mask="url(#mask-2)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
|
||||||
|
fill="#FA6E0F"
|
||||||
|
mask="url(#mask-2)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
|
||||||
|
fill="#0AC855"
|
||||||
|
mask="url(#mask-2)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
|
||||||
|
fill="#0B4902"
|
||||||
|
></path>
|
||||||
|
<g transform="translate(0, 20.57)">
|
||||||
|
<mask id="mask-4" fill="white">
|
||||||
|
<polygon
|
||||||
|
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
|
||||||
|
></polygon>
|
||||||
|
</mask>
|
||||||
|
<path
|
||||||
|
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
|
||||||
|
fill="#FA3200"
|
||||||
|
mask="url(#mask-4)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
|
||||||
|
fill="#0AC855"
|
||||||
|
mask="url(#mask-4)"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
|
||||||
|
fill="#00912D"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
|
||||||
|
fill="#0B4902"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.4 KiB |
BIN
assets/images/rotten-tomatoes.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M370.57 474.214l23.466-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278c-6.748 10.248-19.863 20.86-33.59 27.936zm-78.197 21.631l2.947-244.528c20.894-.599 47.933-3.43 70.97-8.346l-19.07 241.17c-22.724 7.518-35.934 9.848-54.847 11.704zm-99.694-252.874c23.038 4.916 50.077 7.747 70.971 8.346l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705l-19.072-241.17zm-67.974-26.975c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.59-27.936l-30.104-230.282z"/><path fill="gold" d="M118.905 157.445c1.357 28.827 72.771 51.677 160.578 51.176 76.687-.438 140.659-18.546 156.329-42.336a22.976 22.976 0 00-14.058-7.426c.06-.7.098-1.406.095-2.122-.065-11.4-8.429-20.788-19.327-22.54.287-1.474.438-2.999.43-4.559-.072-12.696-10.426-22.928-23.124-22.856-.287.001-.568.036-.853.049a22.911 22.911 0 001.254-7.56c-.074-12.697-10.425-22.93-23.123-22.858a22.914 22.914 0 00-8.247 1.6c-3.632-6.835-10.606-11.6-18.737-12.149-1.416-11.4-11.157-20.195-22.93-20.129-7.41.042-13.963 3.6-18.136 9.065-4.233-4.605-10.3-7.494-17.047-7.456-12.698.072-22.932 10.424-22.86 23.118a22.983 22.983 0 001.115 6.946 22.918 22.918 0 00-13.07 7.459c-2.644-9.847-11.637-17.084-22.314-17.024-9.975.057-18.406 6.47-21.537 15.366-8.474 3.426-14.439 11.738-14.383 21.433.012 2.154.342 4.227.907 6.202a22.876 22.876 0 00-9.328-1.932c-10.012.058-18.47 6.516-21.574 15.465a22.83 22.83 0 00-9.788-2.149c-12.698.072-22.934 10.422-22.86 23.118a22.833 22.833 0 003.159 11.463c-.202.203-.379.426-.571.636"/><path fill="#FA320A" d="M404.161 446.278c-6.749 10.248-19.864 20.86-33.59 27.936l23.465-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278zM347.22 484.14c-22.723 7.519-35.934 9.85-54.847 11.705l2.947-244.528c20.894-.599 47.933-3.43 70.973-8.346L347.22 484.14zm-135.47 0l-19.07-241.17c23.037 4.917 50.076 7.748 70.97 8.347l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705zm-56.94-37.862l-30.105-230.282c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.588-27.936zm247.668-321.143c.298 1.453.465 2.955.473 4.498a23.018 23.018 0 01-.43 4.56c10.9 1.749 19.263 11.137 19.328 22.54a23.59 23.59 0 01-.095 2.12 22.976 22.976 0 0114.058 7.425c-15.669 23.792-79.642 41.9-156.327 42.34-87.807.502-159.221-22.346-160.58-51.175.192-.208.37-.433.57-.634-1.355-2.311-2.29-4.887-2.773-7.62-8.408 7.979-13.495 14.412-12.6 23.78.085 1.251 37.196 266.911 37.196 266.911 4.282 42.075 65.391 75.703 138.187 76.12 72.796-.417 133.907-34.045 138.187-76.12 0 0 37.11-265.66 37.197-266.912 1.777-18.736-20.15-35.745-52.39-47.833z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><g transform="translate(33 140)"><path d="m43.802 267.32l237.94 23.482c4.7937 14.937 11.149 29.517 20.259 40.256l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.612zm222.88-75.298c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083c-7.518-22.739-9.8466-35.959-11.704-54.885l244.51 2.951zm8.3462-102.71c-4.9146 23.053-7.7456 50.111-8.3462 71.017l-244.51 2.951c1.8576-18.926 4.1862-32.146 11.704-54.885l241.15-19.083zm26.973-68.019c-9.1095 10.74-15.465 25.318-20.259 40.257l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125z" fill="#fff"/><path d="m303.57 264.67c3.155-7.8209 14.337-12.586 22.367-12.028 8.5825 0.59581 17.699 9.6258 19.292 18.507 0.29589-0.32244 0.60578-0.62735 0.92093-0.92701 2.7558-2.6356 6.2084-4.3845 9.9867-4.8664-0.57777-2.562-0.71609-5.3045-0.3204-8.1188 1.3954-9.901 9.3336-17.326 18.422-17.252 5.8652 0.047314 11.011 3.0509 14.364 7.6649 0.29939-0.37501 0.6303-0.71848 0.95245-1.069 3.8343-19.991 6.2644-42.578 6.8177-66.547 1.8996-82.42-18.993-149.75-46.663-150.39-27.672-0.63962-51.644 65.656-53.544 148.08 0 0-1.4654 30.062 7.4042 86.951" fill="#00641E"/><path d="m490.91 354.8c1.6353-2.732 2.5492-6.0072 2.4862-9.4874 0.5305-11.245-7.1819-21.439-17.913-20.31 0.3099-1.2862 0.51299-2.6233 0.59178-4.0024 0.64255-11.264-7.1188-20.972-17.337-21.682-0.2241-0.014019-0.44471-0.021029-0.66706-0.028039 1.0627-2.795 1.5897-5.916 1.4024-9.2228-0.52875-9.3717-6.9858-17.268-15.386-18.822-3.0342-0.56076-5.9773-0.28213-8.6718 0.65188-2.5457-6.277-7.8909-10.933-14.393-11.916-0.51474-10.192-7.8699-18.58-17.34-19.238-5.9545-0.41356-11.426 2.3237-15.085 6.9061-3.3528-4.614-8.4985-7.6158-14.364-7.6632-9.0885-0.075352-17.027 7.3495-18.422 17.252-0.39569 2.8126-0.25737 5.555 0.3204 8.1188-3.7783 0.48015-7.2309 2.2308-9.9867 4.8646-0.31515 0.29966-0.62504 0.60457-0.92093 0.92701-1.5932-8.8811-10.71-17.909-19.292-18.507-8.0293-0.55726-19.357 4.3249-22.367 12.028 1.3201 13.434 9.71 50.053 40.055 82.903l0.26963 0.019276c2.9256 2.6496 6.7459 4.1093 10.818 3.7466 2.5247-0.22606 4.8498-1.1303 6.8527-2.5252l0.48848 0.033295c2.67 1.8558 5.8793 2.8266 9.2706 2.5252 1.2956-0.11566 2.5317-0.42758 3.7065-0.87269 2.9064 6.0142 9.3879 9.901 16.622 9.2631 5.6026-0.49417 10.365-3.5959 13.164-7.9611l0.90692 0.063086c2.7961 2.774 6.513 4.3897 10.522 4.2688 3.3143 5.0188 9.4019 8.1065 16.12 7.516 2.5299-0.22255 4.8918-0.95505 6.998-2.0643 3.5139 4.3266 9.2811 6.8991 15.609 6.3419 6.2557-0.5485 11.54-4.02 14.414-8.8197 2.8241 2.2693 6.3625 3.4872 10.12 3.1525 3.6452-0.32594 6.8842-2.0485 9.3179-4.6543l0.40619 0.028038c0.55326-0.80259 1.026-1.6245 1.4654-2.4533 0.010505-0.015772 0.019259-0.033296 0.028014-0.049067 0.059527-0.1104 0.13306-0.21905 0.18909-0.3312" fill="#FFD700"/><path d="m281.75 61.547l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125c-9.1095 10.74-15.465 25.318-20.259 40.257zm20.259 269.51l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.611l237.94 23.48c4.7937 14.937 11.149 29.517 20.259 40.256zm-268.13-87.102c-7.518-22.739-9.8466-35.957-11.704-54.885l244.51 2.951c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083zm0-135.56l241.15-19.083c-4.9146 23.053-7.7456 50.111-8.3462 71.019l-244.51 2.9493c1.8576-18.926 4.1862-32.146 11.704-54.885zm344.72-82.679c-15.255-17.778-26.206-26.124-35.587-25.04-1.7491 0.22255-266.89 37.222-266.89 37.222-42.074 4.2828-75.7 65.432-76.117 138.28 0.4167 72.843 34.043 133.99 76.117 138.28 0 0 265.64 37.135 266.89 37.221 2.2183-0.014019 4.4086-0.31192 6.5673-0.86568-2.101-0.6256-4.0391-1.7208-5.6867-3.2139l-0.26963-0.019276c-30.345-32.848-38.735-69.47-40.055-82.903 0.003501-0.010514 0.010505-0.019276 0.014006-0.02979-0.003501 0.010514-0.010505 0.019276-0.014006 0.02979-8.8697-56.889-7.4042-86.951-7.4042-86.951 1.8996-82.42 25.872-148.72 53.544-148.08 27.67 0.63962 48.562 67.973 46.663 150.39-0.55326 23.969-2.9834 46.556-6.8177 66.547 3.9393-4.3512 9.2233-6.1842 14.133-5.8372 0.90342 0.064838 1.7823 0.2173 2.6437 0.41531 16.804-92.03-0.81238-181.89-27.729-215.44z" fill="#04A53C"/></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="m478.29 296.98c-3.99-63.966-36.52-111.82-85.468-138.58 0.278 1.56-1.109 3.508-2.688 2.818-32.016-14.006-86.328 31.32-124.28 7.584 0.285 8.519-1.378 50.072-59.914 52.483-1.382 0.056-2.142-1.355-1.268-2.354 7.828-8.929 15.732-31.535 4.367-43.586-24.338 21.81-38.472 30.017-85.138 19.186-29.878 31.241-46.809 74-43.485 127.26 6.78 108.74 108.63 170.89 211.19 164.49 102.56-6.395 193.47-80.572 186.68-189.31" fill="#FA320A"/><path d="M291.375 132.293c21.075-5.023 81.693-.49 101.114 25.274 1.166 1.545-.475 4.468-2.355 3.648-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.73-31.535 4.367-43.586-26.512 23.758-40.884 31.392-98.426 15.838-1.883-.508-1.241-3.535.762-4.298 10.876-4.157 35.515-22.361 58.824-30.385 4.438-1.526 8.862-2.71 13.18-3.4-25.665-2.293-37.235-5.862-53.559-3.4-1.789.27-3.004-1.813-1.895-3.241 21.995-28.332 62.513-36.888 87.512-21.837-15.41-19.094-27.48-34.321-27.48-34.321l28.601-16.246s11.817 26.4 20.414 45.614c21.275-31.435 60.86-34.336 77.585-12.033.992 1.326-.045 3.21-1.702 3.171-13.612-.331-21.107 12.05-21.675 21.466l.197.023" fill="#00912D"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="M445.185 444.684c-79.369 4.167-95.587-86.652-126.726-86.006-13.268.279-23.726 14.151-19.133 30.32 2.525 8.888 9.53 21.923 13.944 30.011 15.57 28.544-7.447 60.845-34.383 63.577-44.76 4.54-63.433-21.426-62.278-48.007 1.3-29.84 26.6-60.331.65-73.305-27.194-13.597-49.301 39.572-75.325 51.439-23.553 10.741-56.248 2.413-67.872-23.741-8.164-18.379-6.68-53.768 29.67-67.27 22.706-8.433 73.305 11.029 75.9-13.623 2.992-28.416-53.155-30.812-70.06-37.626-29.912-12.055-47.567-37.85-33.734-65.522 10.378-20.757 40.915-29.203 64.223-20.11 27.922 10.892 32.404 39.853 46.71 51.897 12.324 10.38 29.19 11.68 40.22 4.543 8.135-5.265 10.843-16.828 7.774-27.39-4.07-14.023-14.875-22.773-25.415-31.346-18.758-15.249-45.24-28.36-29.222-69.983 13.13-34.11 51.642-35.34 51.642-35.34 15.3-1.72 29.002 2.9 40.167 12.875 14.927 13.335 17.834 31.16 15.336 50.176-2.283 17.358-8.426 32.56-11.63 49.759-3.717 19.966 6.954 40.086 27.249 40.869 26.694 1.031 34.698-19.486 37.964-32.492 4.782-19.028 11.058-36.694 28.718-47.82 25.346-15.97 60.552-12.47 76.886 18.222 12.92 24.284 8.772 57.715-11.047 75.97-8.892 8.188-19.584 11.075-31.148 11.156-16.585.117-33.162-.29-48.556 7.471-10.48 5.281-15.047 13.888-15.045 25.423 0 11.242 5.853 18.585 15.336 23.363 17.86 9.003 37.577 10.843 56.871 14.222 27.98 4.9 52.581 14.755 68.375 40.72.142.228.28.458.415.69 18.139 30.741-.831 75.005-36.476 76.878" fill="#0AC855"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190.24 81.52"><defs><linearGradient id="a" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><path d="M105.67 36.06h66.9a17.67 17.67 0 0017.67-17.66A17.67 17.67 0 00172.57.73h-66.9A17.67 17.67 0 0088 18.4a17.67 17.67 0 0017.67 17.66zm-88 45h76.9a17.67 17.67 0 0017.67-17.66 17.67 17.67 0 00-17.67-17.67h-76.9A17.67 17.67 0 000 63.4a17.67 17.67 0 0017.67 17.66zm-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8zm113.92 20.25a15.07 15.07 0 00-4.52-5.52 18.57 18.57 0 00-6.68-3.08 33.54 33.54 0 00-8.07-1h-11.7v35.4h12.75a24.58 24.58 0 007.55-1.15 19.34 19.34 0 006.35-3.32 16.27 16.27 0 004.37-5.5 16.91 16.91 0 001.63-7.58 18.5 18.5 0 00-1.68-8.25zM145 68.6a8.8 8.8 0 01-2.64 3.4 10.7 10.7 0 01-4 1.82 21.57 21.57 0 01-5 .55h-4.05v-21h4.6a17 17 0 014.67.63 11.66 11.66 0 013.88 1.87A9.14 9.14 0 01145 59a9.87 9.87 0 011 4.52 11.89 11.89 0 01-1 5.08zm44.63-.13a8 8 0 00-1.58-2.62 8.38 8.38 0 00-2.42-1.85 10.31 10.31 0 00-3.17-1v-.1a9.22 9.22 0 004.42-2.82 7.43 7.43 0 001.68-5 8.42 8.42 0 00-1.15-4.65 8.09 8.09 0 00-3-2.72 12.56 12.56 0 00-4.18-1.3 32.84 32.84 0 00-4.62-.33h-13.2v35.4h14.5a22.41 22.41 0 004.72-.5 13.53 13.53 0 004.28-1.65 9.42 9.42 0 003.1-3 8.52 8.52 0 001.2-4.68 9.39 9.39 0 00-.55-3.18zm-19.42-15.75h5.3a10 10 0 011.85.18 6.18 6.18 0 011.7.57 3.39 3.39 0 011.22 1.13 3.22 3.22 0 01.48 1.82 3.63 3.63 0 01-.43 1.8 3.4 3.4 0 01-1.12 1.2 4.92 4.92 0 01-1.58.65 7.51 7.51 0 01-1.77.2h-5.65zm11.72 20a3.9 3.9 0 01-1.22 1.3 4.64 4.64 0 01-1.68.7 8.18 8.18 0 01-1.82.2h-7v-8h5.9a15.35 15.35 0 012 .15 8.47 8.47 0 012.05.55 4 4 0 011.57 1.18 3.11 3.11 0 01.63 2 3.71 3.71 0 01-.43 1.92z" fill="url(#a)"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB |
1
bun.lock
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
|
|||||||
@@ -89,14 +89,14 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row items-center mt-4'>
|
<View className='flex flex-row items-center mt-4'>
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/icons/seerr-logo.svg")}
|
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||||
style={{
|
style={{
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View className='shrink ml-2'>
|
<View className='shrink ml-2'>
|
||||||
<Text className='font-bold mb-1'>Seerr</Text>
|
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
||||||
<Text className='shrink text-xs'>
|
<Text className='shrink text-xs'>
|
||||||
{t("home.intro.jellyseerr_feature_description")}
|
{t("home.intro.jellyseerr_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -23,9 +23,8 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
|
||||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -67,47 +66,44 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
// Single source of truth for all player navigation — SyncPlay,
|
||||||
|
// offline-vs-stream resolution, and the autoplay counter reset all
|
||||||
|
// live inside `playItem`.
|
||||||
|
const { playItem } = usePlayerItemNavigation();
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
const effectiveColors = colors || globalColorAtom;
|
const effectiveColors = colors || globalColorAtom;
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const startWidth = useSharedValue(0);
|
const startWidth = useSharedValue(0);
|
||||||
const targetWidth = useSharedValue(0);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(effectiveColors);
|
const endColor = useSharedValue(effectiveColors);
|
||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(effectiveColors);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string) => {
|
(opts: Parameters<typeof playItem>[1]) => {
|
||||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
void playItem(item, opts);
|
||||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
|
||||||
}
|
|
||||||
router.push(`/player/direct-player?${q}`);
|
|
||||||
},
|
},
|
||||||
[router, isOffline],
|
[item, playItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNormalPlayFlow = useCallback(async () => {
|
const handleNormalPlayFlow = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
// Default play options derived from the page's track / source / bitrate
|
||||||
itemId: item.Id!,
|
// pickers. `playItem` handles SyncPlay broadcasting and offline-vs-online
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
// routing; we just need to pick a destination (device vs Chromecast).
|
||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
const defaultOpts = {
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
audioIndex: selectedOptions.audioIndex,
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
subtitleIndex: selectedOptions.subtitleIndex,
|
||||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
|
||||||
offline: isOffline ? "true" : "false",
|
bitrateValue: selectedOptions.bitrate?.value,
|
||||||
});
|
};
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString);
|
goToPlayer(defaultOpts);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +266,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString);
|
goToPlayer(defaultOpts);
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
@@ -280,35 +276,24 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
client,
|
client,
|
||||||
settings,
|
|
||||||
api,
|
api,
|
||||||
user,
|
user,
|
||||||
router,
|
|
||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
goToPlayer,
|
goToPlayer,
|
||||||
isOffline,
|
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
// Check if item is downloaded
|
// Check if item is downloaded
|
||||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||||
|
|
||||||
// If already in offline mode, play downloaded file directly
|
// If already in offline mode, play downloaded file directly
|
||||||
if (isOffline && downloadedItem) {
|
if (isOffline && downloadedItem) {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer({ forceOffline: true });
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,13 +316,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
hideModal();
|
hideModal();
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer({ forceOffline: true });
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
}}
|
}}
|
||||||
color='purple'
|
color='purple'
|
||||||
>
|
>
|
||||||
@@ -374,13 +353,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
{
|
{
|
||||||
text: t("player.downloaded_file_yes"),
|
text: t("player.downloaded_file_yes"),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
const queryParams = new URLSearchParams({
|
goToPlayer({ forceOffline: true });
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
},
|
},
|
||||||
isPreferred: true,
|
isPreferred: true,
|
||||||
},
|
},
|
||||||
@@ -404,13 +377,12 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
handleNormalPlayFlow();
|
handleNormalPlayFlow();
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
lightHapticFeedback,
|
isOffline,
|
||||||
handleNormalPlayFlow,
|
handleNormalPlayFlow,
|
||||||
goToPlayer,
|
goToPlayer,
|
||||||
t,
|
t,
|
||||||
showModal,
|
showModal,
|
||||||
hideModal,
|
hideModal,
|
||||||
effectiveColors,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
<Image
|
<Image
|
||||||
source={
|
source={
|
||||||
item.CriticRating < 60
|
item.CriticRating < 60
|
||||||
? require("@/assets/images/rt_rotten.svg")
|
? require("@/assets/images/rotten-tomatoes.png")
|
||||||
: require("@/assets/images/rt_fresh.svg")
|
: require("@/assets/images/not-rotten-tomatoes.svg")
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
@@ -89,8 +89,8 @@ export const JellyserrRatings: React.FC<{
|
|||||||
className='mr-1'
|
className='mr-1'
|
||||||
source={
|
source={
|
||||||
data?.criticsRating === "Rotten"
|
data?.criticsRating === "Rotten"
|
||||||
? require("@/assets/images/rt_rotten.svg")
|
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
|
||||||
: require("@/assets/images/rt_fresh.svg")
|
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
@@ -109,8 +109,8 @@ export const JellyserrRatings: React.FC<{
|
|||||||
className='mr-1'
|
className='mr-1'
|
||||||
source={
|
source={
|
||||||
data?.audienceRating === "Spilled"
|
data?.audienceRating === "Spilled"
|
||||||
? require("@/assets/images/rt_aud_rotten.svg")
|
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
||||||
: require("@/assets/images/rt_aud_fresh.svg")
|
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
@@ -127,7 +127,7 @@ export const JellyserrRatings: React.FC<{
|
|||||||
iconLeft={
|
iconLeft={
|
||||||
<Image
|
<Image
|
||||||
className='mr-1'
|
className='mr-1'
|
||||||
source={require("@/assets/images/tmdb_logo.svg")}
|
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
|
|||||||
@@ -69,23 +69,17 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
|||||||
[isAndroid],
|
[isAndroid],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPresentedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
} else if (isPresentedRef.current) {
|
} else {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
isPresentedRef.current = false;
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
const handleSheetChanges = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (index >= 0) {
|
if (index === -1) {
|
||||||
isPresentedRef.current = true;
|
|
||||||
} else if (index === -1 && isPresentedRef.current) {
|
|
||||||
isPresentedRef.current = false;
|
|
||||||
resetState();
|
resetState();
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{selected === -1 && streamType === "Subtitle"
|
{selected === -1 && streamType === "Subtitle"
|
||||||
? t("common.none")
|
? t("common.none")
|
||||||
: selectedSteam?.DisplayTitle || t("common.select")}
|
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
|||||||
<View style={styles.buttonContainer}>
|
<View style={styles.buttonContainer}>
|
||||||
<TVSubmitButton
|
<TVSubmitButton
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
label={t("login.login_button")}
|
label={t("login.login")}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={!password}
|
disabled={!password}
|
||||||
/>
|
/>
|
||||||
|
|||||||
259
components/syncplay/GroupSelectionMenu.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* GroupSelectionMenu
|
||||||
|
*
|
||||||
|
* Content rendered inside the SyncPlay bottom sheet (the sheet itself is
|
||||||
|
* owned by SyncPlayButton). Calls `onClose` after successful actions to
|
||||||
|
* dismiss the parent sheet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import type { GroupInfoDto } from "@/providers/SyncPlay/types";
|
||||||
|
|
||||||
|
interface GroupSelectionMenuProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isEnabled,
|
||||||
|
groupInfo,
|
||||||
|
canCreateGroups,
|
||||||
|
joinGroup,
|
||||||
|
createGroup,
|
||||||
|
leaveGroup,
|
||||||
|
getGroups,
|
||||||
|
resumeGroupPlayback,
|
||||||
|
} = useSyncPlay();
|
||||||
|
|
||||||
|
const [groups, setGroups] = useState<GroupInfoDto[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const fetchedGroups = await getGroups();
|
||||||
|
if (!cancelled) {
|
||||||
|
setGroups(fetchedGroups);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch groups", error);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [getGroups]);
|
||||||
|
|
||||||
|
const handleJoinGroup = useCallback(
|
||||||
|
async (groupId: string) => {
|
||||||
|
try {
|
||||||
|
await joinGroup(groupId);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to join group", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[joinGroup, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateGroup = useCallback(async () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
await createGroup();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create group", error);
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}, [createGroup, onClose]);
|
||||||
|
|
||||||
|
const handleLeaveGroup = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await leaveGroup();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to leave group", error);
|
||||||
|
}
|
||||||
|
}, [leaveGroup, onClose]);
|
||||||
|
|
||||||
|
// Jump (back) into the group's current item. Mirrors jellyfin-web's
|
||||||
|
// "Resume playback" menu entry — close the sheet and navigate to
|
||||||
|
// the player; SyncPlayProvider handles the re-follow + URL build.
|
||||||
|
const handleResumePlayback = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await resumeGroupPlayback();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to resume group playback", error);
|
||||||
|
}
|
||||||
|
}, [resumeGroupPlayback, onClose]);
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
paddingTop: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEnabled && groupInfo) {
|
||||||
|
return (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex-row items-center mb-2'>
|
||||||
|
<Ionicons name='people' size={24} color='#00a4dc' />
|
||||||
|
<Text className='font-bold text-xl text-neutral-100 ml-2'>
|
||||||
|
{t("syncplay.title")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='text-neutral-400'>{t("syncplay.my_group")}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='bg-neutral-800 rounded-xl p-4 mb-4'>
|
||||||
|
<View className='flex-row items-center justify-between mb-3'>
|
||||||
|
<Text className='text-neutral-100 font-semibold text-lg'>
|
||||||
|
{groupInfo.GroupName}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-[#00a4dc] px-2 py-1 rounded'>
|
||||||
|
<Text className='text-white text-xs font-medium'>
|
||||||
|
{groupInfo.State}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{groupInfo.Participants && groupInfo.Participants.length > 0 && (
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Ionicons name='person' size={16} color='#9ca3af' />
|
||||||
|
<Text className='text-neutral-400 ml-2'>
|
||||||
|
{groupInfo.Participants.length} {t("syncplay.members")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mb-3'>
|
||||||
|
<Button onPress={handleResumePlayback} color='black'>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
<Ionicons name='play-circle-outline' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("syncplay.resume_playback")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button onPress={handleLeaveGroup} color='red'>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
<Ionicons name='exit-outline' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("syncplay.leave_group")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex-row items-center mb-2'>
|
||||||
|
<Ionicons name='people-outline' size={24} color='white' />
|
||||||
|
<Text className='font-bold text-xl text-neutral-100 ml-2'>
|
||||||
|
{t("syncplay.title")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='text-neutral-400'>{t("syncplay.join_group")}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<View className='py-8 items-center'>
|
||||||
|
<ActivityIndicator color='#00a4dc' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && groups.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<Text className='text-neutral-400 text-sm mb-2 ml-1'>
|
||||||
|
{t("syncplay.available_groups")}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
|
||||||
|
{groups.map((group, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={group.GroupId ?? index}
|
||||||
|
onPress={() => group.GroupId && handleJoinGroup(group.GroupId)}
|
||||||
|
className={`flex-row items-center p-4 ${
|
||||||
|
index < groups.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='w-10 h-10 bg-[#00a4dc]/20 rounded-full items-center justify-center mr-3'>
|
||||||
|
<Ionicons name='people' size={20} color='#00a4dc' />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='text-neutral-100 font-medium'>
|
||||||
|
{group.GroupName}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{group.Participants?.length ?? 0} {t("syncplay.members")} •{" "}
|
||||||
|
{group.State}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Ionicons name='chevron-forward' size={20} color='#9ca3af' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && groups.length === 0 && (
|
||||||
|
<View className='bg-neutral-800/50 rounded-xl p-6 mb-4 items-center'>
|
||||||
|
<Ionicons name='people-outline' size={40} color='#6b7280' />
|
||||||
|
<Text className='text-neutral-400 text-center mt-3'>
|
||||||
|
{t("syncplay.available_groups")}: 0{"\n"}
|
||||||
|
{t("syncplay.create_new_group")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCreateGroups && (
|
||||||
|
<Button
|
||||||
|
onPress={handleCreateGroup}
|
||||||
|
color='purple'
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-center'>
|
||||||
|
{isCreating ? (
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("syncplay.create_new_group")}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
components/syncplay/SyncPlayActionIcon.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayActionIcon
|
||||||
|
*
|
||||||
|
* In-button SyncPlay status indicator — drops into the player's
|
||||||
|
* play/pause button slot and replaces the normal play/pause/loader
|
||||||
|
* graphic while SyncPlay is mid-transition. Mirrors jellyfin-web's
|
||||||
|
* `#syncPlayIcon` element (see `showIcon()` in
|
||||||
|
* `jellyfin-web/src/controllers/playback/video/index.js`).
|
||||||
|
*
|
||||||
|
* Icon vocabulary (matched 1:1 with jellyfin-web's `showIcon` switch):
|
||||||
|
*
|
||||||
|
* action primary secondary pulse spin
|
||||||
|
* --------------- ------------- ----------------- ---------- ----
|
||||||
|
* schedule-play sync play (centered) infinite yes
|
||||||
|
* unpause play-circle — one-shot no
|
||||||
|
* pause pause-circle — one-shot no
|
||||||
|
* seek refresh — infinite no
|
||||||
|
* buffering clock — infinite no
|
||||||
|
* wait-pause clock pause (shifted) infinite no
|
||||||
|
* wait-unpause clock play (shifted) infinite no
|
||||||
|
*
|
||||||
|
* Material → Ionicons mapping used here:
|
||||||
|
* sync → sync, schedule → time-outline, update → refresh-outline,
|
||||||
|
* play_arrow → play, pause → pause,
|
||||||
|
* play_circle_outline → play-circle-outline,
|
||||||
|
* pause_circle_outline → pause-circle-outline.
|
||||||
|
*
|
||||||
|
* When no SyncPlay action is active the component renders `fallback`
|
||||||
|
* so callers can keep the normal play/pause/loader graphic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { type ReactNode, useEffect } from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
cancelAnimation,
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import type { SyncPlayOsdAction } from "@/providers/SyncPlay";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
|
||||||
|
// SyncPlay cyan (matches jellyfin-web's `.syncPlayIconCircle` color)
|
||||||
|
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||||
|
|
||||||
|
type IoniconName = keyof typeof Ionicons.glyphMap;
|
||||||
|
|
||||||
|
type SecondaryPosition = "centered" | "shifted";
|
||||||
|
|
||||||
|
interface SecondaryIcon {
|
||||||
|
icon: IoniconName;
|
||||||
|
position: SecondaryPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OsdConfig {
|
||||||
|
/** Primary icon — fills the available size. */
|
||||||
|
icon: IoniconName;
|
||||||
|
/** Optional smaller overlay (~42% size). */
|
||||||
|
secondary?: SecondaryIcon;
|
||||||
|
/** Wrapper-level scale animation. */
|
||||||
|
pulse: "infinite" | "oneshot";
|
||||||
|
/** Rotate the primary icon continuously (secondary stays still). */
|
||||||
|
spin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG: Record<SyncPlayOsdAction, OsdConfig> = {
|
||||||
|
"schedule-play": {
|
||||||
|
icon: "sync",
|
||||||
|
secondary: { icon: "play", position: "centered" },
|
||||||
|
pulse: "infinite",
|
||||||
|
spin: true,
|
||||||
|
},
|
||||||
|
unpause: { icon: "play-circle-outline", pulse: "oneshot" },
|
||||||
|
pause: { icon: "pause-circle-outline", pulse: "oneshot" },
|
||||||
|
seek: { icon: "refresh-outline", pulse: "infinite" },
|
||||||
|
buffering: { icon: "time-outline", pulse: "infinite" },
|
||||||
|
"wait-pause": {
|
||||||
|
icon: "time-outline",
|
||||||
|
secondary: { icon: "pause", position: "shifted" },
|
||||||
|
pulse: "infinite",
|
||||||
|
},
|
||||||
|
"wait-unpause": {
|
||||||
|
icon: "time-outline",
|
||||||
|
secondary: { icon: "play", position: "shifted" },
|
||||||
|
pulse: "infinite",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SyncPlayActionIconProps {
|
||||||
|
size: number;
|
||||||
|
color?: string;
|
||||||
|
/** Rendered when no SyncPlay action is active. */
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlayActionIcon({
|
||||||
|
size,
|
||||||
|
color = SYNC_PLAY_COLOR,
|
||||||
|
fallback = null,
|
||||||
|
}: SyncPlayActionIconProps) {
|
||||||
|
const { osdAction } = useSyncPlay();
|
||||||
|
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancelAnimation(rotation);
|
||||||
|
cancelAnimation(scale);
|
||||||
|
rotation.value = 0;
|
||||||
|
scale.value = 1;
|
||||||
|
|
||||||
|
if (!osdAction) return;
|
||||||
|
|
||||||
|
const config = CONFIG[osdAction];
|
||||||
|
|
||||||
|
if (config.spin) {
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, { duration: 1200, easing: Easing.linear }),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.pulse === "infinite") {
|
||||||
|
scale.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1.1, {
|
||||||
|
duration: 700,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
}),
|
||||||
|
withTiming(0.95, {
|
||||||
|
duration: 700,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// one-shot: single scale flash; the provider clears the action
|
||||||
|
// ~1500ms later (transient OSD) so the icon then unmounts.
|
||||||
|
scale.value = withSequence(
|
||||||
|
withTiming(1.2, { duration: 220, easing: Easing.out(Easing.quad) }),
|
||||||
|
withTiming(1, { duration: 220, easing: Easing.inOut(Easing.quad) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [osdAction, rotation, scale]);
|
||||||
|
|
||||||
|
const pulseStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const spinStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${rotation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!osdAction) return <>{fallback}</>;
|
||||||
|
|
||||||
|
const config = CONFIG[osdAction];
|
||||||
|
const secondarySize = Math.round(size * 0.42);
|
||||||
|
|
||||||
|
// centered: geometric middle of the primary (e.g. play arrow inside
|
||||||
|
// the spinning `sync` ring for schedule-play).
|
||||||
|
// shifted: bottom-right corner (e.g. play/pause badge on the clock
|
||||||
|
// for wait-unpause / wait-pause).
|
||||||
|
const secondaryPosStyle =
|
||||||
|
config.secondary?.position === "centered"
|
||||||
|
? {
|
||||||
|
top: (size - secondarySize) / 2,
|
||||||
|
left: (size - secondarySize) / 2,
|
||||||
|
}
|
||||||
|
: { bottom: 0, right: 0 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={pulseStyle}>
|
||||||
|
<View style={{ width: size, height: size }}>
|
||||||
|
<Animated.View style={[StyleSheet.absoluteFill, spinStyle]}>
|
||||||
|
<Ionicons name={config.icon} size={size} color={color} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{config.secondary && (
|
||||||
|
<View
|
||||||
|
pointerEvents='none'
|
||||||
|
style={[styles.secondary, secondaryPosStyle]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={config.secondary.icon}
|
||||||
|
size={secondarySize}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
secondary: {
|
||||||
|
position: "absolute",
|
||||||
|
},
|
||||||
|
});
|
||||||
97
components/syncplay/SyncPlayButton.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayButton
|
||||||
|
*
|
||||||
|
* Header button for accessing SyncPlay functionality.
|
||||||
|
* Shows group status and opens the group selection sheet.
|
||||||
|
*
|
||||||
|
* Uses the @expo/ui drop-in BottomSheetModal (SwiftUI sheet on iOS, Jetpack
|
||||||
|
* Compose ModalBottomSheet on Android). Because it presents natively, it
|
||||||
|
* works correctly even when triggered from `headerRight` — no portal or
|
||||||
|
* provider context is required (unlike @gorhom/bottom-sheet, which fails
|
||||||
|
* silently from detached UINavigationItem subtrees).
|
||||||
|
*
|
||||||
|
* Safe to import statically: this whole module is lazy-required only on
|
||||||
|
* non-TV platforms by app/(auth)/(tabs)/(home)/_layout.tsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BottomSheetMethods,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@expo/ui/community/bottom-sheet";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
|
import { useCastDevice } from "react-native-google-cast";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||||
|
|
||||||
|
interface SyncPlayButtonProps {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlayButton({ size = 22 }: SyncPlayButtonProps) {
|
||||||
|
const { isEnabled, canJoinGroups } = useSyncPlay();
|
||||||
|
const { isConnected } = useNetworkStatus();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const sheetRef = useRef<BottomSheetMethods>(null);
|
||||||
|
|
||||||
|
const isCasting = !!castDevice;
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (isCasting) {
|
||||||
|
toast("SyncPlay not available while casting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sheetRef.current?.present();
|
||||||
|
}, [isCasting]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
sheetRef.current?.dismiss();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
if (!canJoinGroups) return null;
|
||||||
|
if (!isConnected) return null;
|
||||||
|
|
||||||
|
const iconColor = isCasting ? "#6b7280" : isEnabled ? "#00a4dc" : "white";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
className='mr-4'
|
||||||
|
onPress={handlePress}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<View className='relative'>
|
||||||
|
<Ionicons
|
||||||
|
name={isEnabled ? "people" : "people-outline"}
|
||||||
|
size={size}
|
||||||
|
color={iconColor}
|
||||||
|
/>
|
||||||
|
{isEnabled && !isCasting && (
|
||||||
|
<View
|
||||||
|
className='absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-[#00a4dc]'
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#171717",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={sheetRef}
|
||||||
|
snapPoints={Platform.OS === "android" ? ["100%"] : ["60%"]}
|
||||||
|
enablePanDownToClose
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<GroupSelectionMenu onClose={handleDismiss} />
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/syncplay/SyncPlaySpinner.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlaySpinner
|
||||||
|
*
|
||||||
|
* Compact rotating SyncPlay icon shown in place of the play/pause button
|
||||||
|
* while a play/pause command is in flight to the server (the "schedule-play"
|
||||||
|
* indicator from jellyfin-web).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
// SyncPlay cyan color (matches jellyfin-web)
|
||||||
|
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||||
|
|
||||||
|
interface SyncPlaySpinnerProps {
|
||||||
|
size: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlaySpinner({
|
||||||
|
size,
|
||||||
|
color = SYNC_PLAY_COLOR,
|
||||||
|
}: SyncPlaySpinnerProps) {
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, {
|
||||||
|
duration: 1200,
|
||||||
|
easing: Easing.linear,
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}, [rotation]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${rotation.value}deg` }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={animatedStyle}>
|
||||||
|
<Ionicons name='sync' size={size} color={color} />
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
components/syncplay/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay UI Components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||||
|
export { SyncPlayActionIcon } from "./SyncPlayActionIcon";
|
||||||
|
export { SyncPlayButton } from "./SyncPlayButton";
|
||||||
|
export { SyncPlaySpinner } from "./SyncPlaySpinner";
|
||||||
@@ -3,6 +3,7 @@ import type { FC } from "react";
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { SyncPlayActionIcon } from "@/components/syncplay/SyncPlayActionIcon";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import AudioSlider from "./AudioSlider";
|
import AudioSlider from "./AudioSlider";
|
||||||
@@ -121,15 +122,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
|
|
||||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||||
<TouchableOpacity onPress={togglePlay}>
|
<TouchableOpacity onPress={togglePlay}>
|
||||||
{!isBuffering ? (
|
<SyncPlayActionIcon
|
||||||
<Ionicons
|
size={ICON_SIZES.CENTER}
|
||||||
name={isPlaying ? "pause" : "play"}
|
fallback={
|
||||||
size={ICON_SIZES.CENTER}
|
!isBuffering ? (
|
||||||
color='white'
|
<Ionicons
|
||||||
/>
|
name={isPlaying ? "pause" : "play"}
|
||||||
) : (
|
size={ICON_SIZES.CENTER}
|
||||||
<Loader size={"large"} />
|
color='white'
|
||||||
)}
|
/>
|
||||||
|
) : (
|
||||||
|
<Loader size={"large"} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import useRouter from "@/hooks/useAppRouter";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export interface ContinueWatchingOverlayProps {
|
export interface ContinueWatchingOverlayProps {
|
||||||
goToNextItem: (options: {
|
/** Invoked when the user confirms they want to keep watching. */
|
||||||
isAutoPlay: boolean;
|
onContinue: () => void;
|
||||||
resetWatchCount: boolean;
|
|
||||||
}) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
||||||
goToNextItem,
|
onContinue,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -29,13 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
|||||||
<Text className='text-2xl font-bold text-white py-4 '>
|
<Text className='text-2xl font-bold text-white py-4 '>
|
||||||
Are you still watching ?
|
Are you still watching ?
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button onPress={onContinue} color={"purple"} className='my-4 w-2/3'>
|
||||||
onPress={() => {
|
|
||||||
goToNextItem({ isAutoPlay: false, resetWatchCount: true });
|
|
||||||
}}
|
|
||||||
color={"purple"}
|
|
||||||
className='my-4 w-2/3'
|
|
||||||
>
|
|
||||||
{t("player.continue_watching")}
|
{t("player.continue_watching")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -15,17 +15,15 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
|
import { usePlayerItemNavigation } from "@/hooks/usePlayerItemNavigation";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { ticksToMs } from "@/utils/time";
|
import { ticksToMs } from "@/utils/time";
|
||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
@@ -104,9 +102,7 @@ export const Controls: FC<Props> = ({
|
|||||||
transcodeReasons,
|
transcodeReasons,
|
||||||
}) => {
|
}) => {
|
||||||
const offline = useOfflineMode();
|
const offline = useOfflineMode();
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const [episodeView, setEpisodeView] = useState(false);
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||||
@@ -338,130 +334,27 @@ export const Controls: FC<Props> = ({
|
|||||||
maxMs,
|
maxMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToItemCommon = useCallback(
|
/*
|
||||||
(item: BaseItemDto) => {
|
* Single source of truth for next / previous / picker / autoplay
|
||||||
if (!item || !settings) {
|
* navigation. Handles SyncPlay dispatch, autoplay count gating,
|
||||||
return;
|
* platform-appropriate local navigation, and offline param injection.
|
||||||
}
|
*/
|
||||||
lightHapticFeedback();
|
const {
|
||||||
const previousIndexes = {
|
goToNextItem: handleNextEpisodeManual,
|
||||||
subtitleIndex: subtitleIndex
|
goToPreviousItem: handlePreviousItem,
|
||||||
? Number.parseInt(subtitleIndex, 10)
|
goToItem: handleGoToItem,
|
||||||
: undefined,
|
handleAutoPlayNext: handleNextEpisodeAutoPlay,
|
||||||
audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
|
handleContinueWatching,
|
||||||
};
|
} = usePlayerItemNavigation({
|
||||||
|
nextItem,
|
||||||
const {
|
previousItem,
|
||||||
mediaSource: newMediaSource,
|
mediaSource,
|
||||||
audioIndex: defaultAudioIndex,
|
currentAudioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
currentSubtitleIndex: subtitleIndex
|
||||||
} = getDefaultPlaySettings(
|
? Number.parseInt(subtitleIndex, 10)
|
||||||
item,
|
: undefined,
|
||||||
settings,
|
bitrateValue: bitrateValue ? Number.parseInt(bitrateValue, 10) : undefined,
|
||||||
{
|
});
|
||||||
indexes: previousIndexes,
|
|
||||||
source: mediaSource ?? undefined,
|
|
||||||
},
|
|
||||||
{ applyLanguagePreferences: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
|
||||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
|
||||||
router.setParams({
|
|
||||||
...(offline && { offline: "true" }),
|
|
||||||
itemId: item.Id ?? "",
|
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: newMediaSource?.Id ?? "",
|
|
||||||
bitrateValue: bitrateValue?.toString(),
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
settings,
|
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
|
||||||
mediaSource,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
offline,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
|
||||||
if (!previousItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goToItemCommon(previousItem);
|
|
||||||
}, [previousItem, goToItemCommon]);
|
|
||||||
|
|
||||||
const goToNextItem = useCallback(
|
|
||||||
({
|
|
||||||
isAutoPlay,
|
|
||||||
resetWatchCount,
|
|
||||||
}: {
|
|
||||||
isAutoPlay?: boolean;
|
|
||||||
resetWatchCount?: boolean;
|
|
||||||
}) => {
|
|
||||||
if (!nextItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAutoPlay) {
|
|
||||||
// if we are not autoplaying, we won't update anything, we just go to the next item
|
|
||||||
goToItemCommon(nextItem);
|
|
||||||
if (resetWatchCount) {
|
|
||||||
updateSettings({
|
|
||||||
autoPlayEpisodeCount: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip autoplay logic if maxAutoPlayEpisodeCount is -1
|
|
||||||
if (settings.maxAutoPlayEpisodeCount.value === -1) {
|
|
||||||
goToItemCommon(nextItem);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
settings.autoPlayEpisodeCount + 1 <
|
|
||||||
settings.maxAutoPlayEpisodeCount.value
|
|
||||||
) {
|
|
||||||
goToItemCommon(nextItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the autoPlayEpisodeCount is less than maxAutoPlayEpisodeCount for the autoPlay
|
|
||||||
if (
|
|
||||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value
|
|
||||||
) {
|
|
||||||
// update the autoPlayEpisodeCount in settings
|
|
||||||
updateSettings({
|
|
||||||
autoPlayEpisodeCount: settings.autoPlayEpisodeCount + 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[nextItem, goToItemCommon],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add a memoized handler for autoplay next episode
|
|
||||||
const handleNextEpisodeAutoPlay = useCallback(() => {
|
|
||||||
goToNextItem({ isAutoPlay: true });
|
|
||||||
}, [goToNextItem]);
|
|
||||||
|
|
||||||
// Add a memoized handler for manual next episode
|
|
||||||
const handleNextEpisodeManual = useCallback(() => {
|
|
||||||
goToNextItem({ isAutoPlay: false });
|
|
||||||
}, [goToNextItem]);
|
|
||||||
|
|
||||||
// Add a memoized handler for ContinueWatchingOverlay
|
|
||||||
const handleContinueWatching = useCallback(
|
|
||||||
(options: { isAutoPlay?: boolean; resetWatchCount?: boolean }) => {
|
|
||||||
goToNextItem(options);
|
|
||||||
},
|
|
||||||
[goToNextItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hideControls = useCallback(() => {
|
const hideControls = useCallback(() => {
|
||||||
setShowControls(false);
|
setShowControls(false);
|
||||||
@@ -490,7 +383,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<EpisodeList
|
<EpisodeList
|
||||||
item={item}
|
item={item}
|
||||||
close={() => setEpisodeView(false)}
|
close={() => setEpisodeView(false)}
|
||||||
goToItem={goToItemCommon}
|
goToItem={handleGoToItem}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -524,8 +417,8 @@ export const Controls: FC<Props> = ({
|
|||||||
mediaSource={mediaSource}
|
mediaSource={mediaSource}
|
||||||
startPictureInPicture={startPictureInPicture}
|
startPictureInPicture={startPictureInPicture}
|
||||||
switchOnEpisodeMode={switchOnEpisodeMode}
|
switchOnEpisodeMode={switchOnEpisodeMode}
|
||||||
goToPreviousItem={goToPreviousItem}
|
goToPreviousItem={handlePreviousItem}
|
||||||
goToNextItem={goToNextItem}
|
goToNextItem={handleNextEpisodeManual}
|
||||||
previousItem={previousItem}
|
previousItem={previousItem}
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
@@ -597,7 +490,7 @@ export const Controls: FC<Props> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||||
<ContinueWatchingOverlay goToNextItem={handleContinueWatching} />
|
<ContinueWatchingOverlay onContinue={handleContinueWatching} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface HeaderControlsProps {
|
|||||||
startPictureInPicture?: () => Promise<void>;
|
startPictureInPicture?: () => Promise<void>;
|
||||||
switchOnEpisodeMode: () => void;
|
switchOnEpisodeMode: () => void;
|
||||||
goToPreviousItem: () => void;
|
goToPreviousItem: () => void;
|
||||||
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
|
goToNextItem: () => void;
|
||||||
previousItem?: BaseItemDto | null;
|
previousItem?: BaseItemDto | null;
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
aspectRatio?: AspectRatio;
|
aspectRatio?: AspectRatio;
|
||||||
@@ -172,7 +172,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
)}
|
)}
|
||||||
{nextItem && (
|
{nextItem && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => goToNextItem({ isAutoPlay: false })}
|
onPress={() => goToNextItem()}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
22
hooks/useKeepWebSocketAlive.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While `active` is true, hold a keep-alive token on the global
|
||||||
|
* WebSocket so it is NOT closed when the app moves to
|
||||||
|
* background/inactive. Releases automatically when `active` flips
|
||||||
|
* false or the component unmounts.
|
||||||
|
*
|
||||||
|
* Used by the video player while in Picture-in-Picture so SyncPlay
|
||||||
|
* commands (and any other server pushes) keep flowing while the OS
|
||||||
|
* thinks the app is backgrounded.
|
||||||
|
*/
|
||||||
|
export function useKeepWebSocketAlive(active: boolean): void {
|
||||||
|
const { acquireKeepAlive } = useWebSocketContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const release = acquireKeepAlive();
|
||||||
|
return release;
|
||||||
|
}, [active, acquireKeepAlive]);
|
||||||
|
}
|
||||||
405
hooks/usePlayerItemNavigation.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for *all* item-level navigation inside the
|
||||||
|
* player (next / previous / picked-from-episode-list / autoplay-next).
|
||||||
|
*
|
||||||
|
* This hook encapsulates three orthogonal concerns so callers don't
|
||||||
|
* have to:
|
||||||
|
*
|
||||||
|
* 1. **SyncPlay** — when a group is active, every advance/rewind
|
||||||
|
* dispatches through `Controller`. `SyncPlayProvider` handles the
|
||||||
|
* resulting `localPlay` / `localSetCurrentPlaylistItem` events and
|
||||||
|
* navigates the local screen.
|
||||||
|
* 2. **Autoplay gating** — `maxAutoPlayEpisodeCount` limits how many
|
||||||
|
* episodes auto-play before stopping. Manual presses bypass this.
|
||||||
|
* SyncPlay bypasses it too (the server drives the queue).
|
||||||
|
* 3. **Platform navigation** — mobile uses `router.setParams` so the
|
||||||
|
* player view stays mounted (avoids a full re-mount + bitrate /
|
||||||
|
* stream re-pick cycle). TV uses `router.replace` because MPV's
|
||||||
|
* native view can't be re-initialized in place. Offline state is
|
||||||
|
* preserved automatically by `useAppRouter`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, Platform } from "react-native";
|
||||||
|
import useAppRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
|
||||||
|
interface UsePlayerItemNavigationParams {
|
||||||
|
/**
|
||||||
|
* The adjacent item that "next" should target (from `usePlaybackManager`).
|
||||||
|
* Only needed by callers that use the in-session nav methods.
|
||||||
|
*/
|
||||||
|
nextItem?: BaseItemDto | null;
|
||||||
|
/** The adjacent item that "previous" should target. */
|
||||||
|
previousItem?: BaseItemDto | null;
|
||||||
|
/** The active media source for the *current* item; used to seed track defaults. */
|
||||||
|
mediaSource?: MediaSourceInfo | null;
|
||||||
|
/** Live audio track index (may differ from the URL param after the user changed tracks). */
|
||||||
|
currentAudioIndex?: number;
|
||||||
|
/** Live subtitle track index. */
|
||||||
|
currentSubtitleIndex?: number;
|
||||||
|
/** Currently-active bitrate cap. */
|
||||||
|
bitrateValue?: number;
|
||||||
|
/**
|
||||||
|
* Optional guard for "we're already stopping the player". TV passes
|
||||||
|
* `isPlaybackStopped` here to drop spurious next-item dispatches that
|
||||||
|
* fire during teardown.
|
||||||
|
*/
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for `playItem` — the entry-point method used by PlayButton et al. */
|
||||||
|
export interface PlayItemOptions {
|
||||||
|
audioIndex?: number;
|
||||||
|
subtitleIndex?: number;
|
||||||
|
mediaSourceId?: string;
|
||||||
|
bitrateValue?: number;
|
||||||
|
/** Defaults to `item.UserData?.PlaybackPositionTicks`. */
|
||||||
|
playbackPosition?: number;
|
||||||
|
/**
|
||||||
|
* Force local-file playback even outside the offline UI context, and
|
||||||
|
* skip SyncPlay broadcasting. Used when the user explicitly picks the
|
||||||
|
* downloaded copy from a "play downloaded?" prompt.
|
||||||
|
*/
|
||||||
|
forceOffline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerItemNavigation {
|
||||||
|
/** SyncPlay-aware previous. No-op when there's no previous item. */
|
||||||
|
goToPreviousItem: () => void;
|
||||||
|
/**
|
||||||
|
* Manual next (e.g. user tapped the skip-forward / next button).
|
||||||
|
* Autoplay gating is bypassed.
|
||||||
|
*/
|
||||||
|
goToNextItem: () => void;
|
||||||
|
/** Jump to an arbitrary item (episode picker). */
|
||||||
|
goToItem: (item: BaseItemDto) => void;
|
||||||
|
/**
|
||||||
|
* Autoplay next (e.g. "Up Next" overlay countdown completed). Respects
|
||||||
|
* `maxAutoPlayEpisodeCount`. Bypassed when SyncPlay is active.
|
||||||
|
*/
|
||||||
|
handleAutoPlayNext: () => void;
|
||||||
|
/**
|
||||||
|
* Helper for the "Keep Watching" overlay button — advances and resets
|
||||||
|
* the auto-play counter so the next stretch of episodes can autoplay.
|
||||||
|
*/
|
||||||
|
handleContinueWatching: () => void;
|
||||||
|
/**
|
||||||
|
* Entry-point: start playback of an item from outside the player
|
||||||
|
* (PlayButton, Continue Watching, episode picker on the item page).
|
||||||
|
* SyncPlay-aware. Resets the autoplay counter.
|
||||||
|
*/
|
||||||
|
playItem: (item: BaseItemDto, opts?: PlayItemOptions) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayerItemNavigation(
|
||||||
|
params: UsePlayerItemNavigationParams = {},
|
||||||
|
): PlayerItemNavigation {
|
||||||
|
const {
|
||||||
|
nextItem,
|
||||||
|
previousItem,
|
||||||
|
mediaSource,
|
||||||
|
currentAudioIndex,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
bitrateValue,
|
||||||
|
isDisabled,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const router = useAppRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
|
||||||
|
useSyncPlay();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
// Note: "offline mode" is a *UI context* flag (set by pages entered from
|
||||||
|
// the downloads tab), not a network-connectivity status. A user may be
|
||||||
|
// in offline mode with perfect internet, watching a downloaded copy.
|
||||||
|
const inOfflineContext = useOfflineMode();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Compute the destination URL params for a given target item, using
|
||||||
|
* the live selection state (which may differ from the URL params the
|
||||||
|
* episode started with — the user may have switched tracks mid-play).
|
||||||
|
*/
|
||||||
|
const buildNavigationParams = useCallback(
|
||||||
|
(target: BaseItemDto) => {
|
||||||
|
if (!settings) return null;
|
||||||
|
const {
|
||||||
|
mediaSource: newMediaSource,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
|
} = getDefaultPlaySettings(
|
||||||
|
target,
|
||||||
|
settings,
|
||||||
|
{
|
||||||
|
indexes: {
|
||||||
|
subtitleIndex: currentSubtitleIndex,
|
||||||
|
audioIndex: currentAudioIndex,
|
||||||
|
},
|
||||||
|
source: mediaSource ?? undefined,
|
||||||
|
},
|
||||||
|
{ applyLanguagePreferences: true },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
itemId: target.Id ?? "",
|
||||||
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: newMediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrateValue?.toString() ?? "",
|
||||||
|
playbackPosition:
|
||||||
|
target.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[
|
||||||
|
settings,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
currentAudioIndex,
|
||||||
|
mediaSource,
|
||||||
|
bitrateValue,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Stamp the `offline` URL param onto a params object based on whether
|
||||||
|
* the target item is actually downloaded.
|
||||||
|
*
|
||||||
|
* - Online (no offline UI context) → pass through unchanged; the
|
||||||
|
* `offline` key is absent so `useAppRouter` doesn't touch it.
|
||||||
|
* - Offline context, target IS downloaded → `offline: "true"` (play
|
||||||
|
* the local copy).
|
||||||
|
* - Offline context, target is NOT downloaded → `offline: ""` (force
|
||||||
|
* online streaming; the key's presence blocks `useAppRouter` from
|
||||||
|
* auto-injecting `"true"`, and direct-player only treats the value
|
||||||
|
* `"true"` as offline).
|
||||||
|
*
|
||||||
|
* That last case is the important one: a user can be in the offline
|
||||||
|
* UI context with perfect internet (e.g. navigated in from downloads)
|
||||||
|
* and pick an episode they never downloaded. Without this the player
|
||||||
|
* would hang waiting for a local file that doesn't exist.
|
||||||
|
*/
|
||||||
|
const withOfflineParam = useCallback(
|
||||||
|
(params: Record<string, string>, target: BaseItemDto) => {
|
||||||
|
if (!inOfflineContext) return params;
|
||||||
|
const isDownloaded = !!(target.Id && getDownloadedItemById(target.Id));
|
||||||
|
return { ...params, offline: isDownloaded ? "true" : "" };
|
||||||
|
},
|
||||||
|
[inOfflineContext],
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Platform-appropriate local navigation. Mobile keeps the same player
|
||||||
|
* view mounted via setParams; TV swaps the whole route via replace.
|
||||||
|
*/
|
||||||
|
const localNavigate = useCallback(
|
||||||
|
(target: BaseItemDto) => {
|
||||||
|
if (isDisabled) return;
|
||||||
|
const navParams = buildNavigationParams(target);
|
||||||
|
if (!navParams) return;
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
const finalParams = withOfflineParam(navParams, target);
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
const queryString = new URLSearchParams(finalParams).toString();
|
||||||
|
router.replace(`/player/direct-player?${queryString}`);
|
||||||
|
} else {
|
||||||
|
router.setParams(finalParams);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isDisabled,
|
||||||
|
buildNavigationParams,
|
||||||
|
router,
|
||||||
|
lightHapticFeedback,
|
||||||
|
withOfflineParam,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToPreviousItem = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.previousItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!previousItem) return;
|
||||||
|
localNavigate(previousItem);
|
||||||
|
}, [isSyncPlayEnabled, syncPlayController, previousItem, localNavigate]);
|
||||||
|
|
||||||
|
const goToNextItem = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.nextItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nextItem) return;
|
||||||
|
localNavigate(nextItem);
|
||||||
|
}, [isSyncPlayEnabled, syncPlayController, nextItem, localNavigate]);
|
||||||
|
|
||||||
|
const goToItem = useCallback(
|
||||||
|
(target: BaseItemDto) => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController && target.Id) {
|
||||||
|
syncPlayController.goToItem(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localNavigate(target);
|
||||||
|
},
|
||||||
|
[isSyncPlayEnabled, syncPlayController, localNavigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAutoPlayNext = useCallback(() => {
|
||||||
|
// SyncPlay always advances unconditionally — the server is the source
|
||||||
|
// of truth for queue progression and per-client gating would desync us.
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.nextItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nextItem) return;
|
||||||
|
|
||||||
|
const maxCount = settings?.maxAutoPlayEpisodeCount.value ?? 0;
|
||||||
|
const currentCount = settings?.autoPlayEpisodeCount ?? 0;
|
||||||
|
|
||||||
|
// -1 means "no limit"
|
||||||
|
if (maxCount === -1) {
|
||||||
|
localNavigate(nextItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCount + 1 < maxCount) {
|
||||||
|
localNavigate(nextItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCount < maxCount) {
|
||||||
|
updateSettings({ autoPlayEpisodeCount: currentCount + 1 });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlayController,
|
||||||
|
nextItem,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
localNavigate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleContinueWatching = useCallback(() => {
|
||||||
|
if (isSyncPlayEnabled && syncPlayController) {
|
||||||
|
syncPlayController.nextItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!nextItem) return;
|
||||||
|
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||||
|
localNavigate(nextItem);
|
||||||
|
}, [
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlayController,
|
||||||
|
nextItem,
|
||||||
|
updateSettings,
|
||||||
|
localNavigate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Entry-point: start playback of an item from outside the player.
|
||||||
|
*
|
||||||
|
* Used by PlayButton, Continue Watching cards, episode pickers on the
|
||||||
|
* item page, etc. Unlike the in-session methods, this always uses
|
||||||
|
* `router.push` (we're entering the player, not navigating within it)
|
||||||
|
* and runs on both mobile and TV with the same shape.
|
||||||
|
*
|
||||||
|
* SyncPlay: when in a group and the user *didn't* explicitly request
|
||||||
|
* local playback, we route through `controller.play()` so every group
|
||||||
|
* member gets the same `PlayQueue: NewPlaylist` update and navigates
|
||||||
|
* together. Errors surface as an Alert and abort the local navigation
|
||||||
|
* (matches `PlayButton`'s previous behavior).
|
||||||
|
*/
|
||||||
|
const playItem = useCallback(
|
||||||
|
async (item: BaseItemDto, opts: PlayItemOptions = {}) => {
|
||||||
|
if (!item.Id) return;
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
const startPositionTicks =
|
||||||
|
opts.playbackPosition ?? item.UserData?.PlaybackPositionTicks ?? 0;
|
||||||
|
|
||||||
|
// Fresh playback start — reset the autoplay budget so the next
|
||||||
|
// stretch of episodes can autoplay.
|
||||||
|
if (settings && settings.maxAutoPlayEpisodeCount.value !== -1) {
|
||||||
|
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncPlay: broadcast to the group instead of navigating locally.
|
||||||
|
// Skipped when the user explicitly picked the downloaded copy — a
|
||||||
|
// local file can't be part of a synced session.
|
||||||
|
if (!opts.forceOffline && isSyncPlayEnabled && syncPlayController) {
|
||||||
|
try {
|
||||||
|
await syncPlayController.play({
|
||||||
|
items: [item],
|
||||||
|
ids: [item.Id],
|
||||||
|
startPositionTicks,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to start group playback", error);
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("syncplay.failed_to_start", {
|
||||||
|
defaultValue: "Failed to start SyncPlay group playback",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a string-record so we can run it through `withOfflineParam`
|
||||||
|
// and `URLSearchParams` uniformly.
|
||||||
|
const baseParams: Record<string, string> = {
|
||||||
|
itemId: item.Id,
|
||||||
|
playbackPosition: String(startPositionTicks),
|
||||||
|
};
|
||||||
|
if (opts.audioIndex !== undefined) {
|
||||||
|
baseParams.audioIndex = String(opts.audioIndex);
|
||||||
|
}
|
||||||
|
if (opts.subtitleIndex !== undefined) {
|
||||||
|
baseParams.subtitleIndex = String(opts.subtitleIndex);
|
||||||
|
}
|
||||||
|
if (opts.mediaSourceId) {
|
||||||
|
baseParams.mediaSourceId = opts.mediaSourceId;
|
||||||
|
}
|
||||||
|
if (opts.bitrateValue !== undefined) {
|
||||||
|
baseParams.bitrateValue = String(opts.bitrateValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalParams = opts.forceOffline
|
||||||
|
? { ...baseParams, offline: "true" }
|
||||||
|
: withOfflineParam(baseParams, item);
|
||||||
|
|
||||||
|
const queryString = new URLSearchParams(finalParams).toString();
|
||||||
|
router.push(`/player/direct-player?${queryString}`);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
lightHapticFeedback,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
isSyncPlayEnabled,
|
||||||
|
syncPlayController,
|
||||||
|
withOfflineParam,
|
||||||
|
router,
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
goToPreviousItem,
|
||||||
|
goToNextItem,
|
||||||
|
goToItem,
|
||||||
|
handleAutoPlayNext,
|
||||||
|
handleContinueWatching,
|
||||||
|
playItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePlayerItemNavigation;
|
||||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert } from "react-native";
|
import { Alert } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
|
||||||
interface UseWebSocketProps {
|
interface UseWebSocketProps {
|
||||||
@@ -80,9 +81,9 @@ export const useWebSocket = ({
|
|||||||
playTrailers,
|
playTrailers,
|
||||||
}: UseWebSocketProps) => {
|
}: UseWebSocketProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { lastMessage } = useWebSocketContext();
|
const { lastMessage, clearLastMessage } = useWebSocketContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { clearLastMessage } = useWebSocketContext();
|
const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastMessage) return;
|
if (!lastMessage) return;
|
||||||
@@ -96,6 +97,25 @@ export const useWebSocket = ({
|
|||||||
| Record<string, string>
|
| Record<string, string>
|
||||||
| undefined; // Arguments are Dictionary<string, string>
|
| undefined; // Arguments are Dictionary<string, string>
|
||||||
|
|
||||||
|
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
|
||||||
|
const isSyncPlayCommand =
|
||||||
|
lastMessage.MessageType === "SyncPlayCommand" ||
|
||||||
|
lastMessage.MessageType === "SyncPlayGroupUpdate";
|
||||||
|
const isPlaybackCommand = [
|
||||||
|
"PlayPause",
|
||||||
|
"Pause",
|
||||||
|
"Unpause",
|
||||||
|
"Stop",
|
||||||
|
"Seek",
|
||||||
|
"NextTrack",
|
||||||
|
"PreviousTrack",
|
||||||
|
].includes(command ?? "");
|
||||||
|
|
||||||
|
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
|
||||||
|
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (command === "PlayPause") {
|
if (command === "PlayPause") {
|
||||||
console.log("Command ~ PlayPause");
|
console.log("Command ~ PlayPause");
|
||||||
togglePlay();
|
togglePlay();
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ class MpvPlayerModule : Module() {
|
|||||||
// No-op on Android - media session integration would require MediaSessionCompat
|
// No-op on Android - media session integration would require MediaSessionCompat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When true, PiP play/pause/skip controls emit JS events
|
||||||
|
// instead of driving MPV directly, so the host app can route
|
||||||
|
// through SyncPlay (server -> group broadcast -> all clients).
|
||||||
|
Prop("syncPlayDelegated") { view: MpvPlayerView, delegated: Boolean ->
|
||||||
|
view.syncPlayDelegated = delegated
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to play video
|
// Async function to play video
|
||||||
AsyncFunction("play") { view: MpvPlayerView ->
|
AsyncFunction("play") { view: MpvPlayerView ->
|
||||||
view.play()
|
view.play()
|
||||||
@@ -198,7 +205,7 @@ class MpvPlayerModule : Module() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defines events that the view can send to JavaScript
|
// Defines events that the view can send to JavaScript
|
||||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
val onError by EventDispatcher()
|
val onError by EventDispatcher()
|
||||||
val onTracksReady by EventDispatcher()
|
val onTracksReady by EventDispatcher()
|
||||||
val onPictureInPictureChange by EventDispatcher()
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
|
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
|
||||||
|
// (play / pause / skip) emit these events instead of driving MPV
|
||||||
|
// directly, so JS can route the action through the SyncPlay
|
||||||
|
// controller (server -> group broadcast -> all clients). Default
|
||||||
|
// behavior (non-SyncPlay) is unchanged.
|
||||||
|
val onPipPlayRequest by EventDispatcher()
|
||||||
|
val onPipPauseRequest by EventDispatcher()
|
||||||
|
val onPipSkipRequest by EventDispatcher()
|
||||||
|
|
||||||
|
var syncPlayDelegated: Boolean = false
|
||||||
|
|
||||||
private var textureView: TextureView
|
private var textureView: TextureView
|
||||||
private var renderer: MPVLayerRenderer? = null
|
private var renderer: MPVLayerRenderer? = null
|
||||||
@@ -85,14 +95,32 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
pipController?.setPlayerView(textureView)
|
pipController?.setPlayerView(textureView)
|
||||||
pipController?.delegate = object : PiPController.Delegate {
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
|
if (syncPlayDelegated) {
|
||||||
|
onPipPlayRequest(mapOf<String, Any>())
|
||||||
|
return
|
||||||
|
}
|
||||||
play()
|
play()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
if (syncPlayDelegated) {
|
||||||
|
onPipPauseRequest(mapOf<String, Any>())
|
||||||
|
return
|
||||||
|
}
|
||||||
pause()
|
pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekBy(seconds: Double) {
|
override fun onSeekBy(seconds: Double) {
|
||||||
|
if (syncPlayDelegated) {
|
||||||
|
val target = (cachedPosition + seconds).coerceAtLeast(0.0)
|
||||||
|
onPipSkipRequest(
|
||||||
|
mapOf(
|
||||||
|
"targetSeconds" to target,
|
||||||
|
"intervalSeconds" to seconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
seekBy(seconds)
|
seekBy(seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ public class MpvPlayerModule: Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When true, PiP play/pause/skip controls emit JS events instead
|
||||||
|
// of driving MPV directly, so the host app can route through
|
||||||
|
// SyncPlay (server -> group broadcast -> all clients).
|
||||||
|
Prop("syncPlayDelegated") { (view: MpvPlayerView, delegated: Bool) in
|
||||||
|
view.syncPlayDelegated = delegated
|
||||||
|
}
|
||||||
|
|
||||||
// Async function to play video
|
// Async function to play video
|
||||||
AsyncFunction("play") { (view: MpvPlayerView) in
|
AsyncFunction("play") { (view: MpvPlayerView) in
|
||||||
view.play()
|
view.play()
|
||||||
@@ -213,7 +220,7 @@ public class MpvPlayerModule: Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defines events that the view can send to JavaScript
|
// Defines events that the view can send to JavaScript
|
||||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange", "onPipPlayRequest", "onPipPauseRequest", "onPipSkipRequest")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ class MpvPlayerView: ExpoView {
|
|||||||
let onError = EventDispatcher()
|
let onError = EventDispatcher()
|
||||||
let onTracksReady = EventDispatcher()
|
let onTracksReady = EventDispatcher()
|
||||||
let onPictureInPictureChange = EventDispatcher()
|
let onPictureInPictureChange = EventDispatcher()
|
||||||
|
// SyncPlay: when `syncPlayDelegated == true`, PiP playback controls
|
||||||
|
// (play / pause / skip) emit these events instead of driving MPV
|
||||||
|
// directly, so JS can route the action through the SyncPlay
|
||||||
|
// controller (server → group broadcast → all clients). Default
|
||||||
|
// behavior (non-SyncPlay) is unchanged.
|
||||||
|
let onPipPlayRequest = EventDispatcher()
|
||||||
|
let onPipPauseRequest = EventDispatcher()
|
||||||
|
let onPipSkipRequest = EventDispatcher()
|
||||||
|
|
||||||
|
var syncPlayDelegated: Bool = false
|
||||||
|
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
private var cachedPosition: Double = 0
|
private var cachedPosition: Double = 0
|
||||||
@@ -671,6 +681,12 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
|
|
||||||
func pipControllerPlay(_ controller: PiPController) {
|
func pipControllerPlay(_ controller: PiPController) {
|
||||||
print("PiP play requested")
|
print("PiP play requested")
|
||||||
|
if syncPlayDelegated {
|
||||||
|
// Let JS route through SyncPlay. We deliberately do NOT touch
|
||||||
|
// MPV here; the WS command coming back will drive playback.
|
||||||
|
onPipPlayRequest([:])
|
||||||
|
return
|
||||||
|
}
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
@@ -678,6 +694,10 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
|
|
||||||
func pipControllerPause(_ controller: PiPController) {
|
func pipControllerPause(_ controller: PiPController) {
|
||||||
print("PiP pause requested")
|
print("PiP pause requested")
|
||||||
|
if syncPlayDelegated {
|
||||||
|
onPipPauseRequest([:])
|
||||||
|
return
|
||||||
|
}
|
||||||
intendedPlayState = false
|
intendedPlayState = false
|
||||||
renderer?.pausePlayback()
|
renderer?.pausePlayback()
|
||||||
pipController?.setPlaybackRate(0.0)
|
pipController?.setPlaybackRate(0.0)
|
||||||
@@ -687,6 +707,16 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
let seconds = CMTimeGetSeconds(interval)
|
let seconds = CMTimeGetSeconds(interval)
|
||||||
print("PiP skip by interval: \(seconds)")
|
print("PiP skip by interval: \(seconds)")
|
||||||
let target = max(0, cachedPosition + seconds)
|
let target = max(0, cachedPosition + seconds)
|
||||||
|
if syncPlayDelegated {
|
||||||
|
// `targetSeconds` lets JS convert to ticks and call
|
||||||
|
// syncPlayController.seek(). `intervalSeconds` is also sent
|
||||||
|
// for telemetry / debug.
|
||||||
|
onPipSkipRequest([
|
||||||
|
"targetSeconds": target,
|
||||||
|
"intervalSeconds": seconds
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
seekTo(position: target)
|
seekTo(position: target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ export type OnPictureInPictureChangePayload = {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted when the user taps a PiP playback control while the view
|
||||||
|
* was rendered with `syncPlayDelegated`. The host app should route
|
||||||
|
* the action through the SyncPlay controller instead of acting
|
||||||
|
* locally.
|
||||||
|
*/
|
||||||
|
export type OnPipPlayRequestPayload = Record<string, never>;
|
||||||
|
export type OnPipPauseRequestPayload = Record<string, never>;
|
||||||
|
export type OnPipSkipRequestPayload = {
|
||||||
|
/** Absolute target position the user wants to seek to, in seconds. */
|
||||||
|
targetSeconds: number;
|
||||||
|
/** Skip interval requested by the OS (signed seconds). Debug only. */
|
||||||
|
intervalSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type NowPlayingMetadata = {
|
export type NowPlayingMetadata = {
|
||||||
title?: string;
|
title?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
@@ -84,6 +99,18 @@ export type MpvPlayerViewProps = {
|
|||||||
onPictureInPictureChange?: (event: {
|
onPictureInPictureChange?: (event: {
|
||||||
nativeEvent: OnPictureInPictureChangePayload;
|
nativeEvent: OnPictureInPictureChangePayload;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
/**
|
||||||
|
* When true, PiP play/pause/skip controls emit the corresponding
|
||||||
|
* `onPipPlayRequest` / `onPipPauseRequest` / `onPipSkipRequest`
|
||||||
|
* events instead of driving MPV directly. Used to route PiP control
|
||||||
|
* actions through SyncPlay.
|
||||||
|
*/
|
||||||
|
syncPlayDelegated?: boolean;
|
||||||
|
onPipPlayRequest?: (event: { nativeEvent: OnPipPlayRequestPayload }) => void;
|
||||||
|
onPipPauseRequest?: (event: {
|
||||||
|
nativeEvent: OnPipPauseRequestPayload;
|
||||||
|
}) => void;
|
||||||
|
onPipSkipRequest?: (event: { nativeEvent: OnPipSkipRequestPayload }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MpvPlayerViewRef {
|
export interface MpvPlayerViewRef {
|
||||||
|
|||||||
@@ -22,9 +22,7 @@
|
|||||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"doctor": "expo-doctor",
|
"doctor": "expo-doctor",
|
||||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
|
||||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
|
||||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
191
providers/SyncPlay/Controller.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay Controller — public playback API exposed to consumers.
|
||||||
|
*
|
||||||
|
* Methods are fire-and-forget by design: SyncPlay HTTP responses don't
|
||||||
|
* carry useful info (the real state arrives via WebSocket broadcast).
|
||||||
|
* Wrap calls in try/catch so transient network errors don't reach the UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import type { SyncPlayManager } from "./Manager";
|
||||||
|
import {
|
||||||
|
getItemsForPlayback,
|
||||||
|
type TranslateOptions,
|
||||||
|
translateItemsForPlayback,
|
||||||
|
} from "./transport/queueTranslation";
|
||||||
|
|
||||||
|
export interface PlayOptions extends TranslateOptions {
|
||||||
|
items?: BaseItemDto[];
|
||||||
|
ids?: string[];
|
||||||
|
startIndex?: number;
|
||||||
|
startPositionTicks?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Controller {
|
||||||
|
private manager!: SyncPlayManager;
|
||||||
|
|
||||||
|
init(manager: SyncPlayManager): void {
|
||||||
|
this.manager = manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle play/pause for the whole group. */
|
||||||
|
playPause(): void {
|
||||||
|
if (this.manager.isPlaying()) {
|
||||||
|
this.pause();
|
||||||
|
} else {
|
||||||
|
this.unpause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resume the group's playback. */
|
||||||
|
unpause(): void {
|
||||||
|
this.manager.markPendingPlaybackCommand("Unpause");
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayUnpause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.unpause failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause the group's playback. */
|
||||||
|
pause(): void {
|
||||||
|
this.manager.markPendingPlaybackCommand("Pause");
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayPause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.pause failed", error);
|
||||||
|
}
|
||||||
|
// Pause locally too so the user sees instant feedback.
|
||||||
|
this.manager.getPlayerWrapper().localPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seek the group's playback. `positionTicks` is in ticks (1ms = 10000 ticks). */
|
||||||
|
seek(positionTicks: number): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlaySeek({
|
||||||
|
seekRequestDto: { PositionTicks: positionTicks },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.seek failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playback in the group. Expands containers (Series, Season,
|
||||||
|
* BoxSet, Playlist, single Episode w/ autoplay) into the real
|
||||||
|
* playable queue before broadcasting.
|
||||||
|
*
|
||||||
|
* Resolves once the SetNewQueue request completes; the server then
|
||||||
|
* broadcasts a PlayQueue update and Play command to every member.
|
||||||
|
*/
|
||||||
|
async play(options: PlayOptions): Promise<void> {
|
||||||
|
const api = this.manager.getApiClient();
|
||||||
|
|
||||||
|
const sendPlayRequest = async (items: BaseItemDto[]) => {
|
||||||
|
const queue = items
|
||||||
|
.map((item) => item.Id)
|
||||||
|
.filter((id): id is string => typeof id === "string");
|
||||||
|
await getSyncPlayApi(api).syncPlaySetNewQueue({
|
||||||
|
playRequestDto: {
|
||||||
|
PlayingQueue: queue,
|
||||||
|
PlayingItemPosition: options.startIndex ?? 0,
|
||||||
|
StartPositionTicks: options.startPositionTicks ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceItems = options.items
|
||||||
|
? options.items
|
||||||
|
: await getItemsForPlayback(api, options.ids ?? []);
|
||||||
|
const items = await translateItemsForPlayback(api, sourceItems, options);
|
||||||
|
await sendPlayRequest(items);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.play failed", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the group's playback. */
|
||||||
|
stop(): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayStop();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.stop failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump to the next item in the group's queue. */
|
||||||
|
nextItem(): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayNextItem({
|
||||||
|
nextItemRequestDto: {
|
||||||
|
PlaylistItemId: this.manager
|
||||||
|
.getQueueCore()
|
||||||
|
.getCurrentPlaylistItemId(),
|
||||||
|
} as unknown as Parameters<
|
||||||
|
ReturnType<typeof getSyncPlayApi>["syncPlayNextItem"]
|
||||||
|
>[0]["nextItemRequestDto"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.nextItem failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump to the previous item in the group's queue. */
|
||||||
|
previousItem(): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlayPreviousItem({
|
||||||
|
previousItemRequestDto: {
|
||||||
|
PlaylistItemId: this.manager
|
||||||
|
.getQueueCore()
|
||||||
|
.getCurrentPlaylistItemId(),
|
||||||
|
} as unknown as Parameters<
|
||||||
|
ReturnType<typeof getSyncPlayApi>["syncPlayPreviousItem"]
|
||||||
|
>[0]["previousItemRequestDto"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.previousItem failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jump to a specific item in the queue by playlist item id. */
|
||||||
|
setCurrentPlaylistItem(playlistItemId: string): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(this.manager.getApiClient()).syncPlaySetPlaylistItem({
|
||||||
|
setPlaylistItemRequestDto: { PlaylistItemId: playlistItemId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay Controller.setCurrentPlaylistItem failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jump the group to `item`. If the item is already in the current queue
|
||||||
|
* (by `Id`), dispatches a cheap `SetPlaylistItem` so the queue stays
|
||||||
|
* intact. Otherwise starts a new playback request, which replaces the
|
||||||
|
* group's queue (matches jellyfin-web's playbackManager.play behavior
|
||||||
|
* when picking an episode from a different series/season).
|
||||||
|
*/
|
||||||
|
goToItem(item: BaseItemDto): void {
|
||||||
|
if (!item.Id) {
|
||||||
|
console.warn("SyncPlay Controller.goToItem called without item.Id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const queueEntry = this.manager
|
||||||
|
.getQueueCore()
|
||||||
|
.getPlaylist()
|
||||||
|
.find((q) => q.Id === item.Id);
|
||||||
|
if (queueEntry?.PlaylistItemId) {
|
||||||
|
this.setCurrentPlaylistItem(queueEntry.PlaylistItemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.play({
|
||||||
|
ids: [item.Id],
|
||||||
|
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Controller;
|
||||||
93
providers/SyncPlay/EventEmitter.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Per-instance event emitter — replaces jellyfin-web's global `Events.trigger`
|
||||||
|
* bus. Listeners that throw are caught and logged so one bad listener can't
|
||||||
|
* break the rest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WaitForEventDefaultTimeout } from "./constants";
|
||||||
|
|
||||||
|
export class EventEmitter {
|
||||||
|
private listeners: Map<string, Set<(...args: unknown[]) => void>> = new Map();
|
||||||
|
|
||||||
|
on(event: string, callback: (...args: unknown[]) => void): void {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
this.listeners.get(event)!.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, callback: (...args: unknown[]) => void): void {
|
||||||
|
this.listeners.get(event)?.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: unknown[]): void {
|
||||||
|
this.listeners.get(event)?.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(...args);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`SyncPlay EventEmitter: handler for "${event}" threw`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners(event?: string): void {
|
||||||
|
if (event) {
|
||||||
|
this.listeners.delete(event);
|
||||||
|
} else {
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve on the next emission of `event`, or reject after `timeoutMs`
|
||||||
|
* (or any event in `rejectEventTypes`). Cleans up every listener.
|
||||||
|
*/
|
||||||
|
export function waitForEventOnce(
|
||||||
|
emitter: EventEmitter,
|
||||||
|
event: string,
|
||||||
|
timeoutMs: number = WaitForEventDefaultTimeout,
|
||||||
|
rejectEventTypes?: string[],
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
emitter.off(event, handler);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (Array.isArray(rejectEventTypes)) {
|
||||||
|
for (const eventName of rejectEventTypes) {
|
||||||
|
emitter.off(eventName, rejectCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = (...args: unknown[]) => {
|
||||||
|
clearAll();
|
||||||
|
resolve(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectCallback = (...args: unknown[]) => {
|
||||||
|
clearAll();
|
||||||
|
reject(args[0] ?? new Error("rejected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timeoutMs) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
clearAll();
|
||||||
|
reject(new Error("Timed out."));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on(event, handler);
|
||||||
|
|
||||||
|
if (Array.isArray(rejectEventTypes)) {
|
||||||
|
for (const eventName of rejectEventTypes) {
|
||||||
|
emitter.on(eventName, rejectCallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
383
providers/SyncPlay/Manager.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayManager — central orchestrator for a SyncPlay session.
|
||||||
|
*
|
||||||
|
* Owns the three "cores" (TimeSync, PlaybackCore, QueueCore) and the
|
||||||
|
* PlayerWrapper, and routes WebSocket events between them.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* constructor → init() → (joinGroup → group-state-change "Idle"+) →
|
||||||
|
* group-state-change "Playing" → group-state-change "Paused" → ...
|
||||||
|
* → (leaveGroup) → destroy()
|
||||||
|
*
|
||||||
|
* Events emitted (provider listens):
|
||||||
|
* - `group-info-update` `(GroupInfoDto | null)`
|
||||||
|
* - `group-state-change` `(state: string, oldState: string)`
|
||||||
|
* - `enabled` `(isEnabled: boolean)`
|
||||||
|
* - `play-state-change` `(isFollowing: boolean)`
|
||||||
|
* - `playbackstart` / `playbackerror` — from PlayerWrapper hooks
|
||||||
|
* - `osd` `(action: SyncPlayOsdAction)`
|
||||||
|
* - `toast` `(messageKey: string)`
|
||||||
|
*
|
||||||
|
* The manager exposes a per-instance `EventEmitter` rather than upstream
|
||||||
|
* `Events.on(manager, ...)` — replaces the global Events bus pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import { Controller } from "./Controller";
|
||||||
|
import { PlaybackCore } from "./cores/PlaybackCore";
|
||||||
|
import { QueueCore } from "./cores/QueueCore";
|
||||||
|
import { TimeSync } from "./cores/TimeSync";
|
||||||
|
import { EventEmitter } from "./EventEmitter";
|
||||||
|
import { PendingPlaybackTracker } from "./player/PendingPlaybackTracker";
|
||||||
|
import { PlayerWrapper } from "./player/PlayerWrapper";
|
||||||
|
import { reconcileToGroupOnAttach } from "./player/reconcileToGroupOnAttach";
|
||||||
|
import type {
|
||||||
|
GroupInfoDto,
|
||||||
|
GroupUpdate,
|
||||||
|
PlayerControls,
|
||||||
|
PlayQueueUpdate,
|
||||||
|
SendCommand,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** Raw WebSocket message data shapes (already unwrapped by the hook). */
|
||||||
|
|
||||||
|
export class SyncPlayManager extends EventEmitter {
|
||||||
|
private apiClient: Api;
|
||||||
|
private playerWrapper: PlayerWrapper;
|
||||||
|
private timeSync: TimeSync;
|
||||||
|
private playbackCore: PlaybackCore;
|
||||||
|
private queueCore: QueueCore;
|
||||||
|
private pendingPlaybackTracker: PendingPlaybackTracker;
|
||||||
|
private controller: Controller;
|
||||||
|
|
||||||
|
/** Current group info. `null` when not in a group. */
|
||||||
|
private groupInfo: GroupInfoDto | null = null;
|
||||||
|
/** Is SyncPlay actively enabled (i.e., we're in a group)? */
|
||||||
|
private syncPlayEnabledAtPlayer = false;
|
||||||
|
/** Are we mirroring the group's commands locally? */
|
||||||
|
private followingGroupPlayback = true;
|
||||||
|
|
||||||
|
constructor(api: Api) {
|
||||||
|
super();
|
||||||
|
this.apiClient = api;
|
||||||
|
this.playerWrapper = new PlayerWrapper();
|
||||||
|
this.timeSync = new TimeSync(api);
|
||||||
|
this.playbackCore = new PlaybackCore();
|
||||||
|
this.queueCore = new QueueCore();
|
||||||
|
this.pendingPlaybackTracker = new PendingPlaybackTracker();
|
||||||
|
this.controller = new Controller();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire up cores. Called once after construction. */
|
||||||
|
init(): void {
|
||||||
|
this.playbackCore.init(this);
|
||||||
|
this.queueCore.init(this);
|
||||||
|
this.controller.init(this);
|
||||||
|
|
||||||
|
// Forward PlaybackCore OSD events to provider listeners.
|
||||||
|
this.playbackCore.on("osd", (...args) => {
|
||||||
|
this.emit("osd", ...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bridge optimistic pending Pause/Unpause → React state.
|
||||||
|
this.pendingPlaybackTracker.setChangeHandler((cmd) => {
|
||||||
|
this.emit("pending-playback-change", cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.timeSync.startPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public controller for callers. */
|
||||||
|
getController(): Controller {
|
||||||
|
return this.controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by SyncPlayProvider when the user switches Jellyfin servers. */
|
||||||
|
updateApiClient(api: Api): void {
|
||||||
|
this.apiClient = api;
|
||||||
|
this.timeSync.updateApiClient(api);
|
||||||
|
}
|
||||||
|
|
||||||
|
getApiClient(): Api {
|
||||||
|
return this.apiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerWrapper(): PlayerWrapper {
|
||||||
|
return this.playerWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeSync(): TimeSync {
|
||||||
|
return this.timeSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaybackCore(): PlaybackCore {
|
||||||
|
return this.playbackCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
getQueueCore(): QueueCore {
|
||||||
|
return this.queueCore;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingPlaybackTracker(): PendingPlaybackTracker {
|
||||||
|
return this.pendingPlaybackTracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// WebSocket message handlers (called by useSyncPlayWebSocket)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a `SyncPlayGroupUpdate` WebSocket message.
|
||||||
|
*
|
||||||
|
* Cast: the SDK's `GroupUpdate.Type` union is narrower than what the
|
||||||
|
* server actually emits (it omits `SyncPlayIsDisabled`, `GroupUpdate`,
|
||||||
|
* `CreateGroupDenied`, `JoinGroupDenied`). Wire format is the source
|
||||||
|
* of truth here.
|
||||||
|
*/
|
||||||
|
processGroupUpdate(rawUpdate: GroupUpdate): void {
|
||||||
|
if (!rawUpdate) {
|
||||||
|
console.warn("SyncPlay processGroupUpdate: empty update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const update = rawUpdate as unknown as {
|
||||||
|
Type: string;
|
||||||
|
Data: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (update.Type) {
|
||||||
|
case "PlayQueue":
|
||||||
|
this.queueCore.updatePlayQueue(
|
||||||
|
this.apiClient,
|
||||||
|
update.Data as unknown as PlayQueueUpdate,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "UserJoined":
|
||||||
|
case "UserLeft":
|
||||||
|
// Group membership notifications — current group will follow
|
||||||
|
// via GroupUpdate, but emit a toast for friendliness.
|
||||||
|
this.emit("toast", `MessageSyncPlay${update.Type}`, update.Data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "GroupJoined": {
|
||||||
|
this.groupInfo = update.Data as GroupInfoDto;
|
||||||
|
this.enableSyncPlay(this.groupInfo);
|
||||||
|
this.emit("group-update", this.groupInfo);
|
||||||
|
this.emit("toast", "MessageSyncPlayGroupJoined");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GroupLeft":
|
||||||
|
case "NotInGroup":
|
||||||
|
case "SyncPlayIsDisabled": {
|
||||||
|
const previousState = this.groupInfo?.State;
|
||||||
|
this.groupInfo = null;
|
||||||
|
this.disableSyncPlay();
|
||||||
|
this.emit("group-update", null);
|
||||||
|
if (update.Type === "GroupLeft") {
|
||||||
|
this.emit("toast", "MessageSyncPlayGroupLeft");
|
||||||
|
}
|
||||||
|
if (previousState) {
|
||||||
|
this.emit("group-state-change", "Idle", previousState);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "GroupUpdate": {
|
||||||
|
const previousState = this.groupInfo?.State;
|
||||||
|
this.groupInfo = update.Data as GroupInfoDto;
|
||||||
|
this.emit("group-update", this.groupInfo);
|
||||||
|
const newState = this.groupInfo.State;
|
||||||
|
if (newState && newState !== previousState) {
|
||||||
|
this.emit("group-state-change", newState, previousState ?? "Idle");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "StateUpdate": {
|
||||||
|
const stateData = update.Data as {
|
||||||
|
State?: string;
|
||||||
|
PreviousState?: string;
|
||||||
|
Reason?: string;
|
||||||
|
};
|
||||||
|
const newState = stateData.State ?? "Idle";
|
||||||
|
const previousState = stateData.PreviousState ?? "Idle";
|
||||||
|
const reason = stateData.Reason;
|
||||||
|
if (this.groupInfo) {
|
||||||
|
this.groupInfo.State = newState as GroupInfoDto["State"];
|
||||||
|
this.emit("group-update", this.groupInfo);
|
||||||
|
}
|
||||||
|
this.emit("group-state-change", newState, previousState, reason);
|
||||||
|
// Server signals "Playing" or "Paused" → clear any in-flight
|
||||||
|
// optimistic tap state.
|
||||||
|
if (newState === "Playing" || newState === "Paused") {
|
||||||
|
this.pendingPlaybackTracker.clear();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "CreateGroupDenied":
|
||||||
|
this.emit("toast", "MessageSyncPlayCreateGroupDenied");
|
||||||
|
break;
|
||||||
|
case "JoinGroupDenied":
|
||||||
|
this.emit("toast", "MessageSyncPlayJoinGroupDenied");
|
||||||
|
break;
|
||||||
|
case "LibraryAccessDenied":
|
||||||
|
this.emit("toast", "MessageSyncPlayLibraryAccessDenied");
|
||||||
|
break;
|
||||||
|
case "GroupDoesNotExist":
|
||||||
|
this.emit("toast", "MessageSyncPlayGroupDoesNotExist");
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn("SyncPlay processGroupUpdate: unknown type", update.Type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a `SyncPlayCommand` WebSocket message. */
|
||||||
|
processCommand(command: SendCommand): void {
|
||||||
|
if (!command) {
|
||||||
|
console.warn("SyncPlay processCommand: empty command");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.playbackCore.applyCommand(command);
|
||||||
|
// Server told us the new playing state — clear optimistic UI.
|
||||||
|
if (command.Command === "Unpause" || command.Command === "Pause") {
|
||||||
|
this.pendingPlaybackTracker.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Enable / disable SyncPlay
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
private enableSyncPlay(_group: GroupInfoDto): void {
|
||||||
|
if (this.syncPlayEnabledAtPlayer) return;
|
||||||
|
this.syncPlayEnabledAtPlayer = true;
|
||||||
|
this.followingGroupPlayback = true;
|
||||||
|
this.timeSync.forceUpdate();
|
||||||
|
this.emit("enabled", true);
|
||||||
|
this.emit("play-state-change", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private disableSyncPlay(): void {
|
||||||
|
if (!this.syncPlayEnabledAtPlayer) return;
|
||||||
|
this.syncPlayEnabledAtPlayer = false;
|
||||||
|
this.followingGroupPlayback = false;
|
||||||
|
this.playbackCore.clearScheduledCommand();
|
||||||
|
this.queueCore.clear();
|
||||||
|
this.pendingPlaybackTracker.clear();
|
||||||
|
this.emit("enabled", false);
|
||||||
|
this.emit("play-state-change", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume following group playback after the user temporarily took
|
||||||
|
* local control (e.g. scrubbed the seek bar).
|
||||||
|
*/
|
||||||
|
async followGroupPlayback(_api: Api): Promise<void> {
|
||||||
|
this.followingGroupPlayback = true;
|
||||||
|
this.emit("play-state-change", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop following group playback (e.g., user takes local control). */
|
||||||
|
haltGroupPlayback(_api: Api): void {
|
||||||
|
this.followingGroupPlayback = false;
|
||||||
|
this.emit("play-state-change", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
isFollowingGroupPlayback(): boolean {
|
||||||
|
return this.followingGroupPlayback;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncPlayEnabled(): boolean {
|
||||||
|
return this.syncPlayEnabledAtPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Player attach + provider bridges
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind the RN player controls.
|
||||||
|
* Called from the player screen's `useEffect`. Triggers a reconcile
|
||||||
|
* if a group is active and the player is late-arriving.
|
||||||
|
*/
|
||||||
|
setPlayerControls(controls: PlayerControls | null): void {
|
||||||
|
this.playerWrapper.bindToControls(controls);
|
||||||
|
if (controls && this.syncPlayEnabledAtPlayer) {
|
||||||
|
const lastCommand = this.playbackCore.getLastCommand();
|
||||||
|
reconcileToGroupOnAttach(controls, lastCommand, (local) =>
|
||||||
|
this.timeSync.localDateToRemote(local),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player-side notify hook: media is ready to play. */
|
||||||
|
notifyReady(): void {
|
||||||
|
this.emit("playbackstart");
|
||||||
|
if (this.syncPlayEnabledAtPlayer) {
|
||||||
|
this.playbackCore.onReady(this.apiClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player-side notify hook: buffering state changed. */
|
||||||
|
notifyBuffering(isBuffering: boolean): void {
|
||||||
|
if (!this.syncPlayEnabledAtPlayer) return;
|
||||||
|
if (isBuffering) {
|
||||||
|
this.playbackCore.onBuffering(this.apiClient);
|
||||||
|
} else {
|
||||||
|
this.playbackCore.onReady(this.apiClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player-side notify hook: local playback started. */
|
||||||
|
notifyPlaybackStart(): void {
|
||||||
|
this.emit("playbackstart");
|
||||||
|
if (this.syncPlayEnabledAtPlayer) {
|
||||||
|
this.playbackCore.onPlaybackStart(this.apiClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Pending playback (optimistic UI for play/pause taps)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/** Called by Controller before sending an Unpause/Pause request. */
|
||||||
|
markPendingPlaybackCommand(command: "Unpause" | "Pause"): void {
|
||||||
|
this.pendingPlaybackTracker.mark(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the group currently playing? Used by Controller.playPause. */
|
||||||
|
isPlaying(): boolean {
|
||||||
|
const pending = this.pendingPlaybackTracker.get();
|
||||||
|
if (pending === "Unpause") return true;
|
||||||
|
if (pending === "Pause") return false;
|
||||||
|
return this.groupInfo?.State === "Playing";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group info for consumers. */
|
||||||
|
getGroupInfo(): GroupInfoDto | null {
|
||||||
|
return this.groupInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last playback command (for QueueCore.startPlayback resumption). */
|
||||||
|
getLastPlaybackCommand(): SendCommand | null {
|
||||||
|
return this.playbackCore.getLastCommand();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Teardown
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.timeSync.destroy();
|
||||||
|
this.playbackCore.destroy();
|
||||||
|
this.queueCore.destroy();
|
||||||
|
this.playerWrapper.bindToControls(null);
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SyncPlayManager;
|
||||||
600
providers/SyncPlay/SyncPlayProvider.tsx
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlayProvider — React glue around `SyncPlayManager`.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Manager lifecycle (construct on api change, destroy on unmount)
|
||||||
|
* - React mirrors of manager state (`isEnabled`, `groupInfo`,
|
||||||
|
* `pendingPlaybackCommand`) so components re-render
|
||||||
|
* - Navigation handlers wired into `PlayerWrapper.localPlay` /
|
||||||
|
* `localSetCurrentPlaylistItem` — these are what jellyfin-web does
|
||||||
|
* synchronously via `playbackManager.play`; on RN they navigate
|
||||||
|
* to the player screen instead
|
||||||
|
* - AppState foreground re-join (we may miss broadcasts while
|
||||||
|
* suspended)
|
||||||
|
*
|
||||||
|
* External API surface (`useSyncPlay`) is stable; components don't
|
||||||
|
* change when the internals do.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { usePathname } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useAppRouter } from "@/hooks/useAppRouter";
|
||||||
|
import { useKeepWebSocketAlive } from "@/hooks/useKeepWebSocketAlive";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
import type { Controller as SyncPlayController } from "./Controller";
|
||||||
|
import { SyncPlayManager } from "./Manager";
|
||||||
|
import { useSyncPlayWebSocket } from "./transport/useSyncPlayWebSocket";
|
||||||
|
import type { GroupInfoDto, PlayerControls, SyncPlayOsdAction } from "./types";
|
||||||
|
|
||||||
|
interface SyncPlayContextValue {
|
||||||
|
isEnabled: boolean;
|
||||||
|
groupInfo: GroupInfoDto | null;
|
||||||
|
canJoinGroups: boolean;
|
||||||
|
canCreateGroups: boolean;
|
||||||
|
|
||||||
|
joinGroup: (groupId: string) => Promise<void>;
|
||||||
|
createGroup: (groupName?: string) => Promise<void>;
|
||||||
|
leaveGroup: () => Promise<void>;
|
||||||
|
getGroups: () => Promise<GroupInfoDto[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-attach to the group's command stream and jump back to the
|
||||||
|
* group's currently-playing item. Mirrors jellyfin-web's "Resume
|
||||||
|
* playback" menu entry: in jellyfin-web it just calls
|
||||||
|
* `playbackManager.play` on the group's current queue position.
|
||||||
|
* Here we navigate to direct-player with the same params our
|
||||||
|
* `localSetCurrentItem` bridge would use, so the player picks up
|
||||||
|
* mid-group with `syncPlay=true` and the right offset.
|
||||||
|
*/
|
||||||
|
resumeGroupPlayback: () => Promise<void>;
|
||||||
|
|
||||||
|
controller: SyncPlayController | null;
|
||||||
|
|
||||||
|
setPlayerControls: (controls: PlayerControls | null) => void;
|
||||||
|
notifyReady: () => void;
|
||||||
|
notifyBuffering: (isBuffering: boolean) => void;
|
||||||
|
notifyPlaybackStart: () => void;
|
||||||
|
|
||||||
|
pendingPlaybackCommand: "Unpause" | "Pause" | null;
|
||||||
|
/**
|
||||||
|
* Current SyncPlay OSD overlay state. Drives the animated icon over the
|
||||||
|
* video that mirrors jellyfin-web's `#syncPlayIcon`. `null` means hidden.
|
||||||
|
*/
|
||||||
|
osdAction: SyncPlayOsdAction | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SyncPlayContext = createContext<SyncPlayContextValue | null>(null);
|
||||||
|
|
||||||
|
interface SyncPlayProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncPlayProvider({ children }: SyncPlayProviderProps) {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const router = useAppRouter();
|
||||||
|
const { isConnected: isWsConnected } = useWebSocketContext();
|
||||||
|
|
||||||
|
const [manager, setManager] = useState<SyncPlayManager | null>(null);
|
||||||
|
const isNavigatingToPlayerRef = useRef(false);
|
||||||
|
|
||||||
|
// Keep a live ref of the current route pathname so the
|
||||||
|
// navigateToPlayer helper (wired up once inside the manager-lifecycle
|
||||||
|
// effect) can read the *current* page without stale-closure issues.
|
||||||
|
const pathname = usePathname();
|
||||||
|
const pathnameRef = useRef(pathname);
|
||||||
|
useEffect(() => {
|
||||||
|
pathnameRef.current = pathname;
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
const [groupInfo, setGroupInfo] = useState<GroupInfoDto | null>(null);
|
||||||
|
const [pendingPlaybackCommand, setPendingPlaybackCommand] = useState<
|
||||||
|
"Unpause" | "Pause" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// While in a SyncPlay group, hold a keep-alive token on the global
|
||||||
|
// WebSocket so backgrounding the app does NOT cleanly close the
|
||||||
|
// socket. A clean close is interpreted by the Jellyfin server as
|
||||||
|
// leaving the group and is broadcast to every other member as
|
||||||
|
// "<user> has left the group". Keeping the socket open across a
|
||||||
|
// short suspend lets us stay in the group while quickly switching
|
||||||
|
// apps; if the OS eventually tears the TCP connection down anyway,
|
||||||
|
// the app-foreground rejoin effect below will pull us back in.
|
||||||
|
useKeepWebSocketAlive(isEnabled);
|
||||||
|
|
||||||
|
const [osdAction, setOsdAction] = useState<SyncPlayOsdAction | null>(null);
|
||||||
|
const osdTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the OSD overlay action.
|
||||||
|
*
|
||||||
|
* `transient` mirrors jellyfin-web's `iconVisibilityTime = 1500` for the
|
||||||
|
* Unpause / Pause / Seek command-confirmation flashes. Persistent actions
|
||||||
|
* (schedule-play, buffering, wait-*) stay until cleared by a state
|
||||||
|
* transition or a subsequent call with `null`.
|
||||||
|
*/
|
||||||
|
const showOsd = useCallback(
|
||||||
|
(action: SyncPlayOsdAction | null, transient = false) => {
|
||||||
|
if (osdTimeoutRef.current) {
|
||||||
|
clearTimeout(osdTimeoutRef.current);
|
||||||
|
osdTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
setOsdAction(action);
|
||||||
|
if (transient && action !== null) {
|
||||||
|
osdTimeoutRef.current = setTimeout(() => {
|
||||||
|
osdTimeoutRef.current = null;
|
||||||
|
setOsdAction((cur) => (cur === action ? null : cur));
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pending play/pause tap → optimistic schedule-play overlay (unless another
|
||||||
|
// overlay reason has already taken precedence).
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingPlaybackCommand) {
|
||||||
|
setOsdAction((cur) => cur ?? "schedule-play");
|
||||||
|
} else {
|
||||||
|
setOsdAction((cur) => (cur === "schedule-play" ? null : cur));
|
||||||
|
}
|
||||||
|
}, [pendingPlaybackCommand]);
|
||||||
|
|
||||||
|
// Clear the OSD auto-expire timeout on unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (osdTimeoutRef.current) {
|
||||||
|
clearTimeout(osdTimeoutRef.current);
|
||||||
|
osdTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canJoinGroups = useMemo(() => {
|
||||||
|
const access = user?.Policy?.SyncPlayAccess;
|
||||||
|
return access !== "None" && access !== undefined;
|
||||||
|
}, [user?.Policy?.SyncPlayAccess]);
|
||||||
|
|
||||||
|
const canCreateGroups = useMemo(
|
||||||
|
() => user?.Policy?.SyncPlayAccess === "CreateAndJoinGroups",
|
||||||
|
[user?.Policy?.SyncPlayAccess],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Latch: `true` once we've fired the per-attach `playbackstart` event.
|
||||||
|
const playbackStartFiredRef = useRef(false);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Navigation to the player screen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Single navigate-to-direct-player helper, used by every code path
|
||||||
|
* that needs to (re-)open the player while in a SyncPlay group:
|
||||||
|
* - localPlay (group's leader started a new queue / we just joined)
|
||||||
|
* - localSetCurrentPlaylistItem (group advanced to next episode)
|
||||||
|
* - resumeGroupPlayback (user tapped "Resume playback" in the menu)
|
||||||
|
*
|
||||||
|
* Both jellyfin-web's playbackManager.play and its setCurrentPlaylistItem
|
||||||
|
* collapse to "point the player at this item / position" — RN is the
|
||||||
|
* same shape, just a router navigation instead of an in-page DOM swap.
|
||||||
|
*
|
||||||
|
* Note: no "joining playback" toast here — the `GroupJoined`
|
||||||
|
* WebSocket event already triggers a "Joined group" toast via
|
||||||
|
* `Manager.ts`, and showing both on a fresh join was redundant.
|
||||||
|
*/
|
||||||
|
const navigateToPlayer = useCallback(
|
||||||
|
(itemId: string, startPositionTicks: number) => {
|
||||||
|
if (isNavigatingToPlayerRef.current) {
|
||||||
|
console.debug("SyncPlay: already navigating to player");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isNavigatingToPlayerRef.current = true;
|
||||||
|
|
||||||
|
// Opportunistic local playback: if we have a downloaded copy of
|
||||||
|
// the target item, use it instead of streaming. Matters most when
|
||||||
|
// the group advances to an episode you've downloaded — the local
|
||||||
|
// file starts instantly and survives spotty wifi. SyncPlay's
|
||||||
|
// position/pause/seek commands keep flowing normally; only the
|
||||||
|
// source changes.
|
||||||
|
const isDownloaded = !!getDownloadedItemById(itemId);
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId,
|
||||||
|
playbackPosition: String(startPositionTicks),
|
||||||
|
syncPlay: "true",
|
||||||
|
...(isDownloaded && { offline: "true" }),
|
||||||
|
}).toString();
|
||||||
|
// Use `replace` when we're already on the player screen so queue
|
||||||
|
// advances don't stack a second player on the nav stack; `push`
|
||||||
|
// otherwise so the user can back out to where they came from.
|
||||||
|
const onPlayerScreen =
|
||||||
|
pathnameRef.current?.startsWith("/player/direct-player") ?? false;
|
||||||
|
if (onPlayerScreen) {
|
||||||
|
router.replace(`/player/direct-player?${queryParams}`);
|
||||||
|
} else {
|
||||||
|
router.push(`/player/direct-player?${queryParams}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isNavigatingToPlayerRef.current = false;
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Manager lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
const mgr = new SyncPlayManager(api);
|
||||||
|
mgr.init();
|
||||||
|
setManager(mgr);
|
||||||
|
|
||||||
|
const playerWrapper = mgr.getPlayerWrapper();
|
||||||
|
|
||||||
|
// localPlay → navigate to direct-player with syncPlay=true
|
||||||
|
playerWrapper.setLocalPlayHandler((options) => {
|
||||||
|
const itemId = options.ids[0];
|
||||||
|
if (!itemId) {
|
||||||
|
console.warn("SyncPlay: localPlay called with no ids");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToPlayer(itemId, options.startPositionTicks ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// localSetCurrentPlaylistItem → navigate to the new playlist item
|
||||||
|
playerWrapper.setLocalSetCurrentItemHandler((playlistItemId) => {
|
||||||
|
if (!playlistItemId) return;
|
||||||
|
const queueCore = mgr.getQueueCore();
|
||||||
|
const target = queueCore
|
||||||
|
.getPlaylist()
|
||||||
|
.find((i) => i.PlaylistItemId === playlistItemId);
|
||||||
|
const itemId = target?.Id;
|
||||||
|
if (!itemId) {
|
||||||
|
console.warn(
|
||||||
|
"SyncPlay: localSetCurrentPlaylistItem — item not in playlist",
|
||||||
|
playlistItemId,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToPlayer(itemId, queueCore.getStartPositionTicks());
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.on("enabled", (...args: unknown[]) => {
|
||||||
|
const enabled = args[0] as boolean;
|
||||||
|
setIsEnabled(enabled);
|
||||||
|
if (!enabled) setGroupInfo(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.on("group-update", (...args: unknown[]) => {
|
||||||
|
setGroupInfo((args[0] as GroupInfoDto | null | undefined) ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.on("pending-playback-change", (...args: unknown[]) => {
|
||||||
|
setPendingPlaybackCommand(args[0] as "Unpause" | "Pause" | null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// group-state-change → on "Waiting", pause locally so we don't drift
|
||||||
|
// ahead of the group while the server is reconciling buffering/seek
|
||||||
|
// state. Position resync is *only* done from the explicit Pause /
|
||||||
|
// Unpause / Seek SendCommands that follow (`PlaybackCore.applyCommand`
|
||||||
|
// → `scheduleUnpause` etc.) — those commands carry the canonical
|
||||||
|
// `PositionTicks` for the action's `When`. The old code here also
|
||||||
|
// called `wrapper.localSeek(lastCommand.PositionTicks)`, but
|
||||||
|
// `lastCommand` is the *previous* Pause/Unpause and can be many
|
||||||
|
// seconds stale, so it rewound the user every time someone else
|
||||||
|
// buffered. Don't put a seek back here.
|
||||||
|
mgr.on("group-state-change", (...args: unknown[]) => {
|
||||||
|
const state = args[0] as string | undefined;
|
||||||
|
const reason = args[2] as string | undefined;
|
||||||
|
const wrapper = mgr.getPlayerWrapper();
|
||||||
|
if (!wrapper.isPlaybackActive()) return;
|
||||||
|
if (state === "Waiting") {
|
||||||
|
wrapper.localPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive the persistent OSD overlay from (state, reason).
|
||||||
|
// Mirrors jellyfin-web's `group-state-update` → `showIcon` mapping.
|
||||||
|
if (state === "Waiting") {
|
||||||
|
if (reason === "Buffer") showOsd("buffering");
|
||||||
|
else if (reason === "Unpause") showOsd("wait-unpause");
|
||||||
|
else if (reason === "Pause") showOsd("wait-pause");
|
||||||
|
else if (reason === "Seek") showOsd("seek");
|
||||||
|
} else if (state === "Playing" || state === "Paused") {
|
||||||
|
// Stable state — clear any persistent overlay; transient flashes
|
||||||
|
// come from the `osd` event below and self-expire.
|
||||||
|
setOsdAction((cur) => {
|
||||||
|
if (
|
||||||
|
cur === "schedule-play" ||
|
||||||
|
cur === "buffering" ||
|
||||||
|
cur === "wait-pause" ||
|
||||||
|
cur === "wait-unpause"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PlaybackCore-emitted OSD events. Transient ones auto-expire in 1.5s.
|
||||||
|
mgr.on("osd", (...args: unknown[]) => {
|
||||||
|
const action = args[0] as SyncPlayOsdAction;
|
||||||
|
const transient =
|
||||||
|
action === "unpause" || action === "pause" || action === "seek";
|
||||||
|
showOsd(action, transient);
|
||||||
|
});
|
||||||
|
|
||||||
|
mgr.on("toast", (...args: unknown[]) => {
|
||||||
|
const key = args[0] as string;
|
||||||
|
const arg = args[1] as string | undefined;
|
||||||
|
const message = arg
|
||||||
|
? i18n.t(`syncplay.toasts.${key}`, { user: arg })
|
||||||
|
: i18n.t(`syncplay.toasts.${key}`);
|
||||||
|
toast(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mgr.destroy();
|
||||||
|
setManager(null);
|
||||||
|
};
|
||||||
|
}, [api, navigateToPlayer]);
|
||||||
|
|
||||||
|
// Initial join race: once `enabled` flips true, snapshot the current group.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEnabled && manager) {
|
||||||
|
setGroupInfo(manager.getGroupInfo());
|
||||||
|
}
|
||||||
|
}, [isEnabled, manager]);
|
||||||
|
|
||||||
|
// Wire WebSocket messages → manager
|
||||||
|
useSyncPlayWebSocket(manager);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Group management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const getGroups = useCallback(async (): Promise<GroupInfoDto[]> => {
|
||||||
|
if (!api) return [];
|
||||||
|
try {
|
||||||
|
const response = await getSyncPlayApi(api).syncPlayGetGroups();
|
||||||
|
return (response.data as unknown as GroupInfoDto[]) ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to get groups", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const joinGroup = useCallback(
|
||||||
|
async (groupId: string): Promise<void> => {
|
||||||
|
if (!api) return;
|
||||||
|
try {
|
||||||
|
await getSyncPlayApi(api).syncPlayJoinGroup({
|
||||||
|
joinGroupRequestDto: { GroupId: groupId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to join group", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createGroup = useCallback(
|
||||||
|
async (groupName?: string): Promise<void> => {
|
||||||
|
if (!api || !user) return;
|
||||||
|
const name = groupName || `${user.Name}'s Group`;
|
||||||
|
try {
|
||||||
|
await getSyncPlayApi(api).syncPlayCreateGroup({
|
||||||
|
newGroupRequestDto: { GroupName: name },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to create group", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, user],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leaveGroup = useCallback(async (): Promise<void> => {
|
||||||
|
if (!api) return;
|
||||||
|
try {
|
||||||
|
await getSyncPlayApi(api).syncPlayLeaveGroup();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to leave group", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Resume playback: re-follow the group's command stream and jump
|
||||||
|
* the local player to the group's current item + position. This is
|
||||||
|
* the only entry point a user needs from the menu — there is no
|
||||||
|
* separate "halt" UI; the player exit/back already detaches us.
|
||||||
|
*/
|
||||||
|
const resumeGroupPlayback = useCallback(async (): Promise<void> => {
|
||||||
|
if (!api || !manager) return;
|
||||||
|
await manager.followGroupPlayback(api);
|
||||||
|
const queueCore = manager.getQueueCore();
|
||||||
|
const index = queueCore.getCurrentPlaylistIndex();
|
||||||
|
const itemId =
|
||||||
|
index >= 0 ? (queueCore.getPlaylist()[index]?.Id ?? null) : null;
|
||||||
|
if (!itemId) {
|
||||||
|
console.warn("SyncPlay: resumeGroupPlayback — no current group item");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToPlayer(itemId, queueCore.getStartPositionTicks());
|
||||||
|
}, [api, manager, navigateToPlayer]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// App foreground re-join (idempotent; gets us a fresh GroupJoined snapshot)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const lastGroupIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
lastGroupIdRef.current = groupInfo?.GroupId ?? null;
|
||||||
|
}, [groupInfo?.GroupId]);
|
||||||
|
|
||||||
|
// Track whether the WebSocket got torn down while the app was
|
||||||
|
// backgrounded. If it survived (keep-alive worked), the server
|
||||||
|
// still has us in the group and we must NOT call JoinGroup again —
|
||||||
|
// doing so would trigger a redundant "X joined the group" broadcast
|
||||||
|
// to every other member every time we briefly leave the app.
|
||||||
|
const wsClosedWhileBackgroundedRef = useRef(false);
|
||||||
|
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWsConnected && appStateRef.current !== "active") {
|
||||||
|
wsClosedWhileBackgroundedRef.current = true;
|
||||||
|
}
|
||||||
|
}, [isWsConnected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
const previousAppState = appStateRef.current;
|
||||||
|
appStateRef.current = nextAppState;
|
||||||
|
|
||||||
|
const becameActive =
|
||||||
|
(previousAppState === "background" ||
|
||||||
|
previousAppState === "inactive") &&
|
||||||
|
nextAppState === "active";
|
||||||
|
if (!becameActive) return;
|
||||||
|
|
||||||
|
const groupId = lastGroupIdRef.current;
|
||||||
|
if (!groupId) return;
|
||||||
|
|
||||||
|
// Happy path: keep-alive held the socket open across the
|
||||||
|
// suspend. Server still considers us a member — nothing to do.
|
||||||
|
if (!wsClosedWhileBackgroundedRef.current) {
|
||||||
|
console.log(
|
||||||
|
"SyncPlay: app foregrounded with WS still alive, skipping rejoin",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsClosedWhileBackgroundedRef.current = false;
|
||||||
|
|
||||||
|
// Small delay so the WebSocket has a moment to reconnect.
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: app foregrounded after WS drop, rejoining group ${groupId}`,
|
||||||
|
);
|
||||||
|
getSyncPlayApi(api)
|
||||||
|
.syncPlayJoinGroup({ joinGroupRequestDto: { GroupId: groupId } })
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("SyncPlay: failed to rejoin group", error);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Player attach bridges
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const setPlayerControls = useCallback(
|
||||||
|
(controls: PlayerControls | null) => {
|
||||||
|
// Reset the playbackstart latch on each new attach.
|
||||||
|
playbackStartFiredRef.current = false;
|
||||||
|
manager?.setPlayerControls(controls);
|
||||||
|
},
|
||||||
|
[manager],
|
||||||
|
);
|
||||||
|
|
||||||
|
const notifyReady = useCallback(() => {
|
||||||
|
manager?.notifyReady();
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
|
const notifyBuffering = useCallback(
|
||||||
|
(isBuffering: boolean) => {
|
||||||
|
manager?.notifyBuffering(isBuffering);
|
||||||
|
if (!isBuffering && !playbackStartFiredRef.current) {
|
||||||
|
playbackStartFiredRef.current = true;
|
||||||
|
manager?.notifyPlaybackStart();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[manager],
|
||||||
|
);
|
||||||
|
|
||||||
|
const notifyPlaybackStart = useCallback(() => {
|
||||||
|
manager?.notifyPlaybackStart();
|
||||||
|
}, [manager]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context value
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const contextValue: SyncPlayContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
isEnabled,
|
||||||
|
groupInfo,
|
||||||
|
canJoinGroups,
|
||||||
|
canCreateGroups,
|
||||||
|
joinGroup,
|
||||||
|
createGroup,
|
||||||
|
leaveGroup,
|
||||||
|
getGroups,
|
||||||
|
resumeGroupPlayback,
|
||||||
|
controller: manager?.getController() ?? null,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
notifyPlaybackStart,
|
||||||
|
pendingPlaybackCommand,
|
||||||
|
osdAction,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
isEnabled,
|
||||||
|
groupInfo,
|
||||||
|
canJoinGroups,
|
||||||
|
canCreateGroups,
|
||||||
|
joinGroup,
|
||||||
|
createGroup,
|
||||||
|
leaveGroup,
|
||||||
|
getGroups,
|
||||||
|
resumeGroupPlayback,
|
||||||
|
manager,
|
||||||
|
setPlayerControls,
|
||||||
|
notifyReady,
|
||||||
|
notifyBuffering,
|
||||||
|
notifyPlaybackStart,
|
||||||
|
pendingPlaybackCommand,
|
||||||
|
osdAction,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SyncPlayContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</SyncPlayContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSyncPlay(): SyncPlayContextValue {
|
||||||
|
const context = useContext(SyncPlayContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSyncPlay must be used within a SyncPlayProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
23
providers/SyncPlay/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Constants — shared timing/threshold values used across SyncPlay files.
|
||||||
|
* Kept separate from `types.ts` because these are implementation tuning
|
||||||
|
* values, not the public protocol/types surface.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TicksPerMillisecond } from "./types";
|
||||||
|
|
||||||
|
export { TicksPerMillisecond };
|
||||||
|
|
||||||
|
/** Default timeout for `waitForEventOnce` (matches jellyfin-web). */
|
||||||
|
export const WaitForEventDefaultTimeout = 30000;
|
||||||
|
|
||||||
|
/** Short-lived timeout for player events (matches jellyfin-web). */
|
||||||
|
export const WaitForPlayerEventTimeout = 500;
|
||||||
|
|
||||||
|
export function ticksToMs(ticks: number): number {
|
||||||
|
return ticks / TicksPerMillisecond;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function msToTicks(ms: number): number {
|
||||||
|
return Math.round(ms * TicksPerMillisecond);
|
||||||
|
}
|
||||||
381
providers/SyncPlay/cores/PlaybackCore.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay PlaybackCore — schedules unpauses/pauses/seeks/stops to fire
|
||||||
|
* at the precise group-wide moment and keeps the player drift-corrected.
|
||||||
|
*
|
||||||
|
* Design choices that diverge from jellyfin-web:
|
||||||
|
* - **No SpeedToSync**. Our RN players' `setPlaybackSpeed` is unreliable
|
||||||
|
* across platforms (mpv/VLC/expo-video each behave differently for
|
||||||
|
* fractional speeds). We always seek to catch up.
|
||||||
|
* - **No `MessageSyncPlayDuplicateMedia` detection**. Web's detection
|
||||||
|
* used HTML element identity; on RN we don't have a stable handle
|
||||||
|
* and the false-positive rate would be much higher than the value.
|
||||||
|
* - **No syncMethod / showSyncIcon**. We don't surface the sync
|
||||||
|
* technique to the UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import {
|
||||||
|
TicksPerMillisecond,
|
||||||
|
ticksToMs,
|
||||||
|
WaitForPlayerEventTimeout,
|
||||||
|
} from "../constants";
|
||||||
|
import { EventEmitter, waitForEventOnce } from "../EventEmitter";
|
||||||
|
import type { SyncPlayManager } from "../Manager";
|
||||||
|
import { type SendCommand, SYNC_PLAY_TUNING } from "../types";
|
||||||
|
|
||||||
|
export class PlaybackCore extends EventEmitter {
|
||||||
|
private manager!: SyncPlayManager;
|
||||||
|
private lastCommand: SendCommand | null = null;
|
||||||
|
private scheduledCommand: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
init(manager: SyncPlayManager): void {
|
||||||
|
this.manager = manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local "playback started" hook — fires the initial Ready request. */
|
||||||
|
onPlaybackStart(apiClient: Api): void {
|
||||||
|
try {
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
const positionMs = playerWrapper.currentTime();
|
||||||
|
const positionTicks = Math.round(positionMs * TicksPerMillisecond);
|
||||||
|
const isPlaying = playerWrapper.isPlaying();
|
||||||
|
const playlistItemId =
|
||||||
|
this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
|
||||||
|
const now = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||||
|
|
||||||
|
getSyncPlayApi(apiClient).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: playlistItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay onPlaybackStart:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local pause → tell the server. */
|
||||||
|
onPause(apiClient: Api): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayPause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay onPause:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local unpause → tell the server. */
|
||||||
|
onUnpause(apiClient: Api): void {
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayUnpause();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay onUnpause:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local "ready" hook — server uses this to know we've finished buffering. */
|
||||||
|
onReady(apiClient: Api): void {
|
||||||
|
this.sendBufferingRequest(apiClient, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local "buffering" hook — server uses this to (optionally) pause the group. */
|
||||||
|
onBuffering(apiClient: Api): void {
|
||||||
|
this.sendBufferingRequest(apiClient, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a Ready or Buffering request. */
|
||||||
|
sendBufferingRequest(apiClient: Api, isBuffering: boolean): void {
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
const positionMs = playerWrapper.currentTime();
|
||||||
|
const positionTicks = Math.round(positionMs * TicksPerMillisecond);
|
||||||
|
const isPlaying = playerWrapper.isPlaying();
|
||||||
|
const playlistItemId =
|
||||||
|
this.manager.getQueueCore().getCurrentPlaylistItemId() ?? undefined;
|
||||||
|
const now = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isBuffering) {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayBuffering({
|
||||||
|
bufferRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: playlistItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: playlistItemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay sendBufferingRequest:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a group command (Unpause, Pause, Stop, Seek). Times the
|
||||||
|
* execution to fire at the group-wide instant the server selected.
|
||||||
|
*/
|
||||||
|
applyCommand(command: SendCommand): void {
|
||||||
|
(command as unknown as { EmittedAt: Date }).EmittedAt = new Date(
|
||||||
|
command.EmittedAt as unknown as string,
|
||||||
|
);
|
||||||
|
(command as unknown as { When: Date }).When = new Date(
|
||||||
|
command.When as unknown as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Duplicate-detection — mirrors jellyfin-web's PlaybackCore.applyCommand.
|
||||||
|
// The server can redeliver the same command (WebSocket reconnect, multiple
|
||||||
|
// group-state transitions referencing the same instant, etc). If every
|
||||||
|
// identifying field matches the previously applied command, we don't
|
||||||
|
// re-schedule — we just verify player state still matches and bail.
|
||||||
|
//
|
||||||
|
// IMPORTANT: this is NOT a monotonic-clock check. `When` is the scheduled
|
||||||
|
// execution time and can legitimately move backward between commands
|
||||||
|
// (e.g. a Pause emitted now with `When = now` arriving after an earlier
|
||||||
|
// Unpause whose `When` was scheduled 10s in the future). An earlier
|
||||||
|
// version of this code rejected anything whose `When` or `EmittedAt`
|
||||||
|
// wasn't strictly greater than `lastCommand`'s — that silently locked
|
||||||
|
// out every subsequent pause/unpause whenever group playback first
|
||||||
|
// started with a future-scheduled Unpause.
|
||||||
|
if (
|
||||||
|
this.lastCommand &&
|
||||||
|
(this.lastCommand as unknown as { When: Date }).When.getTime() ===
|
||||||
|
(command as unknown as { When: Date }).When.getTime() &&
|
||||||
|
this.lastCommand.PositionTicks === command.PositionTicks &&
|
||||||
|
this.lastCommand.Command === command.Command &&
|
||||||
|
this.lastCommand.PlaylistItemId === command.PlaylistItemId
|
||||||
|
) {
|
||||||
|
console.debug("SyncPlay applyCommand: duplicate command", command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastCommand = command;
|
||||||
|
if (!this.manager.isFollowingGroupPlayback()) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay applyCommand: dropping command (not following playback)",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
if (!playerWrapper.isPlaybackActive()) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay applyCommand: dropping command (playback not active)",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueuedAt = new Date();
|
||||||
|
const remoteEnqueuedAt = this.manager
|
||||||
|
.getTimeSync()
|
||||||
|
.localDateToRemote(enqueuedAt);
|
||||||
|
const localCommandWhen = this.manager
|
||||||
|
.getTimeSync()
|
||||||
|
.remoteDateToLocal(command.When as unknown as Date);
|
||||||
|
|
||||||
|
switch (command.Command) {
|
||||||
|
case "Unpause":
|
||||||
|
this.scheduleUnpause(localCommandWhen, command.PositionTicks ?? 0);
|
||||||
|
this.emit("osd", "unpause");
|
||||||
|
break;
|
||||||
|
case "Pause":
|
||||||
|
this.schedulePause(localCommandWhen, command.PositionTicks ?? 0);
|
||||||
|
this.emit("osd", "pause");
|
||||||
|
break;
|
||||||
|
case "Stop":
|
||||||
|
this.scheduleStop(localCommandWhen);
|
||||||
|
break;
|
||||||
|
case "Seek":
|
||||||
|
this.scheduleSeek(localCommandWhen, command.PositionTicks ?? 0);
|
||||||
|
this.emit("osd", "seek");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn("SyncPlay applyCommand: unknown command", command);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(command as unknown as { When: Date }).When.getTime() <
|
||||||
|
remoteEnqueuedAt.getTime()
|
||||||
|
) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay applyCommand: command was scheduled for the past",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-Unpause hook: emit a wait OSD while we wait for the moment. */
|
||||||
|
scheduleUnpause(when: Date, positionTicks: number): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
const now = Date.now();
|
||||||
|
const playAtTime = when.getTime();
|
||||||
|
const currentPositionMs = this.manager.getPlayerWrapper().currentTime();
|
||||||
|
const currentPositionTicks = Math.round(
|
||||||
|
currentPositionMs * TicksPerMillisecond,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playAtTime > now) {
|
||||||
|
// Future: seek now, then play at the right moment.
|
||||||
|
this.localSeek(positionTicks);
|
||||||
|
this.scheduledCommand = setTimeout(() => {
|
||||||
|
this.localUnpause();
|
||||||
|
// After playback resumes, the player position will need a
|
||||||
|
// small bump to land on the group target. waitForPlayerEvent
|
||||||
|
// is best-effort.
|
||||||
|
waitForEventOnce(
|
||||||
|
this.manager,
|
||||||
|
"unpause",
|
||||||
|
WaitForPlayerEventTimeout,
|
||||||
|
).catch(() => undefined);
|
||||||
|
}, playAtTime - now);
|
||||||
|
this.emit("osd", "wait-unpause");
|
||||||
|
} else {
|
||||||
|
// Past: catch up now.
|
||||||
|
const targetMs = ticksToMs(positionTicks);
|
||||||
|
const delayMs = now - playAtTime;
|
||||||
|
this.localSeek(Math.round((targetMs + delayMs) * TicksPerMillisecond));
|
||||||
|
this.localUnpause();
|
||||||
|
void currentPositionTicks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulePause(when: Date, positionTicks: number): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
const now = Date.now();
|
||||||
|
const pauseAtTime = when.getTime();
|
||||||
|
|
||||||
|
const callback = () => {
|
||||||
|
this.localUnpause();
|
||||||
|
this.localSeek(positionTicks);
|
||||||
|
this.localPause();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pauseAtTime > now) {
|
||||||
|
this.scheduledCommand = setTimeout(callback, pauseAtTime - now);
|
||||||
|
this.emit("osd", "wait-pause");
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleStop(when: Date): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
const now = Date.now();
|
||||||
|
const stopAtTime = when.getTime();
|
||||||
|
if (stopAtTime > now) {
|
||||||
|
this.scheduledCommand = setTimeout(() => {
|
||||||
|
this.localStop();
|
||||||
|
}, stopAtTime - now);
|
||||||
|
} else {
|
||||||
|
this.localStop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSeek(when: Date, positionTicks: number): void {
|
||||||
|
this.applyCommand({
|
||||||
|
...this.lastCommand!,
|
||||||
|
Command: "Pause",
|
||||||
|
PositionTicks: positionTicks,
|
||||||
|
When: when as unknown as string,
|
||||||
|
EmittedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScheduledCommand(): void {
|
||||||
|
if (this.scheduledCommand) {
|
||||||
|
clearTimeout(this.scheduledCommand);
|
||||||
|
this.scheduledCommand = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- local player ops ------------------------------------------------------
|
||||||
|
|
||||||
|
localUnpause(): void {
|
||||||
|
this.manager.getPlayerWrapper().localUnpause();
|
||||||
|
}
|
||||||
|
|
||||||
|
localPause(): void {
|
||||||
|
this.manager.getPlayerWrapper().localPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
localSeek(positionTicks: number): void {
|
||||||
|
this.manager.getPlayerWrapper().localSeek(positionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStop(): void {
|
||||||
|
this.manager.getPlayerWrapper().localStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- queries ---------------------------------------------------------------
|
||||||
|
|
||||||
|
getLastCommand(): SendCommand | null {
|
||||||
|
return this.lastCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate where the group should be in ticks, given a known
|
||||||
|
* starting position and the time the position was valid at.
|
||||||
|
*/
|
||||||
|
estimateCurrentTicks(positionTicks: number, when: Date): number {
|
||||||
|
const lastCommand = this.lastCommand;
|
||||||
|
if (!lastCommand) return positionTicks;
|
||||||
|
const remoteNow = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||||
|
const elapsedMs = remoteNow.getTime() - when.getTime();
|
||||||
|
if (lastCommand.Command === "Unpause") {
|
||||||
|
return positionTicks + elapsedMs * TicksPerMillisecond;
|
||||||
|
}
|
||||||
|
return positionTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drift correction tick — called on every player time update. Skips
|
||||||
|
* to the group's expected position if drift exceeds the threshold.
|
||||||
|
* SpeedToSync is intentionally not implemented (see file header).
|
||||||
|
*/
|
||||||
|
syncPlaybackTime(): void {
|
||||||
|
const lastCommand = this.lastCommand;
|
||||||
|
if (lastCommand?.Command !== "Unpause") return;
|
||||||
|
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
if (!playerWrapper.isPlaying()) return;
|
||||||
|
|
||||||
|
const currentMs = playerWrapper.currentTime();
|
||||||
|
const expectedTicks = this.estimateCurrentTicks(
|
||||||
|
lastCommand.PositionTicks ?? 0,
|
||||||
|
lastCommand.When as unknown as Date,
|
||||||
|
);
|
||||||
|
const expectedMs = ticksToMs(expectedTicks);
|
||||||
|
const driftMs = Math.abs(currentMs - expectedMs);
|
||||||
|
|
||||||
|
if (driftMs > SYNC_PLAY_TUNING.minDelaySkipToSync) {
|
||||||
|
console.log(
|
||||||
|
`SyncPlay syncPlaybackTime: drift ${driftMs.toFixed(
|
||||||
|
0,
|
||||||
|
)}ms exceeds threshold, seeking to ${expectedMs.toFixed(0)}ms`,
|
||||||
|
);
|
||||||
|
this.localSeek(expectedTicks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- teardown --------------------------------------------------------------
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.clearScheduledCommand();
|
||||||
|
this.lastCommand = null;
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlaybackCore;
|
||||||
332
providers/SyncPlay/cores/QueueCore.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay QueueCore — tracks the group's playlist.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Handle `PlayQueue` group updates (NewPlaylist, SetCurrentItem,
|
||||||
|
* NextItem, PreviousItem, RemoveItems, etc.)
|
||||||
|
* - Resolve the server's flat list of ItemIds into full `BaseItemDto`s
|
||||||
|
* (with PlaylistItemId glued on for SyncPlay requests)
|
||||||
|
* - Expose `currentPlaylistItemId` — required by every SyncPlay
|
||||||
|
* request (Ready, Buffering, Seek) so the server can ignore stale
|
||||||
|
* ones from before the playlist moved
|
||||||
|
* - On NewPlaylist, ask the server we're ready by sending a Buffering
|
||||||
|
* request after the local player emits `playbackstart`
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { TicksPerMillisecond, WaitForEventDefaultTimeout } from "../constants";
|
||||||
|
import { EventEmitter, waitForEventOnce } from "../EventEmitter";
|
||||||
|
import type { SyncPlayManager } from "../Manager";
|
||||||
|
import {
|
||||||
|
getItemsForPlayback,
|
||||||
|
translateItemsForPlayback,
|
||||||
|
} from "../transport/queueTranslation";
|
||||||
|
import type {
|
||||||
|
PlayQueueUpdate,
|
||||||
|
PlayQueueUpdateReason,
|
||||||
|
SyncPlayQueueItem,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export class QueueCore extends EventEmitter {
|
||||||
|
private manager!: SyncPlayManager;
|
||||||
|
private lastPlayQueueUpdate: PlayQueueUpdate | null = null;
|
||||||
|
/** Playable items with `PlaylistItemId` glued on. */
|
||||||
|
private playlist: BaseItemDto[] = [];
|
||||||
|
|
||||||
|
init(manager: SyncPlayManager): void {
|
||||||
|
this.manager = manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle a PlayQueue group update from the server. */
|
||||||
|
updatePlayQueue(apiClient: Api, newPlayQueue: PlayQueueUpdate): void {
|
||||||
|
(newPlayQueue as unknown as { LastUpdate: Date }).LastUpdate = new Date(
|
||||||
|
newPlayQueue.LastUpdate as unknown as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(newPlayQueue.LastUpdate as unknown as Date).getTime() <=
|
||||||
|
this.getLastUpdateTime()
|
||||||
|
) {
|
||||||
|
console.debug("SyncPlay updatePlayQueue: ignoring old update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onPlayQueueUpdate(apiClient, newPlayQueue)
|
||||||
|
.then(() => {
|
||||||
|
if (
|
||||||
|
(newPlayQueue.LastUpdate as unknown as Date).getTime() <
|
||||||
|
this.getLastUpdateTime()
|
||||||
|
) {
|
||||||
|
console.warn("SyncPlay updatePlayQueue: trying to apply old update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = newPlayQueue.Reason as PlayQueueUpdateReason;
|
||||||
|
switch (reason) {
|
||||||
|
case "NewPlaylist": {
|
||||||
|
if (!this.manager.isFollowingGroupPlayback()) {
|
||||||
|
this.manager.followGroupPlayback(apiClient).then(() => {
|
||||||
|
this.startPlayback(apiClient);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.startPlayback(apiClient);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "SetCurrentItem":
|
||||||
|
case "NextItem":
|
||||||
|
case "PreviousItem": {
|
||||||
|
const playlistItemId = this.getCurrentPlaylistItemId();
|
||||||
|
this.setCurrentPlaylistItem(apiClient, playlistItemId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "RemoveItems":
|
||||||
|
case "MoveItem":
|
||||||
|
case "Queue":
|
||||||
|
case "QueueNext":
|
||||||
|
case "RepeatMode":
|
||||||
|
case "ShuffleMode":
|
||||||
|
// Video-focused: we don't expose repeat/shuffle/queue mutation
|
||||||
|
// controls in the RN UI yet, so these reasons just update our
|
||||||
|
// local snapshot (already done by onPlayQueueUpdate) without
|
||||||
|
// triggering any local action.
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(
|
||||||
|
"SyncPlay updatePlayQueue: unknown reason",
|
||||||
|
newPlayQueue.Reason,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("SyncPlay updatePlayQueue:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a play-queue update to local state. */
|
||||||
|
async onPlayQueueUpdate(
|
||||||
|
apiClient: Api,
|
||||||
|
playQueueUpdate: PlayQueueUpdate,
|
||||||
|
): Promise<void> {
|
||||||
|
const itemIds = (playQueueUpdate.Playlist ?? [])
|
||||||
|
.map((queueItem: SyncPlayQueueItem) => queueItem.ItemId)
|
||||||
|
.filter((id): id is string => typeof id === "string");
|
||||||
|
|
||||||
|
if (!itemIds.length) {
|
||||||
|
this.lastPlayQueueUpdate = playQueueUpdate;
|
||||||
|
this.playlist = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetched = await getItemsForPlayback(apiClient, itemIds);
|
||||||
|
const items = await translateItemsForPlayback(apiClient, fetched, {
|
||||||
|
ids: itemIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.lastPlayQueueUpdate &&
|
||||||
|
(playQueueUpdate.LastUpdate as unknown as Date).getTime() <=
|
||||||
|
this.getLastUpdateTime()
|
||||||
|
) {
|
||||||
|
throw new Error("Trying to apply old update");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glue PlaylistItemId from the server's playlist entries onto each
|
||||||
|
// resolved item. The server-assigned IDs are what every SyncPlay
|
||||||
|
// request needs to identify the queue slot.
|
||||||
|
const playlistItems = playQueueUpdate.Playlist ?? [];
|
||||||
|
for (let i = 0; i < items.length && i < playlistItems.length; i++) {
|
||||||
|
items[i].PlaylistItemId = playlistItems[i].PlaylistItemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastPlayQueueUpdate = playQueueUpdate;
|
||||||
|
this.playlist = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a Ready request once the local player begins playback. The
|
||||||
|
* server uses this to wait until every member is buffered before
|
||||||
|
* issuing the next Unpause.
|
||||||
|
*
|
||||||
|
* On timeout (player never starts), halt group playback so the rest
|
||||||
|
* of the group can proceed without us.
|
||||||
|
*/
|
||||||
|
scheduleReadyRequestOnPlaybackStart(apiClient: Api, origin: string): void {
|
||||||
|
waitForEventOnce(
|
||||||
|
this.manager,
|
||||||
|
"playbackstart",
|
||||||
|
WaitForEventDefaultTimeout,
|
||||||
|
["playbackerror"],
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay scheduleReadyRequestOnPlaybackStart: notifying server",
|
||||||
|
);
|
||||||
|
const playerWrapper = this.manager.getPlayerWrapper();
|
||||||
|
playerWrapper.localPause();
|
||||||
|
|
||||||
|
const currentPosition = playerWrapper.currentTime();
|
||||||
|
const currentPositionTicks = Math.round(
|
||||||
|
currentPosition * TicksPerMillisecond,
|
||||||
|
);
|
||||||
|
const isPlaying = playerWrapper.isPlaying();
|
||||||
|
const now = this.manager.getTimeSync().localDateToRemote(new Date());
|
||||||
|
|
||||||
|
try {
|
||||||
|
getSyncPlayApi(apiClient).syncPlayReady({
|
||||||
|
readyRequestDto: {
|
||||||
|
When: now.toISOString(),
|
||||||
|
PositionTicks: currentPositionTicks,
|
||||||
|
IsPlaying: isPlaying,
|
||||||
|
PlaylistItemId: this.getCurrentPlaylistItemId() ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay syncPlayReady failed", error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
"Timed out waiting for 'playbackstart' event!",
|
||||||
|
origin,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (!this.manager.isSyncPlayEnabled()) {
|
||||||
|
this.manager.emit("toast", "MessageSyncPlayErrorMedia");
|
||||||
|
}
|
||||||
|
this.manager.haltGroupPlayback(apiClient);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start local playback by navigating to the player screen for the current item. */
|
||||||
|
startPlayback(apiClient: Api): void {
|
||||||
|
if (!this.manager.isFollowingGroupPlayback()) {
|
||||||
|
console.debug("SyncPlay startPlayback: ignoring, not following playback");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPlaylistEmpty()) {
|
||||||
|
console.debug("SyncPlay startPlayback: empty playlist");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate where to start playback from. Prefer the last playback
|
||||||
|
// command if newer than the queue update (playback ticks change
|
||||||
|
// more often than queue position).
|
||||||
|
const playbackCommand = this.manager.getLastPlaybackCommand();
|
||||||
|
let startPositionTicks = 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
playbackCommand &&
|
||||||
|
(
|
||||||
|
playbackCommand as unknown as { EmittedAt: Date }
|
||||||
|
).EmittedAt?.getTime() >= this.getLastUpdateTime()
|
||||||
|
) {
|
||||||
|
startPositionTicks = this.manager
|
||||||
|
.getPlaybackCore()
|
||||||
|
.estimateCurrentTicks(
|
||||||
|
playbackCommand.PositionTicks ?? 0,
|
||||||
|
(playbackCommand as unknown as { When: Date }).When,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
startPositionTicks = this.manager
|
||||||
|
.getPlaybackCore()
|
||||||
|
.estimateCurrentTicks(
|
||||||
|
this.getStartPositionTicks(),
|
||||||
|
(this.getLastUpdate() ?? new Date()) as Date,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = apiClient.deviceInfo?.id ?? "";
|
||||||
|
|
||||||
|
this.scheduleReadyRequestOnPlaybackStart(apiClient, "startPlayback");
|
||||||
|
|
||||||
|
this.manager
|
||||||
|
.getPlayerWrapper()
|
||||||
|
.localPlay({
|
||||||
|
ids: this.getPlaylistAsItemIds(),
|
||||||
|
startPositionTicks,
|
||||||
|
startIndex: this.getCurrentPlaylistIndex(),
|
||||||
|
serverId,
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("SyncPlay startPlayback: localPlay failed", error);
|
||||||
|
this.manager.emit("toast", "MessageSyncPlayErrorMedia");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a specific item in the queue. */
|
||||||
|
setCurrentPlaylistItem(apiClient: Api, playlistItemId: string | null): void {
|
||||||
|
if (!this.manager.isFollowingGroupPlayback()) {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay setCurrentPlaylistItem: ignoring, not following playback",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleReadyRequestOnPlaybackStart(
|
||||||
|
apiClient,
|
||||||
|
"setCurrentPlaylistItem",
|
||||||
|
);
|
||||||
|
|
||||||
|
this.manager.getPlayerWrapper().localSetCurrentPlaylistItem(playlistItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- getters ---------------------------------------------------------------
|
||||||
|
|
||||||
|
getCurrentPlaylistIndex(): number {
|
||||||
|
return this.lastPlayQueueUpdate?.PlayingItemIndex ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentPlaylistItemId(): string | null {
|
||||||
|
if (!this.lastPlayQueueUpdate) return null;
|
||||||
|
const index = this.lastPlayQueueUpdate.PlayingItemIndex ?? -1;
|
||||||
|
if (index === -1) return null;
|
||||||
|
return this.playlist[index]?.PlaylistItemId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaylist(): BaseItemDto[] {
|
||||||
|
return this.playlist.slice(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaylistEmpty(): boolean {
|
||||||
|
return this.playlist.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastUpdate(): Date | null {
|
||||||
|
if (!this.lastPlayQueueUpdate) return null;
|
||||||
|
return this.lastPlayQueueUpdate.LastUpdate as unknown as Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastUpdateTime(): number {
|
||||||
|
if (!this.lastPlayQueueUpdate) return 0;
|
||||||
|
return (this.lastPlayQueueUpdate.LastUpdate as unknown as Date).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
getStartPositionTicks(): number {
|
||||||
|
return this.lastPlayQueueUpdate?.StartPositionTicks ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaylistAsItemIds(): (string | undefined)[] {
|
||||||
|
if (!this.lastPlayQueueUpdate) return [];
|
||||||
|
return (this.lastPlayQueueUpdate.Playlist ?? []).map((q) => q.ItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- teardown --------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Clear cached playlist. Called on group disable so a re-join starts clean. */
|
||||||
|
clear(): void {
|
||||||
|
this.lastPlayQueueUpdate = null;
|
||||||
|
this.playlist = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.clear();
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QueueCore;
|
||||||
220
providers/SyncPlay/cores/TimeSync.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* TimeSync — NTP-style time synchronisation with the Jellyfin server.
|
||||||
|
*
|
||||||
|
* Merged port of jellyfin-web's `core/timeSync/{TimeSync,TimeSyncServer,
|
||||||
|
* TimeSyncCore}.js` — three classes that exist on web because the
|
||||||
|
* abstract layer supports syncing against other group members, not just
|
||||||
|
* the server. RN only syncs against the server, so it's one class.
|
||||||
|
*
|
||||||
|
* Algorithm: repeatedly time a round-trip request to `getUtcTime`,
|
||||||
|
* compute `offset = ((requestReceived - requestSent) + (responseSent -
|
||||||
|
* responseReceived)) / 2`, keep the minimum-delay measurement out of
|
||||||
|
* the last 8. This is the standard NTP outlier-rejection trick — the
|
||||||
|
* measurement with the shortest delay is the most accurate because
|
||||||
|
* less network jitter could have skewed the timestamps.
|
||||||
|
*
|
||||||
|
* Polling: greedy mode at 1s intervals for the first 3 pings to warm
|
||||||
|
* up the offset, then low-profile at 60s intervals for steady-state.
|
||||||
|
* `forceUpdate()` resets to greedy mode (called on group join).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { EventEmitter } from "../EventEmitter";
|
||||||
|
|
||||||
|
const NumberOfTrackedMeasurements = 8;
|
||||||
|
const PollingIntervalGreedy = 1000; // ms
|
||||||
|
const PollingIntervalLowProfile = 60000; // ms
|
||||||
|
const GreedyPingCount = 3;
|
||||||
|
|
||||||
|
class Measurement {
|
||||||
|
requestSent: number;
|
||||||
|
requestReceived: number;
|
||||||
|
responseSent: number;
|
||||||
|
responseReceived: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
requestSent: Date,
|
||||||
|
requestReceived: Date,
|
||||||
|
responseSent: Date,
|
||||||
|
responseReceived: Date,
|
||||||
|
) {
|
||||||
|
this.requestSent = requestSent.getTime();
|
||||||
|
this.requestReceived = requestReceived.getTime();
|
||||||
|
this.responseSent = responseSent.getTime();
|
||||||
|
this.responseReceived = responseReceived.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time offset (ms): positive means server clock is ahead of ours. */
|
||||||
|
getOffset(): number {
|
||||||
|
return (
|
||||||
|
(this.requestReceived -
|
||||||
|
this.requestSent +
|
||||||
|
(this.responseSent - this.responseReceived)) /
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round-trip delay (ms), excluding server processing. */
|
||||||
|
getDelay(): number {
|
||||||
|
return (
|
||||||
|
this.responseReceived -
|
||||||
|
this.requestSent -
|
||||||
|
(this.responseSent - this.requestReceived)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-way ping (ms). */
|
||||||
|
getPing(): number {
|
||||||
|
return this.getDelay() / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the offset between this client's clock and the Jellyfin server's
|
||||||
|
* clock, and exposes conversions between local and remote Dates.
|
||||||
|
*
|
||||||
|
* Listeners:
|
||||||
|
* - `"update"` (timeOffset: number, ping: number) — fires on every
|
||||||
|
* successful ping. Errors are logged but not emitted; consumers
|
||||||
|
* should treat absence of updates as transient.
|
||||||
|
*/
|
||||||
|
export class TimeSync extends EventEmitter {
|
||||||
|
private api: Api;
|
||||||
|
private pingStop = true;
|
||||||
|
private pollingInterval = PollingIntervalGreedy;
|
||||||
|
private poller: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private pings = 0;
|
||||||
|
private measurement: Measurement | null = null;
|
||||||
|
private measurements: Measurement[] = [];
|
||||||
|
|
||||||
|
constructor(api: Api) {
|
||||||
|
super();
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the user switches Jellyfin servers. */
|
||||||
|
updateApiClient(api: Api): void {
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether we've completed at least one successful measurement. */
|
||||||
|
isReady(): boolean {
|
||||||
|
return !!this.measurement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current best-estimate time offset (ms). */
|
||||||
|
getTimeOffset(): number {
|
||||||
|
return this.measurement ? this.measurement.getOffset() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current best-estimate one-way ping (ms). */
|
||||||
|
getPing(): number {
|
||||||
|
return this.measurement ? this.measurement.getPing() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a server-time Date to local time. */
|
||||||
|
remoteDateToLocal(remote: Date): Date {
|
||||||
|
return new Date(remote.getTime() - this.getTimeOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a local Date to server time. */
|
||||||
|
localDateToRemote(local: Date): Date {
|
||||||
|
return new Date(local.getTime() + this.getTimeOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start polling. Idempotent. */
|
||||||
|
startPing(): void {
|
||||||
|
this.pingStop = false;
|
||||||
|
this.scheduleNextPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop polling. Idempotent. */
|
||||||
|
stopPing(): void {
|
||||||
|
this.pingStop = true;
|
||||||
|
if (this.poller) {
|
||||||
|
clearTimeout(this.poller);
|
||||||
|
this.poller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset to greedy polling and force a fresh measurement immediately. */
|
||||||
|
forceUpdate(): void {
|
||||||
|
this.stopPing();
|
||||||
|
this.pollingInterval = PollingIntervalGreedy;
|
||||||
|
this.pings = 0;
|
||||||
|
this.startPing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop all measurements. Used on group leave. */
|
||||||
|
resetMeasurements(): void {
|
||||||
|
this.measurement = null;
|
||||||
|
this.measurements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full teardown on provider unmount. */
|
||||||
|
destroy(): void {
|
||||||
|
this.stopPing();
|
||||||
|
this.resetMeasurements();
|
||||||
|
this.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextPing(): void {
|
||||||
|
if (this.poller || this.pingStop) return;
|
||||||
|
this.poller = setTimeout(() => {
|
||||||
|
this.poller = null;
|
||||||
|
this.requestPing()
|
||||||
|
.then((result) => this.onPingResponse(result))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("SyncPlay TimeSync: ping failed", error);
|
||||||
|
})
|
||||||
|
.finally(() => this.scheduleNextPing());
|
||||||
|
}, this.pollingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestPing() {
|
||||||
|
const requestSent = new Date();
|
||||||
|
const response = await getTimeSyncApi(this.api).getUtcTime();
|
||||||
|
const responseReceived = new Date();
|
||||||
|
const data = response.data;
|
||||||
|
const requestReceived = new Date(data.RequestReceptionTime as string);
|
||||||
|
const responseSent = new Date(data.ResponseTransmissionTime as string);
|
||||||
|
return { requestSent, requestReceived, responseSent, responseReceived };
|
||||||
|
}
|
||||||
|
|
||||||
|
private onPingResponse(result: {
|
||||||
|
requestSent: Date;
|
||||||
|
requestReceived: Date;
|
||||||
|
responseSent: Date;
|
||||||
|
responseReceived: Date;
|
||||||
|
}): void {
|
||||||
|
const measurement = new Measurement(
|
||||||
|
result.requestSent,
|
||||||
|
result.requestReceived,
|
||||||
|
result.responseSent,
|
||||||
|
result.responseReceived,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.measurements.push(measurement);
|
||||||
|
if (this.measurements.length > NumberOfTrackedMeasurements) {
|
||||||
|
this.measurements.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outlier rejection: pick the measurement with the shortest delay.
|
||||||
|
const sorted = [...this.measurements].sort(
|
||||||
|
(a, b) => a.getDelay() - b.getDelay(),
|
||||||
|
);
|
||||||
|
this.measurement = sorted[0];
|
||||||
|
|
||||||
|
// Throttle once we've warmed up.
|
||||||
|
if (this.pings >= GreedyPingCount) {
|
||||||
|
this.pollingInterval = PollingIntervalLowProfile;
|
||||||
|
} else {
|
||||||
|
this.pings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit("update", this.getTimeOffset(), this.getPing());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimeSync;
|
||||||
13
providers/SyncPlay/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay — public exports.
|
||||||
|
*
|
||||||
|
* Only what external consumers (components, hooks, screens) need.
|
||||||
|
* Internal modules (PlaybackCore, QueueCore, TimeSync, PlayerWrapper,
|
||||||
|
* queueTranslation, EventEmitter, etc.) stay package-private.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { Controller as SyncPlayController } from "./Controller";
|
||||||
|
export { msToTicks, ticksToMs } from "./constants";
|
||||||
|
export { SyncPlayManager } from "./Manager";
|
||||||
|
export { SyncPlayProvider, useSyncPlay } from "./SyncPlayProvider";
|
||||||
|
export * from "./types";
|
||||||
58
providers/SyncPlay/player/PendingPlaybackTracker.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* PendingPlaybackTracker — tracks an in-flight `Unpause` / `Pause` request
|
||||||
|
* that we've sent to the server but haven't seen echoed back via
|
||||||
|
* `SyncPlayCommand`.
|
||||||
|
*
|
||||||
|
* Drives three things:
|
||||||
|
* 1. Drop duplicate rapid taps
|
||||||
|
* 2. Provide an optimistic-UI hint for the in-flight state
|
||||||
|
* 3. Override "current play state" when deciding pause-vs-unpause
|
||||||
|
* for the next tap
|
||||||
|
*
|
||||||
|
* Auto-clears after `pendingPlaybackTimeoutMs` so a lost broadcast
|
||||||
|
* doesn't freeze the UI forever.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SYNC_PLAY_TUNING } from "../types";
|
||||||
|
|
||||||
|
export class PendingPlaybackTracker {
|
||||||
|
private command: "Unpause" | "Pause" | null = null;
|
||||||
|
private timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private onChange: ((cmd: "Unpause" | "Pause" | null) => void) | null = null;
|
||||||
|
|
||||||
|
setChangeHandler(
|
||||||
|
handler: ((cmd: "Unpause" | "Pause" | null) => void) | null,
|
||||||
|
): void {
|
||||||
|
this.onChange = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): "Unpause" | "Pause" | null {
|
||||||
|
return this.command;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark(command: "Unpause" | "Pause"): void {
|
||||||
|
this.command = command;
|
||||||
|
if (this.timeout) clearTimeout(this.timeout);
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay PendingPlaybackTracker: timed out waiting for broadcast",
|
||||||
|
command,
|
||||||
|
);
|
||||||
|
this.command = null;
|
||||||
|
this.timeout = null;
|
||||||
|
this.onChange?.(null);
|
||||||
|
}, SYNC_PLAY_TUNING.pendingPlaybackTimeoutMs);
|
||||||
|
this.onChange?.(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
if (this.timeout) {
|
||||||
|
clearTimeout(this.timeout);
|
||||||
|
this.timeout = null;
|
||||||
|
}
|
||||||
|
if (this.command !== null) {
|
||||||
|
this.command = null;
|
||||||
|
this.onChange?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
providers/SyncPlay/player/PlayerWrapper.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* PlayerWrapper — adapter between jellyfin's tick-based playerWrapper API
|
||||||
|
* and our millisecond-based `PlayerControls`. Methods that have no RN
|
||||||
|
* analog (queue mutation hooks) delegate to provider-supplied handlers
|
||||||
|
* which navigate to the player screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TicksPerMillisecond } from "../constants";
|
||||||
|
import type { PlayerControls } from "../types";
|
||||||
|
|
||||||
|
/** Options passed to `playerWrapper.localPlay` — provider navigates to the player screen. */
|
||||||
|
export interface LocalPlayOptions {
|
||||||
|
ids: (string | undefined)[];
|
||||||
|
startPositionTicks: number;
|
||||||
|
startIndex: number;
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlayerWrapper {
|
||||||
|
private controls: PlayerControls | null = null;
|
||||||
|
private localPlayHandler: ((options: LocalPlayOptions) => void) | null = null;
|
||||||
|
private setCurrentItemHandler:
|
||||||
|
| ((playlistItemId: string | null) => void)
|
||||||
|
| null = null;
|
||||||
|
|
||||||
|
/** Attach / detach the underlying player. */
|
||||||
|
bindToControls(controls: PlayerControls | null): void {
|
||||||
|
this.controls = controls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider wires this to navigate to the player screen. */
|
||||||
|
setLocalPlayHandler(handler: ((options: LocalPlayOptions) => void) | null) {
|
||||||
|
this.localPlayHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider wires this to navigate to a different queue item. */
|
||||||
|
setLocalSetCurrentItemHandler(
|
||||||
|
handler: ((playlistItemId: string | null) => void) | null,
|
||||||
|
) {
|
||||||
|
this.setCurrentItemHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
localUnpause(): void {
|
||||||
|
this.controls?.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
localPause(): void {
|
||||||
|
this.controls?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upstream takes ticks; RN's `seekTo` takes ms. */
|
||||||
|
localSeek(positionTicks: number): void {
|
||||||
|
this.controls?.seekTo(positionTicks / TicksPerMillisecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RN: pause instead of teardown — leaving the player screen is the navigator's job. */
|
||||||
|
localStop(): void {
|
||||||
|
this.controls?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position in ms. */
|
||||||
|
currentTime(): number {
|
||||||
|
return this.controls?.getCurrentPosition() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying(): boolean {
|
||||||
|
return this.controls?.isPlaying() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaybackActive(): boolean {
|
||||||
|
return this.controls !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RN never runs as a remote-managed player. */
|
||||||
|
isRemote(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
localPlay(options: LocalPlayOptions): Promise<void> {
|
||||||
|
this.localPlayHandler?.(options);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
localSetCurrentPlaylistItem(playlistItemId: string | null): void {
|
||||||
|
this.setCurrentItemHandler?.(playlistItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
providers/SyncPlay/player/bufferingDebouncer.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* bufferingDebouncer — wrap an `isBuffering: boolean → void` notify callback
|
||||||
|
* with three RN-only guards. Web gets these for free from HTML `waiting`/
|
||||||
|
* `canplay`; our `PlayerControls` exposes state (not events) and the React
|
||||||
|
* effect that polls it can fire many times per second.
|
||||||
|
*
|
||||||
|
* - **dedup**: drop redundant calls when state hasn't changed
|
||||||
|
* - **debounce buffering→true**: only escalate after the threshold;
|
||||||
|
* going back to ready cancels the pending escalation
|
||||||
|
* - **coalesce inflight**: serialize concurrent sends
|
||||||
|
*
|
||||||
|
* Returns `{ notify, dispose }`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SYNC_PLAY_TUNING } from "../types";
|
||||||
|
|
||||||
|
export function createBufferingDebouncer(
|
||||||
|
send: (isBuffering: boolean) => Promise<void>,
|
||||||
|
) {
|
||||||
|
let lastSent: boolean | null = null;
|
||||||
|
let inflight: Promise<void> | null = null;
|
||||||
|
let pendingTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flush = async (isBuffering: boolean) => {
|
||||||
|
if (lastSent === isBuffering) return;
|
||||||
|
if (inflight) {
|
||||||
|
try {
|
||||||
|
await inflight;
|
||||||
|
} catch {
|
||||||
|
// ignore — used only for ordering
|
||||||
|
}
|
||||||
|
if (lastSent === isBuffering) return;
|
||||||
|
}
|
||||||
|
lastSent = isBuffering;
|
||||||
|
inflight = send(isBuffering).finally(() => {
|
||||||
|
inflight = null;
|
||||||
|
});
|
||||||
|
return inflight;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
notify(isBuffering: boolean): void {
|
||||||
|
if (pendingTimeout) {
|
||||||
|
clearTimeout(pendingTimeout);
|
||||||
|
pendingTimeout = null;
|
||||||
|
}
|
||||||
|
if (!isBuffering) {
|
||||||
|
// Ready always fires immediately.
|
||||||
|
void flush(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingTimeout = setTimeout(() => {
|
||||||
|
pendingTimeout = null;
|
||||||
|
void flush(true);
|
||||||
|
}, SYNC_PLAY_TUNING.minBufferingThresholdMs);
|
||||||
|
},
|
||||||
|
dispose(): void {
|
||||||
|
if (pendingTimeout) {
|
||||||
|
clearTimeout(pendingTimeout);
|
||||||
|
pendingTimeout = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
58
providers/SyncPlay/player/reconcileToGroupOnAttach.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* reconcileToGroupOnAttach — estimate the group's current position from
|
||||||
|
* the last play/pause broadcast and seek the freshly-attached player
|
||||||
|
* there if drift exceeds the threshold.
|
||||||
|
*
|
||||||
|
* Web's player binds at group-join, so this race doesn't exist there.
|
||||||
|
* On RN the player mounts in a separate route after the join, so
|
||||||
|
* commands arrive before controls attach. Without this, the player
|
||||||
|
* resumes from its local position and is silently behind the group.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TicksPerMillisecond } from "../constants";
|
||||||
|
import {
|
||||||
|
type PlayerControls,
|
||||||
|
type SendCommand,
|
||||||
|
SYNC_PLAY_TUNING,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export function reconcileToGroupOnAttach(
|
||||||
|
controls: PlayerControls,
|
||||||
|
lastCommand: SendCommand | null,
|
||||||
|
localToRemote: (local: Date) => Date,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!lastCommand ||
|
||||||
|
(lastCommand.Command !== "Unpause" && lastCommand.Command !== "Pause") ||
|
||||||
|
!lastCommand.When ||
|
||||||
|
lastCommand.PositionTicks == null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commandWhen = new Date(lastCommand.When);
|
||||||
|
let targetTicks = lastCommand.PositionTicks;
|
||||||
|
if (lastCommand.Command === "Unpause") {
|
||||||
|
const remoteNow = localToRemote(new Date());
|
||||||
|
targetTicks +=
|
||||||
|
(remoteNow.getTime() - commandWhen.getTime()) * TicksPerMillisecond;
|
||||||
|
}
|
||||||
|
const targetMs = Math.max(0, targetTicks / TicksPerMillisecond);
|
||||||
|
const currentMs = controls.getCurrentPosition();
|
||||||
|
if (
|
||||||
|
Math.abs(currentMs - targetMs) >
|
||||||
|
SYNC_PLAY_TUNING.positionReconcileThresholdMs
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: player attached — seeking to estimated group position ${targetMs}ms (was ${currentMs}ms)`,
|
||||||
|
);
|
||||||
|
controls.seekTo(targetMs);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"SyncPlay: failed to estimate group position on attach",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
183
providers/SyncPlay/transport/queueTranslation.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* queueTranslation — expand container items into a real playable queue.
|
||||||
|
*
|
||||||
|
* The server takes the queue we send via `syncPlaySetNewQueue` and
|
||||||
|
* rebroadcasts it verbatim to every group member. Sending a container
|
||||||
|
* ID (Series, Season, BoxSet, Playlist) means every receiver fails to
|
||||||
|
* open the player because they can't directly play a container. We must
|
||||||
|
* expand to real playable item IDs before sending the queue.
|
||||||
|
*
|
||||||
|
* Video-focused: music (MusicArtist/MusicGenre) and photo branches are
|
||||||
|
* intentionally omitted. Live TV (Program), Episode auto-advance, and
|
||||||
|
* folder expansion are preserved because they're the common video flows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
|
||||||
|
export interface TranslateOptions {
|
||||||
|
ids?: string[];
|
||||||
|
shuffle?: boolean;
|
||||||
|
queryOptions?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAYBACK_FIELDS = ["Chapters", "Trickplay"] as const;
|
||||||
|
|
||||||
|
async function getCurrentUser(api: Api) {
|
||||||
|
const user = (await getUserApi(api).getCurrentUser()).data;
|
||||||
|
if (!user?.Id) {
|
||||||
|
throw new Error("SyncPlay queueTranslation: no authenticated user");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryItems(
|
||||||
|
api: Api,
|
||||||
|
userId: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
const res = await getItemsApi(api).getItems({
|
||||||
|
limit: 300,
|
||||||
|
fields: PLAYBACK_FIELDS as unknown as never,
|
||||||
|
excludeLocationTypes: ["Virtual"] as unknown as never,
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
...params,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return res.data.Items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchFolderChildren(
|
||||||
|
api: Api,
|
||||||
|
userId: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
return queryItems(api, userId, {
|
||||||
|
filters: ["IsNotFolder"],
|
||||||
|
recursive: true,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve item IDs into full `BaseItemDto`s.
|
||||||
|
*
|
||||||
|
* - single ID → `getItem` (cheap, no Items wrapper)
|
||||||
|
* - multi ID → `getItems` with playback defaults
|
||||||
|
*/
|
||||||
|
export async function getItemsForPlayback(
|
||||||
|
api: Api,
|
||||||
|
ids: string[],
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const userId = (await getCurrentUser(api)).Id as string;
|
||||||
|
if (ids.length === 1) {
|
||||||
|
const res = await getUserLibraryApi(api).getItem({
|
||||||
|
userId,
|
||||||
|
itemId: ids[0],
|
||||||
|
});
|
||||||
|
return res.data ? [res.data] : [];
|
||||||
|
}
|
||||||
|
return queryItems(api, userId, { ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a "first item" into a real playable queue.
|
||||||
|
*
|
||||||
|
* - Program → channel items
|
||||||
|
* - Playlist → playlist children
|
||||||
|
* - IsFolder (Series, Season, BoxSet, MusicAlbum, ...) → recursive descendants
|
||||||
|
* - single Episode (when `EnableNextEpisodeAutoPlay`) → remaining series episodes
|
||||||
|
* - everything else → passthrough (Movies, Audio, single Episode w/ autoplay off)
|
||||||
|
*
|
||||||
|
* Preserves the caller's `ids` order so the receiver sees the same
|
||||||
|
* queue order the sender intended.
|
||||||
|
*/
|
||||||
|
export async function translateItemsForPlayback(
|
||||||
|
api: Api,
|
||||||
|
items: BaseItemDto[],
|
||||||
|
options: TranslateOptions = {},
|
||||||
|
): Promise<BaseItemDto[]> {
|
||||||
|
if (!items.length) return [];
|
||||||
|
|
||||||
|
const workingItems =
|
||||||
|
items.length > 1 && options.ids
|
||||||
|
? [...items].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(options.ids ?? []).indexOf(a.Id ?? "") -
|
||||||
|
(options.ids ?? []).indexOf(b.Id ?? ""),
|
||||||
|
)
|
||||||
|
: items;
|
||||||
|
|
||||||
|
const firstItem = workingItems[0];
|
||||||
|
|
||||||
|
if (firstItem.Type === "Program" && firstItem.ChannelId) {
|
||||||
|
return getItemsForPlayback(api, [firstItem.ChannelId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getCurrentUser(api);
|
||||||
|
const userId = user.Id as string;
|
||||||
|
|
||||||
|
if (firstItem.Type === "Playlist") {
|
||||||
|
return queryItems(api, userId, {
|
||||||
|
parentId: firstItem.Id,
|
||||||
|
sortBy: options.shuffle ? ["Random"] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.IsFolder) {
|
||||||
|
// Series, Season, BoxSet, MusicAlbum, etc.
|
||||||
|
const sortBy = options.shuffle
|
||||||
|
? ["Random"]
|
||||||
|
: firstItem.Type === "BoxSet"
|
||||||
|
? ["SortName"]
|
||||||
|
: undefined;
|
||||||
|
return fetchFolderChildren(api, userId, {
|
||||||
|
parentId: firstItem.Id,
|
||||||
|
mediaTypes: ["Audio", "Video"],
|
||||||
|
sortBy,
|
||||||
|
...(options.queryOptions ?? {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstItem.Type === "Episode" && workingItems.length === 1) {
|
||||||
|
// Single-episode auto-next: load all remaining episodes in the
|
||||||
|
// series, starting at this one. Gated on the user preference so we
|
||||||
|
// don't surprise users who disabled autoplay.
|
||||||
|
if (!user.Configuration?.EnableNextEpisodeAutoPlay || !firstItem.SeriesId) {
|
||||||
|
return workingItems;
|
||||||
|
}
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: firstItem.SeriesId,
|
||||||
|
userId,
|
||||||
|
isMissing: false,
|
||||||
|
fields: PLAYBACK_FIELDS as unknown as never,
|
||||||
|
// SDK omits `isVirtualUnaired` from typed request; server honours
|
||||||
|
// it. Cast keeps wire payload identical to jellyfin-web.
|
||||||
|
...({ isVirtualUnaired: false } as Record<string, unknown>),
|
||||||
|
} as Parameters<ReturnType<typeof getTvShowsApi>["getEpisodes"]>[0]);
|
||||||
|
const all = res.data.Items ?? [];
|
||||||
|
// Drop everything before firstItem; keep firstItem and everything
|
||||||
|
// after. Empty list if firstItem isn't in the series (shouldn't
|
||||||
|
// happen, but matches upstream's behaviour).
|
||||||
|
let foundItem = false;
|
||||||
|
return all.filter((e) => {
|
||||||
|
if (foundItem) return true;
|
||||||
|
if (e.Id === firstItem.Id) {
|
||||||
|
foundItem = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movies, Audio, single Episode w/ autoplay off, etc.
|
||||||
|
return workingItems;
|
||||||
|
}
|
||||||
94
providers/SyncPlay/transport/useSyncPlayWebSocket.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* useSyncPlayWebSocket
|
||||||
|
*
|
||||||
|
* Hook that connects the SyncPlay manager to WebSocket messages.
|
||||||
|
* Listens for SyncPlayCommand and SyncPlayGroupUpdate messages.
|
||||||
|
*
|
||||||
|
* IMPORTANT: We subscribe directly to the WebSocket via `addEventListener`
|
||||||
|
* rather than reading WebSocketProvider's `lastMessage` state. That state
|
||||||
|
* only holds the most recent message, so when the server emits bursts
|
||||||
|
* after a join (GroupJoined + StateUpdate + UserJoined + PlayQueue, all
|
||||||
|
* within a few ms), React's batching causes earlier messages to be
|
||||||
|
* overwritten before our effect can read them — most notably the
|
||||||
|
* GroupJoined message, which left the joining client thinking it hadn't
|
||||||
|
* joined while other members already saw it as a participant.
|
||||||
|
*
|
||||||
|
* Listening on the raw socket guarantees we see every frame in order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||||
|
import type { SyncPlayManager } from "../Manager";
|
||||||
|
import type { GroupUpdate, SendCommand } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to connect SyncPlay manager to WebSocket
|
||||||
|
*/
|
||||||
|
export function useSyncPlayWebSocket(manager: SyncPlayManager | null): void {
|
||||||
|
const { ws } = useWebSocketContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ws || !manager) return;
|
||||||
|
|
||||||
|
const handleMessage = (event: WebSocketMessageEvent) => {
|
||||||
|
let parsed: { MessageType?: string; Data?: unknown };
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(event.data as string);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SyncPlay: failed to parse WebSocket message", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { MessageType, Data } = parsed;
|
||||||
|
|
||||||
|
// Only handle SyncPlay messages here; everything else is handled
|
||||||
|
// elsewhere via WebSocketProvider's lastMessage.
|
||||||
|
if (!MessageType?.startsWith("SyncPlay")) return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SyncPlay WebSocket [${MessageType}]:`,
|
||||||
|
JSON.stringify(Data).substring(0, 300),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (MessageType) {
|
||||||
|
case "SyncPlayCommand": {
|
||||||
|
const command = Data as SendCommand;
|
||||||
|
console.log(
|
||||||
|
`SyncPlay: COMMAND received - ${command.Command} at ${command.When}`,
|
||||||
|
command.Command === "Seek"
|
||||||
|
? `position=${command.PositionTicks}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: it's normal for controls to be missing here during the
|
||||||
|
// join → navigate → load window. Manager stashes the command and
|
||||||
|
// replays it on attach.
|
||||||
|
manager.processCommand(command);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SyncPlayGroupUpdate": {
|
||||||
|
// SDK's `GroupUpdate` type is a discriminated union with a
|
||||||
|
// narrower `Type` enum than the wire format. Cast through
|
||||||
|
// unknown so upstream `Manager.processGroupUpdate` can switch
|
||||||
|
// on the real string.
|
||||||
|
const update = Data as unknown as GroupUpdate;
|
||||||
|
console.debug(
|
||||||
|
"SyncPlay: group update -",
|
||||||
|
(update as { Type?: string }).Type,
|
||||||
|
);
|
||||||
|
manager.processGroupUpdate(update);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.addEventListener("message", handleMessage);
|
||||||
|
return () => {
|
||||||
|
ws.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}, [ws, manager]);
|
||||||
|
}
|
||||||
88
providers/SyncPlay/types.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* SyncPlay — public types and tuning constants.
|
||||||
|
*
|
||||||
|
* Re-exports the SDK types we use, defines the small RN-specific
|
||||||
|
* extensions (PlayerControls, OSD actions), and centralises the magic
|
||||||
|
* numbers that govern sync behaviour.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
|
// SDK type re-exports — kept narrow on purpose, only what callers
|
||||||
|
// actually reach for.
|
||||||
|
export type {
|
||||||
|
GroupInfoDto,
|
||||||
|
GroupQueueMode,
|
||||||
|
GroupRepeatMode,
|
||||||
|
GroupShuffleMode,
|
||||||
|
GroupStateType,
|
||||||
|
GroupUpdate,
|
||||||
|
PlayQueueUpdate,
|
||||||
|
PlayQueueUpdateReason,
|
||||||
|
SendCommand,
|
||||||
|
SendCommandType,
|
||||||
|
SyncPlayQueueItem,
|
||||||
|
SyncPlayUserAccessType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
|
||||||
|
/** Jellyfin's tick unit. 1ms = 10000 ticks. */
|
||||||
|
export const TicksPerMillisecond = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player controls SyncPlay drives. The provider wires this up against
|
||||||
|
* the active RN player (mpv / VLC / expo-video).
|
||||||
|
*/
|
||||||
|
export interface PlayerControls {
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
/** Seek to absolute position in milliseconds. */
|
||||||
|
seekTo: (positionMs: number) => void;
|
||||||
|
setSpeed: (speed: number) => void;
|
||||||
|
getSpeed: () => number;
|
||||||
|
/** Current position in milliseconds. */
|
||||||
|
getCurrentPosition: () => number;
|
||||||
|
isPlaying: () => boolean;
|
||||||
|
isBuffering: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OSD action types — drive optional player-overlay feedback. */
|
||||||
|
export type SyncPlayOsdAction =
|
||||||
|
/** transient — 1.5s pulse, the unpause command fired locally */
|
||||||
|
| "unpause"
|
||||||
|
/** transient — 1.5s pulse, the pause command fired locally */
|
||||||
|
| "pause"
|
||||||
|
/** transient — 1.5s pulse, a seek command applied locally */
|
||||||
|
| "seek"
|
||||||
|
/** persistent — group is about to play (Waiting+Unpause / pending Unpause) */
|
||||||
|
| "schedule-play"
|
||||||
|
/** persistent — another client is buffering (Waiting+Buffer) */
|
||||||
|
| "buffering"
|
||||||
|
/** persistent — group transitioning to pause (Waiting+Pause) */
|
||||||
|
| "wait-pause"
|
||||||
|
/** persistent — group transitioning to unpause; sibling of schedule-play */
|
||||||
|
| "wait-unpause";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tuning constants. These mirror jellyfin-web's defaults; tweak with
|
||||||
|
* care — they affect perceived sync quality across all clients.
|
||||||
|
*/
|
||||||
|
export const SYNC_PLAY_TUNING = {
|
||||||
|
/** Drift threshold (ms) above which we hard-seek to catch up. */
|
||||||
|
minDelaySkipToSync: 400,
|
||||||
|
/** Drift beyond this (ms) is always corrected by seeking. */
|
||||||
|
maxDelaySync: 3000,
|
||||||
|
/** Don't escalate buffering to the group for blips shorter than this (ms). */
|
||||||
|
minBufferingThresholdMs: 3000,
|
||||||
|
/** Player-attach drift (ms) above which we reconcile to group position. */
|
||||||
|
positionReconcileThresholdMs: 500,
|
||||||
|
/** Safety timeout (ms) for in-flight Pause/Unpause optimistic UI. */
|
||||||
|
pendingPlaybackTimeoutMs: 1500,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Options accepted by `Controller.play`. */
|
||||||
|
export interface PlayOptions {
|
||||||
|
ids?: string[];
|
||||||
|
items?: BaseItemDto[];
|
||||||
|
startIndex?: number;
|
||||||
|
startPositionTicks?: number;
|
||||||
|
}
|
||||||
@@ -44,6 +44,15 @@ interface WebSocketContextType {
|
|||||||
lastMessage: WebSocketMessage | null;
|
lastMessage: WebSocketMessage | null;
|
||||||
sendMessage: (message: any) => void;
|
sendMessage: (message: any) => void;
|
||||||
clearLastMessage: () => void;
|
clearLastMessage: () => void;
|
||||||
|
/**
|
||||||
|
* Acquire a keep-alive token. While at least one token is held the
|
||||||
|
* WebSocket will NOT be closed on AppState background/inactive. Used
|
||||||
|
* by the video player while in Picture-in-Picture so SyncPlay (and
|
||||||
|
* any other server-pushed events) keep flowing. Returns a release
|
||||||
|
* function — call it (or rely on the React effect cleanup) when the
|
||||||
|
* keep-alive is no longer needed.
|
||||||
|
*/
|
||||||
|
acquireKeepAlive: () => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
const WebSocketContext = createContext<WebSocketContextType | null>(null);
|
||||||
@@ -63,6 +72,21 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
// Ref-counted keep-alive: while > 0 we skip the AppState→background
|
||||||
|
// close so the socket survives PiP / brief OS suspensions. iOS keeps
|
||||||
|
// the audio session (and therefore networking) alive while PiP is
|
||||||
|
// active, so the WS can continue to receive SyncPlay commands.
|
||||||
|
const keepAliveCountRef = useRef(0);
|
||||||
|
|
||||||
|
const acquireKeepAlive = useCallback((): (() => void) => {
|
||||||
|
keepAliveCountRef.current += 1;
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
keepAliveCountRef.current = Math.max(0, keepAliveCountRef.current - 1);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||||
@@ -235,9 +259,20 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleAppStateChange = (state: AppStateStatus) => {
|
const handleAppStateChange = (state: AppStateStatus) => {
|
||||||
if (state === "background" || state === "inactive") {
|
if (state === "background" || state === "inactive") {
|
||||||
|
if (keepAliveCountRef.current > 0) {
|
||||||
|
console.log(
|
||||||
|
`App backgrounded but WS keep-alive held (${keepAliveCountRef.current}); leaving socket open`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log("App moving to background, closing WebSocket...");
|
console.log("App moving to background, closing WebSocket...");
|
||||||
ws?.close();
|
ws?.close();
|
||||||
} else if (state === "active") {
|
} else if (state === "active") {
|
||||||
|
// Only reconnect if we actually lost the socket (we may have
|
||||||
|
// skipped the close above because of a keep-alive token).
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log("App coming to foreground, reconnecting WebSocket...");
|
console.log("App coming to foreground, reconnecting WebSocket...");
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
}
|
}
|
||||||
@@ -267,7 +302,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<WebSocketContext.Provider
|
<WebSocketContext.Provider
|
||||||
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
|
value={{
|
||||||
|
ws,
|
||||||
|
isConnected,
|
||||||
|
lastMessage,
|
||||||
|
sendMessage,
|
||||||
|
clearLastMessage,
|
||||||
|
acquireKeepAlive,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</WebSocketContext.Provider>
|
</WebSocketContext.Provider>
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* i18n key checker for Streamyfin.
|
|
||||||
*
|
|
||||||
* Detects:
|
|
||||||
* - MISSING keys: a static `t("a.b.c")` / `i18nKey="a.b.c"` referenced in the code
|
|
||||||
* that does not exist in the source locale (translations/en.json). These are bugs —
|
|
||||||
* the app renders the raw key. Always fails CI.
|
|
||||||
* - UNUSED (dead) keys: a key in the source locale that is referenced nowhere in the
|
|
||||||
* code, neither statically nor via a detected dynamic prefix (`t(`a.b.${x}`)`).
|
|
||||||
* These are dead weight that also clutter every locale on Crowdin.
|
|
||||||
*
|
|
||||||
* Dynamic usage is handled conservatively:
|
|
||||||
* - `t(`prefix.${x}`)` -> every key starting with `prefix.` is considered used.
|
|
||||||
* - `t(`${x}`)` -> fully dynamic, reported for manual review, never used to
|
|
||||||
* whitelist keys (in Streamyfin these are user-defined section
|
|
||||||
* titles, not translation keys).
|
|
||||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
|
||||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
|
||||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
|
||||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
|
||||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
existsSync,
|
|
||||||
readdirSync,
|
|
||||||
readFileSync,
|
|
||||||
statSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { extname, join, relative } from "node:path";
|
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const flag = (name, def) => {
|
|
||||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
|
||||||
if (!a) return def;
|
|
||||||
const [, v] = a.split("=");
|
|
||||||
return v === undefined ? true : v;
|
|
||||||
};
|
|
||||||
const UNUSED_MODE = String(flag("unused", "error")); // error | warn | off
|
|
||||||
const JSON_OUT = !!flag("json", false);
|
|
||||||
const FIX_UNUSED = !!flag("fix-unused", false);
|
|
||||||
|
|
||||||
// ---- config ----
|
|
||||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
localesDir: "translations",
|
|
||||||
sourceLocale: "en",
|
|
||||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
|
||||||
// (e.g. packages/, key constants in utils/atoms) are not wrongly flagged as dead.
|
|
||||||
srcDirs: ["."],
|
|
||||||
srcExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
|
||||||
excludeDirs: [
|
|
||||||
"node_modules",
|
|
||||||
"ios",
|
|
||||||
"android",
|
|
||||||
".expo",
|
|
||||||
".git",
|
|
||||||
"dist",
|
|
||||||
"build",
|
|
||||||
"translations",
|
|
||||||
"scripts",
|
|
||||||
],
|
|
||||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
|
||||||
ignoreUnused: [],
|
|
||||||
};
|
|
||||||
const config = existsSync(CONFIG_PATH)
|
|
||||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
|
||||||
: DEFAULT_CONFIG;
|
|
||||||
|
|
||||||
// ---- helpers ----
|
|
||||||
const flatten = (obj, prefix = "", out = {}) => {
|
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
|
||||||
const key = prefix ? `${prefix}.${k}` : k;
|
|
||||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
|
||||||
else out[key] = v;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
const globMatch = (key, pattern) => {
|
|
||||||
if (pattern.endsWith(".*"))
|
|
||||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
|
||||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
|
||||||
return key === pattern;
|
|
||||||
};
|
|
||||||
|
|
||||||
const walk = (dir, files = []) => {
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = readdirSync(dir);
|
|
||||||
} catch {
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
for (const name of entries) {
|
|
||||||
const full = join(dir, name);
|
|
||||||
let st;
|
|
||||||
try {
|
|
||||||
st = statSync(full);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (st.isDirectory()) {
|
|
||||||
if (config.excludeDirs.includes(name)) continue;
|
|
||||||
walk(full, files);
|
|
||||||
} else if (config.srcExtensions.includes(extname(name))) {
|
|
||||||
files.push(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- load source keys ----
|
|
||||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
|
||||||
const sourceKeys = Object.keys(
|
|
||||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
|
||||||
);
|
|
||||||
const sourceKeySet = new Set(sourceKeys);
|
|
||||||
|
|
||||||
// ---- scan code ----
|
|
||||||
const STATIC_RE = /\bt\(\s*(['"])((?:\\.|(?!\1).)+?)\1/g; // t("a.b") / t('a.b')
|
|
||||||
const TPL_STATIC_RE = /\bt\(\s*`([^`$]+)`/g; // t(`a.b`) no interpolation
|
|
||||||
const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
|
||||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
|
||||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
|
||||||
|
|
||||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
|
||||||
const dynamicPrefixes = new Set();
|
|
||||||
const fullyDynamic = []; // { file, line }
|
|
||||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
|
||||||
|
|
||||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
|
||||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
|
||||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
|
||||||
// `://` inside string URLs intact.
|
|
||||||
const stripComments = (src) =>
|
|
||||||
src
|
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
|
||||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
|
||||||
|
|
||||||
const files = config.srcDirs.flatMap((d) =>
|
|
||||||
walk(join(ROOT, d === "." ? "" : d) || ROOT),
|
|
||||||
);
|
|
||||||
for (const file of files) {
|
|
||||||
const text = readFileSync(file, "utf8");
|
|
||||||
const clean = stripComments(text);
|
|
||||||
codeBlob += `\n${clean}`;
|
|
||||||
for (const m of clean.matchAll(STATIC_RE)) usedStatic.add(m[2]);
|
|
||||||
for (const m of clean.matchAll(TPL_STATIC_RE)) usedStatic.add(m[1]);
|
|
||||||
for (const m of clean.matchAll(I18NKEY_RE)) usedStatic.add(m[2]);
|
|
||||||
for (const m of clean.matchAll(TPL_DYN_RE)) {
|
|
||||||
const prefix = m[1];
|
|
||||||
if (prefix?.includes(".")) dynamicPrefixes.add(prefix);
|
|
||||||
else {
|
|
||||||
const idx = clean.slice(0, m.index).split("\n").length;
|
|
||||||
fullyDynamic.push({ file: relative(ROOT, file), line: idx });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefixList = [...dynamicPrefixes];
|
|
||||||
// A key counts as used if its EXACT delimited literal ("k" / 'k' / `k`) appears anywhere in
|
|
||||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
|
||||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
|
||||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
|
||||||
const literalUsed = (key) =>
|
|
||||||
codeBlob.includes(`"${key}"`) ||
|
|
||||||
codeBlob.includes(`'${key}'`) ||
|
|
||||||
codeBlob.includes(`\`${key}\``);
|
|
||||||
const isUsed = (key) =>
|
|
||||||
literalUsed(key) ||
|
|
||||||
prefixList.some((p) => key.startsWith(p)) ||
|
|
||||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
|
||||||
|
|
||||||
// ---- compute ----
|
|
||||||
const unused = sourceKeys.filter((k) => !isUsed(k)).sort();
|
|
||||||
// Static references are always validated, even under a dynamic prefix: a dynamic prefix only
|
|
||||||
// affects the UNUSED calculation, never MISSING.
|
|
||||||
const missing = [...usedStatic]
|
|
||||||
.filter((k) => KEY_SHAPE.test(k) && !sourceKeySet.has(k))
|
|
||||||
.sort();
|
|
||||||
// Known limitation: only keys seen in a static t("…") / i18nKey="…" / t(`…`) call are
|
|
||||||
// validated for MISSING. A key stored as a bare string constant and resolved via t(variable)
|
|
||||||
// counts as USED (via literalUsed → not flagged unused) but its existence in en.json is not
|
|
||||||
// checked here — static analysis can't resolve which key a runtime variable holds. Streamyfin
|
|
||||||
// keys are static literals in practice; revisit if dynamic key constants become common.
|
|
||||||
|
|
||||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
|
||||||
const removeKey = (obj, parts) => {
|
|
||||||
const [head, ...rest] = parts;
|
|
||||||
if (!(head in obj)) return;
|
|
||||||
if (rest.length === 0) {
|
|
||||||
delete obj[head];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
removeKey(obj[head], rest);
|
|
||||||
if (
|
|
||||||
obj[head] &&
|
|
||||||
typeof obj[head] === "object" &&
|
|
||||||
Object.keys(obj[head]).length === 0
|
|
||||||
)
|
|
||||||
delete obj[head];
|
|
||||||
};
|
|
||||||
if (FIX_UNUSED && unused.length) {
|
|
||||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
|
||||||
// the keys from them automatically on the next sync once they disappear from the source.
|
|
||||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
|
||||||
for (const key of unused) removeKey(data, key.split("."));
|
|
||||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
|
||||||
console.log(
|
|
||||||
`🧹 Removed ${unused.length} dead key(s) from ${config.sourceLocale}.json (Crowdin will sync the other locales).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- report ----
|
|
||||||
if (JSON_OUT) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
sourceKeys: sourceKeys.length,
|
|
||||||
missing,
|
|
||||||
unused,
|
|
||||||
dynamicPrefixes: prefixList,
|
|
||||||
fullyDynamic,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`🔑 i18n key check — source: ${relative(ROOT, sourcePath)} (${sourceKeys.length} keys), scanned ${files.length} files`,
|
|
||||||
);
|
|
||||||
if (prefixList.length)
|
|
||||||
console.log(
|
|
||||||
` dynamic prefixes treated as used: ${prefixList.map((p) => `${p}*`).join(", ")}`,
|
|
||||||
);
|
|
||||||
if (fullyDynamic.length)
|
|
||||||
console.log(
|
|
||||||
` ⚠️ ${fullyDynamic.length} fully-dynamic t(\`\${…}\`) call(s) (not key-based, manual review): ${fullyDynamic.map((d) => `${d.file}:${d.line}`).join(", ")}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missing.length) {
|
|
||||||
console.log(
|
|
||||||
`\n❌ MISSING keys (used in code, absent from ${config.sourceLocale}.json) — ${missing.length}:`,
|
|
||||||
);
|
|
||||||
for (const k of missing) console.log(` - ${k}`);
|
|
||||||
} else console.log("\n✅ No missing keys.");
|
|
||||||
|
|
||||||
if (UNUSED_MODE !== "off") {
|
|
||||||
if (unused.length) {
|
|
||||||
console.log(
|
|
||||||
`\n${UNUSED_MODE === "error" ? "❌" : "⚠️ "} UNUSED keys (in ${config.sourceLocale}.json, referenced nowhere) — ${unused.length}:`,
|
|
||||||
);
|
|
||||||
for (const k of unused) console.log(` - ${k}`);
|
|
||||||
console.log(
|
|
||||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
|
||||||
);
|
|
||||||
} else console.log("\n✅ No unused keys.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fail =
|
|
||||||
missing.length > 0 || (UNUSED_MODE === "error" && unused.length > 0);
|
|
||||||
process.exit(fail ? 1 : 0);
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* Flags likely-duplicate issues when a new issue is opened, using lexical similarity
|
|
||||||
* (Jaccard over word sets of the title and body) — no API key, no embeddings.
|
|
||||||
*
|
|
||||||
* On a match it posts ONE comment listing the closest open issues and adds the
|
|
||||||
* "possible duplicate" label. If nothing is similar enough, it does nothing.
|
|
||||||
*
|
|
||||||
* Env:
|
|
||||||
* GITHUB_REPOSITORY owner/repo
|
|
||||||
* ISSUE_NUMBER the new issue number
|
|
||||||
* ISSUE_TITLE the new issue title
|
|
||||||
* ISSUE_BODY the new issue body
|
|
||||||
* GH_TOKEN/GITHUB_TOKEN for gh (provided in CI)
|
|
||||||
* DUP_THRESHOLD similarity threshold 0..1 (default 0.3)
|
|
||||||
* DUP_MAX max matches to report (default 5)
|
|
||||||
* DUP_FIXTURE optional path to a JSON array of {number,title,body} (local testing)
|
|
||||||
* DRY_RUN if set, print results instead of commenting/labelling
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
|
|
||||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
|
||||||
const numEnv = (name, def) => {
|
|
||||||
const raw = process.env[name];
|
|
||||||
if (raw === undefined || raw === "") return def;
|
|
||||||
const n = Number(raw);
|
|
||||||
return Number.isNaN(n) ? def : n;
|
|
||||||
};
|
|
||||||
|
|
||||||
const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin";
|
|
||||||
const NUMBER = numEnv("ISSUE_NUMBER", Number.NaN);
|
|
||||||
const TITLE = process.env.ISSUE_TITLE || "";
|
|
||||||
const BODY = process.env.ISSUE_BODY || "";
|
|
||||||
const THRESHOLD = numEnv("DUP_THRESHOLD", 0.3);
|
|
||||||
const MAX = numEnv("DUP_MAX", 5);
|
|
||||||
const DRY = !!process.env.DRY_RUN;
|
|
||||||
const LABEL = "possible duplicate";
|
|
||||||
const MARKER = "<!-- duplicate-detector -->";
|
|
||||||
|
|
||||||
// Generic stop words only — keep domain/feature/platform words (android, downloads,
|
|
||||||
// subtitles…) since those are exactly what makes two reports the same or different.
|
|
||||||
const STOP = new Set(
|
|
||||||
(
|
|
||||||
"a an the and or but if then of to in on at by for with from as is are was were be been being do does did " +
|
|
||||||
"it its this that these those i you we they me my your our their he she him her " +
|
|
||||||
"when while where what which who how why so just then than too very can could would should will " +
|
|
||||||
"not no nor only own same s t don dont im ive please thanks hi hello also still get got use used using " +
|
|
||||||
"app application streamyfin issue bug"
|
|
||||||
).split(/\s+/),
|
|
||||||
);
|
|
||||||
|
|
||||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
|
||||||
|
|
||||||
const tokens = (s) =>
|
|
||||||
(s || "")
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
|
||||||
.replace(/<!--[\s\S]*?-->/g, " ") // drop html comments
|
|
||||||
.replace(/https?:\/\/\S+/g, " ") // drop urls
|
|
||||||
.replace(/[^a-z0-9\s]/g, " ")
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((w) => w.length > 2 && !STOP.has(w))
|
|
||||||
.map(stem)
|
|
||||||
.filter((w) => w.length > 2);
|
|
||||||
|
|
||||||
const jaccard = (a, b) => {
|
|
||||||
const A = new Set(a);
|
|
||||||
const B = new Set(b);
|
|
||||||
if (!A.size || !B.size) return 0;
|
|
||||||
let inter = 0;
|
|
||||||
for (const x of A) if (B.has(x)) inter++;
|
|
||||||
return inter / (A.size + B.size - inter);
|
|
||||||
};
|
|
||||||
|
|
||||||
const newTitle = tokens(TITLE);
|
|
||||||
const newBody = tokens(BODY);
|
|
||||||
const score = (o) =>
|
|
||||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
|
||||||
0.4 * jaccard(newBody, tokens(o.body));
|
|
||||||
|
|
||||||
// fetch open issues (excluding PRs and the new issue itself)
|
|
||||||
let issues;
|
|
||||||
if (process.env.DUP_FIXTURE) {
|
|
||||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
|
||||||
} else {
|
|
||||||
const raw = execFileSync(
|
|
||||||
"gh",
|
|
||||||
[
|
|
||||||
"api",
|
|
||||||
`repos/${REPO}/issues`,
|
|
||||||
"--paginate",
|
|
||||||
"-X",
|
|
||||||
"GET",
|
|
||||||
"-f",
|
|
||||||
"state=open",
|
|
||||||
"-f",
|
|
||||||
"per_page=100",
|
|
||||||
"--jq",
|
|
||||||
".[] | select(.pull_request | not) | {number, title, body}",
|
|
||||||
],
|
|
||||||
{ encoding: "utf8", maxBuffer: 1e8 },
|
|
||||||
);
|
|
||||||
issues = raw
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((l) => JSON.parse(l));
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = issues
|
|
||||||
.filter((o) => o.number !== NUMBER)
|
|
||||||
.map((o) => ({ ...o, s: score(o) }))
|
|
||||||
.filter((o) => o.s >= THRESHOLD)
|
|
||||||
.sort((a, b) => b.s - a.s)
|
|
||||||
.slice(0, MAX);
|
|
||||||
|
|
||||||
if (!matches.length) {
|
|
||||||
console.log("No likely duplicates found.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
|
||||||
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
|
||||||
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
|
||||||
const safeTitle = (t) =>
|
|
||||||
(t || "")
|
|
||||||
.replace(/@/g, "@")
|
|
||||||
.replace(/[`<>|*_~[\]]/g, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 140);
|
|
||||||
const list = matches
|
|
||||||
.map(
|
|
||||||
(m) =>
|
|
||||||
`- #${m.number} — ${safeTitle(m.title)} (≈ ${Math.round(m.s * 100)}% similar)`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
const comment = [
|
|
||||||
MARKER,
|
|
||||||
"🔍 **This looks like it might be a duplicate.** Possibly related open issues:",
|
|
||||||
"",
|
|
||||||
list,
|
|
||||||
"",
|
|
||||||
"If yours is different, ignore this — a maintainer will confirm. Otherwise, please 👍 the existing issue and add any extra details there.",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
console.log(`Found ${matches.length} possible duplicate(s):\n${list}`);
|
|
||||||
|
|
||||||
if (DRY) {
|
|
||||||
console.log("\nDRY_RUN: not commenting/labelling.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live mode needs a real issue number; refuse rather than POST to /issues/NaN/...
|
|
||||||
if (!Number.isInteger(NUMBER) || NUMBER <= 0) {
|
|
||||||
console.error(
|
|
||||||
`Invalid ISSUE_NUMBER ${JSON.stringify(process.env.ISSUE_NUMBER)} — refusing to comment.`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotency: skip if we've already flagged this issue (guards re-runs / future triggers).
|
|
||||||
const priorComments = execFileSync(
|
|
||||||
"gh",
|
|
||||||
[
|
|
||||||
"api",
|
|
||||||
`repos/${REPO}/issues/${NUMBER}/comments`,
|
|
||||||
"--paginate",
|
|
||||||
"--jq",
|
|
||||||
".[].body",
|
|
||||||
],
|
|
||||||
{ encoding: "utf8", maxBuffer: 1e8 },
|
|
||||||
);
|
|
||||||
if (priorComments.includes(MARKER)) {
|
|
||||||
console.log("Already flagged (marker present); skipping.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
execFileSync(
|
|
||||||
"gh",
|
|
||||||
[
|
|
||||||
"api",
|
|
||||||
"-X",
|
|
||||||
"POST",
|
|
||||||
`repos/${REPO}/issues/${NUMBER}/comments`,
|
|
||||||
"-f",
|
|
||||||
`body=${comment}`,
|
|
||||||
],
|
|
||||||
{ stdio: "ignore" },
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
execFileSync(
|
|
||||||
"gh",
|
|
||||||
[
|
|
||||||
"api",
|
|
||||||
"-X",
|
|
||||||
"POST",
|
|
||||||
`repos/${REPO}/issues/${NUMBER}/labels`,
|
|
||||||
"-f",
|
|
||||||
`labels[]=${LABEL}`,
|
|
||||||
],
|
|
||||||
{ stdio: "ignore" },
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// label may not exist yet — create then add
|
|
||||||
execFileSync(
|
|
||||||
"gh",
|
|
||||||
[
|
|
||||||
"api",
|
|
||||||
"-X",
|
|
||||||
"POST",
|
|
||||||
`repos/${REPO}/labels`,
|
|
||||||
"-f",
|
|
||||||
`name=${LABEL}`,
|
|
||||||
"-f",
|
|
||||||
"color=fbca04",
|
|
||||||
"-f",
|
|
||||||
"description=Automatically flagged as a possible duplicate",
|
|
||||||
],
|
|
||||||
{ stdio: "ignore" },
|
|
||||||
);
|
|
||||||
execFileSync(
|
|
||||||
"gh",
|
|
||||||
[
|
|
||||||
"api",
|
|
||||||
"-X",
|
|
||||||
"POST",
|
|
||||||
`repos/${REPO}/issues/${NUMBER}/labels`,
|
|
||||||
"-f",
|
|
||||||
`labels[]=${LABEL}`,
|
|
||||||
],
|
|
||||||
{ stdio: "ignore" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log("Commented and labelled.");
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"localesDir": "translations",
|
|
||||||
"sourceLocale": "en",
|
|
||||||
"srcDirs": [
|
|
||||||
"app",
|
|
||||||
"components",
|
|
||||||
"hooks",
|
|
||||||
"providers",
|
|
||||||
"utils",
|
|
||||||
"modules",
|
|
||||||
"packages",
|
|
||||||
"constants"
|
|
||||||
],
|
|
||||||
"srcExtensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
|
||||||
"excludeDirs": [
|
|
||||||
"node_modules",
|
|
||||||
"ios",
|
|
||||||
"android",
|
|
||||||
".expo",
|
|
||||||
".git",
|
|
||||||
"dist",
|
|
||||||
"build",
|
|
||||||
"translations"
|
|
||||||
],
|
|
||||||
"_ignoreUnusedNote": "Keys present in en.json but referenced by no t() call — allow-listed so the unused-key cleanup keeps them. Two kinds: (1) ORPHANS of features that already exist but render hardcoded strings or use other keys — watchlists.* (add/remove, handled by WatchlistSheet via hooks), pin.* (confirm step of the existing PIN setup flow), player.* (in-player subtitle search, swipe-down hint, stop-playback confirm); these should be wired to the live UI or removed in a follow-up i18n pass, not assumed to be future work. (2) GENUINELY PLANNED, tracked by issues: home.settings.other.show_large_home_carousel (#1702 media-bar), home.settings.logs.delete_all_logs (#1703 iOS logs fix), home.suggested_episodes (#1704).",
|
|
||||||
"ignoreUnused": [
|
|
||||||
"watchlists.add_to_watchlist",
|
|
||||||
"watchlists.remove_from_watchlist",
|
|
||||||
"watchlists.create_one_first",
|
|
||||||
"watchlists.no_compatible_watchlists",
|
|
||||||
"pin.confirm_pin",
|
|
||||||
"pin.pins_dont_match",
|
|
||||||
"player.search_subtitles",
|
|
||||||
"player.subtitle_search",
|
|
||||||
"player.subtitle_download_hint",
|
|
||||||
"player.subtitle_tracks",
|
|
||||||
"player.using_jellyfin_server",
|
|
||||||
"player.swipe_down_settings",
|
|
||||||
"player.stopPlayback",
|
|
||||||
"player.stopPlayingTitle",
|
|
||||||
"player.stopPlayingConfirm",
|
|
||||||
"home.settings.other.show_large_home_carousel",
|
|
||||||
"home.settings.logs.delete_all_logs",
|
|
||||||
"home.suggested_episodes"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "التنزيلات",
|
"downloads_title": "التنزيلات",
|
||||||
"series": "مسلسلات",
|
"tvseries": "مسلسلات",
|
||||||
"movies": "أفلام",
|
"movies": "أفلام",
|
||||||
"queue": "قائمة الانتظار",
|
"queue": "قائمة الانتظار",
|
||||||
"other_media": "وسائط أخرى",
|
"other_media": "وسائط أخرى",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
||||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
||||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
"delete_all_movies_button": "حذف جميع الأفلام",
|
||||||
"delete_all_series_button": "حذف جميع المسلسلات",
|
"delete_all_tvseries_button": "حذف جميع المسلسلات",
|
||||||
"delete_all_button": "حذف الكل",
|
"delete_all_button": "حذف الكل",
|
||||||
"delete_all_other_media_button": "حذف الوسائط الأخرى",
|
"delete_all_other_media_button": "حذف الوسائط الأخرى",
|
||||||
"active_download": "التنزيل الجاري",
|
"active_download": "التنزيل الجاري",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
|
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
|
||||||
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
|
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
|
||||||
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
|
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
|
||||||
"deleted_all_series_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
"deleted_all_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
||||||
"failed_to_delete_all_series": "فشل حذف جميع المسلسلات",
|
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
|
||||||
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
|
"deleted_media_successfully": "تم حذف الوسائط الأخرى بنجاح!",
|
||||||
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
|
"failed_to_delete_media": "فشل حذف الوسائط الأخرى",
|
||||||
"download_deleted": "تم حذف التنزيل",
|
"download_deleted": "تم حذف التنزيل",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Descàrregues",
|
"downloads_title": "Descàrregues",
|
||||||
"series": "Sèries",
|
"tvseries": "Sèries",
|
||||||
"movies": "Pel·lícules",
|
"movies": "Pel·lícules",
|
||||||
"queue": "Cua",
|
"queue": "Cua",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "No hi ha elements a la cua",
|
"no_items_in_queue": "No hi ha elements a la cua",
|
||||||
"no_downloaded_items": "No hi ha elements descarregats",
|
"no_downloaded_items": "No hi ha elements descarregats",
|
||||||
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
"delete_all_movies_button": "Suprimeix totes les pel·lícules",
|
||||||
"delete_all_series_button": "Suprimeix totes les sèries",
|
"delete_all_tvseries_button": "Suprimeix totes les sèries",
|
||||||
"delete_all_button": "Suprimeix-ho tot",
|
"delete_all_button": "Suprimeix-ho tot",
|
||||||
"delete_all_other_media_button": "Delete other media",
|
"delete_all_other_media_button": "Delete other media",
|
||||||
"active_download": "Descàrrega activa",
|
"active_download": "Descàrrega activa",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "No teniu permís per descarregar fitxers.",
|
"you_are_not_allowed_to_download_files": "No teniu permís per descarregar fitxers.",
|
||||||
"deleted_all_movies_successfully": "S'han suprimit totes les pel·lícules correctament!",
|
"deleted_all_movies_successfully": "S'han suprimit totes les pel·lícules correctament!",
|
||||||
"failed_to_delete_all_movies": "No s'han pogut suprimir totes les pel·lícules",
|
"failed_to_delete_all_movies": "No s'han pogut suprimir totes les pel·lícules",
|
||||||
"deleted_all_series_successfully": "S'han suprimit totes les sèries correctament!",
|
"deleted_all_tvseries_successfully": "S'han suprimit totes les sèries correctament!",
|
||||||
"failed_to_delete_all_series": "No s'han pogut suprimir totes les sèries",
|
"failed_to_delete_all_tvseries": "No s'han pogut suprimir totes les sèries",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
"download_deleted": "Download Deleted",
|
"download_deleted": "Download Deleted",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Stahování",
|
"downloads_title": "Stahování",
|
||||||
"series": "Televizní série",
|
"tvseries": "Televizní série",
|
||||||
"movies": "Filmy",
|
"movies": "Filmy",
|
||||||
"queue": "Fronta",
|
"queue": "Fronta",
|
||||||
"other_media": "Ostatní média",
|
"other_media": "Ostatní média",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Žádné položky ve frontě",
|
"no_items_in_queue": "Žádné položky ve frontě",
|
||||||
"no_downloaded_items": "Žádné stažené položky",
|
"no_downloaded_items": "Žádné stažené položky",
|
||||||
"delete_all_movies_button": "Odstranit všechny filmy",
|
"delete_all_movies_button": "Odstranit všechny filmy",
|
||||||
"delete_all_series_button": "Odstranit všechny TV-série",
|
"delete_all_tvseries_button": "Odstranit všechny TV-série",
|
||||||
"delete_all_button": "Smazat vše",
|
"delete_all_button": "Smazat vše",
|
||||||
"delete_all_other_media_button": "Odstranit ostatní média",
|
"delete_all_other_media_button": "Odstranit ostatní média",
|
||||||
"active_download": "Aktivní stahování",
|
"active_download": "Aktivní stahování",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Nemáte oprávnění stahovat soubory.",
|
"you_are_not_allowed_to_download_files": "Nemáte oprávnění stahovat soubory.",
|
||||||
"deleted_all_movies_successfully": "Všechny filmy byly úspěšně smazány!",
|
"deleted_all_movies_successfully": "Všechny filmy byly úspěšně smazány!",
|
||||||
"failed_to_delete_all_movies": "Nepodařilo se odstranit všechny filmy",
|
"failed_to_delete_all_movies": "Nepodařilo se odstranit všechny filmy",
|
||||||
"deleted_all_series_successfully": "Všechny série televizorů byly úspěšně smazány!",
|
"deleted_all_tvseries_successfully": "Všechny série televizorů byly úspěšně smazány!",
|
||||||
"failed_to_delete_all_series": "Nepodařilo se odstranit všechny TV-série",
|
"failed_to_delete_all_tvseries": "Nepodařilo se odstranit všechny TV-série",
|
||||||
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
|
"deleted_media_successfully": "Ostatní média úspěšně smazána!",
|
||||||
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
|
"failed_to_delete_media": "Nepodařilo se odstranit ostatní média",
|
||||||
"download_deleted": "Stahování smazáno",
|
"download_deleted": "Stahování smazáno",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "TV-serier",
|
"tvseries": "TV-serier",
|
||||||
"movies": "Film",
|
"movies": "Film",
|
||||||
"queue": "Kø",
|
"queue": "Kø",
|
||||||
"other_media": "Andre medier",
|
"other_media": "Andre medier",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Ingen elementer i køen",
|
"no_items_in_queue": "Ingen elementer i køen",
|
||||||
"no_downloaded_items": "Ingen downloadede elementer",
|
"no_downloaded_items": "Ingen downloadede elementer",
|
||||||
"delete_all_movies_button": "Slet alle film",
|
"delete_all_movies_button": "Slet alle film",
|
||||||
"delete_all_series_button": "Slet alle TV-serier",
|
"delete_all_tvseries_button": "Slet alle TV-serier",
|
||||||
"delete_all_button": "Slet alle",
|
"delete_all_button": "Slet alle",
|
||||||
"delete_all_other_media_button": "Slet andre medier",
|
"delete_all_other_media_button": "Slet andre medier",
|
||||||
"active_download": "Aktiv download",
|
"active_download": "Aktiv download",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du har ikke tilladelse til at downloade filer.",
|
"you_are_not_allowed_to_download_files": "Du har ikke tilladelse til at downloade filer.",
|
||||||
"deleted_all_movies_successfully": "Alle film er slettet med succes!",
|
"deleted_all_movies_successfully": "Alle film er slettet med succes!",
|
||||||
"failed_to_delete_all_movies": "Kunne ikke slette alle film",
|
"failed_to_delete_all_movies": "Kunne ikke slette alle film",
|
||||||
"deleted_all_series_successfully": "Alle TV-serier er slettet med succes!",
|
"deleted_all_tvseries_successfully": "Alle TV-serier er slettet med succes!",
|
||||||
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
|
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
|
||||||
"deleted_media_successfully": "Slettede andre medier med succes!",
|
"deleted_media_successfully": "Slettede andre medier med succes!",
|
||||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||||
"download_deleted": "Download Slettet",
|
"download_deleted": "Download Slettet",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "Serien",
|
"tvseries": "Serien",
|
||||||
"movies": "Filme",
|
"movies": "Filme",
|
||||||
"queue": "Warteschlange",
|
"queue": "Warteschlange",
|
||||||
"other_media": "Andere Medien",
|
"other_media": "Andere Medien",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
||||||
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
||||||
"delete_all_movies_button": "Alle Filme löschen",
|
"delete_all_movies_button": "Alle Filme löschen",
|
||||||
"delete_all_series_button": "Alle Serien löschen",
|
"delete_all_tvseries_button": "Alle Serien löschen",
|
||||||
"delete_all_button": "Alles löschen",
|
"delete_all_button": "Alles löschen",
|
||||||
"delete_all_other_media_button": "Alle anderen Medien löschen",
|
"delete_all_other_media_button": "Alle anderen Medien löschen",
|
||||||
"active_download": "Aktiver Download",
|
"active_download": "Aktiver Download",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
|
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
|
||||||
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
|
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
|
||||||
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
|
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
|
||||||
"deleted_all_series_successfully": "Alle Serien erfolgreich gelöscht!",
|
"deleted_all_tvseries_successfully": "Alle Serien erfolgreich gelöscht!",
|
||||||
"failed_to_delete_all_series": "Fehler beim Löschen aller Serien",
|
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller Serien",
|
||||||
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
||||||
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
||||||
"download_deleted": "Download gelöscht",
|
"download_deleted": "Download gelöscht",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Λήψεις",
|
"downloads_title": "Λήψεις",
|
||||||
"series": "Τηλεόραση-Σειρά",
|
"tvseries": "Τηλεόραση-Σειρά",
|
||||||
"movies": "Ταινίες",
|
"movies": "Ταινίες",
|
||||||
"queue": "Ουρά",
|
"queue": "Ουρά",
|
||||||
"other_media": "Άλλα μέσα",
|
"other_media": "Άλλα μέσα",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
|
"no_items_in_queue": "Δεν υπάρχουν αντικείμενα στην ουρά",
|
||||||
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
|
"no_downloaded_items": "Δεν Έχουν Ληφθεί Αντικείμενα",
|
||||||
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
|
"delete_all_movies_button": "Διαγραφή Όλων Των Ταινιών",
|
||||||
"delete_all_series_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
"delete_all_tvseries_button": "Διαγραφή Όλων Των Τηλεοπτικών Σειρών",
|
||||||
"delete_all_button": "Διαγραφή Όλων",
|
"delete_all_button": "Διαγραφή Όλων",
|
||||||
"delete_all_other_media_button": "Διαγραφή άλλων μέσων",
|
"delete_all_other_media_button": "Διαγραφή άλλων μέσων",
|
||||||
"active_download": "Ενεργή Λήψη",
|
"active_download": "Ενεργή Λήψη",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Δεν επιτρέπεται να κατεβάσετε αρχεία.",
|
"you_are_not_allowed_to_download_files": "Δεν επιτρέπεται να κατεβάσετε αρχεία.",
|
||||||
"deleted_all_movies_successfully": "Διαγράφηκε Όλες Οι Ταινίες Επιτυχία!",
|
"deleted_all_movies_successfully": "Διαγράφηκε Όλες Οι Ταινίες Επιτυχία!",
|
||||||
"failed_to_delete_all_movies": "Αποτυχία διαγραφής όλων των ταινιών",
|
"failed_to_delete_all_movies": "Αποτυχία διαγραφής όλων των ταινιών",
|
||||||
"deleted_all_series_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
|
"deleted_all_tvseries_successfully": "Διαγράφηκε Όλη Η Τηλεόραση-Σειρά Επιτυχία!",
|
||||||
"failed_to_delete_all_series": "Αποτυχία διαγραφής Όλων των TV-Series",
|
"failed_to_delete_all_tvseries": "Αποτυχία διαγραφής Όλων των TV-Series",
|
||||||
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
|
"deleted_media_successfully": "Διαγράφηκε άλλο μέσο επιτυχώς!",
|
||||||
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
|
"failed_to_delete_media": "Αποτυχία διαγραφής άλλων πολυμέσων",
|
||||||
"download_deleted": "Η Λήψη Διαγράφηκε",
|
"download_deleted": "Η Λήψη Διαγράφηκε",
|
||||||
|
|||||||
@@ -229,14 +229,14 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Elŝutoj",
|
"downloads_title": "Elŝutoj",
|
||||||
"series": "Televidaj serioj",
|
"tvseries": "Televidaj serioj",
|
||||||
"movies": "Filmoj",
|
"movies": "Filmoj",
|
||||||
"queue": "Vico",
|
"queue": "Vico",
|
||||||
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
|
"queue_hint": "Vico kaj elŝutoj perdiĝos ĉe aplikaĵa rekomenco",
|
||||||
"no_items_in_queue": "Neniuj eroj en vico",
|
"no_items_in_queue": "Neniuj eroj en vico",
|
||||||
"no_downloaded_items": "Neniuj elŝutitaj eroj",
|
"no_downloaded_items": "Neniuj elŝutitaj eroj",
|
||||||
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
|
"delete_all_movies_button": "Forigi ĉiujn Filmojn",
|
||||||
"delete_all_series_button": "Forigi ĉiujn Televidajn Seriojn",
|
"delete_all_tvseries_button": "Forigi ĉiujn Televidajn Seriojn",
|
||||||
"delete_all_button": "Forigi ĉion",
|
"delete_all_button": "Forigi ĉion",
|
||||||
"active_download": "Aktiva elŝuto",
|
"active_download": "Aktiva elŝuto",
|
||||||
"no_active_downloads": "Neniuj aktivaj elŝutoj",
|
"no_active_downloads": "Neniuj aktivaj elŝutoj",
|
||||||
@@ -253,8 +253,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
|
"you_are_not_allowed_to_download_files": "Vi ne rajtas elŝuti dosierojn.",
|
||||||
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
|
"deleted_all_movies_successfully": "Sukcese forigis ĉiujn filmojn!",
|
||||||
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
|
"failed_to_delete_all_movies": "Malsukcesis forigi ĉiujn filmojn",
|
||||||
"deleted_all_series_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
"deleted_all_tvseries_successfully": "Sukcese forigis ĉiujn Televidajn Seriojn!",
|
||||||
"failed_to_delete_all_series": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
"failed_to_delete_all_tvseries": "Malsukcesis forigi ĉiujn Televidajn Seriojn",
|
||||||
"download_cancelled": "Elŝuto nuligita",
|
"download_cancelled": "Elŝuto nuligita",
|
||||||
"could_not_cancel_download": "Ne povis nuligi elŝuton",
|
"could_not_cancel_download": "Ne povis nuligi elŝuton",
|
||||||
"download_completed": "Elŝuto finita",
|
"download_completed": "Elŝuto finita",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Descargas",
|
"downloads_title": "Descargas",
|
||||||
"series": "Series",
|
"tvseries": "Series",
|
||||||
"movies": "Películas",
|
"movies": "Películas",
|
||||||
"queue": "Cola",
|
"queue": "Cola",
|
||||||
"other_media": "Otros medios",
|
"other_media": "Otros medios",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "No hay ítems en la cola",
|
"no_items_in_queue": "No hay ítems en la cola",
|
||||||
"no_downloaded_items": "No hay ítems descargados",
|
"no_downloaded_items": "No hay ítems descargados",
|
||||||
"delete_all_movies_button": "Eliminar todas las películas",
|
"delete_all_movies_button": "Eliminar todas las películas",
|
||||||
"delete_all_series_button": "Eliminar todas las series",
|
"delete_all_tvseries_button": "Eliminar todas las series",
|
||||||
"delete_all_button": "Eliminar todo",
|
"delete_all_button": "Eliminar todo",
|
||||||
"delete_all_other_media_button": "Eliminar otros medios",
|
"delete_all_other_media_button": "Eliminar otros medios",
|
||||||
"active_download": "Descarga activa",
|
"active_download": "Descarga activa",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "No tienes permiso para descargar archivos.",
|
"you_are_not_allowed_to_download_files": "No tienes permiso para descargar archivos.",
|
||||||
"deleted_all_movies_successfully": "¡Todas las películas eliminadas con éxito!",
|
"deleted_all_movies_successfully": "¡Todas las películas eliminadas con éxito!",
|
||||||
"failed_to_delete_all_movies": "Error al eliminar todas las películas",
|
"failed_to_delete_all_movies": "Error al eliminar todas las películas",
|
||||||
"deleted_all_series_successfully": "¡Todas las series eliminadas con éxito!",
|
"deleted_all_tvseries_successfully": "¡Todas las series eliminadas con éxito!",
|
||||||
"failed_to_delete_all_series": "Error al eliminar todas las series",
|
"failed_to_delete_all_tvseries": "Error al eliminar todas las series",
|
||||||
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
|
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
|
||||||
"failed_to_delete_media": "Error al eliminar otros medios",
|
"failed_to_delete_media": "Error al eliminar otros medios",
|
||||||
"download_deleted": "Descarga eliminada",
|
"download_deleted": "Descarga eliminada",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Lataukset",
|
"downloads_title": "Lataukset",
|
||||||
"series": "TV-sarjat",
|
"tvseries": "TV-sarjat",
|
||||||
"movies": "Elokuvat",
|
"movies": "Elokuvat",
|
||||||
"queue": "Jonot",
|
"queue": "Jonot",
|
||||||
"other_media": "Muu media",
|
"other_media": "Muu media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Ei kohteita jonossa",
|
"no_items_in_queue": "Ei kohteita jonossa",
|
||||||
"no_downloaded_items": "Ei ladattuja kohteita",
|
"no_downloaded_items": "Ei ladattuja kohteita",
|
||||||
"delete_all_movies_button": "Poista kaikki elokuvat",
|
"delete_all_movies_button": "Poista kaikki elokuvat",
|
||||||
"delete_all_series_button": "Poista kaikki TV-sarjat",
|
"delete_all_tvseries_button": "Poista kaikki TV-sarjat",
|
||||||
"delete_all_button": "Poista kaikki",
|
"delete_all_button": "Poista kaikki",
|
||||||
"delete_all_other_media_button": "Poista muu media",
|
"delete_all_other_media_button": "Poista muu media",
|
||||||
"active_download": "Aktiivinen lataus",
|
"active_download": "Aktiivinen lataus",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Sinulla ei ole lupaa ladata tiedostoja.",
|
"you_are_not_allowed_to_download_files": "Sinulla ei ole lupaa ladata tiedostoja.",
|
||||||
"deleted_all_movies_successfully": "Kaikki elokuvat poistettu onnistuneesti!",
|
"deleted_all_movies_successfully": "Kaikki elokuvat poistettu onnistuneesti!",
|
||||||
"failed_to_delete_all_movies": "Kaikkien elokuvien poistaminen epäonnistui",
|
"failed_to_delete_all_movies": "Kaikkien elokuvien poistaminen epäonnistui",
|
||||||
"deleted_all_series_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
|
"deleted_all_tvseries_successfully": "Kaikki TV-sarjat poistettu onnistuneesti!",
|
||||||
"failed_to_delete_all_series": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
"failed_to_delete_all_tvseries": "Kaikkien TV-sarjojen poistaminen epäonnistui",
|
||||||
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
|
"deleted_media_successfully": "Muu media poistettu onnistuneesti!",
|
||||||
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
|
"failed_to_delete_media": "Muiden medioiden poistaminen epäonnistui",
|
||||||
"download_deleted": "Lataus Poistettu",
|
"download_deleted": "Lataus Poistettu",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Téléchargements",
|
"downloads_title": "Téléchargements",
|
||||||
"series": "Séries",
|
"tvseries": "Séries",
|
||||||
"movies": "Films",
|
"movies": "Films",
|
||||||
"queue": "File d'attente",
|
"queue": "File d'attente",
|
||||||
"other_media": "Autres médias",
|
"other_media": "Autres médias",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Aucun téléchargement de média dans la file d'attente",
|
"no_items_in_queue": "Aucun téléchargement de média dans la file d'attente",
|
||||||
"no_downloaded_items": "Aucun média téléchargé",
|
"no_downloaded_items": "Aucun média téléchargé",
|
||||||
"delete_all_movies_button": "Supprimer tous les films",
|
"delete_all_movies_button": "Supprimer tous les films",
|
||||||
"delete_all_series_button": "Supprimer toutes les séries",
|
"delete_all_tvseries_button": "Supprimer toutes les séries",
|
||||||
"delete_all_button": "Supprimer tous les médias",
|
"delete_all_button": "Supprimer tous les médias",
|
||||||
"delete_all_other_media_button": "Supprimer un autre média",
|
"delete_all_other_media_button": "Supprimer un autre média",
|
||||||
"active_download": "Téléchargement actif",
|
"active_download": "Téléchargement actif",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers.",
|
"you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers.",
|
||||||
"deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès !",
|
"deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès !",
|
||||||
"failed_to_delete_all_movies": "Échec de la suppression de tous les films",
|
"failed_to_delete_all_movies": "Échec de la suppression de tous les films",
|
||||||
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès !",
|
"deleted_all_tvseries_successfully": "Toutes les séries ont été supprimées avec succès !",
|
||||||
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
|
"failed_to_delete_all_tvseries": "Échec de la suppression de toutes les séries",
|
||||||
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
||||||
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
||||||
"download_deleted": "Téléchargement supprimé",
|
"download_deleted": "Téléchargement supprimé",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "הורדות",
|
"downloads_title": "הורדות",
|
||||||
"series": "סדרות",
|
"tvseries": "סדרות",
|
||||||
"movies": "סרטים",
|
"movies": "סרטים",
|
||||||
"queue": "תוֹר",
|
"queue": "תוֹר",
|
||||||
"other_media": "תוכן אחר",
|
"other_media": "תוכן אחר",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "אין פרטים בתור",
|
"no_items_in_queue": "אין פרטים בתור",
|
||||||
"no_downloaded_items": "אין פריטים שהורדו",
|
"no_downloaded_items": "אין פריטים שהורדו",
|
||||||
"delete_all_movies_button": "מחק את כל הסרטים",
|
"delete_all_movies_button": "מחק את כל הסרטים",
|
||||||
"delete_all_series_button": "מחק את כל הסדרות",
|
"delete_all_tvseries_button": "מחק את כל הסדרות",
|
||||||
"delete_all_button": "מחק הכל",
|
"delete_all_button": "מחק הכל",
|
||||||
"delete_all_other_media_button": "מחק שאר תוכן",
|
"delete_all_other_media_button": "מחק שאר תוכן",
|
||||||
"active_download": "הורדה פעילה",
|
"active_download": "הורדה פעילה",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "אתה לא מורשה להוריד קבצים.",
|
"you_are_not_allowed_to_download_files": "אתה לא מורשה להוריד קבצים.",
|
||||||
"deleted_all_movies_successfully": "כל הסרטים נמחקו בהצלחה!",
|
"deleted_all_movies_successfully": "כל הסרטים נמחקו בהצלחה!",
|
||||||
"failed_to_delete_all_movies": "נכשל במחיקת כל הסרטים",
|
"failed_to_delete_all_movies": "נכשל במחיקת כל הסרטים",
|
||||||
"deleted_all_series_successfully": "כל הסדרות נמחקו בהצלחה!",
|
"deleted_all_tvseries_successfully": "כל הסדרות נמחקו בהצלחה!",
|
||||||
"failed_to_delete_all_series": "נכשל במחיקת כל הסדרות",
|
"failed_to_delete_all_tvseries": "נכשל במחיקת כל הסדרות",
|
||||||
"deleted_media_successfully": "כל שאר התוכן נמחק בהצלחה!",
|
"deleted_media_successfully": "כל שאר התוכן נמחק בהצלחה!",
|
||||||
"failed_to_delete_media": "נכשל במחיקת שאר התוכן",
|
"failed_to_delete_media": "נכשל במחיקת שאר התוכן",
|
||||||
"download_deleted": "ההורדה נמחקה",
|
"download_deleted": "ההורדה נמחקה",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Letöltések",
|
"downloads_title": "Letöltések",
|
||||||
"series": "Sorozatok",
|
"tvseries": "Sorozatok",
|
||||||
"movies": "Filmek",
|
"movies": "Filmek",
|
||||||
"queue": "Sor",
|
"queue": "Sor",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Nincs Elem a Sorban",
|
"no_items_in_queue": "Nincs Elem a Sorban",
|
||||||
"no_downloaded_items": "Nincsenek Letöltött Elemek",
|
"no_downloaded_items": "Nincsenek Letöltött Elemek",
|
||||||
"delete_all_movies_button": "Összes Film Törlése",
|
"delete_all_movies_button": "Összes Film Törlése",
|
||||||
"delete_all_series_button": "Összes Sorozat Törlése",
|
"delete_all_tvseries_button": "Összes Sorozat Törlése",
|
||||||
"delete_all_button": "Összes Törlése",
|
"delete_all_button": "Összes Törlése",
|
||||||
"delete_all_other_media_button": "Delete other media",
|
"delete_all_other_media_button": "Delete other media",
|
||||||
"active_download": "Aktív Letöltés",
|
"active_download": "Aktív Letöltés",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Nem engedélyezett a fájlok letöltése.",
|
"you_are_not_allowed_to_download_files": "Nem engedélyezett a fájlok letöltése.",
|
||||||
"deleted_all_movies_successfully": "Az Összes Film Sikeresen Törölve!",
|
"deleted_all_movies_successfully": "Az Összes Film Sikeresen Törölve!",
|
||||||
"failed_to_delete_all_movies": "Nem Sikerült Törölni Az Összes Filmet",
|
"failed_to_delete_all_movies": "Nem Sikerült Törölni Az Összes Filmet",
|
||||||
"deleted_all_series_successfully": "Az Összes Sorozat Sikeresen Törölve!",
|
"deleted_all_tvseries_successfully": "Az Összes Sorozat Sikeresen Törölve!",
|
||||||
"failed_to_delete_all_series": "Nem Sikerült Törölni Az Összes Sorozatot",
|
"failed_to_delete_all_tvseries": "Nem Sikerült Törölni Az Összes Sorozatot",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
"download_deleted": "Letöltés Törölve",
|
"download_deleted": "Letöltés Törölve",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Scaricati",
|
"downloads_title": "Scaricati",
|
||||||
"series": "Serie TV",
|
"tvseries": "Serie TV",
|
||||||
"movies": "Film",
|
"movies": "Film",
|
||||||
"queue": "Coda",
|
"queue": "Coda",
|
||||||
"other_media": "Altri supporti",
|
"other_media": "Altri supporti",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Nessun elemento in coda",
|
"no_items_in_queue": "Nessun elemento in coda",
|
||||||
"no_downloaded_items": "Nessun elemento scaricato",
|
"no_downloaded_items": "Nessun elemento scaricato",
|
||||||
"delete_all_movies_button": "Cancella tutti i film",
|
"delete_all_movies_button": "Cancella tutti i film",
|
||||||
"delete_all_series_button": "Cancella tutte le serie TV",
|
"delete_all_tvseries_button": "Cancella tutte le serie TV",
|
||||||
"delete_all_button": "Cancella tutti",
|
"delete_all_button": "Cancella tutti",
|
||||||
"delete_all_other_media_button": "Elimina altri supporti",
|
"delete_all_other_media_button": "Elimina altri supporti",
|
||||||
"active_download": "Scaricamento in corso",
|
"active_download": "Scaricamento in corso",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
|
"you_are_not_allowed_to_download_files": "Non è consentito scaricare file.",
|
||||||
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
|
"deleted_all_movies_successfully": "Cancellati tutti i film con successo!",
|
||||||
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
|
"failed_to_delete_all_movies": "Impossibile eliminare tutti i film",
|
||||||
"deleted_all_series_successfully": "Eliminate tutte le serie TV con successo!",
|
"deleted_all_tvseries_successfully": "Eliminate tutte le serie TV con successo!",
|
||||||
"failed_to_delete_all_series": "Impossibile eliminare tutte le serie TV",
|
"failed_to_delete_all_tvseries": "Impossibile eliminare tutte le serie TV",
|
||||||
"deleted_media_successfully": "Eliminato altri supporti con successo!",
|
"deleted_media_successfully": "Eliminato altri supporti con successo!",
|
||||||
"failed_to_delete_media": "Impossibile eliminare altri media",
|
"failed_to_delete_media": "Impossibile eliminare altri media",
|
||||||
"download_deleted": "Download Eliminato",
|
"download_deleted": "Download Eliminato",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "ダウンロード",
|
"downloads_title": "ダウンロード",
|
||||||
"series": "TVシリーズ",
|
"tvseries": "TVシリーズ",
|
||||||
"movies": "映画",
|
"movies": "映画",
|
||||||
"queue": "キュー",
|
"queue": "キュー",
|
||||||
"other_media": "その他のメディア",
|
"other_media": "その他のメディア",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "キューにアイテムがありません",
|
"no_items_in_queue": "キューにアイテムがありません",
|
||||||
"no_downloaded_items": "ダウンロードしたアイテムはありません",
|
"no_downloaded_items": "ダウンロードしたアイテムはありません",
|
||||||
"delete_all_movies_button": "すべての映画を削除",
|
"delete_all_movies_button": "すべての映画を削除",
|
||||||
"delete_all_series_button": "すべてのシリーズを削除",
|
"delete_all_tvseries_button": "すべてのシリーズを削除",
|
||||||
"delete_all_button": "すべて削除",
|
"delete_all_button": "すべて削除",
|
||||||
"delete_all_other_media_button": "他のメディアを削除する",
|
"delete_all_other_media_button": "他のメディアを削除する",
|
||||||
"active_download": "アクティブなダウンロード",
|
"active_download": "アクティブなダウンロード",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "ファイルをダウンロードする権限がありません。",
|
"you_are_not_allowed_to_download_files": "ファイルをダウンロードする権限がありません。",
|
||||||
"deleted_all_movies_successfully": "すべての映画を正常に削除しました!",
|
"deleted_all_movies_successfully": "すべての映画を正常に削除しました!",
|
||||||
"failed_to_delete_all_movies": "すべての映画を削除できませんでした",
|
"failed_to_delete_all_movies": "すべての映画を削除できませんでした",
|
||||||
"deleted_all_series_successfully": "すべてのシリーズを正常に削除しました!",
|
"deleted_all_tvseries_successfully": "すべてのシリーズを正常に削除しました!",
|
||||||
"failed_to_delete_all_series": "すべてのシリーズを削除できませんでした",
|
"failed_to_delete_all_tvseries": "すべてのシリーズを削除できませんでした",
|
||||||
"deleted_media_successfully": "他のメディアを削除しました!",
|
"deleted_media_successfully": "他のメディアを削除しました!",
|
||||||
"failed_to_delete_media": "他のメディアの削除に失敗しました",
|
"failed_to_delete_media": "他のメディアの削除に失敗しました",
|
||||||
"download_deleted": "ダウンロードが削除されました",
|
"download_deleted": "ダウンロードが削除されました",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "TV-Series",
|
"tvseries": "TV-Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"other_media": "Other media",
|
"other_media": "Other media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "No Items in Queue",
|
"no_items_in_queue": "No Items in Queue",
|
||||||
"no_downloaded_items": "No Downloaded Items",
|
"no_downloaded_items": "No Downloaded Items",
|
||||||
"delete_all_movies_button": "Delete All Movies",
|
"delete_all_movies_button": "Delete All Movies",
|
||||||
"delete_all_series_button": "Delete All TV-Series",
|
"delete_all_tvseries_button": "Delete All TV-Series",
|
||||||
"delete_all_button": "Delete All",
|
"delete_all_button": "Delete All",
|
||||||
"delete_all_other_media_button": "Delete other media",
|
"delete_all_other_media_button": "Delete other media",
|
||||||
"active_download": "Active Download",
|
"active_download": "Active Download",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
||||||
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
|
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
|
||||||
"failed_to_delete_all_movies": "Failed to Delete All Movies",
|
"failed_to_delete_all_movies": "Failed to Delete All Movies",
|
||||||
"deleted_all_series_successfully": "Deleted All TV-Series Successfully!",
|
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
|
||||||
"failed_to_delete_all_series": "Failed to Delete All TV-Series",
|
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"failed_to_delete_media": "Failed to Delete other media",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
"download_deleted": "Download Deleted",
|
"download_deleted": "Download Deleted",
|
||||||
|
|||||||
@@ -229,14 +229,14 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Nedlastinger",
|
"downloads_title": "Nedlastinger",
|
||||||
"series": "TV-serier",
|
"tvseries": "TV-serier",
|
||||||
"movies": "Filmer",
|
"movies": "Filmer",
|
||||||
"queue": "Kø",
|
"queue": "Kø",
|
||||||
"queue_hint": "Kø og nedlastinger vil gå tapt ved omstart av appen",
|
"queue_hint": "Kø og nedlastinger vil gå tapt ved omstart av appen",
|
||||||
"no_items_in_queue": "Ingen elementer i køen",
|
"no_items_in_queue": "Ingen elementer i køen",
|
||||||
"no_downloaded_items": "Ingen nedlastede elementer",
|
"no_downloaded_items": "Ingen nedlastede elementer",
|
||||||
"delete_all_movies_button": "Slett alle filmer",
|
"delete_all_movies_button": "Slett alle filmer",
|
||||||
"delete_all_series_button": "Slett alle TV-serier",
|
"delete_all_tvseries_button": "Slett alle TV-serier",
|
||||||
"delete_all_button": "Slett alt",
|
"delete_all_button": "Slett alt",
|
||||||
"active_download": "Aktiv nedlasting",
|
"active_download": "Aktiv nedlasting",
|
||||||
"no_active_downloads": "Ingen aktive nedlastinger",
|
"no_active_downloads": "Ingen aktive nedlastinger",
|
||||||
@@ -253,8 +253,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du har ikke tillatelse til å laste ned filer.",
|
"you_are_not_allowed_to_download_files": "Du har ikke tillatelse til å laste ned filer.",
|
||||||
"deleted_all_movies_successfully": "Alle filmer ble slettet!",
|
"deleted_all_movies_successfully": "Alle filmer ble slettet!",
|
||||||
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
|
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
|
||||||
"deleted_all_series_successfully": "Alle TV-serier ble slettet!",
|
"deleted_all_tvseries_successfully": "Alle TV-serier ble slettet!",
|
||||||
"failed_to_delete_all_series": "Kunne ikke slette alle TV-serier",
|
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-serier",
|
||||||
"download_cancelled": "Nedlasting avbrutt",
|
"download_cancelled": "Nedlasting avbrutt",
|
||||||
"could_not_cancel_download": "Kunne ikke avbryte nedlastingen",
|
"could_not_cancel_download": "Kunne ikke avbryte nedlastingen",
|
||||||
"download_completed": "Nedlasting fullført",
|
"download_completed": "Nedlasting fullført",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"series": "Series",
|
"tvseries": "Series",
|
||||||
"movies": "Films",
|
"movies": "Films",
|
||||||
"queue": "Wachtrij",
|
"queue": "Wachtrij",
|
||||||
"other_media": "Andere media",
|
"other_media": "Andere media",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Geen items in wachtrij",
|
"no_items_in_queue": "Geen items in wachtrij",
|
||||||
"no_downloaded_items": "Geen gedownloade items",
|
"no_downloaded_items": "Geen gedownloade items",
|
||||||
"delete_all_movies_button": "Verwijder alle films",
|
"delete_all_movies_button": "Verwijder alle films",
|
||||||
"delete_all_series_button": "Verwijder alle Series",
|
"delete_all_tvseries_button": "Verwijder alle Series",
|
||||||
"delete_all_button": "Verwijder alles",
|
"delete_all_button": "Verwijder alles",
|
||||||
"delete_all_other_media_button": "Andere media verwijderen",
|
"delete_all_other_media_button": "Andere media verwijderen",
|
||||||
"active_download": "Actieve download",
|
"active_download": "Actieve download",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
|
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
|
||||||
"deleted_all_movies_successfully": "Alle films succesvol verwijderd!",
|
"deleted_all_movies_successfully": "Alle films succesvol verwijderd!",
|
||||||
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
|
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
|
||||||
"deleted_all_series_successfully": "Alle series succesvol verwijderd!",
|
"deleted_all_tvseries_successfully": "Alle series succesvol verwijderd!",
|
||||||
"failed_to_delete_all_series": "Alle series zijn niet verwijderd",
|
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
|
||||||
"deleted_media_successfully": "Andere media succesvol verwijderd!",
|
"deleted_media_successfully": "Andere media succesvol verwijderd!",
|
||||||
"failed_to_delete_media": "Verwijderen van andere media mislukt",
|
"failed_to_delete_media": "Verwijderen van andere media mislukt",
|
||||||
"download_deleted": "Download verwijderd",
|
"download_deleted": "Download verwijderd",
|
||||||
|
|||||||
@@ -229,14 +229,14 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Nedlastingar",
|
"downloads_title": "Nedlastingar",
|
||||||
"series": "TV-seriar",
|
"tvseries": "TV-seriar",
|
||||||
"movies": "Filmar",
|
"movies": "Filmar",
|
||||||
"queue": "Kø",
|
"queue": "Kø",
|
||||||
"queue_hint": "Kø og nedlastingar vil gå tapt ved omstart av appen",
|
"queue_hint": "Kø og nedlastingar vil gå tapt ved omstart av appen",
|
||||||
"no_items_in_queue": "Ingen element i køen",
|
"no_items_in_queue": "Ingen element i køen",
|
||||||
"no_downloaded_items": "Ingen nedlasta element",
|
"no_downloaded_items": "Ingen nedlasta element",
|
||||||
"delete_all_movies_button": "Slett alle filmar",
|
"delete_all_movies_button": "Slett alle filmar",
|
||||||
"delete_all_series_button": "Slett alle TV-seriar",
|
"delete_all_tvseries_button": "Slett alle TV-seriar",
|
||||||
"delete_all_button": "Slett alt",
|
"delete_all_button": "Slett alt",
|
||||||
"active_download": "Aktiv nedlasting",
|
"active_download": "Aktiv nedlasting",
|
||||||
"no_active_downloads": "Ingen aktive nedlastingar",
|
"no_active_downloads": "Ingen aktive nedlastingar",
|
||||||
@@ -253,8 +253,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du har ikkje løyve til å lasta ned filer.",
|
"you_are_not_allowed_to_download_files": "Du har ikkje løyve til å lasta ned filer.",
|
||||||
"deleted_all_movies_successfully": "Alle filmar vart sletta!",
|
"deleted_all_movies_successfully": "Alle filmar vart sletta!",
|
||||||
"failed_to_delete_all_movies": "Kunne ikkje sletta alle filmar",
|
"failed_to_delete_all_movies": "Kunne ikkje sletta alle filmar",
|
||||||
"deleted_all_series_successfully": "Alle TV-seriar vart sletta!",
|
"deleted_all_tvseries_successfully": "Alle TV-seriar vart sletta!",
|
||||||
"failed_to_delete_all_series": "Kunne ikkje sletta alle TV-seriar",
|
"failed_to_delete_all_tvseries": "Kunne ikkje sletta alle TV-seriar",
|
||||||
"download_cancelled": "Nedlasting avbroten",
|
"download_cancelled": "Nedlasting avbroten",
|
||||||
"could_not_cancel_download": "Kunne ikkje avbryta nedlastinga",
|
"could_not_cancel_download": "Kunne ikkje avbryta nedlastinga",
|
||||||
"download_completed": "Nedlasting fullført",
|
"download_completed": "Nedlasting fullført",
|
||||||
|
|||||||
@@ -516,7 +516,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Nedlastinger",
|
"downloads_title": "Nedlastinger",
|
||||||
"series": "TV-Serier",
|
"tvseries": "TV-Serier",
|
||||||
"movies": "Filmer",
|
"movies": "Filmer",
|
||||||
"queue": "Kø",
|
"queue": "Kø",
|
||||||
"other_media": "Andre medier",
|
"other_media": "Andre medier",
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
"no_items_in_queue": "Ingen elementer i køen",
|
"no_items_in_queue": "Ingen elementer i køen",
|
||||||
"no_downloaded_items": "Ingen nedlastede elementer",
|
"no_downloaded_items": "Ingen nedlastede elementer",
|
||||||
"delete_all_movies_button": "Slett alle filmer",
|
"delete_all_movies_button": "Slett alle filmer",
|
||||||
"delete_all_series_button": "Slett alle TV-Serier",
|
"delete_all_tvseries_button": "Slett alle TV-Serier",
|
||||||
"delete_all_button": "Slett alle",
|
"delete_all_button": "Slett alle",
|
||||||
"delete_all_other_media_button": "Slett andre media",
|
"delete_all_other_media_button": "Slett andre media",
|
||||||
"active_download": "Aktiv nedlasting",
|
"active_download": "Aktiv nedlasting",
|
||||||
@@ -542,8 +542,8 @@
|
|||||||
"you_are_not_allowed_to_download_files": "Du har ikke lov til å laste ned filer.",
|
"you_are_not_allowed_to_download_files": "Du har ikke lov til å laste ned filer.",
|
||||||
"deleted_all_movies_successfully": "Slettet alle filmer vellykket!",
|
"deleted_all_movies_successfully": "Slettet alle filmer vellykket!",
|
||||||
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
|
"failed_to_delete_all_movies": "Kunne ikke slette alle filmer",
|
||||||
"deleted_all_series_successfully": "Alle TV-Serier ble slettet!",
|
"deleted_all_tvseries_successfully": "Alle TV-Serier ble slettet!",
|
||||||
"failed_to_delete_all_series": "Kunne ikke slette alle TV-Serier",
|
"failed_to_delete_all_tvseries": "Kunne ikke slette alle TV-Serier",
|
||||||
"deleted_media_successfully": "Slettet andre media vellykket!",
|
"deleted_media_successfully": "Slettet andre media vellykket!",
|
||||||
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
"failed_to_delete_media": "Kunne ikke slette andre medier",
|
||||||
"download_deleted": "Nedlasting slettet",
|
"download_deleted": "Nedlasting slettet",
|
||||||
|
|||||||