Compare commits
1 Commits
fix/save-a
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f52f704091 |
@@ -1,25 +0,0 @@
|
|||||||
# Custom EAS Build config for Android phone APK (downloadable artifact).
|
|
||||||
# Same bun-forcing flow as android-production.yml, but builds an APK
|
|
||||||
# (assembleRelease) instead of an AAB — for sideloading / GitHub artifact.
|
|
||||||
# Referenced from eas.json: build.production-apk.android.config
|
|
||||||
build:
|
|
||||||
name: Android phone APK (bun)
|
|
||||||
steps:
|
|
||||||
- eas/checkout
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install dependencies (bun, frozen)
|
|
||||||
command: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Prebuild (Android, bun)
|
|
||||||
command: bunx expo prebuild --platform android --no-install
|
|
||||||
|
|
||||||
- eas/configure_android_version
|
|
||||||
- eas/inject_android_credentials
|
|
||||||
|
|
||||||
- eas/run_gradle:
|
|
||||||
inputs:
|
|
||||||
command: :app:assembleRelease
|
|
||||||
|
|
||||||
- eas/find_and_upload_build_artifacts
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Custom EAS Build config for Android TV APK (downloadable artifact).
|
|
||||||
# Same bun-forcing flow, with EXPO_TV=1 (set via the profile env in
|
|
||||||
# eas.json) so prebuild generates the TV variant. Builds an APK for
|
|
||||||
# sideloading onto Android TV devices.
|
|
||||||
# Referenced from eas.json: build.production-apk-tv.android.config
|
|
||||||
build:
|
|
||||||
name: Android TV APK (bun)
|
|
||||||
steps:
|
|
||||||
- eas/checkout
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install dependencies (bun, frozen)
|
|
||||||
command: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
|
|
||||||
- run:
|
|
||||||
name: Prebuild (Android TV, bun)
|
|
||||||
command: bunx expo prebuild --platform android --no-install
|
|
||||||
|
|
||||||
- eas/configure_android_version
|
|
||||||
- eas/inject_android_credentials
|
|
||||||
|
|
||||||
- eas/run_gradle:
|
|
||||||
inputs:
|
|
||||||
command: :app:assembleRelease
|
|
||||||
|
|
||||||
- eas/find_and_upload_build_artifacts
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Custom EAS Build config for Android (production AAB).
|
|
||||||
#
|
|
||||||
# Why this exists: EAS's managed build can't detect Bun's text lockfile
|
|
||||||
# (bun.lock) and falls back to yarn, which breaks our install. The managed
|
|
||||||
# steps `eas/install_node_modules` and `eas/prebuild` both use "the package
|
|
||||||
# manager detected based on your project", so we replace them with explicit
|
|
||||||
# `bun` commands. Everything else uses EAS's built-in functions so we still
|
|
||||||
# get remote versioning, credentials, and artifact upload.
|
|
||||||
#
|
|
||||||
# Referenced from eas.json: build.production.android.config = android-production.yml
|
|
||||||
build:
|
|
||||||
name: Android production (bun)
|
|
||||||
steps:
|
|
||||||
- eas/checkout
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install dependencies (bun, frozen)
|
|
||||||
command: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# android/ is gitignored, so generate native code fresh. --no-install
|
|
||||||
# because deps are already installed above; bunx keeps it on bun.
|
|
||||||
- run:
|
|
||||||
name: Prebuild (Android, bun)
|
|
||||||
command: bunx expo prebuild --platform android --no-install
|
|
||||||
|
|
||||||
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
|
|
||||||
# in eas.json) into the freshly prebuilt android/ project.
|
|
||||||
- eas/configure_android_version
|
|
||||||
|
|
||||||
# Injects the remote Android keystore / signing config.
|
|
||||||
- eas/inject_android_credentials
|
|
||||||
|
|
||||||
# Build the Play Store app bundle (.aab).
|
|
||||||
- eas/run_gradle:
|
|
||||||
inputs:
|
|
||||||
command: :app:bundleRelease
|
|
||||||
|
|
||||||
- eas/find_and_upload_build_artifacts
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Custom EAS Build config for iOS + tvOS (App Store), forcing bun.
|
|
||||||
#
|
|
||||||
# Shared by both the iPhone profile (production) and the tvOS profile
|
|
||||||
# (production_tv). The profile decides the rest:
|
|
||||||
# - production_tv sets EXPO_TV=1 (env) so prebuild targets tvOS, and
|
|
||||||
# credentialsSource: local (EAS can't manage tvOS creds remotely).
|
|
||||||
# - production uses remote-managed iOS credentials.
|
|
||||||
#
|
|
||||||
# Like the Android configs, this replaces eas/install_node_modules and
|
|
||||||
# eas/prebuild (both auto-detect the wrong package manager) with explicit
|
|
||||||
# bun commands, and keeps EAS built-ins for credentials/version/fastlane.
|
|
||||||
build:
|
|
||||||
name: iOS/tvOS App Store (bun)
|
|
||||||
steps:
|
|
||||||
- eas/checkout
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install dependencies (bun, frozen)
|
|
||||||
command: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- eas/resolve_apple_team_id_from_credentials:
|
|
||||||
id: resolve_team
|
|
||||||
|
|
||||||
# android/ + ios/ are gitignored, so generate native code fresh.
|
|
||||||
# EXPO_TV (from the profile env) selects iPhone vs tvOS. --no-install
|
|
||||||
# skips JS + pod install; we install pods explicitly below with bun deps.
|
|
||||||
- run:
|
|
||||||
name: Prebuild (iOS/tvOS, bun)
|
|
||||||
command: bunx expo prebuild --platform ios --no-install
|
|
||||||
|
|
||||||
- run:
|
|
||||||
name: Install CocoaPods
|
|
||||||
working_directory: ./ios
|
|
||||||
command: pod install
|
|
||||||
|
|
||||||
- eas/configure_ios_credentials
|
|
||||||
- eas/configure_ios_version
|
|
||||||
|
|
||||||
- eas/generate_gymfile_from_template:
|
|
||||||
inputs:
|
|
||||||
credentials: ${ eas.job.secrets.buildCredentials }
|
|
||||||
|
|
||||||
- eas/run_fastlane
|
|
||||||
- eas/find_and_upload_build_artifacts
|
|
||||||
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
|
||||||
|
|||||||
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"
|
||||||
|
|||||||
216
.github/workflows/release.yml
vendored
@@ -1,216 +0,0 @@
|
|||||||
name: 🚀 Release (EAS build + submit)
|
|
||||||
|
|
||||||
# On merge to main (gated by the `production` GitHub Environment approval),
|
|
||||||
# build all targets on EAS in parallel via custom bun build configs:
|
|
||||||
# 1. iOS phone → App Store (auto-submit)
|
|
||||||
# 2. tvOS → App Store (auto-submit)
|
|
||||||
# 3. Android AAB → Google Play (auto-submit)
|
|
||||||
# 4. Android phone APK→ downloadable artifact
|
|
||||||
# 5. Android TV APK → downloadable artifact
|
|
||||||
# Note: EAS queues builds based on your plan's concurrency; parallel jobs
|
|
||||||
# here just submit them — EAS may still run them serially.
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: release-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
approve:
|
|
||||||
name: 🔐 Approve release
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
environment: production
|
|
||||||
steps:
|
|
||||||
- name: ✅ Release approved
|
|
||||||
run: echo "Release approved for ${{ github.sha }}"
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: 🚀 ${{ matrix.name }}
|
|
||||||
needs: approve
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: 🍎 iOS
|
|
||||||
platform: ios
|
|
||||||
profile: production
|
|
||||||
submit: true
|
|
||||||
- name: 📺 tvOS
|
|
||||||
platform: ios
|
|
||||||
profile: production_tv
|
|
||||||
submit: true
|
|
||||||
- name: 🤖 Android AAB
|
|
||||||
platform: android
|
|
||||||
profile: production
|
|
||||||
submit: true
|
|
||||||
- name: 🤖 Android APK
|
|
||||||
platform: android
|
|
||||||
profile: production-apk
|
|
||||||
submit: false
|
|
||||||
artifact_name: streamyfin-android-phone-apk
|
|
||||||
- name: 📺 Android TV APK
|
|
||||||
platform: android
|
|
||||||
profile: production-apk-tv
|
|
||||||
submit: false
|
|
||||||
artifact_name: streamyfin-android-tv-apk
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
show-progress: false
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-cache
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run submodule-reload
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
|
||||||
with:
|
|
||||||
eas-version: latest
|
|
||||||
token: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
eas-cache: true
|
|
||||||
|
|
||||||
# tvOS uses credentialsSource: local — restore the gitignored
|
|
||||||
# credentials.json + cert + provisioning profiles from secrets.
|
|
||||||
- name: 🔐 Restore tvOS signing credentials
|
|
||||||
if: matrix.profile == 'production_tv'
|
|
||||||
env:
|
|
||||||
EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
|
|
||||||
TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
|
|
||||||
TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
|
|
||||||
TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
mkdir -p certs profiles
|
|
||||||
printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
|
|
||||||
echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
|
|
||||||
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
|
|
||||||
echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
|
|
||||||
|
|
||||||
# Android Play submit needs the Google Play service account JSON.
|
|
||||||
- name: 🔐 Restore Google Play service account
|
|
||||||
if: matrix.platform == 'android' && matrix.submit
|
|
||||||
env:
|
|
||||||
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
|
|
||||||
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
|
|
||||||
|
|
||||||
# App Store Connect API key for iOS/tvOS submit (raw-PEM or base64).
|
|
||||||
- name: 🔐 Restore App Store Connect API key
|
|
||||||
if: matrix.platform == 'ios'
|
|
||||||
env:
|
|
||||||
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
|
|
||||||
run: |
|
|
||||||
if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
|
|
||||||
printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
|
|
||||||
else
|
|
||||||
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Submit builds: cloud build + auto-submit to the store ──
|
|
||||||
- name: 🚀 Build & submit (${{ matrix.name }})
|
|
||||||
if: matrix.submit
|
|
||||||
env:
|
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
|
|
||||||
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
|
||||||
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
|
||||||
run: |
|
|
||||||
eas build \
|
|
||||||
--platform ${{ matrix.platform }} \
|
|
||||||
--profile ${{ matrix.profile }} \
|
|
||||||
--auto-submit \
|
|
||||||
--non-interactive \
|
|
||||||
--wait
|
|
||||||
|
|
||||||
# ── Artifact builds: cloud build, then download + upload the APK ──
|
|
||||||
- name: 🏗️ Build artifact (${{ matrix.name }})
|
|
||||||
if: ${{ !matrix.submit }}
|
|
||||||
env:
|
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
run: |
|
|
||||||
eas build \
|
|
||||||
--platform ${{ matrix.platform }} \
|
|
||||||
--profile ${{ matrix.profile }} \
|
|
||||||
--non-interactive \
|
|
||||||
--wait \
|
|
||||||
--json > build-result.json
|
|
||||||
URL=$(node -e "const b=require('./build-result.json'); const x=Array.isArray(b)?b[0]:b; console.log(x.artifacts.applicationArchiveUrl)")
|
|
||||||
echo "Downloading artifact: $URL"
|
|
||||||
curl -fL "$URL" -o "${{ matrix.artifact_name }}.apk"
|
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact (${{ matrix.name }})
|
|
||||||
if: ${{ !matrix.submit }}
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: ${{ matrix.artifact_name }}
|
|
||||||
path: ${{ matrix.artifact_name }}.apk
|
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
# Draft a GitHub Release with the two APKs attached. The tag comes from the
|
|
||||||
# merged-in app version (app.json → expo.version), NOT the auto-incremented
|
|
||||||
# build number — so cutting a release is a deliberate version bump via PR.
|
|
||||||
github-release:
|
|
||||||
name: 📦 Draft GitHub Release
|
|
||||||
needs: build
|
|
||||||
if: ${{ !cancelled() }}
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
|
||||||
steps:
|
|
||||||
- name: 📥 Checkout code
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
show-progress: false
|
|
||||||
|
|
||||||
- name: 📦 Download APK artifacts from this run
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
mkdir -p apks
|
|
||||||
gh run download ${{ github.run_id }} --name streamyfin-android-phone-apk --dir apks
|
|
||||||
gh run download ${{ github.run_id }} --name streamyfin-android-tv-apk --dir apks
|
|
||||||
ls -la apks
|
|
||||||
|
|
||||||
- name: 📝 Draft release (tag = app.json version, not auto-bumped)
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
VERSION=$(node -e "console.log(require('./app.json').expo.version)")
|
|
||||||
TAG="v$VERSION"
|
|
||||||
echo "Release tag from merged app version: $TAG"
|
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
|
||||||
echo "Release $TAG exists — updating APK assets"
|
|
||||||
gh release upload "$TAG" apks/*.apk --clobber
|
|
||||||
else
|
|
||||||
echo "Creating draft release $TAG"
|
|
||||||
gh release create "$TAG" \
|
|
||||||
--draft \
|
|
||||||
--generate-notes \
|
|
||||||
--title "$TAG" \
|
|
||||||
apks/*.apk
|
|
||||||
fi
|
|
||||||
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
|
|
||||||
20
.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
|
||||||
|
|
||||||
@@ -17,11 +18,10 @@ web-build/
|
|||||||
/androidmobile
|
/androidmobile
|
||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
# Module-specific Builds
|
||||||
**/.gradle/
|
modules/mpv-player/android/build
|
||||||
|
modules/player/android
|
||||||
# Native module build outputs (any module)
|
modules/hls-downloader/android/build
|
||||||
modules/*/android/build/
|
|
||||||
|
|
||||||
# Generated Applications
|
# Generated Applications
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
@@ -66,15 +66,13 @@ 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/
|
||||||
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
|
|
||||||
!.eas/build/
|
|
||||||
!.eas/build/**
|
|
||||||
.claude/
|
.claude/
|
||||||
.agents/skills/**
|
.agents/skills/**
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|
||||||
# CI-injected Google Play service account key (written at build time)
|
|
||||||
google-service-account.json
|
|
||||||
|
|||||||
7
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.54.1",
|
"version": "0.54.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"versionCode": 93,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -143,8 +144,8 @@
|
|||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit",
|
"podName": "MPVKit-GPL",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -12,7 +7,6 @@ import { Alert, Platform, ScrollView, View } from "react-native";
|
|||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -107,7 +101,7 @@ export default function DownloadsPage() {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => bottomSheetModalRef.current?.present()}
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
className='px-2'
|
className='px-2'
|
||||||
>
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
@@ -122,7 +116,7 @@ export default function DownloadsPage() {
|
|||||||
}
|
}
|
||||||
}, [showMigration]);
|
}, [showMigration]);
|
||||||
|
|
||||||
const deleteMovies = () =>
|
const _deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -133,18 +127,18 @@ export default function DownloadsPage() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const _deleteShows = () =>
|
||||||
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(
|
||||||
otherMedia
|
otherMedia
|
||||||
.filter((item) => item.item.Type)
|
.filter((item) => item.item.Type)
|
||||||
@@ -168,9 +162,6 @@ export default function DownloadsPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllMedia = async () =>
|
|
||||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={true}>
|
<OfflineModeProvider isOffline={true}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -207,7 +198,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'>
|
||||||
@@ -265,42 +256,6 @@ export default function DownloadsPage() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className='p-4 space-y-4 mb-4'>
|
|
||||||
<Button color='purple' onPress={deleteMovies}>
|
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
|
||||||
</Button>
|
|
||||||
<Button color='purple' onPress={deleteShows}>
|
|
||||||
{t("home.downloads.delete_all_series_button")}
|
|
||||||
</Button>
|
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
|
||||||
{t("home.downloads.delete_all_other_media_button")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button color='red' onPress={deleteAllMedia}>
|
|
||||||
{t("home.downloads.delete_all_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</OfflineModeProvider>
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,19 +59,17 @@ function SettingsMobile() {
|
|||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
{Platform.OS !== "ios" && (
|
<View className='mb-4'>
|
||||||
<View className='mb-4'>
|
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
<ListItem
|
||||||
<ListItem
|
onPress={() =>
|
||||||
onPress={() =>
|
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
}
|
||||||
}
|
title={t("pairing.pair_with_phone")}
|
||||||
title={t("pairing.pair_with_phone")}
|
textColor='blue'
|
||||||
textColor='blue'
|
/>
|
||||||
/>
|
</ListGroup>
|
||||||
</ListGroup>
|
</View>
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||||
// Update local state with new values
|
// Update local state with new values
|
||||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
setUrl(newUrl);
|
setUrl(newUrl);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
@@ -77,7 +76,7 @@ const MobilePage: React.FC = () => {
|
|||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||||
const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function IndexLayout() {
|
|||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={setDropdownOpen}
|
onOpenChange={setDropdownOpen}
|
||||||
trigger={
|
trigger={
|
||||||
<View>
|
<View className='pl-1.5'>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ const Layout = () => {
|
|||||||
keyboardDismissMode='none'
|
keyboardDismissMode='none'
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarBounces: true,
|
tabBarBounces: true,
|
||||||
tabBarActiveTintColor: "#FFFFFF",
|
|
||||||
tabBarInactiveTintColor: "#9CA3AF",
|
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: TAB_LABEL_FONT_SIZE,
|
fontSize: TAB_LABEL_FONT_SIZE,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
|
|||||||
@@ -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" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -274,11 +274,6 @@ export default function DirectPlayerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
setItem(null);
|
|
||||||
setDownloadedItem(null);
|
|
||||||
// Clear the previous episode's stream so the loader gate stays closed
|
|
||||||
// until the new item's stream resolves (avoids a stale MPV source frame).
|
|
||||||
setStream(null);
|
|
||||||
fetchItemData();
|
fetchItemData();
|
||||||
}
|
}
|
||||||
}, [itemId, offline, api, user?.Id]);
|
}, [itemId, offline, api, user?.Id]);
|
||||||
@@ -321,12 +316,6 @@ export default function DirectPlayerPage() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure item matches the current itemId to avoid race conditions
|
|
||||||
if (item.Id !== itemId) {
|
|
||||||
setStreamStatus({ isLoading: false, isError: false });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline && downloadedItem?.mediaSource) {
|
if (offline && downloadedItem?.mediaSource) {
|
||||||
const url = downloadedItem.videoFilePath;
|
const url = downloadedItem.videoFilePath;
|
||||||
@@ -399,7 +388,6 @@ export default function DirectPlayerPage() {
|
|||||||
item,
|
item,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
downloadedItem,
|
downloadedItem,
|
||||||
offline,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -439,15 +427,21 @@ export default function DirectPlayerPage() {
|
|||||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
await getPlaystateApi(api).reportPlaybackStopped({
|
await getPlaystateApi(api).onPlaybackStopped({
|
||||||
playbackStopInfo: {
|
itemId: item.Id,
|
||||||
ItemId: item.Id,
|
mediaSourceId: mediaSourceId,
|
||||||
MediaSourceId: mediaSourceId,
|
positionTicks: currentTimeInTicks,
|
||||||
PositionTicks: currentTimeInTicks,
|
playSessionId: stream.sessionId,
|
||||||
PlaySessionId: stream.sessionId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
}, [
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
mediaSourceId,
|
||||||
|
stream,
|
||||||
|
progress,
|
||||||
|
offline,
|
||||||
|
revalidateProgressCache,
|
||||||
|
]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
// Update URL with final playback position before stopping
|
// Update URL with final playback position before stopping
|
||||||
@@ -465,10 +459,9 @@ export default function DirectPlayerPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
return () => {
|
return () => {
|
||||||
reportPlaybackStopped();
|
|
||||||
beforeRemoveListener();
|
beforeRemoveListener();
|
||||||
};
|
};
|
||||||
}, [navigation, stop, reportPlaybackStopped]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = useCallback(():
|
const currentPlayStateInfo = useCallback(():
|
||||||
| PlaybackProgressInfo
|
| PlaybackProgressInfo
|
||||||
@@ -832,10 +825,12 @@ export default function DirectPlayerPage() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** PiP handler for MPV */
|
||||||
const _onPictureInPictureChange = useCallback(
|
const _onPictureInPictureChange = useCallback(
|
||||||
(e: { nativeEvent: { isActive: boolean } }) => {
|
(e: { nativeEvent: { isActive: boolean } }) => {
|
||||||
const { isActive } = e.nativeEvent;
|
const { isActive } = e.nativeEvent;
|
||||||
setIsPipMode(isActive);
|
setIsPipMode(isActive);
|
||||||
|
// Hide controls when entering PiP
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
_setShowControls(false);
|
_setShowControls(false);
|
||||||
}
|
}
|
||||||
@@ -853,9 +848,6 @@ export default function DirectPlayerPage() {
|
|||||||
|
|
||||||
// Memoize video ref functions to prevent unnecessary re-renders
|
// Memoize video ref functions to prevent unnecessary re-renders
|
||||||
const startPictureInPicture = useCallback(async () => {
|
const startPictureInPicture = useCallback(async () => {
|
||||||
// Hide controls BEFORE entering PiP so the window captures a clean view
|
|
||||||
_setShowControls(false);
|
|
||||||
setIsPipMode(true);
|
|
||||||
return videoRef.current?.startPictureInPicture?.();
|
return videoRef.current?.startPictureInPicture?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1261,7 +1253,6 @@ export default function DirectPlayerPage() {
|
|||||||
nowPlayingMetadata={nowPlayingMetadata}
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
onPictureInPictureChange={_onPictureInPictureChange}
|
|
||||||
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);
|
||||||
|
|||||||
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,7 +1,10 @@
|
|||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
|
||||||
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
|
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
--- a/ios/BottomAccessoryProvider.swift
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
+++ b/ios/BottomAccessoryProvider.swift
|
||||||
@@ -8,7 +8,7 @@ import SwiftUI
|
@@ -8,7 +8,7 @@ import SwiftUI
|
||||||
self.delegate = delegate
|
self.delegate = delegate
|
||||||
}
|
}
|
||||||
@@ -11,10 +14,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
|
|||||||
@available(iOS 26.0, *)
|
@available(iOS 26.0, *)
|
||||||
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
|
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
|
||||||
var placementValue = "none"
|
var placementValue = "none"
|
||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
|
||||||
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
--- a/ios/TabView/NewTabView.swift
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
+++ b/ios/TabView/NewTabView.swift
|
||||||
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +56,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
+
|
+
|
||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
|
||||||
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
--- a/ios/TabViewImpl.swift
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
+++ b/ios/TabViewImpl.swift
|
||||||
@@ -281,7 +281,7 @@ extension View {
|
@@ -281,7 +281,7 @@ extension View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -66,10 +69,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
|
|||||||
if #available(iOS 26.0, macOS 26.0, *) {
|
if #available(iOS 26.0, macOS 26.0, *) {
|
||||||
if let behavior {
|
if let behavior {
|
||||||
self.tabBarMinimizeBehavior(behavior.convert())
|
self.tabBarMinimizeBehavior(behavior.convert())
|
||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
|
||||||
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
--- a/ios/TabViewProps.swift
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
+++ b/ios/TabViewProps.swift
|
||||||
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
|
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
|
||||||
case onScrollUp
|
case onScrollUp
|
||||||
case onScrollDown
|
case onScrollDown
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
diff --git a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
||||||
--- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
+++ b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
@@ -25,15 +25,14 @@ public extension RCTView {
|
@@ -25,15 +25,14 @@ public extension RCTView {
|
||||||
return rootView.recursivelyFindSubview(whereType: targetType);
|
return rootView.recursivelyFindSubview(whereType: targetType);
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
|
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
|
||||||
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
|
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
|
||||||
--- a/node_modules/react-native-udp/react-native-udp.podspec
|
--- a/react-native-udp.podspec
|
||||||
+++ b/node_modules/react-native-udp/react-native-udp.podspec
|
+++ b/react-native-udp.podspec
|
||||||
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
|
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
|
||||||
s.homepage = package_json["homepage"]
|
s.homepage = package_json["homepage"]
|
||||||
s.license = package_json["license"]
|
s.license = package_json["license"]
|
||||||
65
bun.lock
@@ -11,10 +11,9 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.14",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
"@react-navigation/material-top-tabs": "7.4.28",
|
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
@@ -84,7 +83,6 @@
|
|||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.4",
|
||||||
"react-native-tab-view": "4.3.0",
|
|
||||||
"react-native-text-ticker": "^1.15.0",
|
"react-native-text-ticker": "^1.15.0",
|
||||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -92,7 +90,7 @@
|
|||||||
"react-native-uuid": "^2.0.3",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-worklets": "0.8.3",
|
"react-native-worklets": "0.9.1",
|
||||||
"sonner-native": "0.21.2",
|
"sonner-native": "0.21.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
@@ -116,6 +114,11 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"patchedDependencies": {
|
||||||
|
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch",
|
||||||
|
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
|
||||||
|
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
|
||||||
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||||
|
|
||||||
@@ -197,11 +200,11 @@
|
|||||||
|
|
||||||
"@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="],
|
"@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="],
|
"@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="],
|
"@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="],
|
"@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="],
|
"@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="],
|
||||||
|
|
||||||
@@ -217,13 +220,13 @@
|
|||||||
|
|
||||||
"@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="],
|
"@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="],
|
"@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="],
|
"@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="],
|
"@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="],
|
"@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="],
|
"@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="],
|
||||||
|
|
||||||
@@ -365,7 +368,7 @@
|
|||||||
|
|
||||||
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
||||||
|
|
||||||
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
|
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
|
||||||
|
|
||||||
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
||||||
|
|
||||||
@@ -539,10 +542,6 @@
|
|||||||
|
|
||||||
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
|
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
|
||||||
|
|
||||||
"@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
|
|
||||||
|
|
||||||
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
|
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
|
||||||
|
|
||||||
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
|
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
|
||||||
@@ -1595,8 +1594,6 @@
|
|||||||
|
|
||||||
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
|
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
|
||||||
|
|
||||||
"react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
|
|
||||||
|
|
||||||
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
||||||
|
|
||||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
||||||
@@ -1611,7 +1608,7 @@
|
|||||||
|
|
||||||
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
|
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
|
||||||
|
|
||||||
"react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="],
|
"react-native-worklets": ["react-native-worklets@0.9.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", "@babel/plugin-transform-classes": "^7.28.6", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-optional-chaining": "^7.28.6", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.28.5", "convert-source-map": "^2.0.0", "semver": "^7.7.4" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.83 - 0.86" } }, "sha512-kb6lGtBI5Ap41tvBPM09Np472r2GXuJ+jRApIFy1eXBk699eChG3U+lyqRC2/wz/VDpaJAy6i5XPcceNOoH3mA=="],
|
||||||
|
|
||||||
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
||||||
|
|
||||||
@@ -2003,16 +2000,20 @@
|
|||||||
|
|
||||||
"@react-native/babel-preset/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
"@react-native/babel-preset/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
||||||
|
|
||||||
|
"@react-native/babel-preset/@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="],
|
||||||
|
|
||||||
|
"@react-native/babel-preset/@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="],
|
||||||
|
|
||||||
|
"@react-native/babel-preset/@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="],
|
||||||
|
|
||||||
|
"@react-native/babel-preset/@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="],
|
||||||
|
|
||||||
"@react-native/codegen/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
"@react-native/codegen/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
"@react-native/community-cli-plugin/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||||
|
|
||||||
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
||||||
|
|
||||||
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
|
||||||
|
|
||||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||||
|
|
||||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||||
@@ -2029,6 +2030,14 @@
|
|||||||
|
|
||||||
"babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
"babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||||
|
|
||||||
|
"babel-preset-expo/@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="],
|
||||||
|
|
||||||
|
"babel-preset-expo/@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="],
|
||||||
|
|
||||||
|
"babel-preset-expo/@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="],
|
||||||
|
|
||||||
|
"babel-preset-expo/@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="],
|
||||||
|
|
||||||
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
@@ -2231,14 +2240,6 @@
|
|||||||
|
|
||||||
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
||||||
|
|
||||||
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
|
||||||
|
|
||||||
"@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
|
||||||
|
|
||||||
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||||
@@ -2351,14 +2352,6 @@
|
|||||||
|
|
||||||
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||||
|
|
||||||
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
||||||
|
|
||||||
"@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
||||||
|
|
||||||
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
|
|
||||||
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
@@ -211,6 +217,24 @@ const PlatformDropdownComponent = ({
|
|||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
|
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
||||||
|
// `matchContents` doesn't help here: it reports the native Menu's size via
|
||||||
|
// setStyleSize and overrides any explicit size. Instead we measure the
|
||||||
|
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
||||||
|
const [triggerSize, setTriggerSize] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = e.nativeEvent.layout;
|
||||||
|
setTriggerSize((prev) =>
|
||||||
|
prev && prev.width === width && prev.height === height
|
||||||
|
? prev
|
||||||
|
: { width, height },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
@@ -241,11 +265,25 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||||
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||||
|
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||||
|
// height depends on the parent while the parent depends on the Host — a
|
||||||
|
// circular dependency that collapses to 0 for any selector nested more than
|
||||||
|
// one level deep (so only the first, shallowest dropdown stays visible).
|
||||||
|
// Giving the wrapper the measured size breaks the cycle; the Host then
|
||||||
|
// fills a concrete box.
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={triggerSize ?? { opacity: 0 }}>
|
||||||
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||||
|
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||||
|
sizes to the trigger's content rather than to its parent. */}
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||||
|
pointerEvents='none'
|
||||||
|
aria-hidden
|
||||||
|
onLayout={handleMeasureTrigger}
|
||||||
|
>
|
||||||
{trigger}
|
{trigger}
|
||||||
</View>
|
</View>
|
||||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import {
|
import {
|
||||||
type ChapterEntry,
|
type ChapterEntry,
|
||||||
chapterStartsMs,
|
chapterStartsMs,
|
||||||
@@ -39,7 +38,6 @@ function ChapterListComponent({
|
|||||||
onClose,
|
onClose,
|
||||||
}: ChapterListProps) {
|
}: ChapterListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const safeArea = useControlsSafeAreaInsets();
|
|
||||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||||
|
|
||||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||||
@@ -76,22 +74,9 @@ function ChapterListComponent({
|
|||||||
transparent
|
transparent
|
||||||
animationType='slide'
|
animationType='slide'
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
// iOS defaults <Modal> to portrait-only; without this it rotates the app
|
|
||||||
// back to portrait when opened from the landscape player. Android ignores it.
|
|
||||||
supportedOrientations={["portrait", "landscape"]}
|
|
||||||
>
|
>
|
||||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||||
<Pressable
|
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
style={[
|
|
||||||
styles.sheet,
|
|
||||||
{
|
|
||||||
marginLeft: safeArea.left,
|
|
||||||
marginRight: safeArea.right,
|
|
||||||
paddingBottom: safeArea.bottom,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -172,12 +157,14 @@ const styles = StyleSheet.create({
|
|||||||
backdrop: {
|
backdrop: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.6)",
|
||||||
},
|
},
|
||||||
sheet: {
|
sheet: {
|
||||||
backgroundColor: Colors.background,
|
backgroundColor: Colors.background,
|
||||||
borderTopLeftRadius: 16,
|
borderTopLeftRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
borderTopRightRadius: 16,
|
||||||
maxHeight: "70%",
|
maxHeight: "70%",
|
||||||
|
paddingBottom: 24,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -37,12 +37,11 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={
|
style={{
|
||||||
Platform.isTV
|
width: `${progress}%`,
|
||||||
? { width: `${progress}%`, backgroundColor: "#ffffff" }
|
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
|
||||||
: { width: `${progress}%` }
|
}}
|
||||||
}
|
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
|
||||||
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import { type PropsWithChildren, useCallback } from "react";
|
import { type PropsWithChildren, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -150,7 +149,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
@@ -184,13 +182,11 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
const options: string[] = [
|
const options: string[] = [
|
||||||
t("common.mark_as_played"),
|
"Mark as Played",
|
||||||
t("common.mark_as_not_played"),
|
"Mark as Not Played",
|
||||||
isFavorite
|
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||||
? t("music.track_options.remove_from_favorites")
|
...(isOffline ? ["Delete Download"] : []),
|
||||||
: t("music.track_options.add_to_favorites"),
|
"Cancel",
|
||||||
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
|
||||||
t("common.cancel"),
|
|
||||||
];
|
];
|
||||||
const cancelButtonIndex = options.length - 1;
|
const cancelButtonIndex = options.length - 1;
|
||||||
const destructiveButtonIndex = isOffline
|
const destructiveButtonIndex = isOffline
|
||||||
@@ -223,7 +219,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
isOffline,
|
isOffline,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
item.Id,
|
item.Id,
|
||||||
t,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const HomeMobile = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
|
className='ml-1.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
|
|||||||
@@ -37,20 +37,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
return { ...item, People: people } as BaseItemDto;
|
return { ...item, People: people } as BaseItemDto;
|
||||||
}, [item, people]);
|
}, [item, people]);
|
||||||
|
|
||||||
// Jellyfin can list the same person several times (e.g. an actor also
|
const topPeople = useMemo(() => people.slice(0, 3), [people]);
|
||||||
// credited as writer). Dedupe by Id so the same actor section isn't rendered
|
|
||||||
// twice and we still surface 3 distinct people.
|
|
||||||
const topPeople = useMemo(() => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const unique: BaseItemPerson[] = [];
|
|
||||||
for (const person of people) {
|
|
||||||
if (!person.Id || seen.has(person.Id)) continue;
|
|
||||||
seen.add(person.Id);
|
|
||||||
unique.push(person);
|
|
||||||
if (unique.length >= 3) break;
|
|
||||||
}
|
|
||||||
return unique;
|
|
||||||
}, [people]);
|
|
||||||
|
|
||||||
const renderActorSection = useCallback(
|
const renderActorSection = useCallback(
|
||||||
(person: BaseItemPerson, idx: number, total: number) => {
|
(person: BaseItemPerson, idx: number, total: number) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Pair with Phone */}
|
{/* Pair with Phone */}
|
||||||
{Platform.OS !== "ios" && onStartPairing && (
|
{onStartPairing && (
|
||||||
<View>
|
<View>
|
||||||
<Button
|
<Button
|
||||||
onPress={onStartPairing}
|
onPress={onStartPairing}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const hasMovies = movieResults && movieResults.length > 0;
|
||||||
|
const hasTv = tvResults && tvResults.length > 0;
|
||||||
|
const hasPersons = personResults && personResults.length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{/* No section requests `hasTVPreferredFocus`: the native search field
|
|
||||||
keeps focus while typing, otherwise the first result would re-grab
|
|
||||||
focus on every keystroke as results re-render. The user navigates
|
|
||||||
down to the grid manually. */}
|
|
||||||
<TVJellyseerrMovieSection
|
<TVJellyseerrMovieSection
|
||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={false}
|
isFirstSection={hasMovies}
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={false}
|
isFirstSection={!hasMovies && hasTv}
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={false}
|
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -235,13 +235,10 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
module). It renders the native search bar + grid keyboard and
|
module). It renders the native search bar + grid keyboard and
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
forwards typed text into the existing query pipeline via setSearch;
|
||||||
our own results grid renders below. */}
|
our own results grid renders below. */}
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
|
||||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
|
||||||
margins squeeze the bar's width and clip that trailing hint, so let
|
|
||||||
the native view span the full width and own its own insets. */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
|
marginHorizontal: HORIZONTAL_PADDING,
|
||||||
height: SEARCH_AREA_HEIGHT,
|
height: SEARCH_AREA_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -283,17 +280,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
{/* Library Search Results */}
|
{/* Library Search Results */}
|
||||||
{isLibraryMode && !loading && (
|
{isLibraryMode && !loading && (
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
{sections.map((section) => (
|
{sections.map((section, index) => (
|
||||||
<TVSearchSection
|
<TVSearchSection
|
||||||
key={section.key}
|
key={section.key}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
items={section.items!}
|
items={section.items!}
|
||||||
orientation={section.orientation || "vertical"}
|
orientation={section.orientation || "vertical"}
|
||||||
// Never auto-focus a result. The native search field owns focus
|
isFirstSection={index === 0}
|
||||||
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
|
||||||
// every keystroke as results re-render. User navigates down to the
|
|
||||||
// grid manually.
|
|
||||||
isFirstSection={false}
|
|
||||||
onItemPress={onItemPress}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
|
|||||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
contentInset={{
|
||||||
// contentOffset only applies on initial mount; since this FlatList is
|
left: edgePadding,
|
||||||
// reused across searches (stable key), a second search left the inset
|
right: edgePadding,
|
||||||
// without the offset and the grid snapped flush to the left edge.
|
}}
|
||||||
|
contentOffset={{ x: -edgePadding, y: 0 }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: edgePadding,
|
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,12 +31,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
// Read the live (cached) downloads DB inside the query rather than the
|
const router = useRouter();
|
||||||
// provider's downloadedItems snapshot, so refetches after
|
|
||||||
// updateDownloadedItem() reflect the latest state instead of a stale
|
|
||||||
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
|
|
||||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</OptionGroup>
|
</OptionGroup>
|
||||||
|
|
||||||
<OptionGroup title={t("library.options.options_title")}>
|
<OptionGroup title='Options'>
|
||||||
<ToggleItem
|
<ToggleItem
|
||||||
label={t("library.options.show_titles")}
|
label={t("library.options.show_titles")}
|
||||||
value={settings.showTitles}
|
value={settings.showTitles}
|
||||||
|
|||||||
@@ -196,10 +196,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
|
||||||
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
trigger={
|
trigger={
|
||||||
|
|||||||
@@ -229,10 +229,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
disabled={
|
disabled={!settings.autoPlayNextEpisode}
|
||||||
!settings.autoPlayNextEpisode ||
|
|
||||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
|
|||||||
@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
thumbWidth={0}
|
thumbWidth={0}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
renderBubble={() => null}
|
|
||||||
renderThumb={() => null}
|
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
}}
|
}}
|
||||||
theme={{
|
theme={{
|
||||||
minimumTrackTintColor: "#FDFDFD",
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
maximumTrackTintColor: "#5A5A5A",
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
|
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||||
|
bubbleTextColor: "transparent", // Hide the value text
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { type SharedValue } from "react-native-reanimated";
|
import { type SharedValue } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
@@ -75,6 +75,9 @@ interface BottomControlsProps {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chapter props
|
||||||
|
chapterPositions?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
@@ -108,10 +111,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
|
chapterPositions = [],
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||||
|
|
||||||
// Only expose chapter UI when there are at least two real markers.
|
// Only expose chapter UI when there are at least two real markers.
|
||||||
@@ -142,9 +146,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: insets.right,
|
right:
|
||||||
left: insets.left,
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||||
bottom: Math.max(insets.bottom - 17, 0),
|
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||||
|
bottom:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true)
|
||||||
|
? Math.max(insets.bottom - 17, 0)
|
||||||
|
: 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className={"flex flex-col px-2"}
|
className={"flex flex-col px-2"}
|
||||||
@@ -180,6 +188,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
|
{hasChapters && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setChapterListVisible(true)}
|
||||||
|
hitSlop={10}
|
||||||
|
className='justify-center mr-4'
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={t("chapters.open")}
|
||||||
|
>
|
||||||
|
<Ionicons name='bookmarks' size={24} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -211,17 +230,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
onPress={handleNextEpisodeManual}
|
onPress={handleNextEpisodeManual}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasChapters && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setChapterListVisible(true)}
|
|
||||||
hitSlop={10}
|
|
||||||
className='justify-center ml-4'
|
|
||||||
accessibilityRole='button'
|
|
||||||
accessibilityLabel={t("chapters.open")}
|
|
||||||
>
|
|
||||||
<Ionicons name='bookmarks' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
|
|||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
thumbWidth={0}
|
thumbWidth={0}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
renderBubble={() => null}
|
|
||||||
renderThumb={() => null}
|
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
}}
|
}}
|
||||||
theme={{
|
theme={{
|
||||||
minimumTrackTintColor: "#FDFDFD",
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
maximumTrackTintColor: "#5A5A5A",
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
|
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||||
|
bubbleTextColor: "transparent", // Hide the value text
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
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";
|
||||||
import BrightnessSlider from "./BrightnessSlider";
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "50%",
|
top: "50%",
|
||||||
left: insets.left,
|
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||||
right: insets.right,
|
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export const Controls: FC<Props> = ({
|
|||||||
hasNextChapter,
|
hasNextChapter,
|
||||||
goToPreviousChapter,
|
goToPreviousChapter,
|
||||||
goToNextChapter,
|
goToNextChapter,
|
||||||
|
chapterPositions,
|
||||||
} = useChapterNavigation({
|
} = useChapterNavigation({
|
||||||
chapters: item.Chapters,
|
chapters: item.Chapters,
|
||||||
progress,
|
progress,
|
||||||
@@ -365,9 +366,7 @@ export const Controls: FC<Props> = ({
|
|||||||
{ applyLanguagePreferences: true },
|
{ applyLanguagePreferences: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
const queryParams = new URLSearchParams({
|
||||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
|
||||||
router.setParams({
|
|
||||||
...(offline && { offline: "true" }),
|
...(offline && { offline: "true" }),
|
||||||
itemId: item.Id ?? "",
|
itemId: item.Id ?? "",
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
@@ -376,17 +375,11 @@ export const Controls: FC<Props> = ({
|
|||||||
bitrateValue: bitrateValue?.toString(),
|
bitrateValue: bitrateValue?.toString(),
|
||||||
playbackPosition:
|
playbackPosition:
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
});
|
}).toString();
|
||||||
|
|
||||||
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
},
|
},
|
||||||
[
|
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||||
settings,
|
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
|
||||||
mediaSource,
|
|
||||||
bitrateValue,
|
|
||||||
router,
|
|
||||||
offline,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
const goToPreviousItem = useCallback(() => {
|
||||||
@@ -592,6 +585,7 @@ export const Controls: FC<Props> = ({
|
|||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
|
chapterPositions={chapterPositions}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at", { time: getFinishTime() })}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
{t("player.ends_at", { time: getFinishTime() })}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
@@ -16,10 +17,10 @@ import {
|
|||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
type SeasonIndexState,
|
type SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
getDownloadedEpisodesForSeason,
|
getDownloadedEpisodesForSeason,
|
||||||
getDownloadedSeasonNumbers,
|
getDownloadedSeasonNumbers,
|
||||||
@@ -45,7 +46,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||||
};
|
};
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const { settings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
// Set the initial season index
|
// Set the initial season index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,11 +59,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Read the live (cached) downloads DB inside the query rather than the
|
|
||||||
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
|
||||||
// provider refreshKey, so after updateDownloadedItem() invalidates
|
|
||||||
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
|
||||||
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
|
||||||
const { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||||
@@ -185,9 +182,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
|||||||
backgroundColor: "black",
|
backgroundColor: "black",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
paddingTop: insets.top,
|
paddingTop:
|
||||||
paddingLeft: insets.left,
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||||
paddingRight: insets.right,
|
paddingLeft:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||||
|
paddingRight:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import type {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||||
import DropdownView from "./dropdown/DropdownView";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
@@ -57,8 +58,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
const { orientation, lockOrientation } = useOrientation();
|
const { orientation, lockOrientation } = useOrientation();
|
||||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||||
@@ -97,9 +99,10 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: insets.top,
|
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||||
left: insets.left,
|
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||||
right: insets.right,
|
right:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { HEADER_LAYOUT } from "./constants";
|
import { HEADER_LAYOUT } from "./constants";
|
||||||
|
|
||||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const safeInsets = useControlsSafeAreaInsets();
|
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
|
|
||||||
const opacity = useSharedValue(0);
|
const opacity = useSharedValue(0);
|
||||||
@@ -268,8 +268,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
left: Math.max(insets.left, 48) + 20,
|
left: Math.max(insets.left, 48) + 20,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
top:
|
||||||
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
(settings?.safeAreaInControlsEnabled ?? true)
|
||||||
|
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
||||||
|
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||||
|
left:
|
||||||
|
(settings?.safeAreaInControlsEnabled ?? true)
|
||||||
|
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
||||||
|
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
const textStyle = Platform.isTV
|
const textStyle = Platform.isTV
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
@@ -17,8 +16,6 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const getFinishTime = () => {
|
const getFinishTime = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// remainingTime is in ms
|
// remainingTime is in ms
|
||||||
@@ -40,7 +37,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
-{formatTimeString(remainingTime, "ms")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||||
{t("player.ends_at", { time: getFinishTime() })}
|
ends at {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
49
eas.json
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 16.0.0",
|
"version": ">= 9.1.0"
|
||||||
"appVersionSource": "remote"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
@@ -52,70 +51,44 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"bun": "1.3.5",
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"channel": "0.54.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest",
|
"image": "latest"
|
||||||
"config": "android-production.yml"
|
|
||||||
},
|
|
||||||
"ios": {
|
|
||||||
"config": "ios-production.yml"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"bun": "1.3.5",
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"channel": "0.54.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest",
|
"image": "latest"
|
||||||
"config": "android-production-apk.yml"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"bun": "1.3.5",
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"channel": "0.54.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest",
|
"image": "latest"
|
||||||
"config": "android-production-tv.yml"
|
|
||||||
},
|
},
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_TV": "1"
|
"EXPO_TV": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production_tv": {
|
"production_tv": {
|
||||||
"bun": "1.3.5",
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"autoIncrement": true,
|
"channel": "0.54.0",
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_TV": "1"
|
"EXPO_TV": "1"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"credentialsSource": "local",
|
"credentialsSource": "local"
|
||||||
"config": "ios-production.yml"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
"production": {
|
"production": {},
|
||||||
"ios": {
|
"production_tv": {}
|
||||||
"appleTeamId": "MWD5K362T8",
|
|
||||||
"ascAppId": "6593660679"
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"serviceAccountKeyPath": "./google-service-account.json",
|
|
||||||
"track": "internal",
|
|
||||||
"releaseStatus": "completed"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"production_tv": {
|
|
||||||
"ios": {
|
|
||||||
"appleTeamId": "MWD5K362T8",
|
|
||||||
"ascAppId": "6593660679"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import {
|
|
||||||
type EdgeInsets,
|
|
||||||
useSafeAreaInsets,
|
|
||||||
} from "react-native-safe-area-context";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns safe-area insets to apply to in-player controls, honoring the
|
|
||||||
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
|
|
||||||
* returns zero insets so controls can sit flush against the screen edges.
|
|
||||||
*/
|
|
||||||
export const useControlsSafeAreaInsets = (): EdgeInsets => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { File, Paths } from "expo-file-system";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
@@ -13,28 +12,36 @@ const useImageStorage = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
|
||||||
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
|
||||||
* resolves to an empty payload under RN's New Architecture.
|
|
||||||
*/
|
|
||||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
const tmpFile = new File(
|
let blob: Blob;
|
||||||
Paths.cache,
|
|
||||||
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
// Fetch the data from the URL
|
||||||
idempotent: true,
|
const response = await fetch(url);
|
||||||
});
|
blob = await response.blob();
|
||||||
return await downloaded.base64();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Error fetching image:", error);
|
console.warn("Error fetching image:", error);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
if (tmpFile.exists) tmpFile.delete();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a FileReader instance
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
// Convert blob to base64
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === "string") {
|
||||||
|
// Extract the base64 string (remove the data URL prefix)
|
||||||
|
const base64 = reader.result.split(",")[1];
|
||||||
|
resolve(base64);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to convert image to base64"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveImage = useCallback(
|
const saveImage = useCallback(
|
||||||
|
|||||||
@@ -109,35 +109,30 @@ export const usePlaybackManager = ({
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive prev/next from the current item's real position in the adjacent
|
|
||||||
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
|
||||||
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
|
||||||
* episode it can still return the current item as the first/last entry — so
|
|
||||||
* length-based indexing wrongly surfaces the current episode as "previous".
|
|
||||||
*/
|
|
||||||
const currentIndex = useMemo(
|
|
||||||
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
|
||||||
[adjacentItems, item],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** A neighbour is only navigable if it has an actual media file (not a
|
|
||||||
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
|
||||||
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
|
||||||
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
|
||||||
|
|
||||||
const previousItem = useMemo(() => {
|
const previousItem = useMemo(() => {
|
||||||
if (!adjacentItems || currentIndex <= 0) return null;
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
const candidate = adjacentItems[currentIndex - 1];
|
return null;
|
||||||
return isNavigable(candidate) ? candidate : null;
|
}
|
||||||
}, [adjacentItems, currentIndex, item]);
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[0];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
/** The next item in the series */
|
/** The next item in the series */
|
||||||
const nextItem = useMemo(() => {
|
const nextItem = useMemo(() => {
|
||||||
if (!adjacentItems || currentIndex < 0) return null;
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
const candidate = adjacentItems[currentIndex + 1];
|
return null;
|
||||||
return isNavigable(candidate) ? candidate : null;
|
}
|
||||||
}, [adjacentItems, currentIndex, item]);
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[2];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reports playback progress.
|
* Reports playback progress.
|
||||||
|
|||||||
@@ -1,20 +1,46 @@
|
|||||||
apply plugin: 'expo-module-gradle-plugin'
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'kotlin-android'
|
||||||
|
}
|
||||||
|
|
||||||
group = 'expo.modules.backgrounddownloader'
|
group = 'expo.modules.backgrounddownloader'
|
||||||
version = '1.0.0'
|
version = '1.0.0'
|
||||||
|
|
||||||
expoModule {
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
canBePublished false
|
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||||
}
|
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useDefaultAndroidSdkVersions()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "expo.modules.backgrounddownloader"
|
namespace "expo.modules.backgrounddownloader"
|
||||||
defaultConfig {
|
|
||||||
versionCode 1
|
compileOptions {
|
||||||
versionName "1.0.0"
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -236,43 +236,37 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach surface and ensure video output is active.
|
* Attach surface and re-enable video output.
|
||||||
*
|
* Based on Findroid's implementation.
|
||||||
* During PiP transitions, the surface is destroyed and recreated by Android.
|
|
||||||
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
|
|
||||||
* resumes immediately when the new surface is attached — avoiding the black
|
|
||||||
* screen that occurs when the VO is fully re-initialized via setOptionString.
|
|
||||||
*/
|
*/
|
||||||
fun attachSurface(surface: Surface) {
|
fun attachSurface(surface: Surface) {
|
||||||
this.surface = surface
|
this.surface = surface
|
||||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.attachSurface(surface)
|
MPVLib.attachSurface(surface)
|
||||||
|
// Re-enable video output after attaching surface (Findroid approach)
|
||||||
MPVLib.setOptionString("force-window", "yes")
|
MPVLib.setOptionString("force-window", "yes")
|
||||||
// Read back vo to confirm it's still active
|
MPVLib.setOptionString("vo", voDriver)
|
||||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
|
||||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detach surface without killing the VO pipeline.
|
* Detach surface and disable video output.
|
||||||
*
|
* Based on Findroid's implementation.
|
||||||
* The previous approach (vo=null / force-window=no) destroyed the entire video
|
|
||||||
* output pipeline on every surface transition. During PiP mode, the rapid
|
|
||||||
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
|
|
||||||
* did not properly re-initialize rendering into the new PiP surface.
|
|
||||||
*
|
|
||||||
* By keeping the VO alive, frames are simply dropped while no surface is
|
|
||||||
* attached, and rendering resumes immediately when the new surface arrives.
|
|
||||||
*/
|
*/
|
||||||
fun detachSurface() {
|
fun detachSurface() {
|
||||||
this.surface = null
|
this.surface = null
|
||||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
|
try {
|
||||||
|
// Disable video output before detaching surface (Findroid approach)
|
||||||
|
MPVLib.setOptionString("vo", "null")
|
||||||
|
MPVLib.setOptionString("force-window", "no")
|
||||||
|
Log.i(TAG, "Video output disabled before surface detach")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to disable video output: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
MPVLib.detachSurface()
|
MPVLib.detachSurface()
|
||||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
|
||||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,24 +277,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun updateSurfaceSize(width: Int, height: Int) {
|
fun updateSurfaceSize(width: Int, height: Int) {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
||||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
Log.i(TAG, "Surface size updated: ${width}x$height")
|
||||||
} else {
|
|
||||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force mpv to render a frame to the current surface.
|
|
||||||
* Steps forward one frame then seeks back to the original position.
|
|
||||||
* Used after PiP entry to work around mpv stopping pixel output.
|
|
||||||
*/
|
|
||||||
fun forceRedraw() {
|
|
||||||
if (!isRunning) return
|
|
||||||
val pos = cachedPosition
|
|
||||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
|
||||||
MPVLib.command(arrayOf("frame-step"))
|
|
||||||
if (pos > 0) {
|
|
||||||
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,7 +692,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// dropped), so we (re)apply here for embedded and external alike.
|
// dropped), so we (re)apply here for embedded and external alike.
|
||||||
// This is what makes a carried-over subtitle show up on the next
|
// This is what makes a carried-over subtitle show up on the next
|
||||||
// episode without a manual re-selection.
|
// episode without a manual re-selection.
|
||||||
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
if (initialAudioId != null && initialAudioId > 0) {
|
||||||
|
setAudioTrack(initialAudioId)
|
||||||
|
}
|
||||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||||
|
|
||||||
if (!isReadyToSeek) {
|
if (!isReadyToSeek) {
|
||||||
|
|||||||
@@ -198,7 +198,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ package expo.modules.mpvplayer
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Rect
|
import android.os.Build
|
||||||
import android.graphics.SurfaceTexture
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.TextureView
|
import android.view.SurfaceHolder
|
||||||
import android.view.View
|
import android.view.SurfaceView
|
||||||
import android.view.ViewGroup
|
import android.widget.FrameLayout
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
import expo.modules.kotlin.views.ExpoView
|
import expo.modules.kotlin.views.ExpoView
|
||||||
@@ -31,10 +28,10 @@ data class VideoLoadConfig(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||||
* Uses TextureView for reliable Picture-in-Picture support.
|
* This mirrors the iOS MpvPlayerView implementation.
|
||||||
*/
|
*/
|
||||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||||
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MpvPlayerView"
|
private const val TAG = "MpvPlayerView"
|
||||||
@@ -46,9 +43,8 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
val onProgress by EventDispatcher()
|
val onProgress by EventDispatcher()
|
||||||
val onError by EventDispatcher()
|
val onError by EventDispatcher()
|
||||||
val onTracksReady by EventDispatcher()
|
val onTracksReady by EventDispatcher()
|
||||||
val onPictureInPictureChange by EventDispatcher()
|
|
||||||
|
|
||||||
private var textureView: TextureView
|
private var surfaceView: SurfaceView
|
||||||
private var renderer: MPVLayerRenderer? = null
|
private var renderer: MPVLayerRenderer? = null
|
||||||
private var pipController: PiPController? = null
|
private var pipController: PiPController? = null
|
||||||
|
|
||||||
@@ -60,29 +56,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private var pendingConfig: VideoLoadConfig? = null
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
private var rendererStarted: Boolean = false
|
private var rendererStarted: Boolean = false
|
||||||
private var pendingSurface: Surface? = null
|
private var pendingSurface: Surface? = null
|
||||||
private var surfaceTexture: SurfaceTexture? = null
|
|
||||||
|
|
||||||
// PiP state tracking
|
|
||||||
private var isWaitingForPiPTransition: Boolean = false
|
|
||||||
private var isPiPSurfaceForced: Boolean = false
|
|
||||||
private val pipHandler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
// Create TextureView for video rendering (composites into app window for PiP support)
|
// Create SurfaceView for video rendering
|
||||||
textureView = TextureView(context).apply {
|
surfaceView = SurfaceView(context).apply {
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
FrameLayout.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
surfaceTextureListener = this@MpvPlayerView
|
holder.addCallback(this@MpvPlayerView)
|
||||||
}
|
}
|
||||||
addView(textureView)
|
addView(surfaceView)
|
||||||
|
|
||||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||||
pipController = PiPController(context, appContext)
|
pipController = PiPController(context, appContext)
|
||||||
pipController?.setPlayerView(textureView)
|
pipController?.setPlayerView(surfaceView)
|
||||||
pipController?.delegate = object : PiPController.Delegate {
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
play()
|
play()
|
||||||
@@ -95,23 +85,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
override fun onSeekBy(seconds: Double) {
|
override fun onSeekBy(seconds: Double) {
|
||||||
seekBy(seconds)
|
seekBy(seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
|
||||||
if (isInPiP) {
|
|
||||||
if (!isWaitingForPiPTransition) {
|
|
||||||
isWaitingForPiPTransition = true
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
|
||||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
|
||||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
isWaitingForPiPTransition = false
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
|
||||||
restoreFromPiP()
|
|
||||||
}
|
|
||||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||||
@@ -129,10 +102,13 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
try {
|
try {
|
||||||
renderer?.start(voDriver ?: "gpu-next")
|
renderer?.start(voDriver ?: "gpu-next")
|
||||||
rendererStarted = true
|
rendererStarted = true
|
||||||
|
Log.i(TAG, "Renderer started with vo=$voDriver")
|
||||||
|
|
||||||
|
// If surface was created before renderer started, attach it now
|
||||||
pendingSurface?.let { surface ->
|
pendingSurface?.let { surface ->
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
pendingSurface = null
|
pendingSurface = null
|
||||||
|
Log.i(TAG, "Attached pending surface after renderer start")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||||
@@ -140,18 +116,18 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TextureView.SurfaceTextureListener
|
// MARK: - SurfaceHolder.Callback
|
||||||
|
|
||||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
this.surfaceTexture = surfaceTexture
|
Log.i(TAG, "Surface created")
|
||||||
val surface = Surface(surfaceTexture)
|
|
||||||
surfaceTexture.setDefaultBufferSize(width, height)
|
|
||||||
surfaceReady = true
|
surfaceReady = true
|
||||||
|
|
||||||
if (rendererStarted) {
|
if (rendererStarted) {
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(holder.surface)
|
||||||
} else {
|
} else {
|
||||||
pendingSurface = surface
|
// Renderer not started yet - store surface to attach after start
|
||||||
|
pendingSurface = holder.surface
|
||||||
|
Log.i(TAG, "Surface created before renderer started, storing as pending")
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a pending load, execute it now
|
// If we have a pending load, execute it now
|
||||||
@@ -162,20 +138,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||||
surfaceTexture.setDefaultBufferSize(width, height)
|
Log.i(TAG, "Surface changed: ${width}x${height}")
|
||||||
|
// Update MPV with the new surface size (Findroid approach)
|
||||||
renderer?.updateSurfaceSize(width, height)
|
renderer?.updateSurfaceSize(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
this.surfaceTexture = null
|
Log.i(TAG, "Surface destroyed")
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
renderer?.detachSurface()
|
renderer?.detachSurface()
|
||||||
return false // mpv manages the SurfaceTexture
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
|
||||||
// Called every frame — no action needed, mpv drives rendering directly
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Loading
|
// MARK: - Video Loading
|
||||||
@@ -267,60 +239,11 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// MARK: - Picture in Picture
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
isWaitingForPiPTransition = true
|
Log.i(TAG, "startPictureInPicture called")
|
||||||
pipController?.startPictureInPicture()
|
pipController?.startPictureInPicture()
|
||||||
|
|
||||||
// Resize buffer to match PiP window after animation settles
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
|
||||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
|
||||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
|
||||||
* visible rect so mpv renders at the PiP window's actual dimensions.
|
|
||||||
*/
|
|
||||||
private fun forcePiPBufferSize() {
|
|
||||||
if (!isWaitingForPiPTransition || !surfaceReady) return
|
|
||||||
|
|
||||||
val rect = Rect()
|
|
||||||
textureView.getGlobalVisibleRect(rect)
|
|
||||||
val visW = rect.width()
|
|
||||||
val visH = rect.height()
|
|
||||||
val vw = textureView.width
|
|
||||||
val vh = textureView.height
|
|
||||||
|
|
||||||
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
|
||||||
|
|
||||||
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
|
||||||
renderer?.updateSurfaceSize(visW, visH)
|
|
||||||
|
|
||||||
// Force TextureView layout to match PiP visible area.
|
|
||||||
// layoutParams alone doesn't work during PiP because the parent
|
|
||||||
// never re-lays out its children.
|
|
||||||
textureView.measure(
|
|
||||||
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
|
||||||
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
|
||||||
)
|
|
||||||
textureView.layout(0, 0, visW, visH)
|
|
||||||
isPiPSurfaceForced = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restoreFromPiP() {
|
|
||||||
if (!isPiPSurfaceForced) return
|
|
||||||
isPiPSurfaceForced = false
|
|
||||||
|
|
||||||
val lp = textureView.layoutParams
|
|
||||||
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
textureView.layoutParams = lp
|
|
||||||
textureView.requestLayout()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
isWaitingForPiPTransition = false
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +368,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPauseChanged(isPaused: Boolean) {
|
override fun onPauseChanged(isPaused: Boolean) {
|
||||||
|
// Sync PiP playback rate
|
||||||
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
|
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
|
||||||
|
|
||||||
onPlaybackStateChange(mapOf(
|
onPlaybackStateChange(mapOf(
|
||||||
@@ -470,6 +394,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onVideoDimensionsChanged(width: Int, height: Int) {
|
override fun onVideoDimensionsChanged(width: Int, height: Int) {
|
||||||
|
// Update PiP controller with video dimensions for proper aspect ratio
|
||||||
pipController?.setVideoDimensions(width, height)
|
pipController?.setVideoDimensions(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,12 +405,9 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
isWaitingForPiPTransition = false
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
surfaceTexture = null
|
surfaceView.holder.removeCallback(this)
|
||||||
surfaceReady = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
override fun onDetachedFromWindow() {
|
||||||
|
|||||||
@@ -1,42 +1,33 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.Application
|
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
import android.app.RemoteAction
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.drawable.Icon
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picture-in-Picture controller for Android.
|
||||||
|
* This mirrors the iOS PiPController implementation.
|
||||||
|
*/
|
||||||
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
|
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PiPController"
|
private const val TAG = "PiPController"
|
||||||
private const val DEFAULT_ASPECT_WIDTH = 16
|
private const val DEFAULT_ASPECT_WIDTH = 16
|
||||||
private const val DEFAULT_ASPECT_HEIGHT = 9
|
private const val DEFAULT_ASPECT_HEIGHT = 9
|
||||||
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
|
|
||||||
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
|
|
||||||
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Delegate {
|
interface Delegate {
|
||||||
fun onPlay()
|
fun onPlay()
|
||||||
fun onPause()
|
fun onPause()
|
||||||
fun onSeekBy(seconds: Double)
|
fun onSeekBy(seconds: Double)
|
||||||
fun onPictureInPictureModeChanged(isInPiP: Boolean)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var delegate: Delegate? = null
|
var delegate: Delegate? = null
|
||||||
@@ -45,18 +36,16 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
private var currentDuration: Double = 0.0
|
private var currentDuration: Double = 0.0
|
||||||
private var playbackRate: Double = 1.0
|
private var playbackRate: Double = 1.0
|
||||||
|
|
||||||
|
// Video dimensions for proper aspect ratio
|
||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
|
|
||||||
|
// Reference to the player view for source rect
|
||||||
private var playerView: View? = null
|
private var playerView: View? = null
|
||||||
|
|
||||||
// PiP state tracking
|
/**
|
||||||
private var isInPiPMode: Boolean = false
|
* Check if Picture-in-Picture is supported on this device
|
||||||
private var pipEntryNotified: Boolean = false
|
*/
|
||||||
private val pipHandler = Handler(Looper.getMainLooper())
|
|
||||||
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
|
||||||
private var lifecycleRegistered = false
|
|
||||||
private var pipBroadcastReceiver: BroadcastReceiver? = null
|
|
||||||
|
|
||||||
fun isPictureInPictureSupported(): Boolean {
|
fun isPictureInPictureSupported(): Boolean {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||||
@@ -65,6 +54,9 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Picture-in-Picture is currently active
|
||||||
|
*/
|
||||||
fun isPictureInPictureActive(): Boolean {
|
fun isPictureInPictureActive(): Boolean {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
@@ -73,68 +65,68 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Picture-in-Picture mode
|
||||||
|
*/
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val activity = getActivity()
|
||||||
val activity = getActivity() ?: run {
|
if (activity == null) {
|
||||||
Log.e(TAG, "Cannot start PiP: no activity")
|
Log.e(TAG, "Cannot start PiP: no activity found")
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPictureInPictureSupported()) {
|
|
||||||
Log.e(TAG, "PiP not supported on this device")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val params = buildPiPParams(forEntering = true)
|
|
||||||
val result = activity.enterPictureInPictureMode(params)
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
Log.e(TAG, "enterPictureInPictureMode rejected by system")
|
|
||||||
isInPiPMode = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isInPiPMode = true
|
if (!isPictureInPictureSupported()) {
|
||||||
pipEntryNotified = true
|
Log.e(TAG, "PiP not supported on this device")
|
||||||
delegate?.onPictureInPictureModeChanged(true)
|
return
|
||||||
registerLifecycleCallbacks()
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
try {
|
||||||
|
val params = buildPiPParams(forEntering = true)
|
||||||
|
activity.enterPictureInPictureMode(params)
|
||||||
|
Log.i(TAG, "Entered PiP mode")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "PiP requires Android O or higher")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Picture-in-Picture mode
|
||||||
|
*/
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
isInPiPMode = false
|
// On Android, exiting PiP is typically done by the user
|
||||||
pipEntryNotified = false
|
// or by finishing the activity. We can request to move task to back.
|
||||||
unregisterLifecycleCallbacks()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
if (activity?.isInPictureInPictureMode == true) {
|
if (activity?.isInPictureInPictureMode == true) {
|
||||||
|
// Move task to back which will exit PiP
|
||||||
activity.moveTaskToBack(false)
|
activity.moveTaskToBack(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
/**
|
||||||
|
* Update the current playback position and duration
|
||||||
|
* Note: We don't update PiP params here as we're not using progress in PiP controls
|
||||||
|
*/
|
||||||
fun setCurrentTime(position: Double, duration: Double) {
|
fun setCurrentTime(position: Double, duration: Double) {
|
||||||
currentPosition = position
|
currentPosition = position
|
||||||
currentDuration = duration
|
currentDuration = duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the playback rate (0.0 for paused, 1.0 for playing)
|
||||||
|
*/
|
||||||
fun setPlaybackRate(rate: Double) {
|
fun setPlaybackRate(rate: Double) {
|
||||||
playbackRate = rate
|
playbackRate = rate
|
||||||
|
|
||||||
if (rate > 0) {
|
// Update PiP params to reflect play/pause state
|
||||||
registerLifecycleCallbacks()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update PiP params so autoEnterEnabled and action icons track play/pause state
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
if (activity != null) {
|
if (activity?.isInPictureInPictureMode == true) {
|
||||||
try {
|
try {
|
||||||
activity.setPictureInPictureParams(buildPiPParams())
|
activity.setPictureInPictureParams(buildPiPParams())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -144,14 +136,23 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the video dimensions for proper aspect ratio calculation
|
||||||
|
*/
|
||||||
fun setVideoDimensions(width: Int, height: Int) {
|
fun setVideoDimensions(width: Int, height: Int) {
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
videoWidth = width
|
videoWidth = width
|
||||||
videoHeight = height
|
videoHeight = height
|
||||||
|
Log.i(TAG, "Video dimensions set: ${width}x${height}")
|
||||||
|
|
||||||
|
// Update PiP params if active
|
||||||
updatePiPParamsIfNeeded()
|
updatePiPParamsIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the player view reference for source rect hint
|
||||||
|
*/
|
||||||
fun setPlayerView(view: View?) {
|
fun setPlayerView(view: View?) {
|
||||||
playerView = view
|
playerView = view
|
||||||
}
|
}
|
||||||
@@ -169,15 +170,22 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Picture-in-Picture params for the current player state.
|
||||||
|
* Calculates proper aspect ratio and source rect based on video and view dimensions.
|
||||||
|
*/
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
|
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
|
||||||
val view = playerView
|
val view = playerView
|
||||||
val viewWidth = view?.width ?: 0
|
val viewWidth = view?.width ?: 0
|
||||||
val viewHeight = view?.height ?: 0
|
val viewHeight = view?.height ?: 0
|
||||||
|
|
||||||
|
// Display aspect ratio from view (exactly like Findroid)
|
||||||
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
|
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
|
||||||
|
|
||||||
// Video aspect ratio with 2.39:1 clamping
|
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
|
||||||
|
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
|
||||||
|
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
|
||||||
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
||||||
Rational(
|
Rational(
|
||||||
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
||||||
@@ -187,13 +195,26 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
|
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source rect hint calculation (exactly like Findroid)
|
||||||
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
|
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
|
||||||
if (displayAspectRatio < aspectRatio) {
|
if (displayAspectRatio < aspectRatio) {
|
||||||
|
// Letterboxing - black bars top/bottom
|
||||||
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
|
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
|
||||||
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
|
Rect(
|
||||||
|
0,
|
||||||
|
space,
|
||||||
|
viewWidth,
|
||||||
|
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
|
// Pillarboxing - black bars left/right
|
||||||
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
|
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
|
||||||
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
|
Rect(
|
||||||
|
space,
|
||||||
|
0,
|
||||||
|
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
|
||||||
|
viewHeight
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -204,217 +225,39 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
|
|
||||||
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
||||||
|
|
||||||
ensurePiPReceiverRegistered()
|
// On Android 12+, enable auto-enter (like Findroid)
|
||||||
builder.setActions(buildPiPActions())
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
builder.setAutoEnterEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getActivity(): Activity? {
|
private fun getActivity(): Activity? {
|
||||||
|
// First try Expo's AppContext (preferred in React Native)
|
||||||
appContext?.currentActivity?.let { return it }
|
appContext?.currentActivity?.let { return it }
|
||||||
|
|
||||||
|
// Fallback: Try to get from context wrapper chain
|
||||||
var ctx = context
|
var ctx = context
|
||||||
while (ctx is android.content.ContextWrapper) {
|
while (ctx is android.content.ContextWrapper) {
|
||||||
if (ctx is Activity) return ctx
|
if (ctx is Activity) {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
ctx = ctx.baseContext
|
ctx = ctx.baseContext
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Lifecycle-based PiP Detection
|
/**
|
||||||
|
* Handle PiP action (called from activity when user taps PiP controls)
|
||||||
private fun registerLifecycleCallbacks() {
|
*/
|
||||||
if (lifecycleRegistered) return
|
fun handlePiPAction(action: String) {
|
||||||
|
when (action) {
|
||||||
val app = context.applicationContext as? Application ?: run {
|
"play" -> delegate?.onPlay()
|
||||||
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
|
"pause" -> delegate?.onPause()
|
||||||
startFallbackPolling()
|
"skip_forward" -> delegate?.onSeekBy(10.0)
|
||||||
return
|
"skip_backward" -> delegate?.onSeekBy(-10.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
|
||||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
|
||||||
override fun onActivityStarted(activity: Activity) {}
|
|
||||||
|
|
||||||
override fun onActivityResumed(activity: Activity) {
|
|
||||||
if (!isInPiPMode) return
|
|
||||||
if (!activity.isInPictureInPictureMode) {
|
|
||||||
isInPiPMode = false
|
|
||||||
pipEntryNotified = false
|
|
||||||
delegate?.onPictureInPictureModeChanged(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityPaused(activity: Activity) {
|
|
||||||
// Proactively hide controls when user leaves while playing,
|
|
||||||
// before the PiP window captures the UI. onActivityStopped
|
|
||||||
// will restore if PiP didn't actually enter.
|
|
||||||
if (playbackRate > 0 && !isInPiPMode) {
|
|
||||||
isInPiPMode = true
|
|
||||||
pipEntryNotified = true
|
|
||||||
delegate?.onPictureInPictureModeChanged(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityStopped(activity: Activity) {
|
|
||||||
pipHandler.postDelayed({
|
|
||||||
val inPip = activity.isInPictureInPictureMode
|
|
||||||
|
|
||||||
if (inPip && !isInPiPMode) {
|
|
||||||
isInPiPMode = true
|
|
||||||
pipEntryNotified = true
|
|
||||||
delegate?.onPictureInPictureModeChanged(true)
|
|
||||||
return@postDelayed
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isInPiPMode) return@postDelayed
|
|
||||||
if (inPip) return@postDelayed
|
|
||||||
|
|
||||||
// Not in PiP after 1s — check again to avoid false positive during transition
|
|
||||||
pipHandler.postDelayed({
|
|
||||||
if (!isInPiPMode) return@postDelayed
|
|
||||||
if (!activity.isInPictureInPictureMode) {
|
|
||||||
isInPiPMode = false
|
|
||||||
pipEntryNotified = false
|
|
||||||
delegate?.onPictureInPictureModeChanged(false)
|
|
||||||
}
|
|
||||||
}, 1500)
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
|
||||||
|
|
||||||
override fun onActivityDestroyed(activity: Activity) {
|
|
||||||
isInPiPMode = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
|
||||||
lifecycleRegistered = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterLifecycleCallbacks() {
|
|
||||||
if (!lifecycleRegistered) return
|
|
||||||
lifecycleCallbacks?.let {
|
|
||||||
(context.applicationContext as? Application)
|
|
||||||
?.unregisterActivityLifecycleCallbacks(it)
|
|
||||||
}
|
|
||||||
lifecycleCallbacks = null
|
|
||||||
lifecycleRegistered = false
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
|
||||||
unregisterPiPBroadcastReceiver()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startFallbackPolling() {
|
|
||||||
var falseReadCount = 0
|
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
|
||||||
pipHandler.postDelayed(object : Runnable {
|
|
||||||
override fun run() {
|
|
||||||
if (!isInPiPMode) return
|
|
||||||
|
|
||||||
var ctx = context
|
|
||||||
var activity: Activity? = null
|
|
||||||
while (ctx is android.content.ContextWrapper) {
|
|
||||||
if (ctx is Activity) { activity = ctx; break }
|
|
||||||
ctx = ctx.baseContext
|
|
||||||
}
|
|
||||||
|
|
||||||
val stillInPip = activity?.isInPictureInPictureMode == true
|
|
||||||
|
|
||||||
if (!stillInPip) {
|
|
||||||
falseReadCount++
|
|
||||||
if (falseReadCount >= 3) {
|
|
||||||
isInPiPMode = false
|
|
||||||
delegate?.onPictureInPictureModeChanged(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pipHandler.postDelayed(this, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
falseReadCount = 0
|
|
||||||
pipHandler.postDelayed(this, 1000)
|
|
||||||
}
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PiP Remote Actions
|
|
||||||
|
|
||||||
private fun ensurePiPReceiverRegistered() {
|
|
||||||
if (pipBroadcastReceiver != null) return
|
|
||||||
|
|
||||||
pipBroadcastReceiver = object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
when (intent.action) {
|
|
||||||
ACTION_PIP_PLAY_PAUSE -> {
|
|
||||||
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
|
|
||||||
}
|
|
||||||
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
|
|
||||||
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val filter = IntentFilter().apply {
|
|
||||||
addAction(ACTION_PIP_PLAY_PAUSE)
|
|
||||||
addAction(ACTION_PIP_SKIP_FORWARD)
|
|
||||||
addAction(ACTION_PIP_SKIP_BACKWARD)
|
|
||||||
}
|
|
||||||
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
Context.RECEIVER_EXPORTED
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unregisterPiPBroadcastReceiver() {
|
|
||||||
pipBroadcastReceiver?.let {
|
|
||||||
try {
|
|
||||||
context.applicationContext.unregisterReceiver(it)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
pipBroadcastReceiver = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildPiPActions(): List<RemoteAction> {
|
|
||||||
val isPlaying = playbackRate > 0
|
|
||||||
|
|
||||||
return listOf(
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
|
|
||||||
"Rewind", "Skip backward 10 seconds",
|
|
||||||
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
|
|
||||||
),
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(
|
|
||||||
context,
|
|
||||||
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
|
|
||||||
),
|
|
||||||
if (isPlaying) "Pause" else "Play",
|
|
||||||
if (isPlaying) "Pause playback" else "Resume playback",
|
|
||||||
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
|
|
||||||
),
|
|
||||||
RemoteAction(
|
|
||||||
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
|
|
||||||
"Fast Forward", "Skip forward 10 seconds",
|
|
||||||
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
|
|
||||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
android.app.PendingIntent.FLAG_IMMUTABLE
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
return android.app.PendingIntent.getBroadcast(
|
|
||||||
context.applicationContext, 0, Intent(action), flags
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
Pod::Spec.new do |s|
|
Pod::Spec.new do |s|
|
||||||
s.name = 'MpvPlayer'
|
s.name = 'MpvPlayer'
|
||||||
s.version = '1.0.0'
|
s.version = '1.0.0'
|
||||||
s.summary = 'MPV-based video player for Streamyfin (Expo module)'
|
s.summary = 'MPVKit for Expo'
|
||||||
s.author = 'Streamyfin'
|
s.description = 'MPVKit for Expo'
|
||||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
s.author = 'mpvkit'
|
||||||
s.platforms = { :ios => '15.1', :tvos => '15.1' }
|
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
||||||
s.source = { git: '' }
|
s.platforms = {
|
||||||
|
:ios => '15.1',
|
||||||
|
:tvos => '15.1'
|
||||||
|
}
|
||||||
|
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
||||||
s.static_framework = true
|
s.static_framework = true
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
s.dependency 'ExpoModulesCore'
|
||||||
s.dependency 'MPVKit'
|
s.dependency 'MPVKit-GPL'
|
||||||
|
|
||||||
|
# Swift/Objective-C compatibility
|
||||||
s.pod_target_xcconfig = {
|
s.pod_target_xcconfig = {
|
||||||
'DEFINES_MODULE' => 'YES',
|
'DEFINES_MODULE' => 'YES',
|
||||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
'VALID_ARCHS' => 'arm64',
|
||||||
|
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
|
||||||
|
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||||
|
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||||
|
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||||
|
}
|
||||||
|
|
||||||
|
s.user_target_xcconfig = {
|
||||||
|
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
|
||||||
}
|
}
|
||||||
|
|
||||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
|||||||
@@ -213,7 +213,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
let onProgress = EventDispatcher()
|
let onProgress = EventDispatcher()
|
||||||
let onError = EventDispatcher()
|
let onError = EventDispatcher()
|
||||||
let onTracksReady = EventDispatcher()
|
let onTracksReady = EventDispatcher()
|
||||||
let onPictureInPictureChange = EventDispatcher()
|
|
||||||
|
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
private var cachedPosition: Double = 0
|
private var cachedPosition: Double = 0
|
||||||
@@ -82,6 +81,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
private func setupView() {
|
private func setupView() {
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
backgroundColor = .black
|
backgroundColor = .black
|
||||||
|
configureAudioSession()
|
||||||
|
|
||||||
videoContainer = UIView()
|
videoContainer = UIView()
|
||||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -141,26 +141,21 @@ class MpvPlayerView: ExpoView {
|
|||||||
CATransaction.commit()
|
CATransaction.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Session & Notifications
|
|
||||||
|
|
||||||
private func configureAudioSession() {
|
private func configureAudioSession() {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
try audioSession.setCategory(
|
||||||
try session.setActive(true)
|
.playback,
|
||||||
|
mode: .moviePlayback,
|
||||||
|
policy: .longFormAudio,
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
try audioSession.setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to configure audio session: \(error)")
|
print("Failed to configure audio session: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// MARK: - Audio Session & Notifications
|
||||||
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
|
||||||
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
|
||||||
/// reactivation (foreground, route change, other modules) re-steals audio.
|
|
||||||
private func tearDownAudioSession() {
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
||||||
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupNotifications() {
|
private func setupNotifications() {
|
||||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||||
@@ -275,7 +270,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
configureAudioSession()
|
|
||||||
setupRemoteCommands()
|
setupRemoteCommands()
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
@@ -446,7 +440,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
displayLayer.removeFromSuperlayer()
|
displayLayer.removeFromSuperlayer()
|
||||||
clearNowPlayingInfo()
|
clearNowPlayingInfo()
|
||||||
tearDownAudioSession()
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -526,7 +519,9 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||||
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
||||||
|
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
||||||
|
nowPlayingManager.activateAudioSession()
|
||||||
syncNowPlaying(isPlaying: !isPaused())
|
syncNowPlaying(isPlaying: !isPaused())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -638,9 +633,6 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
print("PiP did start: \(didStartPictureInPicture)")
|
print("PiP did start: \(didStartPictureInPicture)")
|
||||||
// Ensure current time is synced when PiP starts
|
// Ensure current time is synced when PiP starts
|
||||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||||
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
|
||||||
// is `false` when AVKit reports a failure to start, so reflect that.
|
|
||||||
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||||
@@ -659,9 +651,6 @@ extension MpvPlayerView: PiPControllerDelegate {
|
|||||||
if _isZoomedToFill {
|
if _isZoomedToFill {
|
||||||
displayLayer.videoGravity = .resizeAspectFill
|
displayLayer.videoGravity = .resizeAspectFill
|
||||||
}
|
}
|
||||||
// Notify JS that PiP has fully stopped so the controls overlay can
|
|
||||||
// be re-mounted when the user returns to full screen.
|
|
||||||
onPictureInPictureChange(["isActive": false])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ export type OnErrorEventPayload = {
|
|||||||
|
|
||||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||||
|
|
||||||
export type OnPictureInPictureChangePayload = {
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NowPlayingMetadata = {
|
export type NowPlayingMetadata = {
|
||||||
title?: string;
|
title?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
@@ -81,9 +77,6 @@ export type MpvPlayerViewProps = {
|
|||||||
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
||||||
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
||||||
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
||||||
onPictureInPictureChange?: (event: {
|
|
||||||
nativeEvent: OnPictureInPictureChangePayload;
|
|
||||||
}) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MpvPlayerViewRef {
|
export interface MpvPlayerViewRef {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
|
|||||||
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||||
requireNativeView("MpvPlayer");
|
requireNativeView("MpvPlayer");
|
||||||
|
|
||||||
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
|
|
||||||
|
|
||||||
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||||
function MpvPlayerView(props, ref) {
|
function MpvPlayerView(props, ref) {
|
||||||
const nativeRef = useRef<any>(null);
|
const nativeRef = useRef<any>(null);
|
||||||
@@ -42,24 +40,16 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
|||||||
return await nativeRef.current?.getDuration();
|
return await nativeRef.current?.getDuration();
|
||||||
},
|
},
|
||||||
startPictureInPicture: async () => {
|
startPictureInPicture: async () => {
|
||||||
console.log(PIP_LOG, "startPictureInPicture → native");
|
|
||||||
await nativeRef.current?.startPictureInPicture();
|
await nativeRef.current?.startPictureInPicture();
|
||||||
console.log(PIP_LOG, "startPictureInPicture ← native returned");
|
|
||||||
},
|
},
|
||||||
stopPictureInPicture: async () => {
|
stopPictureInPicture: async () => {
|
||||||
console.log(PIP_LOG, "stopPictureInPicture → native");
|
|
||||||
await nativeRef.current?.stopPictureInPicture();
|
await nativeRef.current?.stopPictureInPicture();
|
||||||
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
|
|
||||||
},
|
},
|
||||||
isPictureInPictureSupported: async () => {
|
isPictureInPictureSupported: async () => {
|
||||||
const result = await nativeRef.current?.isPictureInPictureSupported();
|
return await nativeRef.current?.isPictureInPictureSupported();
|
||||||
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
isPictureInPictureActive: async () => {
|
isPictureInPictureActive: async () => {
|
||||||
const result = await nativeRef.current?.isPictureInPictureActive();
|
return await nativeRef.current?.isPictureInPictureActive();
|
||||||
console.log(PIP_LOG, "isPictureInPictureActive =", result);
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
getSubtitleTracks: async () => {
|
getSubtitleTracks: async () => {
|
||||||
return await nativeRef.current?.getSubtitleTracks();
|
return await nativeRef.current?.getSubtitleTracks();
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
||||||
|
|
||||||
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
||||||
const url = props.source?.url ?? "";
|
const url = props.source?.url ?? "";
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<iframe
|
<iframe
|
||||||
title={t("player.mpv_player_title")}
|
title='MPV Player'
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
src={url}
|
src={url}
|
||||||
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}
|
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}
|
||||||
|
|||||||
17
package.json
@@ -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": {
|
||||||
@@ -34,10 +32,9 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.14",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
"@react-navigation/material-top-tabs": "7.4.28",
|
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
@@ -107,7 +104,6 @@
|
|||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.4",
|
||||||
"react-native-tab-view": "4.3.0",
|
|
||||||
"react-native-text-ticker": "^1.15.0",
|
"react-native-text-ticker": "^1.15.0",
|
||||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -115,7 +111,7 @@
|
|||||||
"react-native-uuid": "^2.0.3",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-worklets": "0.8.3",
|
"react-native-worklets": "0.9.1",
|
||||||
"sonner-native": "0.21.2",
|
"sonner-native": "0.21.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
@@ -166,5 +162,10 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
]
|
],
|
||||||
|
"patchedDependencies": {
|
||||||
|
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
|
||||||
|
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
|
||||||
|
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,28 +39,6 @@ function buildPatch() {
|
|||||||
" end",
|
" end",
|
||||||
" end",
|
" end",
|
||||||
"",
|
"",
|
||||||
" # iOS 26 / Xcode 26: the APP target itself compiles ExpoModulesProvider.swift,",
|
|
||||||
" # which imports SwiftUI-based modules (ExpoUI, ExpoGlassEffect, GlassPoster, ExpoBlur, …).",
|
|
||||||
" # That emits a `-framework SwiftUICore` autolink into the app executable's OWN object",
|
|
||||||
" # files, so the pods-only flag above is not enough — the app's link still fails with",
|
|
||||||
" # `cannot link directly with 'SwiftUICore'`. Drop the autolink on the user app target",
|
|
||||||
" # too. Phone-only — tvOS has no SwiftUICore split and must stay untouched.",
|
|
||||||
" if ENV['EXPO_TV'] != '1'",
|
|
||||||
" installer.aggregate_targets.each do |agg|",
|
|
||||||
" next unless agg.user_project",
|
|
||||||
" agg.user_project.native_targets.each do |target|",
|
|
||||||
" target.build_configurations.each do |cfg|",
|
|
||||||
" existing = cfg.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'",
|
|
||||||
" existing = existing.join(' ') if existing.is_a?(Array)",
|
|
||||||
" unless existing.include?('-disable-autolink-framework -Xfrontend SwiftUICore')",
|
|
||||||
" cfg.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
|
|
||||||
" end",
|
|
||||||
" end",
|
|
||||||
" end",
|
|
||||||
" agg.user_project.save",
|
|
||||||
" end",
|
|
||||||
" end",
|
|
||||||
"",
|
|
||||||
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
|
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
|
||||||
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
|
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
|
||||||
" if File.exist?(filepath)",
|
" if File.exist?(filepath)",
|
||||||
|
|||||||
@@ -4,16 +4,9 @@ const { withEntitlementsPlist } = require("expo/config-plugins");
|
|||||||
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
||||||
*/
|
*/
|
||||||
const withTVUserManagement = (config) => {
|
const withTVUserManagement = (config) => {
|
||||||
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
|
||||||
// be present in the provisioning profile, so injecting it into mobile builds
|
|
||||||
// breaks signing ("Entitlement ... not found and could not be included in
|
|
||||||
// profile"). The entitlement is only needed for tvOS
|
|
||||||
// TVUserManager.currentUserIdentifier.
|
|
||||||
if (process.env.EXPO_TV !== "1") {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
return withEntitlementsPlist(config, (config) => {
|
return withEntitlementsPlist(config, (config) => {
|
||||||
|
// Only add for tvOS builds (check if building for TV)
|
||||||
|
// The entitlement is needed for TVUserManager.currentUserIdentifier to work
|
||||||
config.modResults["com.apple.developer.user-management"] = [
|
config.modResults["com.apple.developer.user-management"] = [
|
||||||
"runs-as-current-user",
|
"runs-as-current-user",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,68 +4,28 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
|
|||||||
|
|
||||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||||
|
|
||||||
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
|
|
||||||
let cachedDb: DownloadsDatabase | null = null;
|
|
||||||
let cacheVersion = 0;
|
|
||||||
|
|
||||||
// Performance optimization: Cache the flattened items array
|
|
||||||
let cachedItems: DownloadedItem[] | null = null;
|
|
||||||
let itemsCacheVersion = -1;
|
|
||||||
|
|
||||||
// Performance optimization: Index for O(1) item lookups by ID
|
|
||||||
let itemIndex: Map<string, DownloadedItem> | null = null;
|
|
||||||
let indexCacheVersion = -1;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the downloads database from storage
|
* Get the downloads database from storage
|
||||||
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
|
|
||||||
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
|
|
||||||
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
|
|
||||||
* saveDownloadsDatabase() runs and the derived caches stay consistent.
|
|
||||||
*/
|
*/
|
||||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||||
// Return cached database if available
|
|
||||||
if (cachedDb !== null) {
|
|
||||||
return cachedDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse from storage and cache the result
|
|
||||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||||
if (file) {
|
if (file) {
|
||||||
cachedDb = JSON.parse(file) as DownloadsDatabase;
|
return JSON.parse(file) as DownloadsDatabase;
|
||||||
return cachedDb;
|
|
||||||
}
|
}
|
||||||
|
return { movies: {}, series: {}, other: {} };
|
||||||
const emptyDb = { movies: {}, series: {}, other: {} };
|
|
||||||
cachedDb = emptyDb;
|
|
||||||
return emptyDb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the downloads database to storage
|
* Save the downloads database to storage
|
||||||
* PERFORMANCE: Updates cache and invalidates derived caches
|
|
||||||
*/
|
*/
|
||||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||||
// Update the cache with the new database
|
|
||||||
cachedDb = db;
|
|
||||||
// Invalidate derived caches (items array and index)
|
|
||||||
cachedItems = null;
|
|
||||||
itemIndex = null;
|
|
||||||
cacheVersion++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all downloaded items as a flat array
|
* Get all downloaded items as a flat array
|
||||||
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
|
|
||||||
*/
|
*/
|
||||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||||
// Return cached items if available and up-to-date
|
|
||||||
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
|
|
||||||
return cachedItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the items array from the database
|
|
||||||
const db = getDownloadsDatabase();
|
const db = getDownloadsDatabase();
|
||||||
const items: DownloadedItem[] = [];
|
const items: DownloadedItem[] = [];
|
||||||
|
|
||||||
@@ -87,41 +47,34 @@ export function getAllDownloadedItems(): DownloadedItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
cachedItems = items;
|
|
||||||
itemsCacheVersion = cacheVersion;
|
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build or refresh the item index for O(1) lookups
|
* Get a downloaded item by its ID
|
||||||
*/
|
*/
|
||||||
function ensureItemIndex(): void {
|
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||||
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
|
const db = getDownloadsDatabase();
|
||||||
return; // Index is up-to-date
|
|
||||||
|
if (db.movies[id]) {
|
||||||
|
return db.movies[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build new index from all items
|
for (const series of Object.values(db.series)) {
|
||||||
itemIndex = new Map<string, DownloadedItem>();
|
for (const season of Object.values(series.seasons)) {
|
||||||
const items = getAllDownloadedItems();
|
for (const episode of Object.values(season.episodes)) {
|
||||||
|
if (episode.item.Id === id) {
|
||||||
for (const item of items) {
|
return episode;
|
||||||
if (item.item.Id) {
|
}
|
||||||
itemIndex.set(item.item.Id, item);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
indexCacheVersion = cacheVersion;
|
if (db.other?.[id]) {
|
||||||
}
|
return db.other[id];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
return undefined;
|
||||||
* Get a downloaded item by its ID
|
|
||||||
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
|
|
||||||
*/
|
|
||||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
|
||||||
ensureItemIndex();
|
|
||||||
return itemIndex!.get(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,5 +221,4 @@ export function updateDownloadedItem(
|
|||||||
*/
|
*/
|
||||||
export function clearAllDownloadedItems(): void {
|
export function clearAllDownloadedItems(): void {
|
||||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||||
// saveDownloadsDatabase already invalidates caches
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const initialApi = (() => {
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
const jellyfinInstance = new Jellyfin({
|
const jellyfinInstance = new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
clientInfo: { name: "Streamyfin", version: "0.54.0" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -69,13 +69,6 @@ const initialApi = (() => {
|
|||||||
|
|
||||||
const initialUser = (() => {
|
const initialUser = (() => {
|
||||||
try {
|
try {
|
||||||
// Only return a stored user if we also have a token. Otherwise the
|
|
||||||
// user atom would be populated while the api atom is null (e.g. after
|
|
||||||
// a logout that left stale user JSON in storage), which causes
|
|
||||||
// useProtectedRoute to keep us inside the (auth) group instead of
|
|
||||||
// redirecting to /login.
|
|
||||||
const token = storage.getString("token");
|
|
||||||
if (!token) return null;
|
|
||||||
const userStr = storage.getString("user");
|
const userStr = storage.getString("user");
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
return JSON.parse(userStr) as UserDto;
|
return JSON.parse(userStr) as UserDto;
|
||||||
@@ -135,7 +128,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
return new Jellyfin({
|
return new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
clientInfo: { name: "Streamyfin", version: "0.54.0" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -169,7 +162,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
}, DeviceId="${deviceId}", Version="0.54.0"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
@@ -409,7 +402,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
storage.remove("token");
|
storage.remove("token");
|
||||||
storage.remove("user");
|
|
||||||
clearTVDiscoverySafely();
|
clearTVDiscoverySafely();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setApi(null);
|
setApi(null);
|
||||||
@@ -619,54 +611,44 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss splash screen with cached data immediately,
|
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||||
// fetch fresh user data in the background
|
setUser(response.data);
|
||||||
setInitialLoaded(true);
|
|
||||||
|
|
||||||
try {
|
// Migrate current session to secure storage if not already saved
|
||||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
if (storedUser?.Id && storedUser?.Name) {
|
||||||
setUser(response.data);
|
const existingCredential = await getAccountCredential(
|
||||||
|
serverUrl,
|
||||||
// Migrate current session to secure storage if not already saved
|
storedUser.Id,
|
||||||
if (storedUser?.Id && storedUser?.Name) {
|
);
|
||||||
const existingCredential = await getAccountCredential(
|
if (!existingCredential) {
|
||||||
|
await saveAccountCredential({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
storedUser.Id,
|
serverName: "",
|
||||||
);
|
token,
|
||||||
if (!existingCredential) {
|
userId: storedUser.Id,
|
||||||
await saveAccountCredential({
|
username: storedUser.Name,
|
||||||
serverUrl,
|
savedAt: Date.now(),
|
||||||
serverName: "",
|
securityType: "none",
|
||||||
token,
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
userId: storedUser.Id,
|
});
|
||||||
username: storedUser.Name,
|
} else if (
|
||||||
savedAt: Date.now(),
|
response.data.PrimaryImageTag !==
|
||||||
securityType: "none",
|
existingCredential.primaryImageTag
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
) {
|
||||||
});
|
// Update image tag if it has changed
|
||||||
} else if (
|
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||||
response.data.PrimaryImageTag !==
|
userId: existingCredential.userId,
|
||||||
existingCredential.primaryImageTag
|
username: existingCredential.username,
|
||||||
) {
|
securityType: existingCredential.securityType,
|
||||||
// Update image tag if it has changed
|
savedAt: existingCredential.savedAt,
|
||||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||||
userId: existingCredential.userId,
|
});
|
||||||
username: existingCredential.username,
|
|
||||||
securityType: existingCredential.securityType,
|
|
||||||
savedAt: existingCredential.savedAt,
|
|
||||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
// Background fetch failed — app already rendered with cached data
|
|
||||||
console.warn("Background user fetch failed, using cached data:", e);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setInitialLoaded(true);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
} finally {
|
||||||
setInitialLoaded(true);
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -29,10 +29,6 @@
|
|||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
|
||||||
<array>
|
|
||||||
<string>arm64</string>
|
|
||||||
</array>
|
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
"error_title": "خطأ",
|
"error_title": "خطأ",
|
||||||
"login_title": "تسجيل الدخول",
|
"login_title": "تسجيل الدخول",
|
||||||
"login_to_title": "تسجيل الدخول إلى",
|
"login_to_title": "تسجيل الدخول إلى",
|
||||||
"select_user": "Select a user to log in",
|
|
||||||
"add_user_to_login": "Add a user to log in",
|
|
||||||
"add_user": "Add User",
|
|
||||||
"username_placeholder": "اسم المستخدم",
|
"username_placeholder": "اسم المستخدم",
|
||||||
"password_placeholder": "كلمة المرور",
|
"password_placeholder": "كلمة المرور",
|
||||||
"login_button": "تسجيل الدخول",
|
"login_button": "تسجيل الدخول",
|
||||||
@@ -33,54 +30,48 @@
|
|||||||
"connect_button": "اتصل",
|
"connect_button": "اتصل",
|
||||||
"previous_servers": "الخوادم السابقة",
|
"previous_servers": "الخوادم السابقة",
|
||||||
"clear_button": "مسح",
|
"clear_button": "مسح",
|
||||||
"swipe_to_remove": "مرر للإزالة",
|
"swipe_to_remove": "Swipe to remove",
|
||||||
"search_for_local_servers": "البحث عن الخوادم المحلية",
|
"search_for_local_servers": "البحث عن الخوادم المحلية",
|
||||||
"searching": "جاري البحث...",
|
"searching": "جاري البحث...",
|
||||||
"servers": "الخوادم",
|
"servers": "الخوادم",
|
||||||
"saved": "تم الحفظ",
|
"saved": "Saved",
|
||||||
"session_expired": "انتهت الجلسة",
|
"session_expired": "Session Expired",
|
||||||
"please_login_again": "انتهت مدة صلاحية جلستك. الرجاء تسجيل الدخول مرة أخرى.",
|
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||||
"remove_saved_login": "إزالة تسجيل دخول محفوظ",
|
"remove_saved_login": "Remove Saved Login",
|
||||||
"remove_saved_login_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول الخاص بك المحفوظة لهذا الخادم. ستحتاج إلى إدخال اسم المستخدم وكلمة المرور مرة أخرى في المرة القادمة.",
|
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||||
"accounts_count": "الحسابات {{count}}",
|
"accounts_count": "{{count}} accounts",
|
||||||
"select_account": "اختر الحساب",
|
"select_account": "Select Account",
|
||||||
"add_account": "إضافة حساب",
|
"add_account": "Add Account",
|
||||||
"remove_account_description": "سيؤدي هذا إلى إزالة بيانات تسجيل الدخول لـ {{username}}.",
|
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||||
"remove_server": "Remove Server",
|
|
||||||
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
|
||||||
"select_your_server": "Select Your Server",
|
|
||||||
"add_server_to_get_started": "Add a server to get started",
|
|
||||||
"add_server": "Add Server",
|
|
||||||
"change_server": "Change Server"
|
|
||||||
},
|
},
|
||||||
"save_account": {
|
"save_account": {
|
||||||
"title": "حفظ الحساب",
|
"title": "Save Account",
|
||||||
"save_for_later": "حفظ هذا الحساب",
|
"save_for_later": "Save this account",
|
||||||
"security_option": "خيارات الأمان",
|
"security_option": "Security Option",
|
||||||
"no_protection": "بدون حماية",
|
"no_protection": "No protection",
|
||||||
"no_protection_desc": "تسجيل دخول سريع بدون مصادقة",
|
"no_protection_desc": "Quick login without authentication",
|
||||||
"pin_code": "رمز PIN",
|
"pin_code": "PIN code",
|
||||||
"pin_code_desc": "رمز PIN مكون من 4 أرقام مطلوب عند التبديل",
|
"pin_code_desc": "4-digit PIN required when switching",
|
||||||
"password": "أعد إدخال كلمة المرور",
|
"password": "Re-enter password",
|
||||||
"password_desc": "كلمة المرور مطلوبة عند التبديل",
|
"password_desc": "Password required when switching",
|
||||||
"save_button": "حفظ",
|
"save_button": "Save",
|
||||||
"cancel_button": "إلغاء"
|
"cancel_button": "Cancel"
|
||||||
},
|
},
|
||||||
"pin": {
|
"pin": {
|
||||||
"enter_pin": "أدخل رمز PIN",
|
"enter_pin": "Enter PIN",
|
||||||
"enter_pin_for": "أدخل رمز PIN لـ {{username}}",
|
"enter_pin_for": "Enter PIN for {{username}}",
|
||||||
"enter_4_digits": "ادخل 4 أرقام",
|
"enter_4_digits": "Enter 4 digits",
|
||||||
"invalid_pin": "PIN غير صالح",
|
"invalid_pin": "Invalid PIN",
|
||||||
"setup_pin": "تعيين رمز PIN",
|
"setup_pin": "Set Up PIN",
|
||||||
"confirm_pin": "تأكيد رمز PIN",
|
"confirm_pin": "Confirm PIN",
|
||||||
"pins_dont_match": "رموز PIN غير متطابقة",
|
"pins_dont_match": "PINs don't match",
|
||||||
"forgot_pin": "نسيت رمز PIN؟",
|
"forgot_pin": "Forgot PIN?",
|
||||||
"forgot_pin_desc": "سيتم إزالة بيانات تسجيل الدخول المحفوظة الخاصة بك"
|
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||||
},
|
},
|
||||||
"password": {
|
"password": {
|
||||||
"enter_password": "أدخل كلمة المرور",
|
"enter_password": "Enter Password",
|
||||||
"enter_password_for": "أدخل كلمة المرور لـ {{username}}",
|
"enter_password_for": "Enter password for {{username}}",
|
||||||
"invalid_password": "كلمة المرور غير صحيحة"
|
"invalid_password": "Invalid password"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"checking_server_connection": "التحقق من اتصال الخادم...",
|
"checking_server_connection": "التحقق من اتصال الخادم...",
|
||||||
@@ -95,9 +86,8 @@
|
|||||||
"oops": "عفوًا!",
|
"oops": "عفوًا!",
|
||||||
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
|
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
|
||||||
"continue_watching": "متابعة المشاهدة",
|
"continue_watching": "متابعة المشاهدة",
|
||||||
"continue": "Continue",
|
|
||||||
"next_up": "التالي",
|
"next_up": "التالي",
|
||||||
"continue_and_next_up": "تابع و التالي",
|
"continue_and_next_up": "Continue & Next Up",
|
||||||
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
||||||
"suggested_movies": "أفلام مقترحة",
|
"suggested_movies": "أفلام مقترحة",
|
||||||
"suggested_episodes": "حلقات مقترحة",
|
"suggested_episodes": "حلقات مقترحة",
|
||||||
@@ -119,12 +109,6 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "الإعدادات",
|
"settings_title": "الإعدادات",
|
||||||
"log_out_button": "تسجيل الخروج",
|
"log_out_button": "تسجيل الخروج",
|
||||||
"switch_user": {
|
|
||||||
"title": "Switch User",
|
|
||||||
"account": "Account",
|
|
||||||
"switch_user": "Switch User on This Server",
|
|
||||||
"current": "current"
|
|
||||||
},
|
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "الأقسام"
|
"title": "الأقسام"
|
||||||
},
|
},
|
||||||
@@ -136,45 +120,36 @@
|
|||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "المظهر",
|
"title": "المظهر",
|
||||||
"merge_next_up_continue_watching": "دمج تابع المشاهدة والتالي",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
"hide_remote_session_button": "إخفاء زر جلسة البث عن بُعد",
|
"hide_remote_session_button": "Hide Remote Session Button"
|
||||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
|
||||||
"show_hero_carousel": "Hero Carousel",
|
|
||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
|
||||||
"theme_music": "Theme Music",
|
|
||||||
"display_size": "Display Size",
|
|
||||||
"display_size_small": "Small",
|
|
||||||
"display_size_default": "Default",
|
|
||||||
"display_size_large": "Large",
|
|
||||||
"display_size_extra_large": "Extra Large"
|
|
||||||
},
|
},
|
||||||
"network": {
|
"network": {
|
||||||
"title": "الشبكة",
|
"title": "Network",
|
||||||
"local_network": "الشبكة المحلية",
|
"local_network": "Local Network",
|
||||||
"auto_switch_enabled": "التبديل التلقائي عند المنزل",
|
"auto_switch_enabled": "Auto-switch when at home",
|
||||||
"auto_switch_description": "التبديل تلقائياً إلى رابط URL محلي عند الاتصال بشبكة WiFi المنزلية",
|
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||||
"local_url": "رابط محلي",
|
"local_url": "Local URL",
|
||||||
"local_url_hint": "أدخل عنوان الخادم المحلي الخاص بك (على سبيل المثال http://192.168.1.100:8096)",
|
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||||
"home_wifi_networks": "شبكات WiFi المنزل",
|
"home_wifi_networks": "Home WiFi Networks",
|
||||||
"add_current_network": "إضافة \"{{ssid}}\"",
|
"add_current_network": "Add \"{{ssid}}\"",
|
||||||
"not_connected_to_wifi": "غير متصل بشبكة WiFi",
|
"not_connected_to_wifi": "Not connected to WiFi",
|
||||||
"no_networks_configured": "لا توجد شبكات مكونة",
|
"no_networks_configured": "No networks configured",
|
||||||
"add_network_hint": "إضافة شبكة WiFi المنزلية الخاصة بك لتمكين التبديل التلقائي",
|
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||||
"current_wifi": "شبكة WiFi الحالية",
|
"current_wifi": "Current WiFi",
|
||||||
"using_url": "استخدام",
|
"using_url": "Using",
|
||||||
"local": "رابط محلي",
|
"local": "Local URL",
|
||||||
"remote": "الـ URL الخارجي",
|
"remote": "Remote URL",
|
||||||
"not_connected": "غير متصل",
|
"not_connected": "Not connected",
|
||||||
"current_server": "الخادم الحالي",
|
"current_server": "Current Server",
|
||||||
"remote_url": "الـ URL الخارجي",
|
"remote_url": "Remote URL",
|
||||||
"active_url": "الرابط النشط",
|
"active_url": "Active URL",
|
||||||
"not_configured": "لم يتم تكوينه",
|
"not_configured": "Not configured",
|
||||||
"network_added": "تمت إضافة الشبكة",
|
"network_added": "Network added",
|
||||||
"network_already_added": "الشبكة مضافة مسبقاً",
|
"network_already_added": "Network already added",
|
||||||
"no_wifi_connected": "غير متصل بشبكة WiFi",
|
"no_wifi_connected": "Not connected to WiFi",
|
||||||
"permission_denied": "تم رفض إذن الوصول إلى الموقع",
|
"permission_denied": "Location permission denied",
|
||||||
"permission_denied_explanation": "يتطلب التعرف على شبكة WiFi للتبديل التلقائي الحصول على إذن الوصول إلى الموقع. يرجى تفعيله من الإعدادات."
|
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||||
},
|
},
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"user_info_title": "معلومات المستخدم",
|
"user_info_title": "معلومات المستخدم",
|
||||||
@@ -199,22 +174,6 @@
|
|||||||
"rewind_length": "مدة الترجيع",
|
"rewind_length": "مدة الترجيع",
|
||||||
"seconds_unit": "ث"
|
"seconds_unit": "ث"
|
||||||
},
|
},
|
||||||
"buffer": {
|
|
||||||
"title": "Buffer Settings",
|
|
||||||
"cache_mode": "Cache Mode",
|
|
||||||
"cache_auto": "Auto",
|
|
||||||
"cache_yes": "Enabled",
|
|
||||||
"cache_no": "Disabled",
|
|
||||||
"buffer_duration": "Buffer Duration",
|
|
||||||
"max_cache_size": "Max Cache Size",
|
|
||||||
"max_backward_cache": "Max Backward Cache"
|
|
||||||
},
|
|
||||||
"vo_driver": {
|
|
||||||
"title": "Video Output",
|
|
||||||
"vo_mode": "VO Driver",
|
|
||||||
"gpu_next": "gpu-next (Recommended)",
|
|
||||||
"gpu": "gpu"
|
|
||||||
},
|
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "التحكم بالإيماءات",
|
"gesture_controls_title": "التحكم بالإيماءات",
|
||||||
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
|
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
|
||||||
@@ -223,10 +182,10 @@
|
|||||||
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
||||||
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
||||||
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
|
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت",
|
||||||
"hide_volume_slider": "إخفاء شريط مستوى الصوت",
|
"hide_volume_slider": "Hide Volume Slider",
|
||||||
"hide_volume_slider_description": "إخفاء شريط التحكم في مستوى الصوت في مشغل الفيديو",
|
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||||
"hide_brightness_slider": "إخفاء شريط السطوع",
|
"hide_brightness_slider": "Hide Brightness Slider",
|
||||||
"hide_brightness_slider_description": "إخفاء شريط التحكم في السطوع في مشغل الفيديو"
|
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "الصوت",
|
"audio_title": "الصوت",
|
||||||
@@ -236,12 +195,12 @@
|
|||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"language": "اللغة",
|
"language": "اللغة",
|
||||||
"transcode_mode": {
|
"transcode_mode": {
|
||||||
"title": "تحويل ترميز الصوت",
|
"title": "Audio Transcoding",
|
||||||
"description": "يتحكم في كيفية التعامل مع الصوت المحيطي (7.1، TrueHD، DTS-HD)",
|
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||||
"auto": "تلقائي",
|
"auto": "Auto",
|
||||||
"stereo": "إجبار تشغيل ستيريو",
|
"stereo": "Force Stereo",
|
||||||
"5_1": "السماح بـ 5.1",
|
"5_1": "Allow 5.1",
|
||||||
"passthrough": "تمرير الصوت"
|
"passthrough": "Passthrough"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
@@ -292,45 +251,29 @@
|
|||||||
"Normal": "عادي",
|
"Normal": "عادي",
|
||||||
"Thick": "سميك"
|
"Thick": "سميك"
|
||||||
},
|
},
|
||||||
"subtitle_color": "لون الترجمة",
|
"subtitle_color": "Subtitle Color",
|
||||||
"subtitle_background_color": "لون الخلفية",
|
"subtitle_background_color": "Background Color",
|
||||||
"subtitle_font": "خط الترجمة",
|
"subtitle_font": "Subtitle Font",
|
||||||
"ksplayer_title": "إعدادات KSPlayer",
|
"ksplayer_title": "KSPlayer Settings",
|
||||||
"hardware_decode": "فك الترميز بواسطة الجهاز",
|
"hardware_decode": "Hardware Decoding",
|
||||||
"hardware_decode_description": "استخدم تسريع العتاد لفك ترميز الفيديو. قم بتعطيله إذا واجهت مشكلات في التشغيل.",
|
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
||||||
"opensubtitles_title": "OpenSubtitles",
|
|
||||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
|
||||||
"opensubtitles_api_key": "API Key",
|
|
||||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
|
||||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
|
||||||
"mpv_subtitle_scale": "Subtitle Scale",
|
|
||||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
|
||||||
"mpv_subtitle_align_x": "Horizontal Align",
|
|
||||||
"mpv_subtitle_align_y": "Vertical Align",
|
|
||||||
"align": {
|
|
||||||
"left": "Left",
|
|
||||||
"center": "Center",
|
|
||||||
"right": "Right",
|
|
||||||
"top": "Top",
|
|
||||||
"bottom": "Bottom"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"vlc_subtitles": {
|
"vlc_subtitles": {
|
||||||
"title": "إعدادات ترجمة VLC",
|
"title": "VLC Subtitle Settings",
|
||||||
"hint": "تخصيص مظهر الترجمة لمشغل VLC. تصبح التغييرات سارية المفعول عند التشغيل التالي.",
|
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||||
"text_color": "لون النص",
|
"text_color": "Text Color",
|
||||||
"background_color": "لون الخلفية",
|
"background_color": "Background Color",
|
||||||
"background_opacity": "شفافية الخلفية",
|
"background_opacity": "Background Opacity",
|
||||||
"outline_color": "لون إطار الخط",
|
"outline_color": "Outline Color",
|
||||||
"outline_opacity": "شفافية إطار الخط",
|
"outline_opacity": "Outline Opacity",
|
||||||
"outline_thickness": "سمك إطار الخط",
|
"outline_thickness": "Outline Thickness",
|
||||||
"bold": "خط عريض",
|
"bold": "Bold Text",
|
||||||
"margin": "الهامش السفلي"
|
"margin": "Bottom Margin"
|
||||||
},
|
},
|
||||||
"video_player": {
|
"video_player": {
|
||||||
"title": "مشغل الفيديو",
|
"title": "Video Player",
|
||||||
"video_player": "مشغل الفيديو",
|
"video_player": "Video Player",
|
||||||
"video_player_description": "اختر مشغل الفيديو الذي سيتم استخدامه على نظام iOS.",
|
"video_player_description": "Choose which video player to use on iOS.",
|
||||||
"ksplayer": "KSPlayer",
|
"ksplayer": "KSPlayer",
|
||||||
"vlc": "VLC"
|
"vlc": "VLC"
|
||||||
},
|
},
|
||||||
@@ -362,8 +305,8 @@
|
|||||||
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
"select_liraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
||||||
"default_quality": "الجودة الافتراضية",
|
"default_quality": "الجودة الافتراضية",
|
||||||
"default_playback_speed": "سرعة التشغيل الافتراضية",
|
"default_playback_speed": "Default Playback Speed",
|
||||||
"auto_play_next_episode": "تشغيل الحلقة التالية تلقائياً",
|
"auto_play_next_episode": "Auto-play Next Episode",
|
||||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||||
"disabled": "معطل"
|
"disabled": "معطل"
|
||||||
},
|
},
|
||||||
@@ -371,15 +314,15 @@
|
|||||||
"downloads_title": "التنزيلات"
|
"downloads_title": "التنزيلات"
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "الموسيقى",
|
"title": "Music",
|
||||||
"playback_title": "التشغيل",
|
"playback_title": "Playback",
|
||||||
"playback_description": "ضبط كيفية تشغيل الموسيقى.",
|
"playback_description": "Configure how music is played.",
|
||||||
"prefer_downloaded": "تفضيل الأغاني التي تم تنزيلها",
|
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||||
"caching_title": "التخزين المؤقت",
|
"caching_title": "Caching",
|
||||||
"caching_description": "تخزين الأغاني التالية مؤقتاً تلقائياً لضمان تشغيل أكثر سلاسة.",
|
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||||
"lookahead_enabled": "تفعيل التخزين المؤقت الاستباقي",
|
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||||
"lookahead_count": "عدد الأغاني المراد تخزينها مسبقاً",
|
"lookahead_count": "Tracks to Pre-cache",
|
||||||
"max_cache_size": "الحد الأقصى لحجم التخزين المؤقت"
|
"max_cache_size": "Max Cache Size"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "الإضافات",
|
"plugins_title": "الإضافات",
|
||||||
@@ -414,39 +357,39 @@
|
|||||||
"save_button": "حفظ",
|
"save_button": "حفظ",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "تم الحفظ",
|
"saved": "تم الحفظ",
|
||||||
"refreshed": "تم تحديث الإعدادات من الخادم"
|
"refreshed": "Settings refreshed from server"
|
||||||
},
|
},
|
||||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"streamystats": {
|
"streamystats": {
|
||||||
"enable_streamystats": "تفعيل Streamystats",
|
"enable_streamystats": "Enable Streamystats",
|
||||||
"disable_streamystats": "تعطيل Streamystats",
|
"disable_streamystats": "Disable Streamystats",
|
||||||
"enable_search": "استخدم للبحث",
|
"enable_search": "Use for Search",
|
||||||
"url": "الرابط",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||||
"streamystats_search_hint": "أدخل رابط خادم Streamystats الخاص بك. يجب أن يتضمن الرابط البروتوكول http أو https مع رقم المنفذ اختيارياً.",
|
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_streamystats": "اقرأ المزيد عن Streamystats.",
|
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||||
"save_button": "حفظ",
|
"save_button": "Save",
|
||||||
"save": "حفظ",
|
"save": "Save",
|
||||||
"features_title": "المميزات",
|
"features_title": "Features",
|
||||||
"home_sections_title": "أقسام الرئيسية",
|
"home_sections_title": "Home Sections",
|
||||||
"enable_movie_recommendations": "توصيات الأفلام",
|
"enable_movie_recommendations": "Movie Recommendations",
|
||||||
"enable_series_recommendations": "توصيات المسلسلات",
|
"enable_series_recommendations": "Series Recommendations",
|
||||||
"enable_promoted_watchlists": "قوائم مشاهدة مختارة",
|
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||||
"hide_watchlists_tab": "إخفاء تبويب قوائم المشاهدة",
|
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||||
"home_sections_hint": "إظهار التوصيات المخصصة وقوائم المشاهدة المختارة من Streamystats في الصفحة الرئيسية.",
|
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||||
"recommended_movies": "أفلام موصى بها",
|
"recommended_movies": "Recommended Movies",
|
||||||
"recommended_series": "مسلسلات موصى بها",
|
"recommended_series": "Recommended Series",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "تم الحفظ",
|
"saved": "Saved",
|
||||||
"refreshed": "تم تحديث الإعدادات من الخادم",
|
"refreshed": "Settings refreshed from server",
|
||||||
"disabled": "تم تعطيل Streamystats"
|
"disabled": "Streamystats disabled"
|
||||||
},
|
},
|
||||||
"refresh_from_server": "تحديث الإعدادات من الخادم"
|
"refresh_from_server": "Refresh Settings from Server"
|
||||||
},
|
},
|
||||||
"kefinTweaks": {
|
"kefinTweaks": {
|
||||||
"watchlist_enabler": "تفعيل الربط مع قائمة المشاهدة الخاصة بنا",
|
"watchlist_enabler": "Enable our Watchlist integration",
|
||||||
"watchlist_button": "تبديل حالة ربط قائمة المشاهدة"
|
"watchlist_button": "Toggle Watchlist integration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
@@ -455,21 +398,15 @@
|
|||||||
"device_usage": "الجهاز {{availableSpace}}%",
|
"device_usage": "الجهاز {{availableSpace}}%",
|
||||||
"size_used": "تم استخدام {{used}} من {{total}}",
|
"size_used": "تم استخدام {{used}} من {{total}}",
|
||||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها",
|
||||||
"music_cache_title": "التخزين المؤقت للموسيقى",
|
"music_cache_title": "Music Cache",
|
||||||
"music_cache_description": "تخزين الأغاني تلقائياً أثناء الاستماع لضمان تشغيل أكثر سلاسة ودعم الاستماع بدون اتصال",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"enable_music_cache": "تمكين التخزين المؤقت للموسيقى",
|
"enable_music_cache": "Enable Music Cache",
|
||||||
"clear_music_cache": "مسح التخزين المؤقت للموسيقى",
|
"clear_music_cache": "Clear Music Cache",
|
||||||
"music_cache_size": "تم تخزين {{size}} مؤقتاً",
|
"music_cache_size": "{{size}} cached",
|
||||||
"music_cache_cleared": "تم مسح التخزين المؤقت للموسيقى",
|
"music_cache_cleared": "Music cache cleared",
|
||||||
"delete_all_downloaded_songs": "حذف جميع الأغاني التي تم تنزيلها",
|
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||||
"downloaded_songs_size": "تم تنزيل {{size}}",
|
"downloaded_songs_size": "{{size}} downloaded",
|
||||||
"downloaded_songs_deleted": "تم حذف الأغاني التي تم تنزيلها",
|
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||||
"clear_all_cache": "Clear All Cache",
|
|
||||||
"clear_all_cache_confirm": "Clear All Cache?",
|
|
||||||
"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.",
|
|
||||||
"clear_all_cache_success": "Cache Cleared",
|
|
||||||
"clear_all_cache_success_desc": "All cache has been cleared successfully.",
|
|
||||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"title": "المقدمة",
|
"title": "المقدمة",
|
||||||
@@ -493,21 +430,6 @@
|
|||||||
"error_deleting_files": "خطأ في حذف الملفات",
|
"error_deleting_files": "خطأ في حذف الملفات",
|
||||||
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
|
"background_downloads_enabled": "تم تفعيل التنزيلات في الخلفية",
|
||||||
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
|
"background_downloads_disabled": "تم تعطيل التنزيلات في الخلفية"
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"title": "Security",
|
|
||||||
"inactivity_timeout": {
|
|
||||||
"title": "Inactivity Timeout",
|
|
||||||
"description": "Auto logout after inactivity",
|
|
||||||
"disabled": "Disabled",
|
|
||||||
"1_minute": "1 minute",
|
|
||||||
"5_minutes": "5 minutes",
|
|
||||||
"15_minutes": "15 minutes",
|
|
||||||
"30_minutes": "30 minutes",
|
|
||||||
"1_hour": "1 hour",
|
|
||||||
"4_hours": "4 hours",
|
|
||||||
"24_hours": "24 hours"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
@@ -516,7 +438,7 @@
|
|||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "التنزيلات",
|
"downloads_title": "التنزيلات",
|
||||||
"series": "مسلسلات",
|
"tvseries": "مسلسلات",
|
||||||
"movies": "أفلام",
|
"movies": "أفلام",
|
||||||
"queue": "قائمة الانتظار",
|
"queue": "قائمة الانتظار",
|
||||||
"other_media": "وسائط أخرى",
|
"other_media": "وسائط أخرى",
|
||||||
@@ -524,7 +446,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": "التنزيل الجاري",
|
||||||
@@ -534,7 +456,6 @@
|
|||||||
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
|
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
|
||||||
"back": "رجوع",
|
"back": "رجوع",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"delete_download": "Delete Download",
|
|
||||||
"something_went_wrong": "حدث خطأ ما",
|
"something_went_wrong": "حدث خطأ ما",
|
||||||
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
|
||||||
"eta": "الوقت المتبقي {{eta}}",
|
"eta": "الوقت المتبقي {{eta}}",
|
||||||
@@ -542,8 +463,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": "تم حذف التنزيل",
|
||||||
@@ -571,28 +492,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"no_results": "No Results",
|
|
||||||
"select": "اختر",
|
"select": "اختر",
|
||||||
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
||||||
"video": "فيديو",
|
"video": "فيديو",
|
||||||
"audio": "الصوت",
|
"audio": "الصوت",
|
||||||
"subtitle": "الترجمة",
|
"subtitle": "الترجمة",
|
||||||
"play": "تشغيل",
|
"play": "تشغيل",
|
||||||
"mark_as_played": "Mark as Played",
|
|
||||||
"mark_as_not_played": "Mark as not Played",
|
|
||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"track": "أغنية",
|
"track": "Track",
|
||||||
"cancel": "إلغاء",
|
"cancel": "Cancel",
|
||||||
"stop": "Stop",
|
"delete": "Delete",
|
||||||
"delete": "حذف",
|
"ok": "OK",
|
||||||
"ok": "حسناً",
|
"remove": "Remove",
|
||||||
"remove": "إزالة",
|
"next": "Next",
|
||||||
"next": "التالي",
|
"back": "Back",
|
||||||
"back": "رجوع",
|
"continue": "Continue",
|
||||||
"continue": "متابعة",
|
"verifying": "Verifying..."
|
||||||
"verifying": "جارٍ التحقق...",
|
|
||||||
"login": "Login",
|
|
||||||
"refresh": "Refresh"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search": "بحث...",
|
"search": "بحث...",
|
||||||
@@ -606,10 +521,10 @@
|
|||||||
"episodes": "حلقات",
|
"episodes": "حلقات",
|
||||||
"collections": "مجموعات",
|
"collections": "مجموعات",
|
||||||
"actors": "ممثلون",
|
"actors": "ممثلون",
|
||||||
"artists": "الفنانون",
|
"artists": "Artists",
|
||||||
"albums": "الألبومات",
|
"albums": "Albums",
|
||||||
"songs": "الأغاني",
|
"songs": "Songs",
|
||||||
"playlists": "قوائم التشغيل",
|
"playlists": "Playlists",
|
||||||
"request_movies": "طلب أفلام",
|
"request_movies": "طلب أفلام",
|
||||||
"request_series": "طلب مسلسلات",
|
"request_series": "طلب مسلسلات",
|
||||||
"recently_added": "أضيف مؤخرًا",
|
"recently_added": "أضيف مؤخرًا",
|
||||||
@@ -641,7 +556,6 @@
|
|||||||
"movies": "أفلام",
|
"movies": "أفلام",
|
||||||
"series": "مسلسلات",
|
"series": "مسلسلات",
|
||||||
"boxsets": "مجموعات",
|
"boxsets": "مجموعات",
|
||||||
"playlists": "Playlists",
|
|
||||||
"items": "عناصر"
|
"items": "عناصر"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -652,20 +566,15 @@
|
|||||||
"poster": "ملصق",
|
"poster": "ملصق",
|
||||||
"cover": "غلاف",
|
"cover": "غلاف",
|
||||||
"show_titles": "إظهار العناوين",
|
"show_titles": "إظهار العناوين",
|
||||||
"show_stats": "إظهار الإحصائيات",
|
"show_stats": "إظهار الإحصائيات"
|
||||||
"options_title": "Options"
|
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "الأنواع",
|
"genres": "الأنواع",
|
||||||
"years": "السنوات",
|
"years": "السنوات",
|
||||||
"sort_by": "ترتيب حسب",
|
"sort_by": "ترتيب حسب",
|
||||||
"filter_by": "تصفية حسب",
|
"filter_by": "Filter By",
|
||||||
"sort_order": "اتجاه الترتيب",
|
"sort_order": "اتجاه الترتيب",
|
||||||
"tags": "الوسوم",
|
"tags": "الوسوم"
|
||||||
"all": "All",
|
|
||||||
"reset": "Reset",
|
|
||||||
"asc": "Ascending",
|
|
||||||
"desc": "Descending"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
@@ -682,8 +591,6 @@
|
|||||||
"no_links": "لا توجد روابط"
|
"no_links": "لا توجد روابط"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"live": "LIVE",
|
|
||||||
"mpv_player_title": "MPV Player",
|
|
||||||
"error": "خطأ",
|
"error": "خطأ",
|
||||||
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
||||||
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
||||||
@@ -697,39 +604,11 @@
|
|||||||
"index": "الفِهْرِس:",
|
"index": "الفِهْرِس:",
|
||||||
"continue_watching": "متابعة المشاهدة",
|
"continue_watching": "متابعة المشاهدة",
|
||||||
"go_back": "رجوع",
|
"go_back": "رجوع",
|
||||||
"downloaded_file_title": "تم تنزيل هذا الملف",
|
"downloaded_file_title": "You have this file downloaded",
|
||||||
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
|
"downloaded_file_message": "هل تريد تشغيل الملف الذي تم تنزيله؟",
|
||||||
"downloaded_file_yes": "نعم",
|
"downloaded_file_yes": "نعم",
|
||||||
"downloaded_file_no": "لا",
|
"downloaded_file_no": "لا",
|
||||||
"downloaded_file_cancel": "إلغاء",
|
"downloaded_file_cancel": "Cancel"
|
||||||
"swipe_down_settings": "Swipe down for settings",
|
|
||||||
"ends_at": "Ends at {{time}}",
|
|
||||||
"search_subtitles": "Search Subtitles",
|
|
||||||
"subtitle_tracks": "Tracks",
|
|
||||||
"subtitle_search": "Search & Download",
|
|
||||||
"download": "Download",
|
|
||||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
|
||||||
"using_jellyfin_server": "Using Jellyfin Server",
|
|
||||||
"language": "Language",
|
|
||||||
"results": "Results",
|
|
||||||
"searching": "Searching...",
|
|
||||||
"search_failed": "Search failed",
|
|
||||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
|
||||||
"no_subtitles_found": "No subtitles found",
|
|
||||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
|
||||||
"settings": "Settings",
|
|
||||||
"skip_intro": "Skip Intro",
|
|
||||||
"skip_credits": "Skip Credits",
|
|
||||||
"stopPlayback": "Stop Playback",
|
|
||||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
|
||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
|
||||||
"downloaded": "Downloaded"
|
|
||||||
},
|
|
||||||
"chapters": {
|
|
||||||
"title": "Chapters",
|
|
||||||
"chapter_number": "Chapter {{number}}",
|
|
||||||
"open": "Open chapters",
|
|
||||||
"close": "Close chapters"
|
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "التالي",
|
"next_up": "التالي",
|
||||||
@@ -738,11 +617,6 @@
|
|||||||
"series": "مسلسلات",
|
"series": "مسلسلات",
|
||||||
"seasons": "مواسم",
|
"seasons": "مواسم",
|
||||||
"season": "موسم",
|
"season": "موسم",
|
||||||
"from_this_series": "From This Series",
|
|
||||||
"more_from_this_season": "More from this Season",
|
|
||||||
"view_series": "View Series",
|
|
||||||
"view_season": "View Season",
|
|
||||||
"select_season": "Select Season",
|
|
||||||
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
|
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
|
||||||
"overview": "نظرة عامة",
|
"overview": "نظرة عامة",
|
||||||
"more_with": "المزيد مع {{name}}",
|
"more_with": "المزيد مع {{name}}",
|
||||||
@@ -750,24 +624,13 @@
|
|||||||
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
|
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
|
||||||
"video": "فيديو",
|
"video": "فيديو",
|
||||||
"more_details": "المزيد من التفاصيل",
|
"more_details": "المزيد من التفاصيل",
|
||||||
"media_options": "خيارات الوسائط",
|
"media_options": "Media Options",
|
||||||
"quality": "الجودة",
|
"quality": "الجودة",
|
||||||
"audio": "الصوت",
|
"audio": "الصوت",
|
||||||
"subtitles": {
|
"subtitles": "الترجمة",
|
||||||
"label": "Subtitle",
|
|
||||||
"none": "None",
|
|
||||||
"tracks": "Tracks"
|
|
||||||
},
|
|
||||||
"show_more": "عرض المزيد",
|
"show_more": "عرض المزيد",
|
||||||
"show_less": "عرض أقل",
|
"show_less": "عرض أقل",
|
||||||
"left": "left",
|
|
||||||
"more_info": "More Info",
|
|
||||||
"director": "Director",
|
|
||||||
"cast": "Cast",
|
|
||||||
"technical_details": "Technical Details",
|
|
||||||
"appeared_in": "ظهر في",
|
"appeared_in": "ظهر في",
|
||||||
"movies": "Movies",
|
|
||||||
"shows": "Shows",
|
|
||||||
"could_not_load_item": "تعذر تحميل العنصر",
|
"could_not_load_item": "تعذر تحميل العنصر",
|
||||||
"none": "لا شيء",
|
"none": "لا شيء",
|
||||||
"download": {
|
"download": {
|
||||||
@@ -778,13 +641,7 @@
|
|||||||
"download_x_item": "تنزيل {{item_count}} عناصر",
|
"download_x_item": "تنزيل {{item_count}} عناصر",
|
||||||
"download_unwatched_only": "غير المشاهدة فقط",
|
"download_unwatched_only": "غير المشاهدة فقط",
|
||||||
"download_button": "تنزيل"
|
"download_button": "تنزيل"
|
||||||
},
|
}
|
||||||
"mark_played": "Mark as Watched",
|
|
||||||
"mark_unplayed": "Mark as Unwatched",
|
|
||||||
"resume_playback": "Resume Playback",
|
|
||||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
|
||||||
"play_from_start": "Play from Start",
|
|
||||||
"continue_from": "Continue from {{time}}"
|
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "التالي",
|
"next": "التالي",
|
||||||
@@ -795,18 +652,7 @@
|
|||||||
"movies": "أفلام",
|
"movies": "أفلام",
|
||||||
"sports": "رياضة",
|
"sports": "رياضة",
|
||||||
"for_kids": "للأطفال",
|
"for_kids": "للأطفال",
|
||||||
"news": "أخبار",
|
"news": "أخبار"
|
||||||
"page_of": "Page {{current}} of {{total}}",
|
|
||||||
"no_programs": "No programs available",
|
|
||||||
"no_channels": "No channels available",
|
|
||||||
"tabs": {
|
|
||||||
"programs": "Programs",
|
|
||||||
"guide": "Guide",
|
|
||||||
"channels": "Channels",
|
|
||||||
"recordings": "Recordings",
|
|
||||||
"schedule": "Schedule",
|
|
||||||
"series": "Series"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"jellyseerr": {
|
"jellyseerr": {
|
||||||
"confirm": "تأكيد",
|
"confirm": "تأكيد",
|
||||||
@@ -851,12 +697,6 @@
|
|||||||
"decline": "رفض",
|
"decline": "رفض",
|
||||||
"requested_by": "مطلوب من {{user}}",
|
"requested_by": "مطلوب من {{user}}",
|
||||||
"unknown_user": "مستخدم غير معروف",
|
"unknown_user": "مستخدم غير معروف",
|
||||||
"select": "Select",
|
|
||||||
"request_all": "Request All",
|
|
||||||
"request_seasons": "Request Seasons",
|
|
||||||
"select_seasons": "Select Seasons",
|
|
||||||
"request_selected": "Request Selected",
|
|
||||||
"n_selected": "{{count}} selected",
|
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "خادم Seerr لا يستوفي الحد الأدنى للإصدار المطلوب! يرجى التحديث إلى إصدار 2.0.0 على الأقل",
|
"jellyseer_does_not_meet_requirements": "خادم Seerr لا يستوفي الحد الأدنى للإصدار المطلوب! يرجى التحديث إلى إصدار 2.0.0 على الأقل",
|
||||||
"jellyseerr_test_failed": "فشل اختبار Seerr. يرجى المحاولة مرة أخرى.",
|
"jellyseerr_test_failed": "فشل اختبار Seerr. يرجى المحاولة مرة أخرى.",
|
||||||
@@ -876,162 +716,130 @@
|
|||||||
"search": "بحث",
|
"search": "بحث",
|
||||||
"library": "المكتبة",
|
"library": "المكتبة",
|
||||||
"custom_links": "روابط مخصصة",
|
"custom_links": "روابط مخصصة",
|
||||||
"favorites": "المفضلة",
|
"favorites": "المفضلة"
|
||||||
"settings": "Settings"
|
|
||||||
},
|
},
|
||||||
"music": {
|
"music": {
|
||||||
"title": "الموسيقى",
|
"title": "Music",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"suggestions": "الإقتراحات",
|
"suggestions": "Suggestions",
|
||||||
"albums": "الألبومات",
|
"albums": "Albums",
|
||||||
"artists": "الفنانون",
|
"artists": "Artists",
|
||||||
"playlists": "قوائم التشغيل",
|
"playlists": "Playlists",
|
||||||
"tracks": "الأغاني"
|
"tracks": "tracks"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "الكل"
|
"all": "All"
|
||||||
},
|
},
|
||||||
"recently_added": "أضيف مؤخرًا",
|
"recently_added": "Recently Added",
|
||||||
"recently_played": "تم تشغيله مؤخرًا",
|
"recently_played": "Recently Played",
|
||||||
"frequently_played": "الأكثر تشغيلاً",
|
"frequently_played": "Frequently Played",
|
||||||
"explore": "اكتشف",
|
"explore": "Explore",
|
||||||
"top_tracks": "أفضل الأغاني",
|
"top_tracks": "Top Tracks",
|
||||||
"play": "تشغيل",
|
"play": "Play",
|
||||||
"shuffle": "ترتيب عشوائي",
|
"shuffle": "Shuffle",
|
||||||
"play_top_tracks": "تشغيل أفضل الأغاني",
|
"play_top_tracks": "Play Top Tracks",
|
||||||
"no_suggestions": "لا توجد مقترحات متاحة",
|
"no_suggestions": "No suggestions available",
|
||||||
"no_albums": "لا توجد ألبومات",
|
"no_albums": "No albums found",
|
||||||
"no_artists": "لا يوجد فنانون",
|
"no_artists": "No artists found",
|
||||||
"no_playlists": "لا توجد قوائم تشغيل",
|
"no_playlists": "No playlists found",
|
||||||
"album_not_found": "الألبوم غير موجود",
|
"album_not_found": "Album not found",
|
||||||
"artist_not_found": "الفنان غير موجود",
|
"artist_not_found": "Artist not found",
|
||||||
"playlist_not_found": "قائمة التشغيل غير موجودة",
|
"playlist_not_found": "Playlist not found",
|
||||||
"track_options": {
|
"track_options": {
|
||||||
"play_next": "تشغيل التالي",
|
"play_next": "Play Next",
|
||||||
"add_to_queue": "إضافة إلى قائمة الانتظار",
|
"add_to_queue": "Add to Queue",
|
||||||
"add_to_playlist": "أضف إلى قائمة التشغيل",
|
"add_to_playlist": "Add to Playlist",
|
||||||
"download": "تنزيل",
|
"download": "Download",
|
||||||
"downloaded": "تم التنزيل",
|
"downloaded": "Downloaded",
|
||||||
"downloading": "جارٍ التنزيل...",
|
"downloading": "Downloading...",
|
||||||
"cached": "تم التخزين مؤقتاً",
|
"cached": "Cached",
|
||||||
"delete_download": "حذف ملف التنزيل",
|
"delete_download": "Delete Download",
|
||||||
"delete_cache": "إزالة من التخزين المؤقت",
|
"delete_cache": "Remove from Cache",
|
||||||
"go_to_artist": "انتقال إلى الفنان",
|
"go_to_artist": "Go to Artist",
|
||||||
"go_to_album": "انتقال إلى الألبوم",
|
"go_to_album": "Go to Album",
|
||||||
"add_to_favorites": "إضافة إلى المفضلة",
|
"add_to_favorites": "Add to Favorites",
|
||||||
"remove_from_favorites": "إزالة من المفضلة",
|
"remove_from_favorites": "Remove from Favorites",
|
||||||
"remove_from_playlist": "إزالة من قائمة التشغيل"
|
"remove_from_playlist": "Remove from Playlist"
|
||||||
},
|
},
|
||||||
"playlists": {
|
"playlists": {
|
||||||
"create_playlist": "إنشاء قائمة التشغيل",
|
"create_playlist": "Create Playlist",
|
||||||
"playlist_name": "اسم قائمة التشغيل",
|
"playlist_name": "Playlist Name",
|
||||||
"enter_name": "أدخل اسم قائمة التشغيل",
|
"enter_name": "Enter playlist name",
|
||||||
"create": "إنشاء",
|
"create": "Create",
|
||||||
"search_playlists": "البحث عن قوائم التشغيل...",
|
"search_playlists": "Search playlists...",
|
||||||
"added_to": "تمت الإضافة إلى {{name}}",
|
"added_to": "Added to {{name}}",
|
||||||
"added": "تمت الإضافة إلى قائمة التشغيل",
|
"added": "Added to playlist",
|
||||||
"removed_from": "تمت الإزالة من {{name}}",
|
"removed_from": "Removed from {{name}}",
|
||||||
"removed": "تمت الازالة من قائمة التشغيل",
|
"removed": "Removed from playlist",
|
||||||
"created": "تم إنشاء قائمة التشغيل",
|
"created": "Playlist created",
|
||||||
"create_new": "إنشاء قائمة تشغيل جديدة",
|
"create_new": "Create New Playlist",
|
||||||
"failed_to_add": "فشلت الإضافة إلى قائمة التشغيل",
|
"failed_to_add": "Failed to add to playlist",
|
||||||
"failed_to_remove": "فشلت الإزالة من قائمة التشغيل",
|
"failed_to_remove": "Failed to remove from playlist",
|
||||||
"failed_to_create": "فشل إنشاء قائمة التشغيل",
|
"failed_to_create": "Failed to create playlist",
|
||||||
"delete_playlist": "حذف قائمة التشغيل",
|
"delete_playlist": "Delete Playlist",
|
||||||
"delete_confirm": "هل أنت متأكد من رغبتك في حذف {{name}}؟ لا يمكن التراجع عن هذا الإجراء.",
|
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
"deleted": "تم حذف قائمة التشغيل",
|
"deleted": "Playlist deleted",
|
||||||
"failed_to_delete": "فشل إنشاء قائمة التشغيل"
|
"failed_to_delete": "Failed to delete playlist"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"title": "ترتيب حسب",
|
"title": "Sort By",
|
||||||
"alphabetical": "أبجدي",
|
"alphabetical": "Alphabetical",
|
||||||
"date_created": "تاريخ الإنشاء"
|
"date_created": "Date Created"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"watchlists": {
|
"watchlists": {
|
||||||
"title": "قوائم المشاهدة",
|
"title": "Watchlists",
|
||||||
"my_watchlists": "قوائم المشاهدة الخاصة بي",
|
"my_watchlists": "My Watchlists",
|
||||||
"public_watchlists": "قوائم مشاهدة عامة",
|
"public_watchlists": "Public Watchlists",
|
||||||
"create_title": "إنشاء قائمة مشاهدة",
|
"create_title": "Create Watchlist",
|
||||||
"edit_title": "تعديل قائمة المشاهدة",
|
"edit_title": "Edit Watchlist",
|
||||||
"create_button": "إنشاء قائمة مشاهدة",
|
"create_button": "Create Watchlist",
|
||||||
"save_button": "حفظ التغييرات",
|
"save_button": "Save Changes",
|
||||||
"delete_button": "حذف",
|
"delete_button": "Delete",
|
||||||
"remove_button": "إزالة",
|
"remove_button": "Remove",
|
||||||
"cancel_button": "إلغاء",
|
"cancel_button": "Cancel",
|
||||||
"name_label": "الاسم",
|
"name_label": "Name",
|
||||||
"name_placeholder": "أدخل اسم قائمة المشاهدة",
|
"name_placeholder": "Enter watchlist name",
|
||||||
"description_label": "الوصف",
|
"description_label": "Description",
|
||||||
"description_placeholder": "أدخل الوصف (اختياري)",
|
"description_placeholder": "Enter description (optional)",
|
||||||
"is_public_label": "قائمة مشاهدة عامة",
|
"is_public_label": "Public Watchlist",
|
||||||
"is_public_description": "السماح للآخرين بعرض قائمة المشاهدة هذه",
|
"is_public_description": "Allow others to view this watchlist",
|
||||||
"allowed_type_label": "نوع المحتوى",
|
"allowed_type_label": "Content Type",
|
||||||
"sort_order_label": "الترتيب الافتراضي",
|
"sort_order_label": "Default Sort Order",
|
||||||
"empty_title": "لا توجد قوائم مشاهدة",
|
"empty_title": "No Watchlists",
|
||||||
"empty_description": "قم بإنشاء أول قائمة مشاهدة لبدء تنظيم الوسائط الخاصة بك",
|
"empty_description": "Create your first watchlist to start organizing your media",
|
||||||
"empty_watchlist": "قائمة المشاهدة هذه فارغة",
|
"empty_watchlist": "This watchlist is empty",
|
||||||
"empty_watchlist_hint": "إضافة عناصر من مكتبتك إلى قائمة المشاهدة هذه",
|
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||||
"not_configured_title": "لم يتم ضبط Streamystats",
|
"not_configured_title": "Streamystats Not Configured",
|
||||||
"not_configured_description": "اضبط Streamystats في الإعدادات لاستخدام قوائم المشاهدة",
|
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||||
"go_to_settings": "الذهاب إلى الإعدادات",
|
"go_to_settings": "Go to Settings",
|
||||||
"add_to_watchlist": "إضافة إلى قائمة المشاهدة",
|
"add_to_watchlist": "Add to Watchlist",
|
||||||
"remove_from_watchlist": "إزالة من قائمة المشاهدة",
|
"remove_from_watchlist": "Remove from Watchlist",
|
||||||
"select_watchlist": "تحديد قائمة المشاهدة",
|
"select_watchlist": "Select Watchlist",
|
||||||
"create_new": "إنشاء قائمة مشاهدة جديدة",
|
"create_new": "Create New Watchlist",
|
||||||
"item": "عنصر",
|
"item": "item",
|
||||||
"items": "عناصر",
|
"items": "items",
|
||||||
"public": "عامة",
|
"public": "Public",
|
||||||
"private": "خاصة",
|
"private": "Private",
|
||||||
"you": "أنت",
|
"you": "You",
|
||||||
"by_owner": "بواسطة مستخدم آخر",
|
"by_owner": "By another user",
|
||||||
"not_found": "قائمة المشاهدة غير موجودة",
|
"not_found": "Watchlist not found",
|
||||||
"delete_confirm_title": "حذف قائمة المشاهدة",
|
"delete_confirm_title": "Delete Watchlist",
|
||||||
"delete_confirm_message": "هل أنت متأكد من رغبتك في حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.",
|
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
"remove_item_title": "إزالة من قائمة المشاهدة",
|
"remove_item_title": "Remove from Watchlist",
|
||||||
"remove_item_message": "إزالة \"{{name}}\" من قائمة المشاهدة هذه؟",
|
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||||
"loading": "تحميل قوائم المشاهدة...",
|
"loading": "Loading watchlists...",
|
||||||
"no_compatible_watchlists": "لا توجد قوائم مشاهدة متوافقة",
|
"no_compatible_watchlists": "No compatible watchlists",
|
||||||
"create_one_first": "إنشاء قائمة مشاهدة تقبل نوع المحتوى هذا"
|
"create_one_first": "Create a watchlist that accepts this content type"
|
||||||
},
|
},
|
||||||
"playback_speed": {
|
"playback_speed": {
|
||||||
"title": "سرعة التشغيل",
|
"title": "Playback Speed",
|
||||||
"apply_to": "تطبيق على",
|
"apply_to": "Apply To",
|
||||||
"speed": "السرعة",
|
"speed": "Speed",
|
||||||
"scope": {
|
"scope": {
|
||||||
"media": "الوسائط هذه فقط",
|
"media": "This media only",
|
||||||
"show": "هذا المسلسل",
|
"show": "This show",
|
||||||
"all": "جميع الوسائط (الافتراضي)"
|
"all": "All media (default)"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"companion_login": {
|
|
||||||
"title": "Pair with TV",
|
|
||||||
"align_qr": "Align the QR code within the frame",
|
|
||||||
"enter_code_manually": "Enter code manually",
|
|
||||||
"pairing_enter_credentials": "Enter credentials for TV",
|
|
||||||
"pairing_code_label": "Pairing code",
|
|
||||||
"server": "Server",
|
|
||||||
"authorize_button": "Authorize",
|
|
||||||
"authorizing": "Authorizing...",
|
|
||||||
"scan_again": "Scan Again",
|
|
||||||
"done": "Done",
|
|
||||||
"success_title": "Authorization Sent",
|
|
||||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
|
||||||
"error_title": "Authorization Failed",
|
|
||||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
|
||||||
"error_generic": "Something went wrong. Please try again.",
|
|
||||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
|
||||||
"login_as": "Log in as {{username}}?",
|
|
||||||
"on_server": "on {{server}}",
|
|
||||||
"use_different_user": "Use a different user",
|
|
||||||
"open_settings": "Open Settings"
|
|
||||||
},
|
|
||||||
"pairing": {
|
|
||||||
"pair_with_phone": "Pair with Phone",
|
|
||||||
"pair_with_phone_title": "Login TV",
|
|
||||||
"pair_with_phone_description": "Scan the QR code displayed on your TV to log in",
|
|
||||||
"waiting_for_phone": "Waiting for phone...",
|
|
||||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
|
||||||
"logging_in": "Logging in...",
|
|
||||||
"logging_in_description": "Connecting to your server"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||