mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 00:43:08 +01:00
Compare commits
1 Commits
fix/downlo
...
renovate/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b512024c93 |
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
|
||||
390
.github/workflows/build-apps.yml
vendored
390
.github/workflows/build-apps.yml
vendored
@@ -11,7 +11,7 @@ on:
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
# Exposed to `expo prebuild` (app.config.ts → extra.build) so Settings can show the
|
||||
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||
# branch + commit + Actions run a CI build was made from. EAS cloud builds use
|
||||
# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
|
||||
# run (artifacts + logs) without needing Expo access.
|
||||
@@ -30,71 +30,69 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- parallel:
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -132,71 +130,69 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- parallel:
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
@@ -233,36 +229,22 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -277,6 +259,19 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
@@ -306,29 +301,22 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -343,6 +331,12 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
@@ -374,36 +368,22 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -418,6 +398,19 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
@@ -445,29 +438,22 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -482,6 +468,12 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
|
||||
26
.github/workflows/check-lockfile.yml
vendored
26
.github/workflows/check-lockfile.yml
vendored
@@ -18,22 +18,22 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
|
||||
17
.github/workflows/detect-duplicate.yml
vendored
17
.github/workflows/detect-duplicate.yml
vendored
@@ -20,18 +20,17 @@ jobs:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.ts
|
||||
run: bun scripts/detect-duplicate-issue.mjs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
104
.github/workflows/linting.yml
vendored
104
.github/workflows/linting.yml
vendored
@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -11,11 +11,39 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate_pr_title:
|
||||
name: "📝 Validate PR Title"
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
|
||||
**Error details:**
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
|
||||
dependency-review:
|
||||
name: 🔍 Vulnerable Dependencies
|
||||
runs-on: ubuntu-26.04
|
||||
@@ -39,26 +67,18 @@ jobs:
|
||||
name: 🚑 Expo Doctor Check
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 📦 Install dependencies (bun)
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -83,26 +103,24 @@ jobs:
|
||||
- "i18n:check"
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
# renovate: datasource=node-version depName=node versioning=node
|
||||
node-version: "24.17.0"
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
81
.github/workflows/pr-title-comment.yml
vendored
81
.github/workflows/pr-title-comment.yml
vendored
@@ -1,81 +0,0 @@
|
||||
name: 📝 PR Title Comment
|
||||
|
||||
# Posts / updates / deletes the PR-title lint sticky comment with a write token,
|
||||
# so it works on FORK PRs too (the pull_request-triggered pr-title.yml only has a
|
||||
# read-only token on forks). workflow_run runs from the base branch with the base
|
||||
# repo's token. This is comment-only — the required check stays pr-title.yml.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["📝 PR Title"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read # download the artifact from the triggering run
|
||||
pull-requests: write # post/update/delete the sticky comment
|
||||
|
||||
concurrency:
|
||||
group: pr-title-comment-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
# Only run when the lint run actually produced a result artifact: a run
|
||||
# cancelled by concurrency (rapid pushes to the same PR) completes with
|
||||
# conclusion "cancelled" before uploading it, and would fail the download.
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion)
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: ⬇️ Download lint result
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh run download "${{ github.event.workflow_run.id }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--name pr-title-result \
|
||||
--dir result
|
||||
|
||||
- name: 🔎 Read result
|
||||
id: result
|
||||
run: |
|
||||
echo "number=$(cat result/number)" >> "$GITHUB_OUTPUT"
|
||||
if [ -s result/error ]; then
|
||||
echo "has_error=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_error=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 📥 Load error message
|
||||
if: steps.result.outputs.has_error == 'true'
|
||||
run: |
|
||||
{
|
||||
echo "LINT_ERROR<<PR_TITLE_ERR_EOF"
|
||||
cat result/error
|
||||
echo
|
||||
echo "PR_TITLE_ERR_EOF"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: 💬 Post title error
|
||||
if: steps.result.outputs.has_error == 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
number: ${{ steps.result.outputs.number }}
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
|
||||
**Error details:**
|
||||
```
|
||||
${{ env.LINT_ERROR }}
|
||||
```
|
||||
|
||||
- name: 🧹 Clear title error
|
||||
if: steps.result.outputs.has_error == 'false'
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
number: ${{ steps.result.outputs.number }}
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
42
.github/workflows/pr-title.yml
vendored
42
.github/workflows/pr-title.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: 📝 PR Title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate_pr_title:
|
||||
name: "📝 Validate PR Title"
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Fork PRs only get a read-only GITHUB_TOKEN here, so the sticky comment
|
||||
# is posted from the privileged pr-title-comment.yml (workflow_run). Hand
|
||||
# off the result (PR number + lint error) as an artifact for it to read.
|
||||
- if: always()
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
LINT_ERROR: ${{ steps.lint_pr_title.outputs.error_message }}
|
||||
run: |
|
||||
mkdir -p pr-title-result
|
||||
printf '%s' "$PR_NUMBER" > pr-title-result/number
|
||||
printf '%s' "$LINT_ERROR" > pr-title-result/error
|
||||
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pr-title-result
|
||||
path: pr-title-result/
|
||||
retention-days: 1
|
||||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@@ -13,8 +13,6 @@ name: 🚀 Release (EAS build + submit)
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
# Queue successive releases in order instead of dropping the extra pending run.
|
||||
queue: max
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -65,29 +63,21 @@ jobs:
|
||||
artifact_name: streamyfin-android-tv-apk
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
@@ -99,6 +89,13 @@ jobs:
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # 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
|
||||
|
||||
25
.github/workflows/update-issue-form.yml
vendored
25
.github/workflows/update-issue-form.yml
vendored
@@ -25,20 +25,19 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||
# PR would revert any form edits made on develop since that release.
|
||||
ref: develop
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||
# PR would revert any form edits made on develop since that release.
|
||||
ref: develop
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔢 Populate version dropdown from GitHub releases
|
||||
id: populate
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,6 +12,10 @@ web-build/
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
/iosmobile
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use TypeScript for all files (no .js)
|
||||
- Use functional React components with hooks
|
||||
- Use Jotai atoms for global state, React Query for server state
|
||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||
|
||||
@@ -73,7 +73,7 @@ Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building
|
||||
|
||||
## 🛣️ Roadmap
|
||||
|
||||
Check out our [Roadmap](https://github.com/orgs/streamyfin/projects/3/views/1) to see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||
|
||||
## 📥 Download Streamyfin
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// Registers the tsx require hook so the TypeScript config plugins referenced
|
||||
// from app.json ("./plugins/*.ts") can be loaded by Node during config evaluation.
|
||||
import "tsx/cjs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { ConfigContext, ExpoConfig } from "expo/config";
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
// Build metadata, injected into `extra.build` and read at runtime via
|
||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||
const git = (args: string[]): string | null => {
|
||||
const git = (args) => {
|
||||
try {
|
||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
@@ -46,16 +42,16 @@ const buildMeta = {
|
||||
builtAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
module.exports = ({ config }) => {
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
config.plugins?.push("expo-background-task");
|
||||
config.plugins.push("expo-background-task");
|
||||
|
||||
config.plugins?.push([
|
||||
config.plugins.push([
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
|
||||
config.plugins?.push([
|
||||
config.plugins.push([
|
||||
"expo-camera",
|
||||
{
|
||||
cameraPermission:
|
||||
@@ -65,7 +61,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
}
|
||||
|
||||
// Only override googleServicesFile if env var is set
|
||||
const androidConfig: { googleServicesFile?: string } = {};
|
||||
const androidConfig = {};
|
||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||
}
|
||||
@@ -75,5 +71,5 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
return {
|
||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||
...config,
|
||||
} as ExpoConfig;
|
||||
};
|
||||
};
|
||||
24
app.json
24
app.json
@@ -71,8 +71,8 @@
|
||||
],
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"./plugins/withExcludeMedia3Dash.ts",
|
||||
"./plugins/withTVUserManagement.ts",
|
||||
"./plugins/withExcludeMedia3Dash.js",
|
||||
"./plugins/withTVUserManagement.js",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -134,17 +134,17 @@
|
||||
}
|
||||
],
|
||||
"expo-web-browser",
|
||||
["./plugins/with-runtime-framework-headers.ts"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.ts"],
|
||||
["./plugins/withAndroidAlertColors.ts"],
|
||||
["./plugins/withAndroidManifest.ts"],
|
||||
["./plugins/withTrustLocalCerts.ts"],
|
||||
["./plugins/withGradleProperties.ts"],
|
||||
["./plugins/withTVOSAppIcon.ts"],
|
||||
["./plugins/withTVOSTopShelf.ts"],
|
||||
["./plugins/withTVXcodeEnv.ts"],
|
||||
["./plugins/with-runtime-framework-headers.js"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidAlertColors.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withTVOSAppIcon.js"],
|
||||
["./plugins/withTVOSTopShelf.js"],
|
||||
["./plugins/withTVXcodeEnv.js"],
|
||||
[
|
||||
"./plugins/withGitPod.ts",
|
||||
"./plugins/withGitPod.js",
|
||||
{
|
||||
"podName": "MPVKit",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||
|
||||
@@ -12,16 +12,11 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import {
|
||||
useFocusEffect,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
} from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BackHandler,
|
||||
FlatList,
|
||||
Platform,
|
||||
ScrollView,
|
||||
@@ -85,9 +80,8 @@ const Page = () => {
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
filterBy?: string;
|
||||
fromSeeAll?: string;
|
||||
};
|
||||
const { libraryId, fromSeeAll } = searchParams;
|
||||
const { libraryId } = searchParams;
|
||||
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
@@ -118,22 +112,6 @@ const Page = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { showOptions } = useTVOptionModal();
|
||||
|
||||
// When this library detail was opened from the home "See All" button, its
|
||||
// libraries stack is just [detail], so the default TV Back would exit to home.
|
||||
// Intercept Back (scoped to while this screen is focused via useFocusEffect) and
|
||||
// route to the library list instead, so the user can switch libraries. Normal
|
||||
// entries from the list keep their native pop-to-list behavior.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!Platform.isTV || fromSeeAll !== "true") return;
|
||||
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
router.replace("/(auth)/(tabs)/(libraries)");
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [fromSeeAll, router]),
|
||||
);
|
||||
const { showItemActions } = useTVItemActionModal();
|
||||
|
||||
// TV Filter queries
|
||||
@@ -291,23 +269,6 @@ const Page = () => {
|
||||
});
|
||||
}, [library]);
|
||||
|
||||
// If this See-All detail was deep-linked on top of the libraries index, collapse
|
||||
// the libraries stack to just this screen. Otherwise the stack is [index, detail],
|
||||
// which the native bottom tab reliably auto-pops back to the index (the detail
|
||||
// "bounces" to the library list ~0.5s after opening). With [detail] alone it stays
|
||||
// put, and Back is handled explicitly by the fromSeeAll interceptor above.
|
||||
const didCollapseRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV || fromSeeAll !== "true" || didCollapseRef.current)
|
||||
return;
|
||||
const state = navigation.getState();
|
||||
if (state?.routes && state.routes.length > 1) {
|
||||
didCollapseRef.current = true;
|
||||
const top = state.routes[state.routes.length - 1];
|
||||
navigation.reset({ index: 0, routes: [top] } as any);
|
||||
}
|
||||
}, [navigation, fromSeeAll]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
|
||||
@@ -305,8 +305,6 @@ export default function SearchPage() {
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: false,
|
||||
// Android: color of the user-typed text (was dark and unreadable on the dark header)
|
||||
textColor: "#fff",
|
||||
// Android: placeholder and icon color
|
||||
hintTextColor: "#fff",
|
||||
headerIconColor: "#fff",
|
||||
|
||||
@@ -3,24 +3,16 @@ import {
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
import { Stack, useSegments, withLayoutContext } from "expo-router";
|
||||
import { withLayoutContext } from "expo-router";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "expo-router/react-navigation";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import type { TVNavBarTab } from "@/components/tv/TVNavBar";
|
||||
import { TVNavBar } from "@/components/tv/TVNavBar";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
isTabRoute,
|
||||
useTVHomeBackHandler,
|
||||
useTVTabRootBackHandler,
|
||||
} from "@/hooks/useTVBackHandler";
|
||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
@@ -41,108 +33,13 @@ export const NativeTabs = withLayoutContext<
|
||||
NativeBottomTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
const IS_ANDROID_TV = Platform.isTV && Platform.OS === "android";
|
||||
|
||||
function TVTabLayout() {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
const currentTab = segments.find(isTabRoute);
|
||||
const lastSegment = segments[segments.length - 1] ?? "";
|
||||
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
|
||||
|
||||
const tabs: TVNavBarTab[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ key: "(home)", label: t("tabs.home") },
|
||||
{ key: "(search)", label: t("tabs.search") },
|
||||
{ key: "(favorites)", label: t("tabs.favorites") },
|
||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
|
||||
? null
|
||||
: { key: "(watchlists)", label: t("watchlists.title") },
|
||||
{ key: "(libraries)", label: t("tabs.library") },
|
||||
!settings?.showCustomMenuLinks
|
||||
? null
|
||||
: { key: "(custom-links)", label: t("tabs.custom_links") },
|
||||
{ key: "(settings)", label: t("tabs.settings") },
|
||||
].filter((tab): tab is TVNavBarTab => tab !== null),
|
||||
[
|
||||
settings?.streamyStatsServerUrl,
|
||||
settings?.hideWatchlistsTab,
|
||||
settings?.showCustomMenuLinks,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const activeTabKey = currentTab ?? "(home)";
|
||||
|
||||
const visibleKeys = useMemo(
|
||||
() => new Set(tabs.map((tab) => tab.key)),
|
||||
[tabs],
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(key: string) => {
|
||||
if (key === currentTab) return;
|
||||
|
||||
if (key === "(home)") eventBus.emit("scrollToTop");
|
||||
if (key === "(search)") eventBus.emit("searchTabPressed");
|
||||
|
||||
router.replace(`/(auth)/(tabs)/${key}`);
|
||||
},
|
||||
[currentTab, router],
|
||||
);
|
||||
|
||||
const navigateHome = useCallback(() => {
|
||||
router.replace("/(auth)/(tabs)/(home)");
|
||||
}, [router]);
|
||||
useTVTabRootBackHandler(navigateHome, atTabRoot, currentTab);
|
||||
|
||||
// If current tab is no longer visible (setting changed), navigate to home
|
||||
useEffect(() => {
|
||||
if (!visibleKeys.has(activeTabKey) && activeTabKey !== "(home)") {
|
||||
router.replace("/(auth)/(tabs)/(home)");
|
||||
}
|
||||
}, [visibleKeys, activeTabKey, router]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<Stack
|
||||
screenOptions={{ headerShown: false, animation: "none" }}
|
||||
initialRouteName='(home)'
|
||||
>
|
||||
<Stack.Screen name='index' redirect />
|
||||
</Stack>
|
||||
<TVNavBar
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
onTabChange={handleTabChange}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TabLayout() {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Must be called before any conditional return (rules of hooks)
|
||||
// Handle TV back button - prevent app exit when at root
|
||||
useTVHomeBackHandler();
|
||||
|
||||
if (IS_ANDROID_TV) {
|
||||
return <TVTabLayout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
|
||||
@@ -456,23 +456,10 @@ export default function DirectPlayerPage() {
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
// Synchronously destroy the mpv instance + decoder + surface buffers
|
||||
// BEFORE the screen unmounts. Otherwise the next screen (or the next
|
||||
// episode's player) mounts while the old 4K decoder is still alive,
|
||||
// causing OOM on low-RAM devices. Native stop() is idempotent so the
|
||||
// later React unmount cleanup is still safe.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
// Pre-libmpv-1.0 used `stop()`:
|
||||
// videoRef.current?.stop();
|
||||
videoRef.current?.pause();
|
||||
revalidateProgressCache();
|
||||
// Resume inactivity timer when leaving player (TV only)
|
||||
resumeInactivityTimer();
|
||||
// Release the keep-awake wakelock acquired during playback so it
|
||||
// doesn't follow us back to the home screen and block the TV
|
||||
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
|
||||
// and only released on the "paused" event; without this, navigating
|
||||
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
|
||||
deactivateKeepAwake();
|
||||
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -893,27 +880,6 @@ export default function DirectPlayerPage() {
|
||||
// Check if we're transcoding
|
||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||
|
||||
// A transcoded stream only carries the audio track the server encoded
|
||||
// into it — switching requires re-negotiating the stream with the new
|
||||
// index (like the mobile menu's replacePlayer), not an mpv aid change.
|
||||
if (isTranscoding) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item?.Id ?? "",
|
||||
audioIndex: String(index),
|
||||
subtitleIndex: String(currentSubtitleIndex),
|
||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString() ?? "",
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
}).toString();
|
||||
// Destroy the current mpv instance BEFORE navigating, same rationale as
|
||||
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
|
||||
// during the transition, and two simultaneous decoders OOM-kill low-RAM
|
||||
// devices. Resume is preserved via the playbackPosition param.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert Jellyfin index to MPV track ID
|
||||
const mpvTrackId = getMpvAudioId(
|
||||
stream?.mediaSource,
|
||||
@@ -925,14 +891,7 @@ export default function DirectPlayerPage() {
|
||||
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||
}
|
||||
},
|
||||
[
|
||||
stream?.mediaSource,
|
||||
item?.Id,
|
||||
currentSubtitleIndex,
|
||||
bitrateValue,
|
||||
router,
|
||||
progress,
|
||||
],
|
||||
[stream?.mediaSource],
|
||||
);
|
||||
|
||||
// TV subtitle track change handler
|
||||
@@ -1146,15 +1105,6 @@ export default function DirectPlayerPage() {
|
||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
// Destroy the current mpv instance BEFORE navigating so the old 4K
|
||||
// decoder + surface buffers are freed before the new player screen
|
||||
// mounts. Without this, Expo Router briefly holds two simultaneous
|
||||
// mpv instances during the transition (~768 MB of surface buffers
|
||||
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
|
||||
// devices. Native stop() is idempotent so the subsequent React
|
||||
// unmount cleanup is still safe.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
}, [
|
||||
nextItem,
|
||||
@@ -1165,7 +1115,6 @@ export default function DirectPlayerPage() {
|
||||
bitrateValue,
|
||||
router,
|
||||
isPlaybackStopped,
|
||||
videoRef,
|
||||
]);
|
||||
|
||||
// Apply subtitle settings when video loads
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
@@ -76,20 +75,6 @@ export default function TVOptionModal() {
|
||||
}, [isReady]);
|
||||
|
||||
const handleSelect = (value: any) => {
|
||||
if (modalState?.deferApplyUntilDismissed) {
|
||||
// onSelect navigates (the transcode audio switch replacing the player);
|
||||
// a router.replace fired while this modal is the active route would be
|
||||
// swallowed. Close FIRST, apply after dismissal.
|
||||
const onSelect = modalState.onSelect;
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
InteractionManager.runAfterInteractions(() => onSelect?.(value));
|
||||
return;
|
||||
}
|
||||
// State-only callers (detail page, library filters, settings): run before
|
||||
// closing so the re-render happens while the modal is up. Deferring it until
|
||||
// after dismissal re-renders the page after focus returns and yanks TV
|
||||
// focus, leaving navigation stuck.
|
||||
modalState?.onSelect(value);
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
|
||||
@@ -7,7 +7,6 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Image } from "expo-image";
|
||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
@@ -101,22 +100,6 @@ SplashScreen.setOptions({
|
||||
fade: true,
|
||||
});
|
||||
|
||||
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
|
||||
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
|
||||
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
|
||||
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
|
||||
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
|
||||
try {
|
||||
Image.configureCache({
|
||||
maxMemoryCost: Platform.isTV
|
||||
? 8 * 1024 * 1024 // ~8 MB on TV
|
||||
: 128 * 1024 * 1024, // ~128 MB on mobile
|
||||
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
|
||||
});
|
||||
} catch {
|
||||
// configureCache is a no-op on some platforms/versions; safe to ignore.
|
||||
}
|
||||
|
||||
function useNotificationObserver() {
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
69
bun.lock
69
bun.lock
@@ -16,7 +16,7 @@
|
||||
"@react-native-community/netinfo": "^12.0.0",
|
||||
"@react-navigation/material-top-tabs": "7.4.28",
|
||||
"@react-navigation/native": "^7.2.5",
|
||||
"@shopify/flash-list": "2.0.3",
|
||||
"@shopify/flash-list": "2.3.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||
"@tanstack/react-pacer": "^0.19.1",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
@@ -109,11 +109,10 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.20.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.8",
|
||||
"lint-staged": "17.0.7",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
},
|
||||
@@ -295,58 +294,6 @@
|
||||
|
||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.38", "", {}, "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A=="],
|
||||
|
||||
"@expo/cli": ["@expo/cli@56.1.16", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.9", "@expo/config-plugins": "~56.0.9", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.1", "@expo/inline-modules": "^0.0.12", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.13", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.14", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.16", "@expo/require-utils": "^56.1.3", "@expo/router-server": "^56.0.14", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.5", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw=="],
|
||||
@@ -589,7 +536,7 @@
|
||||
|
||||
"@react-navigation/routers": ["@react-navigation/routers@7.6.0", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow=="],
|
||||
|
||||
"@shopify/flash-list": ["@shopify/flash-list@2.0.3", "", { "dependencies": { "tslib": "2.8.1" }, "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-jUlHuZFoPdqRCDvOqsb2YkTttRPyV8Tb/EjCx3gE2wjr4UTM+fE0Ltv9bwBg0K7yo/SxRNXaW7xu5utusRb0xA=="],
|
||||
"@shopify/flash-list": ["@shopify/flash-list@2.3.2", "", { "peerDependencies": { "@babel/runtime": "*", "react": "*", "react-native": "*" } }, "sha512-vQnd0y0Ag11yzS30llaeCrtIU3xYTMe7KEOP3dveLImnVjQEUw6BEg1+Ztja6aODlVNz1BpvxtlQ5eZGxiLwKw=="],
|
||||
|
||||
"@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="],
|
||||
|
||||
@@ -961,8 +908,6 @@
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
@@ -1015,7 +960,7 @@
|
||||
|
||||
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
||||
|
||||
"expo-doctor": ["expo-doctor@1.20.0", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-YL0uGQGZ65tWOCOiDH6BFzBkx/9N46MyBUdRfa4t6iuG4tbLg/phRrhmuaNm99QJVMqYtt3ksRbFCzuYOuX20Q=="],
|
||||
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
||||
|
||||
"expo-file-system": ["expo-file-system@56.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ=="],
|
||||
|
||||
@@ -1325,7 +1270,7 @@
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"lint-staged": ["lint-staged@17.0.8", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA=="],
|
||||
"lint-staged": ["lint-staged@17.0.7", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, "optionalDependencies": { "yaml": "^2.9.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA=="],
|
||||
|
||||
"listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="],
|
||||
|
||||
@@ -1863,8 +1808,6 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
||||
|
||||
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
99
components/TrackSheet.tsx
Normal file
99
components/TrackSheet.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
streamType?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const TrackSheet: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
streamType,
|
||||
title,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const streams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||
[source, streamType],
|
||||
);
|
||||
|
||||
const selectedSteam = useMemo(
|
||||
() => streams?.find((x) => x.Index === selected),
|
||||
[streams, selected],
|
||||
);
|
||||
|
||||
const noneOption = useMemo(
|
||||
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
// Creates a modified data array that includes a "None" option for subtitles
|
||||
// We might want to possibly do this for other places, like audio?
|
||||
const addNoneToSubtitles = useMemo(() => {
|
||||
if (streamType === "Subtitle") {
|
||||
const result = streams ? [noneOption, ...streams] : [noneOption];
|
||||
return result;
|
||||
}
|
||||
return streams;
|
||||
}, [streams, streamType, noneOption]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (isTv || (streams && streams.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{selected === -1 && streamType === "Subtitle"
|
||||
? t("common.none")
|
||||
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={title}
|
||||
data={addNoneToSubtitles || []}
|
||||
values={
|
||||
selected === -1 && streamType === "Subtitle"
|
||||
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
||||
: selectedSteam
|
||||
? [selectedSteam]
|
||||
: []
|
||||
}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).DisplayTitle || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => (
|
||||
<Text>{(item as any).DisplayTitle || ""}</Text>
|
||||
)}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as any;
|
||||
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
|
||||
onChange(chosen.Index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,119 +1,43 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { Platform, View, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
const isAggregateType = (item: BaseItemDto) =>
|
||||
item.Type === "Series" || item.Type === "BoxSet";
|
||||
|
||||
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
|
||||
const tvBadgeBase: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: scaleSize(8),
|
||||
right: scaleSize(8),
|
||||
height: scaleSize(28),
|
||||
borderRadius: scaleSize(14),
|
||||
backgroundColor: "rgba(255,255,255,0.92)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
// Mobile uses raw dp — no scaling.
|
||||
const mobileBadgeBase: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#9333ea",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the unplayed-episode count badge for Series/BoxSet items that still
|
||||
* have episodes left to watch. Returns null for non-aggregate types, fully
|
||||
* watched items, or items with no unplayed count, so it is safe to mount
|
||||
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
|
||||
* the watched checkmark is drawn natively and only the count needs RN).
|
||||
*/
|
||||
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
if (!isAggregateType(item)) return null;
|
||||
if (item.UserData?.Played) return null;
|
||||
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
||||
if (unplayed <= 0) return null;
|
||||
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
|
||||
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
|
||||
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
tvBadgeBase,
|
||||
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: scaleSize(15),
|
||||
fontWeight: "700",
|
||||
color: "black",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
import type React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
|
||||
const isAggregate = isAggregateType(item);
|
||||
const isPlayed = item.UserData?.Played === true;
|
||||
|
||||
if (Platform.isTV) {
|
||||
// Fully watched → white checkmark badge (top-right)
|
||||
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
|
||||
// TV: Show white checkmark when watched
|
||||
if (
|
||||
item.UserData?.Played &&
|
||||
(item.Type === "Movie" || item.Type === "Episode")
|
||||
) {
|
||||
return (
|
||||
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
||||
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
borderRadius: 14,
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='checkmark' size={18} color='black' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Series/BoxSet with remaining episodes → count badge
|
||||
return <UnplayedCountBadge item={item} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
||||
// Mobile: Show purple triangle for unwatched
|
||||
return (
|
||||
<>
|
||||
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
|
||||
{isMovieOrEpisode && item.UserData?.Played === false && (
|
||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||
)}
|
||||
|
||||
{/* Fully watched Series/BoxSet → small purple checkmark */}
|
||||
{isAggregate && isPlayed && (
|
||||
<View style={[mobileBadgeBase, { width: 20 }]}>
|
||||
<Ionicons name='checkmark' size={13} color='white' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Series/BoxSet with remaining episodes → count badge */}
|
||||
<UnplayedCountBadge item={item} />
|
||||
{item.UserData?.Played === false &&
|
||||
(item.Type === "Movie" || item.Type === "Episode") && (
|
||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,12 +16,9 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const router = useRouter();
|
||||
|
||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
||||
const base64Image = useMemo(() => {
|
||||
const seriesId = items[0]?.SeriesId;
|
||||
return seriesId ? storage.getString(seriesId) : undefined;
|
||||
}, [items[0]?.SeriesId]);
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () =>
|
||||
|
||||
@@ -140,11 +140,9 @@ export const Home = () => {
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
|
||||
// decoded ARGB) is too large to pin in the memory cache on every
|
||||
// focus change. Disk cache is fast enough for a 500ms crossfade.
|
||||
// Prefetch the image before starting the crossfade
|
||||
try {
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
await Image.prefetch(backdropUrl);
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
@@ -201,18 +201,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
|
||||
const handleSeeAllPress = useCallback(() => {
|
||||
if (!parentId) return;
|
||||
// Navigate into the library detail (lives in the libraries tab) sorted by most
|
||||
// recently added. The `fromSeeAll` flag tells the detail page to (a) collapse
|
||||
// the libraries stack so the native tab can't auto-pop it back to the list, and
|
||||
// (b) intercept Back to route to the library list so the user can switch
|
||||
// libraries. See app/(auth)/(tabs)/(libraries)/[libraryId].tsx.
|
||||
router.push({
|
||||
pathname: "/[libraryId]",
|
||||
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||
params: {
|
||||
libraryId: parentId,
|
||||
sortBy: SortByOption.DateCreated,
|
||||
sortOrder: SortOrderOption.Descending,
|
||||
fromSeeAll: "true",
|
||||
},
|
||||
} as any);
|
||||
}, [router, parentId]);
|
||||
@@ -332,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={0.5}
|
||||
initialNumToRender={4}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={3}
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={false}
|
||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||
style={{ overflow: "visible" }}
|
||||
@@ -354,14 +348,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
// contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||
// contentContainerStyle={{ paddingVertical: SCALE_PADDING }}
|
||||
ListFooterComponent={
|
||||
// No fixed width: the footer must size to the "See All" card so the
|
||||
// FlatList's scrollable content extends to fully reveal it. A fixed
|
||||
// (narrow) width clipped the card at the right edge. Trailing space is
|
||||
// provided by contentContainerStyle.paddingRight.
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
width: sizes.padding.horizontal,
|
||||
}}
|
||||
>
|
||||
{isFetchingNextPage && (
|
||||
|
||||
@@ -256,11 +256,8 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
|
||||
// out of the memory cache avoids bloat when the user cycles through
|
||||
// hero items quickly.
|
||||
try {
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
await Image.prefetch(backdropUrl);
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
@@ -382,7 +379,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// Extra top padding for tvOS to clear the menu bar
|
||||
const tvosTopPadding = scaleSize(145);
|
||||
const tvosTopPadding = Platform.OS === "ios" ? scaleSize(145) : 0;
|
||||
const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight;
|
||||
|
||||
return (
|
||||
|
||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
||||
// Prefetch the image before starting the crossfade
|
||||
try {
|
||||
await Image.prefetch(backdropUrl, "disk");
|
||||
await Image.prefetch(backdropUrl);
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useState } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { ItemImage } from "../common/ItemImage";
|
||||
import { WatchedIndicator } from "../WatchedIndicator";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -53,7 +52,6 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -12,7 +12,6 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -28,38 +27,16 @@ export const StorageSettings = () => {
|
||||
used: (app.total - app.remaining) / app.total,
|
||||
};
|
||||
},
|
||||
// Keep the bar moving while a download is writing to disk.
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const onDeleteClicked = () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
} finally {
|
||||
// Reflect the freed space immediately instead of waiting for
|
||||
// the next poll.
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
};
|
||||
|
||||
const calculatePercentage = (value: number, total: number) => {
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Animated,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVPadding } from "@/constants/TVSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
export interface TVNavBarTab {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TVNavBarProps {
|
||||
tabs: TVNavBarTab[];
|
||||
activeTabKey: string;
|
||||
onTabChange: (key: string) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const TVNavBarTabItem: React.FC<{
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onLayout: (e: {
|
||||
nativeEvent: { layout: { x: number; width: number } };
|
||||
}) => void;
|
||||
hasTVPreferredFocus: boolean;
|
||||
}> = ({ label, isActive, onSelect, onLayout, hasTVPreferredFocus }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
scaleAmount: 1.05,
|
||||
duration: 120,
|
||||
});
|
||||
|
||||
const bg = focused
|
||||
? "rgba(255, 255, 255, 0.95)"
|
||||
: isActive
|
||||
? "rgba(255, 255, 255, 0.15)"
|
||||
: "transparent";
|
||||
|
||||
const textColor = focused
|
||||
? "#000"
|
||||
: isActive
|
||||
? "#fff"
|
||||
: "rgba(255, 255, 255, 0.7)";
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onSelect}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
backgroundColor: bg,
|
||||
borderRadius: scaleSize(24),
|
||||
borderWidth: isActive && !focused ? 1 : 0,
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
paddingHorizontal: scaleSize(28),
|
||||
paddingVertical: scaleSize(14),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
color: textColor,
|
||||
fontWeight: isActive || focused ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const TVNavBar: React.FC<TVNavBarProps> = ({
|
||||
tabs,
|
||||
activeTabKey,
|
||||
onTabChange,
|
||||
style,
|
||||
}) => {
|
||||
const scrollRef = React.useRef<ScrollView>(null);
|
||||
const tabLayouts = React.useRef<Record<string, { x: number; width: number }>>(
|
||||
{},
|
||||
);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const handleTabLayout = React.useCallback(
|
||||
(key: string) =>
|
||||
(e: { nativeEvent: { layout: { x: number; width: number } } }) => {
|
||||
tabLayouts.current[key] = e.nativeEvent.layout;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTabChange = React.useCallback(
|
||||
(key: string) => {
|
||||
onTabChange(key);
|
||||
|
||||
const layout = tabLayouts.current[key];
|
||||
if (layout && scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
x: Math.max(0, layout.x - TVPadding.horizontal / 2),
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onTabChange],
|
||||
);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={[{ paddingTop: insets.top + 16, paddingBottom: 8 }, style]}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
gap: scaleSize(12),
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TVNavBarTabItem
|
||||
key={tab.key}
|
||||
label={tab.label}
|
||||
isActive={tab.key === activeTabKey}
|
||||
onSelect={() => handleTabChange(tab.key)}
|
||||
onLayout={handleTabLayout(tab.key)}
|
||||
hasTVPreferredFocus={tab.key === activeTabKey}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
} from "react-native";
|
||||
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
UnplayedCountBadge,
|
||||
WatchedIndicator,
|
||||
} from "@/components/WatchedIndicator";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
@@ -430,12 +427,6 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
{/*
|
||||
The glass view draws the watched checkmark natively but cannot show
|
||||
an unplayed-episode count, so render it as an RN overlay on top.
|
||||
Returns null when not applicable (non-series / fully watched).
|
||||
*/}
|
||||
{showWatchedIndicator && <UnplayedCountBadge item={item} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -457,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
<Image
|
||||
placeholder={{ blurhash }}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{ uri: imageUrl }}
|
||||
recyclingKey={item.Id}
|
||||
cachePolicy='memory-disk'
|
||||
contentFit='cover'
|
||||
style={{
|
||||
@@ -468,7 +459,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
{showWatchedIndicator && <WatchedIndicator item={item} />}
|
||||
<WatchedIndicator item={item} />
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -35,8 +35,6 @@ export type { TVLanguageCardProps } from "./TVLanguageCard";
|
||||
export { TVLanguageCard } from "./TVLanguageCard";
|
||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||
export { TVMetadataBadges } from "./TVMetadataBadges";
|
||||
export type { TVNavBarProps, TVNavBarTab } from "./TVNavBar";
|
||||
export { TVNavBar } from "./TVNavBar";
|
||||
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
||||
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
||||
export type { TVOptionButtonProps } from "./TVOptionButton";
|
||||
|
||||
@@ -564,9 +564,6 @@ export const Controls: FC<Props> = ({
|
||||
title: t("item_card.audio"),
|
||||
options: audioOptions,
|
||||
onSelect: handleAudioChange,
|
||||
// In-player audio selection navigates (replacePlayer while transcoding);
|
||||
// apply it after the modal is dismissed so it isn't swallowed.
|
||||
deferApplyUntilDismissed: true,
|
||||
});
|
||||
controlsInteractionRef.current();
|
||||
}, [showOptions, t, audioOptions, handleAudioChange]);
|
||||
|
||||
560
components/video-player/controls/TVSubtitleSheet.tsx
Normal file
560
components/video-player/controls/TVSubtitleSheet.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
TVCancelButton,
|
||||
TVLanguageCard,
|
||||
TVSubtitleResultCard,
|
||||
TVTabButton,
|
||||
TVTrackCard,
|
||||
} from "@/components/tv";
|
||||
import {
|
||||
type SubtitleSearchResult,
|
||||
useRemoteSubtitles,
|
||||
} from "@/hooks/useRemoteSubtitles";
|
||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||
|
||||
interface TVSubtitleSheetProps {
|
||||
visible: boolean;
|
||||
item: BaseItemDto;
|
||||
mediaSourceId?: string | null;
|
||||
subtitleTracks: MediaStream[];
|
||||
currentSubtitleIndex: number;
|
||||
onSubtitleIndexChange: (index: number) => void;
|
||||
onClose: () => void;
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
}
|
||||
|
||||
type TabType = "tracks" | "download";
|
||||
|
||||
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
visible,
|
||||
item,
|
||||
mediaSourceId,
|
||||
subtitleTracks,
|
||||
currentSubtitleIndex,
|
||||
onSubtitleIndexChange,
|
||||
onClose,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
console.log(
|
||||
"[TVSubtitleSheet] visible:",
|
||||
visible,
|
||||
"tracks:",
|
||||
subtitleTracks.length,
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isTabContentReady, setIsTabContentReady] = useState(false);
|
||||
const firstTrackRef = useRef<View>(null);
|
||||
|
||||
const {
|
||||
hasOpenSubtitlesApiKey,
|
||||
isSearching,
|
||||
searchError,
|
||||
searchResults,
|
||||
search,
|
||||
downloadAsync,
|
||||
reset,
|
||||
} = useRemoteSubtitles({
|
||||
itemId: item.Id ?? "",
|
||||
item,
|
||||
mediaSourceId,
|
||||
});
|
||||
|
||||
const resetRef = useRef(reset);
|
||||
resetRef.current = reset;
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
const initialSelectedTrackIndex = useMemo(() => {
|
||||
if (currentSubtitleIndex === -1) return 0;
|
||||
const trackIdx = subtitleTracks.findIndex(
|
||||
(t) => t.Index === currentSubtitleIndex,
|
||||
);
|
||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||
}, [subtitleTracks, currentSubtitleIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(300);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setHasSearchedThisSession(false);
|
||||
setActiveTab("tracks");
|
||||
resetRef.current();
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && activeTab === "download" && !hasSearchedThisSession) {
|
||||
search({ language: selectedLanguage });
|
||||
setHasSearchedThisSession(true);
|
||||
}
|
||||
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
setIsTabContentReady(false);
|
||||
const timer = setTimeout(() => setIsTabContentReady(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsTabContentReady(false);
|
||||
}, [activeTab, isReady]);
|
||||
|
||||
const handleLanguageSelect = useCallback(
|
||||
(code: string) => {
|
||||
setSelectedLanguage(code);
|
||||
search({ language: code });
|
||||
},
|
||||
[search],
|
||||
);
|
||||
|
||||
const handleTrackSelect = useCallback(
|
||||
(index: number) => {
|
||||
onSubtitleIndexChange(index);
|
||||
onClose();
|
||||
},
|
||||
[onSubtitleIndexChange, onClose],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (result: SubtitleSearchResult) => {
|
||||
setDownloadingId(result.id);
|
||||
|
||||
try {
|
||||
const downloadResult = await downloadAsync(result);
|
||||
|
||||
if (downloadResult.type === "server") {
|
||||
onServerSubtitleDownloaded?.();
|
||||
} else if (downloadResult.type === "local" && downloadResult.path) {
|
||||
onLocalSubtitleDownloaded?.(downloadResult.path);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to download subtitle:", error);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
downloadAsync,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
onClose,
|
||||
],
|
||||
);
|
||||
|
||||
const displayLanguages = useMemo(
|
||||
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
||||
[],
|
||||
);
|
||||
|
||||
const trackOptions = useMemo(() => {
|
||||
const noneOption = {
|
||||
label: t("item_card.subtitles.none"),
|
||||
sublabel: undefined as string | undefined,
|
||||
value: -1,
|
||||
selected: currentSubtitleIndex === -1,
|
||||
};
|
||||
const options = subtitleTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
sublabel: track.Codec?.toUpperCase(),
|
||||
value: track.Index!,
|
||||
selected: track.Index === currentSubtitleIndex,
|
||||
}));
|
||||
return [noneOption, ...options];
|
||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Header with tabs */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>
|
||||
{t("item_card.subtitles.label") || "Subtitles"}
|
||||
</Text>
|
||||
|
||||
{/* Tab bar */}
|
||||
<View style={styles.tabRow}>
|
||||
<TVTabButton
|
||||
label={t("item_card.subtitles.tracks") || "Tracks"}
|
||||
active={activeTab === "tracks"}
|
||||
onSelect={() => setActiveTab("tracks")}
|
||||
/>
|
||||
<TVTabButton
|
||||
label={t("player.download") || "Download"}
|
||||
active={activeTab === "download"}
|
||||
onSelect={() => setActiveTab("download")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tracks Tab Content */}
|
||||
{activeTab === "tracks" && isTabContentReady && (
|
||||
<View style={styles.section}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tracksScroll}
|
||||
contentContainerStyle={styles.tracksScrollContent}
|
||||
>
|
||||
{trackOptions.map((option, index) => (
|
||||
<TVTrackCard
|
||||
key={option.value}
|
||||
ref={
|
||||
index === initialSelectedTrackIndex
|
||||
? firstTrackRef
|
||||
: undefined
|
||||
}
|
||||
label={option.label}
|
||||
sublabel={option.sublabel}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedTrackIndex}
|
||||
onPress={() => handleTrackSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Download Tab Content */}
|
||||
{activeTab === "download" && isTabContentReady && (
|
||||
<>
|
||||
{/* Language Selector */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.language") || "Language"}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.languageScroll}
|
||||
contentContainerStyle={styles.languageScrollContent}
|
||||
>
|
||||
{displayLanguages.map((lang, index) => (
|
||||
<TVLanguageCard
|
||||
key={lang.code}
|
||||
code={lang.code}
|
||||
name={lang.name}
|
||||
selected={selectedLanguage === lang.code}
|
||||
hasTVPreferredFocus={
|
||||
index === 0 &&
|
||||
(!searchResults || searchResults.length === 0)
|
||||
}
|
||||
onPress={() => handleLanguageSelect(lang.code)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Results Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.results") || "Results"}
|
||||
{searchResults && ` (${searchResults.length})`}
|
||||
</Text>
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size='large' color='#fff' />
|
||||
<Text style={styles.loadingText}>
|
||||
{t("player.searching") || "Searching..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{searchError && !isSearching && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons
|
||||
name='alert-circle-outline'
|
||||
size={32}
|
||||
color='rgba(255,100,100,0.8)'
|
||||
/>
|
||||
<Text style={styles.errorText}>
|
||||
{t("player.search_failed") || "Search failed"}
|
||||
</Text>
|
||||
<Text style={styles.errorHint}>
|
||||
{!hasOpenSubtitlesApiKey
|
||||
? t("player.no_subtitle_provider") ||
|
||||
"No subtitle provider configured on server"
|
||||
: String(searchError)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{searchResults &&
|
||||
searchResults.length === 0 &&
|
||||
!isSearching &&
|
||||
!searchError && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name='document-text-outline'
|
||||
size={32}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.emptyText}>
|
||||
{t("player.no_subtitles_found") ||
|
||||
"No subtitles found"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
{searchResults &&
|
||||
searchResults.length > 0 &&
|
||||
!isSearching && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.resultsScroll}
|
||||
contentContainerStyle={styles.resultsScrollContent}
|
||||
>
|
||||
{searchResults.map((result, index) => (
|
||||
<TVSubtitleResultCard
|
||||
key={result.id}
|
||||
result={result}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
isDownloading={downloadingId === result.id}
|
||||
onPress={() => handleDownload(result)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* API Key hint if no fallback available */}
|
||||
{!hasOpenSubtitlesApiKey && (
|
||||
<View style={styles.apiKeyHint}>
|
||||
<Ionicons
|
||||
name='information-circle-outline'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.apiKeyHintText}>
|
||||
{t("player.add_opensubtitles_key_hint") ||
|
||||
"Add OpenSubtitles API key in settings for client-side fallback"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
{isReady && (
|
||||
<View style={styles.cancelButtonContainer}>
|
||||
<TVCancelButton
|
||||
onPress={onClose}
|
||||
label={t("common.cancel") || "Cancel"}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
maxHeight: "70%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
marginBottom: 16,
|
||||
},
|
||||
tabRow: {
|
||||
flexDirection: "row",
|
||||
gap: 24,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 48,
|
||||
},
|
||||
tracksScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
tracksScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
languageScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
languageScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 10,
|
||||
},
|
||||
resultsScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
resultsScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
errorContainer: {
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: "rgba(255,100,100,0.9)",
|
||||
marginTop: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
errorHint: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
},
|
||||
apiKeyHint: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 8,
|
||||
},
|
||||
apiKeyHintText: {
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
fontSize: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 20,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
@@ -342,12 +342,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||
{info?.demuxerMaxBytes !== undefined
|
||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
|
||||
")"
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.voDriver && (
|
||||
@@ -356,12 +350,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.estimatedVfFps !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
||||
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||
<Text style={[textStyle, styles.warningText]}>
|
||||
Dropped: {info.droppedFrames} frames
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
const MediaTypes = {
|
||||
export default {
|
||||
Audio: "Audio",
|
||||
Video: "Video",
|
||||
Photo: "Photo",
|
||||
Book: "Book",
|
||||
} as const;
|
||||
|
||||
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||
|
||||
export default MediaTypes;
|
||||
};
|
||||
@@ -28,7 +28,7 @@ Apple TV uses a Top Shelf extension target, not the main app process.
|
||||
|
||||
Relevant files:
|
||||
|
||||
- [plugins/withTVOSTopShelf.ts](../plugins/withTVOSTopShelf.ts)
|
||||
- [plugins/withTVOSTopShelf.js](../plugins/withTVOSTopShelf.js)
|
||||
- [targets/StreamyfinTopShelf/TopShelfProvider.swift](../targets/StreamyfinTopShelf/TopShelfProvider.swift)
|
||||
- [modules/top-shelf-cache/ios/TopShelfCacheModule.swift](../modules/top-shelf-cache/ios/TopShelfCacheModule.swift)
|
||||
- [utils/topshelf/cache.ts](../utils/topshelf/cache.ts)
|
||||
|
||||
@@ -4,42 +4,41 @@ import { Platform } from "react-native";
|
||||
import {
|
||||
disableTVMenuKeyInterception,
|
||||
enableTVMenuKeyInterception,
|
||||
useTVBackPress,
|
||||
} from "./useTVBackPress";
|
||||
|
||||
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
||||
|
||||
/** All tab route names used in the bottom tab navigator. */
|
||||
export const TAB_ROUTES = [
|
||||
"(home)",
|
||||
"(search)",
|
||||
"(favorites)",
|
||||
"(libraries)",
|
||||
"(watchlists)",
|
||||
"(custom-links)",
|
||||
"(settings)",
|
||||
] as const;
|
||||
|
||||
export type TabRoute = (typeof TAB_ROUTES)[number];
|
||||
|
||||
/** Check if a segment string is a tab route. */
|
||||
export function isTabRoute(s: string): s is TabRoute {
|
||||
return (TAB_ROUTES as readonly string[]).includes(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're at the root of a tab
|
||||
*/
|
||||
function isAtTabRoot(segments: string[]): boolean {
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
return isTabRoute(lastSegment) || lastSegment === "index";
|
||||
const tabNames = [
|
||||
"(home)",
|
||||
"(search)",
|
||||
"(favorites)",
|
||||
"(libraries)",
|
||||
"(watchlists)",
|
||||
"(settings)",
|
||||
"(custom-links)",
|
||||
];
|
||||
return tabNames.includes(lastSegment) || lastSegment === "index";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tab name from segments
|
||||
*/
|
||||
function getCurrentTab(segments: string[]): TabRoute | undefined {
|
||||
return segments.find(isTabRoute);
|
||||
function getCurrentTab(segments: string[]): string | undefined {
|
||||
return segments.find(
|
||||
(s) =>
|
||||
s === "(home)" ||
|
||||
s === "(search)" ||
|
||||
s === "(favorites)" ||
|
||||
s === "(libraries)" ||
|
||||
s === "(watchlists)" ||
|
||||
s === "(settings)" ||
|
||||
s === "(custom-links)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +49,7 @@ function getCurrentTab(segments: string[]): TabRoute | undefined {
|
||||
export function useTVHomeBackHandler() {
|
||||
const segments = useSegments();
|
||||
|
||||
// Get current state
|
||||
const currentTab = getCurrentTab(segments);
|
||||
const atTabRoot = isAtTabRoot(segments);
|
||||
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
||||
@@ -65,24 +65,3 @@ export function useTVHomeBackHandler() {
|
||||
enableTVMenuKeyInterception();
|
||||
}, [isOnHomeRoot]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
|
||||
*
|
||||
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
|
||||
* built-in tab-level back handling — pressing back at a tab root would pop the
|
||||
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
|
||||
* to Home instead.
|
||||
*/
|
||||
export function useTVTabRootBackHandler(
|
||||
onNavigateHome: () => void,
|
||||
isAtTabRoot: boolean,
|
||||
currentTab: string | undefined,
|
||||
) {
|
||||
useTVBackPress(() => {
|
||||
if (!Platform.isTV || Platform.OS !== "android") return false;
|
||||
if (!isAtTabRoot || currentTab === "(home)") return false;
|
||||
onNavigateHome();
|
||||
return true;
|
||||
}, [isAtTabRoot, currentTab, onNavigateHome]);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ interface ShowOptionsParams<T> {
|
||||
onSelect: (value: T) => void;
|
||||
cardWidth?: number;
|
||||
cardHeight?: number;
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
}
|
||||
|
||||
export const useTVOptionModal = () => {
|
||||
@@ -27,7 +26,6 @@ export const useTVOptionModal = () => {
|
||||
onSelect: params.onSelect,
|
||||
cardWidth: params.cardWidth,
|
||||
cardHeight: params.cardHeight,
|
||||
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
||||
});
|
||||
router.push("/(auth)/tv-option-modal");
|
||||
},
|
||||
|
||||
@@ -53,5 +53,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
// libmpv from Maven Central
|
||||
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
|
||||
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
|
||||
}
|
||||
|
||||
BIN
modules/mpv-player/android/src/main/assets/subfont.ttf
Normal file
BIN
modules/mpv-player/android/src/main/assets/subfont.ttf
Normal file
Binary file not shown.
@@ -3,14 +3,13 @@ package expo.modules.mpvplayer
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* MPV renderer that wraps libmpv for video playback.
|
||||
@@ -36,30 +35,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
|
||||
/**
|
||||
* True only on the Android emulator. Its goldfish/ranchu MediaCodec can't bind a
|
||||
* decode output surface (decode opens with surface 0x0): HEVC then fails cleanly and
|
||||
* mpv auto-falls-back to software, but H.264 "opens" deceptively and wedges the core
|
||||
* (no fallback) — black video, then any command (seek/pause) deadlocks the UI thread
|
||||
* → ANR. We force software decoding here.
|
||||
*
|
||||
* Only QEMU/SDK-exclusive signals are checked so a real device can never match — a
|
||||
* false positive would needlessly drop shipping hardware to software decoding. The
|
||||
* emulator reports ro.hardware=goldfish|ranchu, an sdk_* product, or a generic/
|
||||
* emulator build fingerprint, none of which appear on real devices.
|
||||
*/
|
||||
private fun isEmulator(): Boolean {
|
||||
val hardware = Build.HARDWARE.lowercase()
|
||||
if (hardware == "goldfish" || hardware == "ranchu") return true
|
||||
|
||||
val product = Build.PRODUCT
|
||||
if (product == "sdk" || product.startsWith("sdk_")) return true
|
||||
|
||||
val fingerprint = Build.FINGERPRINT
|
||||
return fingerprint.startsWith("generic") ||
|
||||
fingerprint.contains("emulator", ignoreCase = true)
|
||||
}
|
||||
|
||||
interface Delegate {
|
||||
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
||||
fun onPauseChanged(isPaused: Boolean)
|
||||
@@ -76,15 +51,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
private var surface: Surface? = null
|
||||
private var isRunning = false
|
||||
|
||||
// This renderer's own mpv handle. Per-instance (not singleton) — each
|
||||
// player screen gets a fresh mpv handle and drops the reference on stop.
|
||||
// We intentionally do NOT call a destroy() equivalent: libmpv 1.0's
|
||||
// nativeDestroy has an internal use-after-free we can't fix from Kotlin,
|
||||
// so we mirror Findroid and let the JVM GC + native finalization path
|
||||
// reclaim resources. Only one player is alive at a time in this app.
|
||||
private var mpv: MPVLib? = null
|
||||
|
||||
private var isStopping = false
|
||||
|
||||
// Cached state
|
||||
private var cachedPosition: Double = 0.0
|
||||
private var cachedDuration: Double = 0.0
|
||||
@@ -144,108 +112,100 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
fun start(voDriver: String = "gpu-next") {
|
||||
if (isRunning) return
|
||||
|
||||
|
||||
try {
|
||||
// Per-instance handle — see class-level comment. Each player gets
|
||||
// its own mpv; we drop the reference in stop().
|
||||
val mpv = MPVLib.create(context)
|
||||
this.mpv = mpv
|
||||
mpv.addObserver(this)
|
||||
|
||||
// Resolved once — TV gets the memory-pressure customizations
|
||||
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
||||
// audio-buffer) that would be counterproductive on higher-RAM
|
||||
// mobile devices. Demuxer cache sizes are NOT included here —
|
||||
// those come from user settings via load().
|
||||
val isTV = isTvDevice()
|
||||
|
||||
// mpv config directory — used by the config-dir option below and
|
||||
// as XDG_CONFIG_HOME for fontconfig.
|
||||
MPVLib.create(context)
|
||||
MPVLib.addObserver(this)
|
||||
|
||||
/**
|
||||
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
|
||||
*
|
||||
* Technical Background:
|
||||
* ====================
|
||||
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
|
||||
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
|
||||
* even when subtitle tracks are properly detected and loaded.
|
||||
*
|
||||
* Why This Is Necessary:
|
||||
* =====================
|
||||
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
|
||||
* mpv cannot access them directly due to sandboxing and library isolation.
|
||||
*
|
||||
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
|
||||
* configured directory, mpv either:
|
||||
* - Fails silently (subtitles don't appear)
|
||||
* - Falls back to a default font that may not support the required character set
|
||||
* - Crashes or produces rendering errors
|
||||
*
|
||||
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
|
||||
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
|
||||
*
|
||||
* Reference:
|
||||
* =========
|
||||
* This workaround is documented in the mpv-android project:
|
||||
* https://github.com/mpv-android/mpv-android/issues/96
|
||||
*
|
||||
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
|
||||
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
|
||||
*/
|
||||
// Create mpv config directory and copy font files
|
||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||
//Log.i(TAG, "mpv config dir: $mpvDir")
|
||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
||||
|
||||
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it
|
||||
// persists its font index across runs instead of re-walking
|
||||
// /system/fonts on every subtitle/seek event. Each rebuild costs
|
||||
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
|
||||
// holds onto. Without this we see "No usable fontconfig
|
||||
// configuration file found, using fallback" on every re-init.
|
||||
try {
|
||||
val cacheDir = context.cacheDir.absolutePath
|
||||
val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath
|
||||
Os.setenv("XDG_CACHE_HOME", cacheDir, true)
|
||||
Os.setenv("XDG_CONFIG_HOME", configDir, true)
|
||||
Os.setenv("HOME", configDir, true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}")
|
||||
// This needs to be named `subfont.ttf` else it won't work
|
||||
arrayOf("subfont.ttf").forEach { fileName ->
|
||||
val file = File(mpvDir, fileName)
|
||||
if (file.exists()) return@forEach
|
||||
context.assets
|
||||
.open(fileName, AssetManager.ACCESS_STREAMING)
|
||||
.copyTo(FileOutputStream(file))
|
||||
}
|
||||
|
||||
mpv?.setOptionString("config", "yes")
|
||||
mpv?.setOptionString("config-dir", mpvDir.path)
|
||||
MPVLib.setOptionString("config", "yes")
|
||||
MPVLib.setOptionString("config-dir", mpvDir.path)
|
||||
|
||||
// Configure mpv options before initialization (based on Findroid)
|
||||
this.voDriver = voDriver
|
||||
mpv?.setOptionString("vo", voDriver)
|
||||
mpv?.setOptionString("gpu-context", "android")
|
||||
mpv?.setOptionString("opengl-es", "yes")
|
||||
MPVLib.setOptionString("vo", voDriver)
|
||||
MPVLib.setOptionString("gpu-context", "android")
|
||||
MPVLib.setOptionString("opengl-es", "yes")
|
||||
|
||||
// Hardware decoder codecs (shared)
|
||||
mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||
|
||||
// Pause on initial cache fill (shared default). The actual
|
||||
// cache mode, cache-secs, and demuxer cache sizes come from
|
||||
// user preferences and are applied per-load in load().
|
||||
mpv?.setOptionString("cache-pause-initial", "yes")
|
||||
|
||||
// Hardware decode path + TV-only memory options. Demuxer cache
|
||||
// sizes and cache-secs are NOT set here — they come from user
|
||||
// preferences via load().
|
||||
// - Emulator: software decode. Its MediaCodec can't bind an
|
||||
// output surface (surface 0x0); HEVC then fails cleanly and
|
||||
// mpv auto-falls-back to software, but H.264 "opens"
|
||||
// deceptively and wedges the core with no fallback (black
|
||||
// video, then any command — seek/pause — deadlocks the UI
|
||||
// thread → ANR). hwdec=no makes every codec render via the
|
||||
// gpu-next VO. Real devices unaffected.
|
||||
// - Real TV hardware: zero-copy `mediacodec` (fastest on
|
||||
// low-power devices) + fast profile.
|
||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
||||
when {
|
||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
||||
isTV -> {
|
||||
mpv?.setOptionString("hwdec", "mediacodec")
|
||||
mpv?.setOptionString("profile", "fast")
|
||||
// Don't retain already-played content for backward
|
||||
// seeking over a network source — Jellyfin can re-fetch
|
||||
// on demand. Saves up to ~30 MiB on long seeks and
|
||||
// reduces swap pressure.
|
||||
mpv?.setOptionString("demuxer-seekable-cache", "no")
|
||||
// Larger audio buffer to absorb page-fault stalls
|
||||
// (default ~0.2s). Cheap insurance against the audio
|
||||
// underruns that happen when the kernel is swap-thrashing.
|
||||
mpv?.setOptionString("audio-buffer", "0.5")
|
||||
}
|
||||
else -> mpv?.setOptionString("hwdec", "mediacodec-copy")
|
||||
// Hardware video decoding
|
||||
// TV: zero-copy (mediacodec) for better performance on low-power devices
|
||||
// Mobile: copy mode (mediacodec-copy) for better compatibility
|
||||
val isTV = isTvDevice()
|
||||
if (isTV) {
|
||||
MPVLib.setOptionString("hwdec", "mediacodec")
|
||||
MPVLib.setOptionString("profile", "fast")
|
||||
} else {
|
||||
MPVLib.setOptionString("hwdec", "mediacodec-copy")
|
||||
}
|
||||
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||
|
||||
// Cache settings for better network streaming
|
||||
MPVLib.setOptionString("cache", "yes")
|
||||
MPVLib.setOptionString("cache-pause-initial", "yes")
|
||||
MPVLib.setOptionString("demuxer-max-bytes", "150MiB")
|
||||
MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB")
|
||||
MPVLib.setOptionString("demuxer-readahead-secs", "20")
|
||||
|
||||
// Seeking optimization - faster seeking at the cost of less precision
|
||||
// Use keyframe seeking by default (much faster for network streams)
|
||||
mpv?.setOptionString("hr-seek", "no")
|
||||
MPVLib.setOptionString("hr-seek", "no")
|
||||
// Drop frames during seeking for faster response
|
||||
mpv?.setOptionString("hr-seek-framedrop", "yes")
|
||||
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
||||
|
||||
// Subtitle settings
|
||||
mpv?.setOptionString("sub-scale-with-window", "no")
|
||||
mpv?.setOptionString("sub-use-margins", "no")
|
||||
mpv?.setOptionString("subs-match-os-language", "yes")
|
||||
mpv?.setOptionString("subs-fallback", "yes")
|
||||
MPVLib.setOptionString("sub-scale-with-window", "no")
|
||||
MPVLib.setOptionString("sub-use-margins", "no")
|
||||
MPVLib.setOptionString("subs-match-os-language", "yes")
|
||||
MPVLib.setOptionString("subs-fallback", "yes")
|
||||
|
||||
// Important: Start with force-window=no, will be set to yes when surface is attached
|
||||
mpv?.setOptionString("force-window", "no")
|
||||
mpv?.setOptionString("keep-open", "always")
|
||||
|
||||
mpv.initialize()
|
||||
|
||||
MPVLib.setOptionString("force-window", "no")
|
||||
MPVLib.setOptionString("keep-open", "always")
|
||||
|
||||
MPVLib.initialize()
|
||||
|
||||
// Observe properties
|
||||
observeProperties()
|
||||
|
||||
@@ -258,68 +218,21 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (isStopping) return
|
||||
if (!isRunning) return
|
||||
|
||||
isStopping = true
|
||||
isRunning = false
|
||||
|
||||
val m = mpv
|
||||
mpv = null
|
||||
|
||||
// Clear cached media state on the main thread so the next player
|
||||
// screen doesn't observe stale position/duration values during the
|
||||
// (async) teardown below.
|
||||
currentUrl = null
|
||||
currentHeaders = null
|
||||
pendingExternalSubtitles = emptyList()
|
||||
initialSubtitleId = null
|
||||
initialAudioId = null
|
||||
cachedPosition = 0.0
|
||||
cachedDuration = 0.0
|
||||
cachedCacheSeconds = 0.0
|
||||
|
||||
if (m == null) return
|
||||
|
||||
// Teardown runs on a background daemon thread. mpv's "stop" command
|
||||
// flushes the demuxer queue and releases the MediaCodec hardware
|
||||
// decoder — synchronous JNI work that can block for hundreds of ms
|
||||
// on TV hardware. Running it on the main thread produced a visible
|
||||
// delay/stutter between pressing "exit" and the confirm alert
|
||||
// appearing. The local `m` keeps the MPVLib instance alive for the
|
||||
// lifetime of this thread even though we've already nulled `mpv`.
|
||||
Thread {
|
||||
// Drop force-window BEFORE issuing stop. With keep-open=always +
|
||||
// force-window=yes, mpv tears down the decoder at stop time but
|
||||
// tries to keep the VO alive — which fires an internal
|
||||
// video-reconfig. On libmpv 1.0's gpu-next/android backend that
|
||||
// reconfig path crashes with "Missing surface pointer" because we
|
||||
// detach the Surface below before mpv's worker reaches the
|
||||
// reconfig step (command() is async). Setting force-window=no
|
||||
// first makes mpv tear VO down cleanly instead of attempting a
|
||||
// doomed re-init, eliminating the fatal VO error and the
|
||||
// "playback won't restart" aftermath.
|
||||
try {
|
||||
m.setOptionString("force-window", "no")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing force-window: ${e.message}")
|
||||
}
|
||||
try {
|
||||
// Stop playback — flushes demuxer queue and signals MediaCodec
|
||||
// to release its hardware decoders. This is the bulk of what
|
||||
// we can reclaim without calling destroy().
|
||||
m.command(arrayOf("stop"))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping mpv playback: ${e.message}")
|
||||
}
|
||||
try {
|
||||
m.removeObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error removing mpv observer: ${e.message}")
|
||||
}
|
||||
try {
|
||||
m.detachSurface()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error detaching mpv surface: ${e.message}")
|
||||
}
|
||||
}.also { it.isDaemon = true }.start()
|
||||
|
||||
try {
|
||||
MPVLib.removeObserver(this)
|
||||
MPVLib.detachSurface()
|
||||
MPVLib.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping MPV: ${e.message}")
|
||||
}
|
||||
|
||||
isStopping = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,10 +247,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
this.surface = surface
|
||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||
if (isRunning) {
|
||||
mpv?.attachSurface(surface)
|
||||
mpv?.setOptionString("force-window", "yes")
|
||||
MPVLib.attachSurface(surface)
|
||||
MPVLib.setOptionString("force-window", "yes")
|
||||
// Read back vo to confirm it's still active
|
||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||
}
|
||||
}
|
||||
@@ -357,8 +270,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
this.surface = null
|
||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||
if (isRunning) {
|
||||
mpv?.detachSurface()
|
||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
MPVLib.detachSurface()
|
||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||
}
|
||||
}
|
||||
@@ -369,7 +282,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
*/
|
||||
fun updateSurfaceSize(width: Int, height: Int) {
|
||||
if (isRunning) {
|
||||
mpv?.setPropertyString("android-surface-size", "${width}x$height")
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||
} else {
|
||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||
@@ -385,9 +298,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
if (!isRunning) return
|
||||
val pos = cachedPosition
|
||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||
mpv?.command(arrayOf("frame-step"))
|
||||
MPVLib.command(arrayOf("frame-step"))
|
||||
if (pos > 0) {
|
||||
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,43 +310,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
startPosition: Double? = null,
|
||||
externalSubtitles: List<String>? = null,
|
||||
initialSubtitleId: Int? = null,
|
||||
initialAudioId: Int? = null,
|
||||
cacheEnabled: String? = null,
|
||||
cacheSeconds: Int? = null,
|
||||
demuxerMaxBytes: Int? = null,
|
||||
demuxerMaxBackBytes: Int? = null
|
||||
initialAudioId: Int? = null
|
||||
) {
|
||||
currentUrl = url
|
||||
currentHeaders = headers
|
||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||
this.initialSubtitleId = initialSubtitleId
|
||||
this.initialAudioId = initialAudioId
|
||||
|
||||
|
||||
_isLoading = true
|
||||
isReadyToSeek = false
|
||||
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||
|
||||
|
||||
// Stop previous playback
|
||||
mpv?.command(arrayOf("stop"))
|
||||
|
||||
MPVLib.command(arrayOf("stop"))
|
||||
|
||||
// Set HTTP headers if provided
|
||||
updateHttpHeaders(headers)
|
||||
|
||||
// Apply cache/buffer settings from user preferences (mirrors iOS).
|
||||
// These override the conservative defaults applied in start() so the
|
||||
// TV/mobile settings screen actually takes effect on Android.
|
||||
cacheEnabled?.let { mpv?.setOptionString("cache", it) }
|
||||
cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) }
|
||||
demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") }
|
||||
demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") }
|
||||
|
||||
// Set start position. mpv's time parser requires '.' as the decimal
|
||||
// separator; use Locale.US so devices with other default locales
|
||||
// (e.g. ',' as decimal separator) don't break resume-from-position.
|
||||
// Set start position
|
||||
if (startPosition != null && startPosition > 0) {
|
||||
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
|
||||
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
|
||||
} else {
|
||||
mpv?.setPropertyString("start", "0")
|
||||
MPVLib.setPropertyString("start", "0")
|
||||
}
|
||||
|
||||
// Set initial audio track if specified
|
||||
@@ -453,7 +352,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
// Load the file
|
||||
mpv?.command(arrayOf("loadfile", url, "replace"))
|
||||
MPVLib.command(arrayOf("loadfile", url, "replace"))
|
||||
}
|
||||
|
||||
fun reloadCurrentItem() {
|
||||
@@ -469,29 +368,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
||||
mpv?.setPropertyString("http-header-fields", headerString)
|
||||
MPVLib.setPropertyString("http-header-fields", headerString)
|
||||
}
|
||||
|
||||
private fun observeProperties() {
|
||||
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||
// Video dimensions for PiP aspect ratio
|
||||
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
fun play() {
|
||||
mpv?.setPropertyBoolean("pause", false)
|
||||
MPVLib.setPropertyBoolean("pause", false)
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mpv?.setPropertyBoolean("pause", true)
|
||||
MPVLib.setPropertyBoolean("pause", true)
|
||||
}
|
||||
|
||||
fun togglePause() {
|
||||
@@ -501,22 +400,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun seekTo(seconds: Double) {
|
||||
val clamped = maxOf(0.0, seconds)
|
||||
cachedPosition = clamped
|
||||
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||
}
|
||||
|
||||
fun seekBy(seconds: Double) {
|
||||
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
||||
cachedPosition = newPosition
|
||||
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
_playbackSpeed = speed
|
||||
mpv?.setPropertyDouble("speed", speed)
|
||||
MPVLib.setPropertyDouble("speed", speed)
|
||||
}
|
||||
|
||||
fun getSpeed(): Double {
|
||||
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
|
||||
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
@@ -524,19 +423,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||
val tracks = mutableListOf<Map<String, Any>>()
|
||||
|
||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||
|
||||
for (i in 0 until trackCount) {
|
||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||
if (trackType != "sub") continue
|
||||
|
||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||
|
||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
|
||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
track["selected"] = selected
|
||||
|
||||
tracks.add(track)
|
||||
@@ -548,61 +447,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
||||
if (trackId < 0) {
|
||||
mpv?.setPropertyString("sid", "no")
|
||||
MPVLib.setPropertyString("sid", "no")
|
||||
} else {
|
||||
mpv?.setPropertyInt("sid", trackId)
|
||||
MPVLib.setPropertyInt("sid", trackId)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableSubtitles() {
|
||||
mpv?.setPropertyString("sid", "no")
|
||||
MPVLib.setPropertyString("sid", "no")
|
||||
}
|
||||
|
||||
fun getCurrentSubtitleTrack(): Int {
|
||||
return mpv?.getPropertyInt("sid") ?: 0
|
||||
return MPVLib.getPropertyInt("sid") ?: 0
|
||||
}
|
||||
|
||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||
val flag = if (select) "select" else "cached"
|
||||
mpv?.command(arrayOf("sub-add", url, flag))
|
||||
MPVLib.command(arrayOf("sub-add", url, flag))
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
fun setSubtitlePosition(position: Int) {
|
||||
mpv?.setPropertyInt("sub-pos", position)
|
||||
MPVLib.setPropertyInt("sub-pos", position)
|
||||
}
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
mpv?.setPropertyDouble("sub-scale", scale)
|
||||
MPVLib.setPropertyDouble("sub-scale", scale)
|
||||
}
|
||||
|
||||
fun setSubtitleMarginY(margin: Int) {
|
||||
mpv?.setPropertyInt("sub-margin-y", margin)
|
||||
MPVLib.setPropertyInt("sub-margin-y", margin)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignX(alignment: String) {
|
||||
mpv?.setPropertyString("sub-align-x", alignment)
|
||||
MPVLib.setPropertyString("sub-align-x", alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleAlignY(alignment: String) {
|
||||
mpv?.setPropertyString("sub-align-y", alignment)
|
||||
MPVLib.setPropertyString("sub-align-y", alignment)
|
||||
}
|
||||
|
||||
fun setSubtitleFontSize(size: Int) {
|
||||
mpv?.setPropertyInt("sub-font-size", size)
|
||||
MPVLib.setPropertyInt("sub-font-size", size)
|
||||
}
|
||||
|
||||
fun setSubtitleBorderStyle(style: String) {
|
||||
mpv?.setPropertyString("sub-border-style", style)
|
||||
MPVLib.setPropertyString("sub-border-style", style)
|
||||
}
|
||||
|
||||
fun setSubtitleBackgroundColor(color: String) {
|
||||
mpv?.setPropertyString("sub-back-color", color)
|
||||
MPVLib.setPropertyString("sub-back-color", color)
|
||||
}
|
||||
|
||||
fun setSubtitleAssOverride(mode: String) {
|
||||
mpv?.setPropertyString("sub-ass-override", mode)
|
||||
MPVLib.setPropertyString("sub-ass-override", mode)
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
@@ -610,25 +509,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun getAudioTracks(): List<Map<String, Any>> {
|
||||
val tracks = mutableListOf<Map<String, Any>>()
|
||||
|
||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
||||
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||
|
||||
for (i in 0 until trackCount) {
|
||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
||||
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||
if (trackType != "audio") continue
|
||||
|
||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||
|
||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||
|
||||
val channels = mpv?.getPropertyInt("track-list/$i/audio-channels")
|
||||
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
|
||||
if (channels != null && channels > 0) {
|
||||
track["channels"] = channels
|
||||
}
|
||||
|
||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||
track["selected"] = selected
|
||||
|
||||
tracks.add(track)
|
||||
@@ -639,11 +538,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
||||
mpv?.setPropertyInt("aid", trackId)
|
||||
MPVLib.setPropertyInt("aid", trackId)
|
||||
}
|
||||
|
||||
fun getCurrentAudioTrack(): Int {
|
||||
return mpv?.getPropertyInt("aid") ?: 0
|
||||
return MPVLib.getPropertyInt("aid") ?: 0
|
||||
}
|
||||
|
||||
// MARK: - Video Scaling
|
||||
@@ -652,7 +551,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
||||
val panscanValue = if (zoomed) 1.0 else 0.0
|
||||
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
||||
mpv?.setPropertyDouble("panscan", panscanValue)
|
||||
MPVLib.setPropertyDouble("panscan", panscanValue)
|
||||
}
|
||||
|
||||
// MARK: - Technical Info
|
||||
@@ -661,79 +560,58 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
val info = mutableMapOf<String, Any>()
|
||||
|
||||
// Video dimensions
|
||||
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||
info["videoWidth"] = it
|
||||
}
|
||||
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||
info["videoHeight"] = it
|
||||
}
|
||||
|
||||
// Video codec
|
||||
mpv?.getPropertyString("video-format")?.let {
|
||||
MPVLib.getPropertyString("video-format")?.let {
|
||||
info["videoCodec"] = it
|
||||
}
|
||||
|
||||
// Audio codec
|
||||
mpv?.getPropertyString("audio-codec-name")?.let {
|
||||
MPVLib.getPropertyString("audio-codec-name")?.let {
|
||||
info["audioCodec"] = it
|
||||
}
|
||||
|
||||
// FPS (container fps)
|
||||
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||
info["fps"] = it
|
||||
}
|
||||
|
||||
// Video bitrate (bits per second)
|
||||
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["videoBitrate"] = it
|
||||
}
|
||||
|
||||
// Audio bitrate (bits per second)
|
||||
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||
info["audioBitrate"] = it
|
||||
}
|
||||
|
||||
// Demuxer cache duration (seconds of video buffered)
|
||||
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||
info["cacheSeconds"] = it
|
||||
}
|
||||
|
||||
// Configured cache limits — read back from mpv to confirm user
|
||||
// settings actually took effect. mpv stores byte sizes as int64
|
||||
// (bytes); convert to MiB for display.
|
||||
mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes ->
|
||||
info["demuxerMaxBytes"] = bytes / (1024 * 1024)
|
||||
}
|
||||
mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes ->
|
||||
info["demuxerMaxBackBytes"] = bytes / (1024 * 1024)
|
||||
}
|
||||
mpv?.getPropertyDouble("cache-secs")?.let { secs ->
|
||||
info["cacheSecsLimit"] = secs
|
||||
}
|
||||
|
||||
// Dropped frames
|
||||
mpv?.getPropertyInt("frame-drop-count")?.let {
|
||||
MPVLib.getPropertyInt("frame-drop-count")?.let {
|
||||
info["droppedFrames"] = it
|
||||
}
|
||||
|
||||
// Active video output driver (read from MPV to confirm what's actually applied)
|
||||
mpv?.getPropertyString("vo")?.let {
|
||||
MPVLib.getPropertyString("vo")?.let {
|
||||
info["voDriver"] = it
|
||||
}
|
||||
|
||||
// Active hardware decoder.
|
||||
// hwdec-current yields e.g. "mediacodec",
|
||||
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
|
||||
mpv?.getPropertyString("hwdec-current")?.let {
|
||||
// Active hardware decoder
|
||||
MPVLib.getPropertyString("hwdec-active")?.let {
|
||||
info["hwdec"] = it
|
||||
}
|
||||
|
||||
// Estimated video output fps (renderer-side, after filtering).
|
||||
// Useful for diagnosing display/pipeline drops vs container fps.
|
||||
mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let {
|
||||
info["estimatedVfFps"] = it
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -826,7 +704,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
||||
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
||||
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
||||
mpv?.command(arrayOf("sub-add", subUrl, "auto"))
|
||||
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
|
||||
}
|
||||
pendingExternalSubtitles = emptyList()
|
||||
}
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import dev.jdtech.mpv.MPVLib as LibMPV
|
||||
|
||||
/**
|
||||
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||
*
|
||||
* libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns
|
||||
* a fresh, independent handle. Each player creates its own MPVLib instance
|
||||
* (Findroid pattern) and on teardown we simply drop the reference. We do NOT
|
||||
* call `LibMPV.destroy()` — its native implementation has an internal
|
||||
* use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the
|
||||
* GC reach the JVM-level finalizer (or never reaching it, since the native
|
||||
* handle lives in process-global state until exit) is strictly safer than
|
||||
* crashing.
|
||||
*
|
||||
* Trade-off: mpv's native footprint (decoder + demuxer cache) for one player
|
||||
* stays allocated until the next player's allocation displaces it in scudo's
|
||||
* arena. On a TV app where the player is the dominant memory consumer and
|
||||
* only one player is alive at a time, this is acceptable.
|
||||
* Wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||
* This provides a consistent interface for the rest of the app.
|
||||
*/
|
||||
class MPVLib private constructor(private val instance: LibMPV) {
|
||||
|
||||
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
|
||||
// so MPVLayerRenderer implements a stable, wrapper-owned signature.
|
||||
object MPVLib {
|
||||
private const val TAG = "MPVLib"
|
||||
|
||||
private var initialized = false
|
||||
|
||||
// Event observer interface
|
||||
interface EventObserver {
|
||||
fun eventProperty(property: String)
|
||||
fun eventProperty(property: String, value: Long)
|
||||
@@ -32,144 +23,198 @@ class MPVLib private constructor(private val instance: LibMPV) {
|
||||
fun eventProperty(property: String, value: Double)
|
||||
fun event(eventId: Int)
|
||||
}
|
||||
|
||||
|
||||
private val observers = mutableListOf<EventObserver>()
|
||||
|
||||
// Library event observer that forwards LibMPV callbacks to our observers.
|
||||
|
||||
// Library event observer that forwards to our observers
|
||||
private val libObserver = object : LibMPV.EventObserver {
|
||||
override fun eventProperty(property: String) =
|
||||
dispatch { it.eventProperty(property) }
|
||||
|
||||
override fun eventProperty(property: String, value: Long) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: String) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun eventProperty(property: String, value: Double) =
|
||||
dispatch { it.eventProperty(property, value) }
|
||||
|
||||
override fun event(eventId: Int) =
|
||||
dispatch { it.event(eventId) }
|
||||
|
||||
private inline fun dispatch(block: (EventObserver) -> Unit) {
|
||||
override fun eventProperty(property: String) {
|
||||
synchronized(observers) {
|
||||
observers.forEach(block)
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Long) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Boolean) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: String) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String, value: Double) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.eventProperty(property, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun event(eventId: Int) {
|
||||
synchronized(observers) {
|
||||
for (observer in observers) {
|
||||
observer.event(eventId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun addObserver(observer: EventObserver) {
|
||||
synchronized(observers) { observers.add(observer) }
|
||||
}
|
||||
|
||||
fun removeObserver(observer: EventObserver) {
|
||||
synchronized(observers) { observers.remove(observer) }
|
||||
}
|
||||
|
||||
fun initialize() {
|
||||
instance.init()
|
||||
}
|
||||
|
||||
fun attachSurface(surface: android.view.Surface) {
|
||||
instance.attachSurface(surface)
|
||||
}
|
||||
|
||||
fun detachSurface() {
|
||||
instance.detachSurface()
|
||||
}
|
||||
|
||||
fun command(cmd: Array<String>) {
|
||||
instance.command(cmd)
|
||||
}
|
||||
|
||||
fun setOptionString(name: String, value: String): Int {
|
||||
return instance.setOptionString(name, value)
|
||||
}
|
||||
|
||||
fun getPropertyInt(name: String): Int? = try {
|
||||
instance.getPropertyInt(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun getPropertyDouble(name: String): Double? = try {
|
||||
instance.getPropertyDouble(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun getPropertyBoolean(name: String): Boolean? = try {
|
||||
instance.getPropertyBoolean(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun getPropertyString(name: String): String? = try {
|
||||
instance.getPropertyString(name)
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
fun setPropertyInt(name: String, value: Int) {
|
||||
instance.setPropertyInt(name, value)
|
||||
}
|
||||
|
||||
fun setPropertyDouble(name: String, value: Double) {
|
||||
instance.setPropertyDouble(name, value)
|
||||
}
|
||||
|
||||
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||
instance.setPropertyBoolean(name, value)
|
||||
}
|
||||
|
||||
fun setPropertyString(name: String, value: String) {
|
||||
instance.setPropertyString(name, value)
|
||||
}
|
||||
|
||||
fun observeProperty(name: String, format: Int) {
|
||||
instance.observeProperty(name, format)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a fresh mpv handle. Each call returns an independent instance —
|
||||
* do not share across players. Attach exactly one [EventObserver] per
|
||||
* player via [addObserver].
|
||||
*/
|
||||
fun create(context: Context): MPVLib {
|
||||
val lib = LibMPV.create(context)
|
||||
?: throw IllegalStateException("LibMPV.create returned null")
|
||||
val wrapper = MPVLib(lib)
|
||||
// The libObserver is attached for the lifetime of this MPVLib
|
||||
// instance and forwards every LibMPV callback to our observers
|
||||
// list. Player-specific observers are added/removed via
|
||||
// addObserver/removeObserver.
|
||||
lib.addObserver(wrapper.libObserver)
|
||||
return wrapper
|
||||
synchronized(observers) {
|
||||
observers.add(observer)
|
||||
}
|
||||
|
||||
// MPV Event IDs (kept here so observers can reference them without
|
||||
// holding a reference to an instance).
|
||||
const val MPV_EVENT_NONE = 0
|
||||
const val MPV_EVENT_SHUTDOWN = 1
|
||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||
const val MPV_EVENT_START_FILE = 6
|
||||
const val MPV_EVENT_END_FILE = 7
|
||||
const val MPV_EVENT_FILE_LOADED = 8
|
||||
const val MPV_EVENT_IDLE = 11
|
||||
const val MPV_EVENT_TICK = 14
|
||||
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||
const val MPV_EVENT_SEEK = 20
|
||||
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||
|
||||
// End file reason
|
||||
const val MPV_END_FILE_REASON_EOF = 0
|
||||
const val MPV_END_FILE_REASON_STOP = 2
|
||||
const val MPV_END_FILE_REASON_QUIT = 3
|
||||
const val MPV_END_FILE_REASON_ERROR = 4
|
||||
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||
}
|
||||
|
||||
fun removeObserver(observer: EventObserver) {
|
||||
synchronized(observers) {
|
||||
observers.remove(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// MPV Event IDs
|
||||
const val MPV_EVENT_NONE = 0
|
||||
const val MPV_EVENT_SHUTDOWN = 1
|
||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||
const val MPV_EVENT_START_FILE = 6
|
||||
const val MPV_EVENT_END_FILE = 7
|
||||
const val MPV_EVENT_FILE_LOADED = 8
|
||||
const val MPV_EVENT_IDLE = 11
|
||||
const val MPV_EVENT_TICK = 14
|
||||
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||
const val MPV_EVENT_SEEK = 20
|
||||
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||
|
||||
// End file reason
|
||||
const val MPV_END_FILE_REASON_EOF = 0
|
||||
const val MPV_END_FILE_REASON_STOP = 2
|
||||
const val MPV_END_FILE_REASON_QUIT = 3
|
||||
const val MPV_END_FILE_REASON_ERROR = 4
|
||||
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||
|
||||
/**
|
||||
* Create and initialize the MPV library
|
||||
*/
|
||||
fun create(context: Context, configDir: String? = null) {
|
||||
if (initialized) return
|
||||
|
||||
try {
|
||||
LibMPV.create(context)
|
||||
LibMPV.addObserver(libObserver)
|
||||
initialized = true
|
||||
Log.i(TAG, "libmpv created successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create libmpv: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
fun initialize() {
|
||||
LibMPV.init()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
if (!initialized) return
|
||||
try {
|
||||
LibMPV.removeObserver(libObserver)
|
||||
LibMPV.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error destroying mpv: ${e.message}")
|
||||
}
|
||||
initialized = false
|
||||
}
|
||||
|
||||
fun isInitialized(): Boolean = initialized
|
||||
|
||||
fun attachSurface(surface: Surface) {
|
||||
LibMPV.attachSurface(surface)
|
||||
}
|
||||
|
||||
fun detachSurface() {
|
||||
LibMPV.detachSurface()
|
||||
}
|
||||
|
||||
fun command(cmd: Array<String?>) {
|
||||
LibMPV.command(cmd)
|
||||
}
|
||||
|
||||
fun setOptionString(name: String, value: String): Int {
|
||||
return LibMPV.setOptionString(name, value)
|
||||
}
|
||||
|
||||
fun getPropertyInt(name: String): Int? {
|
||||
return try {
|
||||
LibMPV.getPropertyInt(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPropertyDouble(name: String): Double? {
|
||||
return try {
|
||||
LibMPV.getPropertyDouble(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPropertyBoolean(name: String): Boolean? {
|
||||
return try {
|
||||
LibMPV.getPropertyBoolean(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPropertyString(name: String): String? {
|
||||
return try {
|
||||
LibMPV.getPropertyString(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun setPropertyInt(name: String, value: Int) {
|
||||
LibMPV.setPropertyInt(name, value)
|
||||
}
|
||||
|
||||
fun setPropertyDouble(name: String, value: Double) {
|
||||
LibMPV.setPropertyDouble(name, value)
|
||||
}
|
||||
|
||||
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||
LibMPV.setPropertyBoolean(name, value)
|
||||
}
|
||||
|
||||
fun setPropertyString(name: String, value: String) {
|
||||
LibMPV.setPropertyString(name, value)
|
||||
}
|
||||
|
||||
fun observeProperty(name: String, format: Int) {
|
||||
LibMPV.observeProperty(name, format)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,7 @@ class MpvPlayerModule : Module() {
|
||||
if (source == null) return@Prop
|
||||
|
||||
val urlString = source["url"] as? String ?: return@Prop
|
||||
|
||||
// Parse cache config if provided (mirrors iOS)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val config = VideoLoadConfig(
|
||||
url = urlString,
|
||||
@@ -42,11 +38,7 @@ class MpvPlayerModule : Module() {
|
||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||
voDriver = source["voDriver"] as? String,
|
||||
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
||||
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
||||
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
||||
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
||||
voDriver = source["voDriver"] as? String
|
||||
)
|
||||
|
||||
view.loadVideo(config)
|
||||
@@ -68,15 +60,6 @@ class MpvPlayerModule : Module() {
|
||||
view.pause()
|
||||
}
|
||||
|
||||
// Stop playback and release the MediaCodec decoder + demuxer.
|
||||
// Does not synchronously tear down the native mpv handle (see
|
||||
// MPVLib / MpvPlayerView.destroy docs). Call before navigating
|
||||
// away from the player screen to avoid OOM during screen
|
||||
// transitions on low-RAM devices.
|
||||
AsyncFunction("destroy") { view: MpvPlayerView ->
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
// Async function to seek to position
|
||||
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
||||
view.seekTo(position)
|
||||
|
||||
@@ -2,12 +2,14 @@ package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.Rect
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
@@ -24,30 +26,15 @@ data class VideoLoadConfig(
|
||||
val autoplay: Boolean = true,
|
||||
val initialSubtitleId: Int? = null,
|
||||
val initialAudioId: Int? = null,
|
||||
val voDriver: String? = null,
|
||||
val cacheEnabled: String? = null,
|
||||
val cacheSeconds: Int? = null,
|
||||
val demuxerMaxBytes: Int? = null,
|
||||
val demuxerMaxBackBytes: Int? = null,
|
||||
val voDriver: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||
*
|
||||
* Uses SurfaceView (not TextureView) so the surface routes directly to
|
||||
* SurfaceFlinger (the OS compositor) rather than compositing into the
|
||||
* app's window surface. This matches mpv-android's architecture and
|
||||
* gives mpv a standalone surface.
|
||||
*
|
||||
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
|
||||
* recreated on PiP entry/exit, and the new surface's initial dimensions
|
||||
* can be stale until the next layout pass. We push dimension updates to
|
||||
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
|
||||
* OnLayoutChangeListener, so the PiP transition (which fires layout
|
||||
* passes on the view itself) reaches mpv promptly.
|
||||
* Uses TextureView for reliable Picture-in-Picture support.
|
||||
*/
|
||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
||||
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerView"
|
||||
@@ -61,7 +48,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
val onTracksReady by EventDispatcher()
|
||||
val onPictureInPictureChange by EventDispatcher()
|
||||
|
||||
private var surfaceView: SurfaceView
|
||||
private var textureView: TextureView
|
||||
private var renderer: MPVLayerRenderer? = null
|
||||
private var pipController: PiPController? = null
|
||||
|
||||
@@ -72,45 +59,30 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
private var surfaceReady: Boolean = false
|
||||
private var pendingConfig: VideoLoadConfig? = null
|
||||
private var rendererStarted: Boolean = false
|
||||
private var activeSurface: 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 {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
|
||||
// SurfaceView for video rendering. Routes the surface directly to
|
||||
// SurfaceFlinger (the OS compositor), giving mpv a standalone
|
||||
// surface. TextureView composites into the app's window surface
|
||||
// which is less efficient and breaks PiP transitions.
|
||||
surfaceView = SurfaceView(context).apply {
|
||||
// Create TextureView for video rendering (composites into app window for PiP support)
|
||||
textureView = TextureView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
surfaceTextureListener = this@MpvPlayerView
|
||||
}
|
||||
surfaceView.holder.addCallback(this@MpvPlayerView)
|
||||
addView(surfaceView)
|
||||
|
||||
// Push dimension updates to mpv on every view bounds change. This
|
||||
// is the primary PiP black-screen fix: entering PiP fires a layout
|
||||
// pass on the SurfaceView itself, and we proactively tell mpv the
|
||||
// new size so it resizes its EGL swapchain before rendering.
|
||||
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
|
||||
oldLeft, oldTop, oldRight, oldBottom ->
|
||||
val w = right - left
|
||||
val h = bottom - top
|
||||
val oldW = oldRight - oldLeft
|
||||
val oldH = oldBottom - oldTop
|
||||
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
|
||||
renderer?.updateSurfaceSize(w, h)
|
||||
}
|
||||
}
|
||||
addView(textureView)
|
||||
|
||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||
pipController = PiPController(context, appContext)
|
||||
pipController?.setPlayerView(surfaceView)
|
||||
pipController?.setPlayerView(textureView)
|
||||
pipController?.delegate = object : PiPController.Delegate {
|
||||
override fun onPlay() {
|
||||
play()
|
||||
@@ -126,17 +98,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||
if (isInPiP) {
|
||||
// Post size syncs after the PiP layout settles. Two passes
|
||||
// catch both the immediate surface re-attach and the
|
||||
// post-animation layout pass. Replaces the old TextureView
|
||||
// measure/layout polling hack (forcePiPBufferSize).
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
|
||||
if (!isWaitingForPiPTransition) {
|
||||
isWaitingForPiPTransition = true
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Restore from PiP: surface resized back to fullscreen.
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
||||
restoreFromPiP()
|
||||
}
|
||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||
}
|
||||
@@ -149,7 +121,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
/**
|
||||
* Start the renderer with the given VO driver.
|
||||
* Called lazily on first loadVideo so user settings are available.
|
||||
* Called lazily on first loadVideo so the voDriver setting is available.
|
||||
*/
|
||||
private fun ensureRendererStarted(voDriver: String?) {
|
||||
if (rendererStarted) return
|
||||
@@ -158,14 +130,9 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
renderer?.start(voDriver ?: "gpu-next")
|
||||
rendererStarted = true
|
||||
|
||||
// If the surface is already alive (surfaceCreated fired before
|
||||
// loadVideo), attach it now. With SurfaceView the surface is
|
||||
// owned by the holder, so we read it from there directly rather
|
||||
// than stashing it on the side.
|
||||
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
|
||||
activeSurface = surface
|
||||
pendingSurface?.let { surface ->
|
||||
renderer?.attachSurface(surface)
|
||||
syncSurfaceSizeToView()
|
||||
pendingSurface = null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||
@@ -173,20 +140,18 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SurfaceHolder.Callback
|
||||
// MARK: - TextureView.SurfaceTextureListener
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
val surface = holder.surface
|
||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
this.surfaceTexture = surfaceTexture
|
||||
val surface = Surface(surfaceTexture)
|
||||
surfaceTexture.setDefaultBufferSize(width, height)
|
||||
surfaceReady = true
|
||||
|
||||
if (rendererStarted) {
|
||||
// The previous Surface reference is holder-owned; do NOT release
|
||||
// it (SurfaceView manages its lifecycle). Just track the new one.
|
||||
activeSurface = surface
|
||||
renderer?.attachSurface(surface)
|
||||
// Push the actual view dimensions immediately so mpv doesn't
|
||||
// render against stale full-screen geometry during PiP transitions.
|
||||
syncSurfaceSizeToView()
|
||||
} else {
|
||||
pendingSurface = surface
|
||||
}
|
||||
|
||||
// If we have a pending load, execute it now
|
||||
@@ -197,36 +162,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
if (width > 0 && height > 0) {
|
||||
renderer?.updateSurfaceSize(width, height)
|
||||
}
|
||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
surfaceTexture.setDefaultBufferSize(width, height)
|
||||
renderer?.updateSurfaceSize(width, height)
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
this.surfaceTexture = null
|
||||
surfaceReady = false
|
||||
renderer?.detachSurface()
|
||||
// Do NOT issue mpv "stop" here. Playback continues against the
|
||||
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
|
||||
// background/foreground), we re-attach and frames resume. This
|
||||
// matches the keep-open=always setting in MPVLayerRenderer.
|
||||
//
|
||||
// Do NOT release activeSurface — SurfaceView owns it via the holder.
|
||||
activeSurface = null
|
||||
return false // mpv manages the SurfaceTexture
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the actual SurfaceView width/height and push them to mpv.
|
||||
* The PiP transition can fire surfaceCreated before the view's layout
|
||||
* has settled to PiP dimensions, so we re-sync after layout passes.
|
||||
*/
|
||||
private fun syncSurfaceSizeToView() {
|
||||
if (!surfaceReady) return
|
||||
val w = surfaceView.width
|
||||
val h = surfaceView.height
|
||||
if (w > 0 && h > 0) {
|
||||
renderer?.updateSurfaceSize(w, h)
|
||||
}
|
||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||
// Called every frame — no action needed, mpv drives rendering directly
|
||||
}
|
||||
|
||||
// MARK: - Video Loading
|
||||
@@ -258,11 +207,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
startPosition = config.startPosition,
|
||||
externalSubtitles = config.externalSubtitles,
|
||||
initialSubtitleId = config.initialSubtitleId,
|
||||
initialAudioId = config.initialAudioId,
|
||||
cacheEnabled = config.cacheEnabled,
|
||||
cacheSeconds = config.cacheSeconds,
|
||||
demuxerMaxBytes = config.demuxerMaxBytes,
|
||||
demuxerMaxBackBytes = config.demuxerMaxBackBytes
|
||||
initialAudioId = config.initialAudioId
|
||||
)
|
||||
|
||||
if (config.autoplay) {
|
||||
@@ -291,50 +236,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback and release decoder resources.
|
||||
*
|
||||
* Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command
|
||||
* on a background thread (flushing the demuxer and releasing the
|
||||
* MediaCodec hardware decoder) and drops the per-instance mpv handle.
|
||||
*
|
||||
* NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's
|
||||
* nativeDestroy has an internal use-after-free on the JNI global ref
|
||||
* path, so the native mpv handle is intentionally left for the JVM GC
|
||||
* / native finalizer rather than torn down synchronously. See
|
||||
* [MPVLib] class doc for the full rationale.
|
||||
*
|
||||
* Call this BEFORE navigating away from the player screen so the
|
||||
* decoder is reclaimed before the next screen (or the next episode's
|
||||
* player) mounts. Otherwise Expo Router renders the new screen first
|
||||
* and you briefly have two mpv instances + two 4K decoders alive —
|
||||
* instant OOM on a 2 GB device.
|
||||
*/
|
||||
fun destroy() {
|
||||
renderer?.stop()
|
||||
|
||||
// Reset view-level state so a subsequent loadVideo() on the SAME view
|
||||
// instance re-creates the mpv handle and re-attaches the still-live
|
||||
// SurfaceView surface. Without this, rendererStarted stays true and
|
||||
// ensureRendererStarted() early-returns, so renderer.start() is never
|
||||
// called again — but stop() already nulled the renderer's mpv handle.
|
||||
// The next loadVideo() then runs loadVideoInternal() -> renderer.load()
|
||||
// against mpv == null, where every mpv?.command() (including the
|
||||
// "stop" and load commands) silently no-ops, leaving a black frame.
|
||||
//
|
||||
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
||||
// which call destroy() immediately before router.replace() to the
|
||||
// same route — Expo Router reuses the same MpvPlayerView instance,
|
||||
// so the next source load happens on this view without a remount.
|
||||
//
|
||||
// SurfaceView note: the surface is owned by the holder and survives
|
||||
// across destroy()/loadVideo() on the same view instance. The next
|
||||
// ensureRendererStarted() reads it from surfaceView.holder.surface.
|
||||
rendererStarted = false
|
||||
currentUrl = null
|
||||
activeSurface = null
|
||||
}
|
||||
|
||||
fun seekTo(position: Double) {
|
||||
renderer?.seekTo(position)
|
||||
}
|
||||
@@ -366,10 +267,59 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
fun startPictureInPicture() {
|
||||
isWaitingForPiPTransition = true
|
||||
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() {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
@@ -529,24 +479,13 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/**
|
||||
* Proactively tear down the player. Called from onDetachedFromWindow so
|
||||
* the app releases mpv + decoder buffers when the View detaches from the
|
||||
* window. The JS-facing destroy() is intentionally thinner (just
|
||||
* renderer.stop()) — see this thread for why the full teardown was kept
|
||||
* off the JS path.
|
||||
*/
|
||||
fun cleanup() {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
renderer?.delegate = null
|
||||
|
||||
// SurfaceView owns the Surface via its holder — do NOT release it.
|
||||
activeSurface = null
|
||||
surfaceTexture = null
|
||||
surfaceReady = false
|
||||
currentUrl = null
|
||||
rendererStarted = false
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
|
||||
@@ -44,11 +44,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
private var currentPosition: Double = 0.0
|
||||
private var currentDuration: Double = 0.0
|
||||
private var playbackRate: Double = 1.0
|
||||
// Independently tracks whether the system should auto-enter PiP on home
|
||||
// press. Decoupled from playbackRate so that disabling auto-enter
|
||||
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
|
||||
// state that buildPiPActions() derives from playbackRate.
|
||||
private var autoEnterEnabled: Boolean = false
|
||||
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
@@ -111,37 +106,15 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
}
|
||||
|
||||
fun stopPictureInPicture() {
|
||||
// Disable auto-enter eligibility without touching playbackRate.
|
||||
// playbackRate drives the play/pause icon in buildPiPActions();
|
||||
// mutating it here would cause a stale icon if PiP is re-entered
|
||||
// before the next playback state callback corrects it.
|
||||
autoEnterEnabled = false
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
unregisterLifecycleCallbacks()
|
||||
|
||||
val activity = getActivity() ?: return
|
||||
|
||||
// Push minimal params with just auto-enter disabled. Do NOT call
|
||||
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
|
||||
// setActions(), which would re-register the broadcast receiver
|
||||
// (just unregistered above) and attach play/pause/skip actions to
|
||||
// params being torn down. That leaves a live receiver + stale
|
||||
// actions after the player has unmounted.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
try {
|
||||
activity.setPictureInPictureParams(
|
||||
PictureInPictureParams.Builder()
|
||||
.setAutoEnterEnabled(false)
|
||||
.build()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity?.isInPictureInPictureMode == true) {
|
||||
activity.moveTaskToBack(false)
|
||||
}
|
||||
}
|
||||
if (activity.isInPictureInPictureMode) {
|
||||
activity.moveTaskToBack(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||
@@ -153,7 +126,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
|
||||
fun setPlaybackRate(rate: Double) {
|
||||
playbackRate = rate
|
||||
autoEnterEnabled = rate > 0
|
||||
|
||||
if (rate > 0) {
|
||||
registerLifecycleCallbacks()
|
||||
@@ -236,7 +208,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
builder.setActions(buildPiPActions())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
|
||||
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
|
||||
@@ -1020,44 +1020,12 @@ final class MPVLayerRenderer {
|
||||
info["cacheSeconds"] = cacheSeconds
|
||||
}
|
||||
|
||||
// Configured cache limits — read back from mpv to confirm user
|
||||
// settings actually took effect. mpv stores byte sizes as int64
|
||||
// (bytes); convert to MiB for display.
|
||||
var demuxerMaxBytes: Int64 = 0
|
||||
if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 {
|
||||
info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024))
|
||||
}
|
||||
var demuxerMaxBackBytes: Int64 = 0
|
||||
if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 {
|
||||
info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024))
|
||||
}
|
||||
var cacheSecsLimit: Double = 0
|
||||
if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 {
|
||||
info["cacheSecsLimit"] = cacheSecsLimit
|
||||
}
|
||||
|
||||
// Dropped frames
|
||||
var droppedFrames: Int64 = 0
|
||||
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
||||
info["droppedFrames"] = Int(droppedFrames)
|
||||
}
|
||||
|
||||
// Active video output driver
|
||||
if let voDriver = getStringProperty(handle: handle, name: "vo") {
|
||||
info["voDriver"] = voDriver
|
||||
}
|
||||
|
||||
// Active hardware decoder
|
||||
if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") {
|
||||
info["hwdec"] = hwdec
|
||||
}
|
||||
|
||||
// Estimated video output fps (post-filter)
|
||||
var estimatedVfFps: Double = 0
|
||||
if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 {
|
||||
info["estimatedVfFps"] = estimatedVfFps
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +74,7 @@ public class MpvPlayerModule: Module {
|
||||
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
// Synchronously destroy mpv instance + decoder before navigating
|
||||
// away from the player screen (cross-platform; matches Android).
|
||||
AsyncFunction("destroy") { (view: MpvPlayerView) in
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
|
||||
// Async function to seek to position
|
||||
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||
view.seekTo(position: position)
|
||||
|
||||
@@ -289,49 +289,6 @@ class MpvPlayerView: ExpoView {
|
||||
pipController?.updatePlaybackState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously stop and destroy the mpv instance + decoder so memory is
|
||||
* freed before the next screen mounts. Safe to call multiple times — the
|
||||
* underlying renderer.stop() guards against re-entry.
|
||||
*
|
||||
* Cross-platform counterpart of MpvPlayerView.destroy() on Android.
|
||||
*/
|
||||
func destroy() {
|
||||
renderer?.stop()
|
||||
|
||||
// Reset view state and re-create the mpv handle so a subsequent
|
||||
// loadVideo() on the SAME view instance can actually load.
|
||||
// Without this, stop() leaves renderer.mpv == nil, and the next
|
||||
// loadVideo(config:) calls renderer.load() which early-returns
|
||||
// at `guard let handle = self.mpv else { return }` — but only
|
||||
// after flipping isLoading = true and dispatching the loading
|
||||
// delegate callback, so the JS layer is stuck in a perpetual
|
||||
// "loading" state with no actual playback.
|
||||
//
|
||||
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
||||
// which call destroy() immediately before router.replace() to
|
||||
// the same route — Expo Router reuses the same MpvPlayerView
|
||||
// instance, so the next `source` prop update arrives on this
|
||||
// view without a remount. setupView() is otherwise the only
|
||||
// place start() is called, so without re-starting here the
|
||||
// renderer stays dead until the whole view is unmounted and
|
||||
// recreated.
|
||||
//
|
||||
// start() is idempotent (`guard !isRunning else { return }`)
|
||||
// and stop() has already nulled mpv synchronously before
|
||||
// dispatching the async mpv_terminate_destroy, so creating a
|
||||
// fresh handle here is safe even while the old handle's
|
||||
// teardown is still in flight on a background queue (libmpv
|
||||
// handles are independent).
|
||||
currentURL = nil
|
||||
intendedPlayState = false
|
||||
do {
|
||||
try renderer?.start()
|
||||
} catch {
|
||||
onError(["error": "Failed to restart renderer after destroy: \(error.localizedDescription)"])
|
||||
}
|
||||
}
|
||||
|
||||
func seekTo(position: Double) {
|
||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||
cachedPosition = position
|
||||
|
||||
@@ -89,14 +89,6 @@ export type MpvPlayerViewProps = {
|
||||
export interface MpvPlayerViewRef {
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
/**
|
||||
* Synchronously destroy the mpv instance + decoder + surface buffers.
|
||||
* Call before navigating away from the player screen so memory is
|
||||
* freed before the next screen mounts. Safe to call multiple times.
|
||||
*/
|
||||
destroy: () => Promise<void>;
|
||||
// Pre-libmpv-1.0 alias (kept for source-history reference):
|
||||
// stop: () => Promise<void>;
|
||||
seekTo: (position: number) => Promise<void>;
|
||||
seekBy: (offset: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
@@ -162,17 +154,9 @@ export type TechnicalInfo = {
|
||||
videoBitrate?: number;
|
||||
audioBitrate?: number;
|
||||
cacheSeconds?: number;
|
||||
/** Configured demuxer forward cache cap (MiB), read back from mpv */
|
||||
demuxerMaxBytes?: number;
|
||||
/** Configured demuxer backward cache cap (MiB), read back from mpv */
|
||||
demuxerMaxBackBytes?: number;
|
||||
/** Configured cache-secs floor, read back from mpv */
|
||||
cacheSecsLimit?: number;
|
||||
droppedFrames?: number;
|
||||
/** Active video output driver (read from MPV at runtime) */
|
||||
voDriver?: string;
|
||||
/** Active hardware decoder (read from MPV at runtime) */
|
||||
hwdec?: string;
|
||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||
estimatedVfFps?: number;
|
||||
};
|
||||
|
||||
@@ -20,9 +20,6 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
destroy: async () => {
|
||||
await nativeRef.current?.destroy();
|
||||
},
|
||||
seekTo: async (position: number) => {
|
||||
await nativeRef.current?.seekTo(position);
|
||||
},
|
||||
|
||||
13
package.json
13
package.json
@@ -17,13 +17,13 @@
|
||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||
"prepare": "husky",
|
||||
"typecheck": "bun scripts/typecheck.ts",
|
||||
"typecheck": "node scripts/typecheck.js",
|
||||
"check": "biome check . --max-diagnostics 1000",
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||
"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"
|
||||
},
|
||||
@@ -39,7 +39,7 @@
|
||||
"@react-native-community/netinfo": "^12.0.0",
|
||||
"@react-navigation/material-top-tabs": "7.4.28",
|
||||
"@react-navigation/native": "^7.2.5",
|
||||
"@shopify/flash-list": "2.0.3",
|
||||
"@shopify/flash-list": "2.3.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||
"@tanstack/react-pacer": "^0.19.1",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
@@ -132,11 +132,10 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.20.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.8",
|
||||
"lint-staged": "17.0.7",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"expo": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
|
||||
const { withPodfile } = require("expo/config-plugins");
|
||||
|
||||
const PATCH_START = "## >>> runtime-framework headers";
|
||||
const PATCH_END = "## <<< runtime-framework headers";
|
||||
@@ -13,7 +13,7 @@ const EXTRA_HDRS = [
|
||||
`\${PODS_CONFIGURATION_BUILD_DIR}/React-rendererconsistency/React_rendererconsistency.framework/Headers`,
|
||||
];
|
||||
|
||||
function buildPatch(): string {
|
||||
function buildPatch() {
|
||||
return [
|
||||
PATCH_START,
|
||||
" extra_hdrs = [",
|
||||
@@ -91,7 +91,7 @@ function buildPatch(): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const withRuntimeFrameworkHeaders: ConfigPlugin = (config) => {
|
||||
module.exports = function withRuntimeFrameworkHeaders(config) {
|
||||
return withPodfile(config, (config) => {
|
||||
let podfile = config.modResults.contents;
|
||||
|
||||
@@ -125,5 +125,3 @@ end
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
export default withRuntimeFrameworkHeaders;
|
||||
@@ -1,20 +1,10 @@
|
||||
import {
|
||||
type ConfigPlugin,
|
||||
const {
|
||||
withAndroidColors,
|
||||
withAndroidColorsNight,
|
||||
} from "expo/config-plugins";
|
||||
} = require("expo/config-plugins");
|
||||
|
||||
interface ColorResourceItem {
|
||||
$: { name: string };
|
||||
_: string;
|
||||
}
|
||||
|
||||
const withAndroidAlertColors: ConfigPlugin = (config) => {
|
||||
const setColor = (
|
||||
colorsList: ColorResourceItem[],
|
||||
name: string,
|
||||
value: string,
|
||||
) => {
|
||||
const withAndroidAlertColors = (config) => {
|
||||
const setColor = (colorsList, name, value) => {
|
||||
const existingColor = colorsList.find(
|
||||
(item) => item.$ && item.$.name === name,
|
||||
);
|
||||
@@ -30,7 +20,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
|
||||
|
||||
config = withAndroidColors(config, (config) => {
|
||||
const colors = config.modResults;
|
||||
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
||||
const colorsList = colors.resources.color || [];
|
||||
setColor(colorsList, "colorPrimary", "#000000");
|
||||
colors.resources.color = colorsList;
|
||||
return config;
|
||||
@@ -38,7 +28,7 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
|
||||
|
||||
config = withAndroidColorsNight(config, (config) => {
|
||||
const colors = config.modResults;
|
||||
const colorsList = (colors.resources.color ?? []) as ColorResourceItem[];
|
||||
const colorsList = colors.resources.color || [];
|
||||
setColor(colorsList, "colorPrimary", "#FFFFFF");
|
||||
colors.resources.color = colorsList;
|
||||
return config;
|
||||
@@ -47,4 +37,4 @@ const withAndroidAlertColors: ConfigPlugin = (config) => {
|
||||
return config;
|
||||
};
|
||||
|
||||
export default withAndroidAlertColors;
|
||||
module.exports = withAndroidAlertColors;
|
||||
@@ -1,12 +1,8 @@
|
||||
import { type ConfigPlugin, withAndroidManifest } from "expo/config-plugins";
|
||||
const { withAndroidManifest } = require("expo/config-plugins");
|
||||
|
||||
const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
|
||||
const _withGoogleCastAndroidManifest = (config) =>
|
||||
withAndroidManifest(config, async (mod) => {
|
||||
const mainApplication = mod.modResults.manifest.application?.[0];
|
||||
|
||||
if (!mainApplication) {
|
||||
return mod;
|
||||
}
|
||||
const mainApplication = mod.modResults.manifest.application[0];
|
||||
|
||||
// Initialize activity array if it doesn't exist
|
||||
if (!mainApplication.activity) {
|
||||
@@ -43,4 +39,4 @@ const withGoogleCastAndroidManifest: ConfigPlugin = (config) =>
|
||||
return mod;
|
||||
});
|
||||
|
||||
export default withGoogleCastAndroidManifest;
|
||||
module.exports = _withGoogleCastAndroidManifest;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
||||
const { readFileSync, writeFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const { withDangerousMod } = require("expo/config-plugins");
|
||||
|
||||
const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
|
||||
const withChangeNativeAndroidTextToWhite = (expoConfig) =>
|
||||
withDangerousMod(expoConfig, [
|
||||
"android",
|
||||
(modConfig) => {
|
||||
@@ -30,4 +30,4 @@ const withChangeNativeAndroidTextToWhite: ConfigPlugin = (expoConfig) =>
|
||||
},
|
||||
]);
|
||||
|
||||
export default withChangeNativeAndroidTextToWhite;
|
||||
module.exports = withChangeNativeAndroidTextToWhite;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ConfigPlugin, withAppBuildGradle } from "expo/config-plugins";
|
||||
const { withAppBuildGradle } = require("expo/config-plugins");
|
||||
|
||||
const withExcludeMedia3Dash: ConfigPlugin = (config) => {
|
||||
module.exports = function withExcludeMedia3Dash(config) {
|
||||
return withAppBuildGradle(config, (config) => {
|
||||
const contents = config.modResults.contents;
|
||||
|
||||
@@ -32,5 +32,3 @@ configurations.all {
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
export default withExcludeMedia3Dash;
|
||||
@@ -1,14 +1,6 @@
|
||||
import { type ConfigPlugin, withPodfile } from "expo/config-plugins";
|
||||
const { withPodfile } = require("@expo/config-plugins");
|
||||
|
||||
interface GitPodOptions {
|
||||
podName: string;
|
||||
podspecUrl: string;
|
||||
}
|
||||
|
||||
const withGitPod: ConfigPlugin<GitPodOptions> = (
|
||||
config,
|
||||
{ podName, podspecUrl },
|
||||
) => {
|
||||
const withGitPod = (config, { podName, podspecUrl }) => {
|
||||
return withPodfile(config, (config) => {
|
||||
const podfile = config.modResults.contents;
|
||||
|
||||
@@ -29,4 +21,4 @@ const withGitPod: ConfigPlugin<GitPodOptions> = (
|
||||
});
|
||||
};
|
||||
|
||||
export default withGitPod;
|
||||
module.exports = withGitPod;
|
||||
@@ -1,21 +1,12 @@
|
||||
import type { ExpoConfig } from "expo/config";
|
||||
import {
|
||||
AndroidConfig,
|
||||
type ConfigPlugin,
|
||||
withGradleProperties,
|
||||
} from "expo/config-plugins";
|
||||
const { withGradleProperties } = require("expo/config-plugins");
|
||||
|
||||
function setGradlePropertiesValue(
|
||||
config: ExpoConfig,
|
||||
key: string,
|
||||
value: string,
|
||||
): ExpoConfig {
|
||||
function setGradlePropertiesValue(config, key, value) {
|
||||
return withGradleProperties(config, (exportedConfig) => {
|
||||
const props = exportedConfig.modResults;
|
||||
const keyIdx = props.findIndex(
|
||||
(item) => item.type === "property" && item.key === key,
|
||||
);
|
||||
const property: AndroidConfig.Properties.PropertiesItem = {
|
||||
const property = {
|
||||
type: "property",
|
||||
key,
|
||||
value,
|
||||
@@ -31,14 +22,11 @@ function setGradlePropertiesValue(
|
||||
});
|
||||
}
|
||||
|
||||
const withCustomGradleProperties: ConfigPlugin = (config) => {
|
||||
module.exports = function withCustomPlugin(config) {
|
||||
// Expo 52 is not setting this
|
||||
// https://github.com/expo/expo/issues/32558
|
||||
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
||||
|
||||
// NDK version required by libmpv 1.0.0
|
||||
config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865");
|
||||
|
||||
// Increase memory
|
||||
config = setGradlePropertiesValue(
|
||||
config,
|
||||
@@ -47,5 +35,3 @@ const withCustomGradleProperties: ConfigPlugin = (config) => {
|
||||
);
|
||||
return config;
|
||||
};
|
||||
|
||||
export default withCustomGradleProperties;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ConfigPlugin, withXcodeProject } from "expo/config-plugins";
|
||||
const { withXcodeProject } = require("@expo/config-plugins");
|
||||
|
||||
const withTVOSAppIcon: ConfigPlugin = (config) => {
|
||||
const withTVOSAppIcon = (config) => {
|
||||
// Only apply for TV builds
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
return config;
|
||||
@@ -28,4 +28,4 @@ const withTVOSAppIcon: ConfigPlugin = (config) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default withTVOSAppIcon;
|
||||
module.exports = withTVOSAppIcon;
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ExpoConfig } from "expo/config";
|
||||
import {
|
||||
type ConfigPlugin,
|
||||
const {
|
||||
withEntitlementsPlist,
|
||||
withInfoPlist,
|
||||
withXcodeProject,
|
||||
} from "expo/config-plugins";
|
||||
} = require("@expo/config-plugins");
|
||||
|
||||
const EXTENSION_TARGET_NAME = "StreamyfinTopShelf";
|
||||
const TARGET_SOURCE_DIR = "../targets/StreamyfinTopShelf";
|
||||
@@ -12,29 +10,19 @@ const APP_GROUP_INFO_PLIST_KEY = "StreamyfinAppGroupIdentifier";
|
||||
const KEYCHAIN_ACCESS_GROUP_INFO_PLIST_KEY =
|
||||
"StreamyfinKeychainAccessGroupIdentifier";
|
||||
|
||||
interface AppExtensionConfig {
|
||||
targetName: string;
|
||||
bundleIdentifier: string;
|
||||
entitlements: {
|
||||
"com.apple.security.application-groups": string[];
|
||||
"keychain-access-groups": string[];
|
||||
};
|
||||
}
|
||||
|
||||
function getBundleIdentifier(config: ExpoConfig): string {
|
||||
function getBundleIdentifier(config) {
|
||||
return config.ios?.bundleIdentifier || "com.fredrikburmester.streamyfin";
|
||||
}
|
||||
|
||||
function getAppGroupIdentifier(config: ExpoConfig): string {
|
||||
function getAppGroupIdentifier(config) {
|
||||
return `group.${getBundleIdentifier(config)}.tvtopshelf`;
|
||||
}
|
||||
|
||||
function getKeychainAccessGroupIdentifier(config: ExpoConfig): string {
|
||||
function getKeychainAccessGroupIdentifier(config) {
|
||||
return `$(AppIdentifierPrefix)${getBundleIdentifier(config)}`;
|
||||
}
|
||||
|
||||
// The xcode project object has no usable typings — keep `any` here.
|
||||
function getBuildConfigurations(project: any, configurationListId: string) {
|
||||
function getBuildConfigurations(project, configurationListId) {
|
||||
const configurationList =
|
||||
project.hash.project.objects.XCConfigurationList[configurationListId];
|
||||
|
||||
@@ -42,21 +30,18 @@ function getBuildConfigurations(project: any, configurationListId: string) {
|
||||
|
||||
const buildConfigurations = project.pbxXCBuildConfigurationSection();
|
||||
return configurationList.buildConfigurations
|
||||
.map((config: { value: string }) => buildConfigurations[config.value])
|
||||
.map((config) => buildConfigurations[config.value])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureAppGroup(value: unknown, appGroupIdentifier: string): string[] {
|
||||
function ensureAppGroup(value, appGroupIdentifier) {
|
||||
const groups = Array.isArray(value) ? value : [];
|
||||
return groups.includes(appGroupIdentifier)
|
||||
? groups
|
||||
: [...groups, appGroupIdentifier];
|
||||
}
|
||||
|
||||
function ensureKeychainAccessGroup(
|
||||
value: unknown,
|
||||
keychainAccessGroupIdentifier: string,
|
||||
): string[] {
|
||||
function ensureKeychainAccessGroup(value, keychainAccessGroupIdentifier) {
|
||||
const groups = Array.isArray(value) ? value : [];
|
||||
return groups.includes(keychainAccessGroupIdentifier)
|
||||
? groups
|
||||
@@ -64,13 +49,13 @@ function ensureKeychainAccessGroup(
|
||||
}
|
||||
|
||||
function ensureAppExtension(
|
||||
appExtensions: unknown,
|
||||
targetName: string,
|
||||
bundleIdentifier: string,
|
||||
appGroupIdentifier: string,
|
||||
keychainAccessGroupIdentifier: string,
|
||||
): AppExtensionConfig[] {
|
||||
const extensionConfig: AppExtensionConfig = {
|
||||
appExtensions,
|
||||
targetName,
|
||||
bundleIdentifier,
|
||||
appGroupIdentifier,
|
||||
keychainAccessGroupIdentifier,
|
||||
) {
|
||||
const extensionConfig = {
|
||||
targetName,
|
||||
bundleIdentifier,
|
||||
entitlements: {
|
||||
@@ -78,9 +63,7 @@ function ensureAppExtension(
|
||||
"keychain-access-groups": [keychainAccessGroupIdentifier],
|
||||
},
|
||||
};
|
||||
const extensions: AppExtensionConfig[] = Array.isArray(appExtensions)
|
||||
? appExtensions
|
||||
: [];
|
||||
const extensions = Array.isArray(appExtensions) ? appExtensions : [];
|
||||
// Keep plugin runs idempotent and preserve unrelated app extension entries.
|
||||
const existingIndex = extensions.findIndex(
|
||||
(appExtension) => appExtension?.targetName === targetName,
|
||||
@@ -95,7 +78,7 @@ function ensureAppExtension(
|
||||
);
|
||||
}
|
||||
|
||||
const withTVOSTopShelf: ConfigPlugin = (config) => {
|
||||
const withTVOSTopShelf = (config) => {
|
||||
const appGroupIdentifier = getAppGroupIdentifier(config);
|
||||
const keychainAccessGroupIdentifier =
|
||||
getKeychainAccessGroupIdentifier(config);
|
||||
@@ -210,4 +193,4 @@ const withTVOSTopShelf: ConfigPlugin = (config) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default withTVOSTopShelf;
|
||||
module.exports = withTVOSTopShelf;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type ConfigPlugin, withEntitlementsPlist } from "expo/config-plugins";
|
||||
const { withEntitlementsPlist } = require("expo/config-plugins");
|
||||
|
||||
/**
|
||||
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
||||
*/
|
||||
const withTVUserManagement: ConfigPlugin = (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
|
||||
@@ -24,4 +24,4 @@ const withTVUserManagement: ConfigPlugin = (config) => {
|
||||
});
|
||||
};
|
||||
|
||||
export default withTVUserManagement;
|
||||
module.exports = withTVUserManagement;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
||||
const { withDangerousMod } = require("@expo/config-plugins");
|
||||
const { execSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
/**
|
||||
* Expo config plugin that adds EXPO_TV=1 and NODE_BINARY to .xcode.env.local for TV builds.
|
||||
@@ -12,7 +12,7 @@ import { type ConfigPlugin, withDangerousMod } from "expo/config-plugins";
|
||||
*
|
||||
* It also sets NODE_BINARY for nvm users since Xcode can't resolve shell functions.
|
||||
*/
|
||||
const withTVXcodeEnv: ConfigPlugin = (config) => {
|
||||
const withTVXcodeEnv = (config) => {
|
||||
// Only apply for TV builds
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
return config;
|
||||
@@ -70,7 +70,7 @@ const withTVXcodeEnv: ConfigPlugin = (config) => {
|
||||
/**
|
||||
* Get the actual node binary path, handling nvm installations.
|
||||
*/
|
||||
function getNodeBinaryPath(): string | null {
|
||||
function getNodeBinaryPath() {
|
||||
try {
|
||||
// First try to get node path directly (works for non-nvm installs)
|
||||
const directPath = execSync("which node 2>/dev/null", {
|
||||
@@ -114,4 +114,4 @@ function getNodeBinaryPath(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default withTVXcodeEnv;
|
||||
module.exports = withTVXcodeEnv;
|
||||
@@ -1,29 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
AndroidConfig,
|
||||
type ConfigPlugin,
|
||||
type ExportedConfigWithProps,
|
||||
withAndroidManifest,
|
||||
} from "expo/config-plugins";
|
||||
|
||||
const { AndroidConfig, withAndroidManifest } = require("expo/config-plugins");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
const { getMainApplicationOrThrow } = AndroidConfig.Manifest;
|
||||
|
||||
type AndroidManifest = AndroidConfig.Manifest.AndroidManifest;
|
||||
|
||||
const withTrustLocalCerts: ConfigPlugin = (config) => {
|
||||
const withTrustLocalCerts = (config) => {
|
||||
return withAndroidManifest(config, async (mod) => {
|
||||
mod.modResults = await setCustomConfigAsync(mod, mod.modResults);
|
||||
return mod;
|
||||
});
|
||||
};
|
||||
|
||||
async function setCustomConfigAsync(
|
||||
config: ExportedConfigWithProps<AndroidManifest>,
|
||||
androidManifest: AndroidManifest,
|
||||
): Promise<AndroidManifest> {
|
||||
async function setCustomConfigAsync(config, androidManifest) {
|
||||
const src_file_path = path.join(__dirname, "network_security_config.xml");
|
||||
const res_file_path = path.join(
|
||||
await AndroidConfig.Paths.getResourceFolderAsync(
|
||||
@@ -56,4 +45,4 @@ async function setCustomConfigAsync(
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
export default withTrustLocalCerts;
|
||||
module.exports = withTrustLocalCerts;
|
||||
@@ -96,24 +96,5 @@ export function getDownloadedItemSize(id: string): number {
|
||||
*/
|
||||
export function calculateTotalDownloadedSize(): number {
|
||||
const items = getAllDownloadedItems();
|
||||
return items.reduce((sum, item) => {
|
||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
||||
// as video + trickplay, the total must match.
|
||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
||||
// Read the live file size on disk so the total reflects actual usage and
|
||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
||||
// value if the file can't be stat'd.
|
||||
if (item.videoFilePath) {
|
||||
try {
|
||||
const file = new File(filePathToUri(item.videoFilePath));
|
||||
if (file.exists) {
|
||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to stat downloaded file for size:", error);
|
||||
}
|
||||
}
|
||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
||||
}, 0);
|
||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||
}
|
||||
|
||||
@@ -289,24 +289,7 @@ export function useDownloadOperations({
|
||||
);
|
||||
|
||||
const appSizeUsage = useCallback(async () => {
|
||||
let totalSize = calculateTotalDownloadedSize();
|
||||
|
||||
// Also count in-progress downloads (they write straight to their final
|
||||
// path) so the growing file shows up as app usage instead of drifting
|
||||
// into the generic device share until completion.
|
||||
for (const process of processes) {
|
||||
try {
|
||||
const file = new File(
|
||||
Paths.document,
|
||||
`${generateFilename(process.item)}.mp4`,
|
||||
);
|
||||
if (file.exists) {
|
||||
totalSize += file.size ?? 0;
|
||||
}
|
||||
} catch {
|
||||
// File not created yet — ignore.
|
||||
}
|
||||
}
|
||||
const totalSize = calculateTotalDownloadedSize();
|
||||
|
||||
try {
|
||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||
@@ -327,7 +310,7 @@ export function useDownloadOperations({
|
||||
appSize: totalSize,
|
||||
};
|
||||
}
|
||||
}, [processes]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
startBackgroundDownload,
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.ts --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
* 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 {
|
||||
@@ -34,20 +34,9 @@ import {
|
||||
} from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
|
||||
type LocaleTree = { [key: string]: LocaleTree | string };
|
||||
|
||||
interface I18nConfig {
|
||||
localesDir: string;
|
||||
sourceLocale: string;
|
||||
srcDirs: string[];
|
||||
srcExtensions: string[];
|
||||
excludeDirs: string[];
|
||||
ignoreUnused: string[];
|
||||
}
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name: string, def: string | boolean): string | boolean => {
|
||||
const flag = (name, def) => {
|
||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||
if (!a) return def;
|
||||
const [, v] = a.split("=");
|
||||
@@ -59,7 +48,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
||||
|
||||
// ---- config ----
|
||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||
const DEFAULT_CONFIG: I18nConfig = {
|
||||
const DEFAULT_CONFIG = {
|
||||
localesDir: "translations",
|
||||
sourceLocale: "en",
|
||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||
@@ -80,36 +69,29 @@ const DEFAULT_CONFIG: I18nConfig = {
|
||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||
ignoreUnused: [],
|
||||
};
|
||||
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||
? {
|
||||
...DEFAULT_CONFIG,
|
||||
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||
}
|
||||
const config = existsSync(CONFIG_PATH)
|
||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||
: DEFAULT_CONFIG;
|
||||
|
||||
// ---- helpers ----
|
||||
const flatten = (
|
||||
obj: LocaleTree,
|
||||
prefix = "",
|
||||
out: Record<string, string> = {},
|
||||
): Record<string, string> => {
|
||||
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 as string;
|
||||
else out[key] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const globMatch = (key: string, pattern: string): boolean => {
|
||||
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: string, files: string[] = []): string[] => {
|
||||
let entries: string[];
|
||||
const walk = (dir, files = []) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
@@ -117,7 +99,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
|
||||
}
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
let st: ReturnType<typeof statSync>;
|
||||
let st;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
@@ -136,7 +118,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
|
||||
// ---- load source keys ----
|
||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||
const sourceKeys = Object.keys(
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||
);
|
||||
const sourceKeySet = new Set(sourceKeys);
|
||||
|
||||
@@ -147,16 +129,16 @@ 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<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set<string>();
|
||||
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||
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: string): string =>
|
||||
const stripComments = (src) =>
|
||||
src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||
@@ -186,11 +168,11 @@ const prefixList = [...dynamicPrefixes];
|
||||
// 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: string): boolean =>
|
||||
const literalUsed = (key) =>
|
||||
codeBlob.includes(`"${key}"`) ||
|
||||
codeBlob.includes(`'${key}'`) ||
|
||||
codeBlob.includes(`\`${key}\``);
|
||||
const isUsed = (key: string): boolean =>
|
||||
const isUsed = (key) =>
|
||||
literalUsed(key) ||
|
||||
prefixList.some((p) => key.startsWith(p)) ||
|
||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||
@@ -209,22 +191,25 @@ const missing = [...usedStatic]
|
||||
// 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: LocaleTree, parts: string[]): void => {
|
||||
const removeKey = (obj, parts) => {
|
||||
const [head, ...rest] = parts;
|
||||
if (!(head in obj)) return;
|
||||
if (rest.length === 0) {
|
||||
delete obj[head];
|
||||
return;
|
||||
}
|
||||
const child = obj[head];
|
||||
if (!child || typeof child !== "object") return;
|
||||
removeKey(child, rest);
|
||||
if (Object.keys(child).length === 0) delete obj[head];
|
||||
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")) as LocaleTree;
|
||||
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(
|
||||
@@ -274,7 +259,7 @@ if (JSON_OUT) {
|
||||
);
|
||||
for (const k of unused) console.log(` - ${k}`);
|
||||
console.log(
|
||||
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
|
||||
`\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").`,
|
||||
@@ -21,14 +21,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
interface Issue {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||
const numEnv = (name: string, def: number): number => {
|
||||
const numEnv = (name, def) => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return def;
|
||||
const n = Number(raw);
|
||||
@@ -57,9 +51,9 @@ const STOP = new Set(
|
||||
).split(/\s+/),
|
||||
);
|
||||
|
||||
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
|
||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
||||
|
||||
const tokens = (s: string | null): string[] =>
|
||||
const tokens = (s) =>
|
||||
(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||
@@ -71,7 +65,7 @@ const tokens = (s: string | null): string[] =>
|
||||
.map(stem)
|
||||
.filter((w) => w.length > 2);
|
||||
|
||||
const jaccard = (a: string[], b: string[]): number => {
|
||||
const jaccard = (a, b) => {
|
||||
const A = new Set(a);
|
||||
const B = new Set(b);
|
||||
if (!A.size || !B.size) return 0;
|
||||
@@ -82,14 +76,14 @@ const jaccard = (a: string[], b: string[]): number => {
|
||||
|
||||
const newTitle = tokens(TITLE);
|
||||
const newBody = tokens(BODY);
|
||||
const score = (o: Issue): number =>
|
||||
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: Issue[];
|
||||
let issues;
|
||||
if (process.env.DUP_FIXTURE) {
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
||||
} else {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
@@ -111,7 +105,7 @@ if (process.env.DUP_FIXTURE) {
|
||||
issues = raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => JSON.parse(l) as Issue);
|
||||
.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
const matches = issues
|
||||
@@ -129,7 +123,7 @@ if (!matches.length) {
|
||||
// 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: string): string =>
|
||||
const safeTitle = (t) =>
|
||||
(t || "")
|
||||
.replace(/@/g, "@")
|
||||
.replace(/[`<>|*_~[\]]/g, " ")
|
||||
62
scripts/symlink-native-dirs.js
Normal file
62
scripts/symlink-native-dirs.js
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const _fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const process = require("node:process");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const root = process.cwd();
|
||||
// const tvosPath = path.join(root, 'iostv');
|
||||
// const iosPath = path.join(root, 'iosmobile');
|
||||
// const androidPath = path.join(root, 'androidmobile');
|
||||
// const androidTVPath = path.join(root, 'androidtv');
|
||||
// const device = process.argv[2];
|
||||
// const platform = process.argv[2];
|
||||
const isTV = process.env.EXPO_TV || false;
|
||||
|
||||
const paths = new Map([
|
||||
["tvos", path.join(root, "iostv")],
|
||||
["ios", path.join(root, "iosmobile")],
|
||||
["android", path.join(root, "androidmobile")],
|
||||
["androidtv", path.join(root, "androidtv")],
|
||||
]);
|
||||
|
||||
// const platformPath = paths.get(platform);
|
||||
|
||||
if (isTV) {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
|
||||
"androidtv",
|
||||
)} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
} else {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
}
|
||||
|
||||
// target = "";
|
||||
// switch (platform) {
|
||||
// case "tvos":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "ios":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "android":
|
||||
// target = "android";
|
||||
// break;
|
||||
// case "androidtv":
|
||||
// target = "android";
|
||||
// break;
|
||||
// }
|
||||
@@ -1,8 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import process from "node:process";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const process = require("node:process");
|
||||
|
||||
// Enhanced ANSI color codes and styles
|
||||
const colors = {
|
||||
@@ -35,7 +32,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
||||
|
||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
|
||||
function log(message: string, color = "") {
|
||||
function log(message, color = "") {
|
||||
if (useColor && color) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
} else {
|
||||
@@ -43,7 +40,7 @@ function log(message: string, color = "") {
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(errorLine: string): string {
|
||||
function formatError(errorLine) {
|
||||
if (!useColor) return errorLine;
|
||||
|
||||
// Color file paths in cyan
|
||||
@@ -73,15 +70,12 @@ function formatError(errorLine: string): string {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
formattedErrors: string[];
|
||||
errorsByFile: Map<string, number>;
|
||||
} {
|
||||
function parseErrorsAndCreateSummary(errorOutput) {
|
||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||
const errorsByFile = new Map<string, number>();
|
||||
const formattedErrors: string[] = [];
|
||||
const errorsByFile = new Map();
|
||||
const formattedErrors = [];
|
||||
|
||||
let currentError: string[] = [];
|
||||
let currentError = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -102,7 +96,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
if (!errorsByFile.has(filePath)) {
|
||||
errorsByFile.set(filePath, 0);
|
||||
}
|
||||
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
||||
|
||||
// Start new error
|
||||
currentError.push(formatError(line));
|
||||
@@ -125,7 +119,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
return { formattedErrors, errorsByFile };
|
||||
}
|
||||
|
||||
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||
function createErrorSummaryTable(errorsByFile) {
|
||||
if (errorsByFile.size === 0) return "";
|
||||
|
||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||
@@ -142,7 +136,7 @@ function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||
return table;
|
||||
}
|
||||
|
||||
function runTypeCheck(): { ok: boolean } {
|
||||
function runTypeCheck() {
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
// Prefer local TypeScript binary when available
|
||||
@@ -156,13 +150,16 @@ function runTypeCheck(): { ok: boolean } {
|
||||
"false",
|
||||
...extraArgs,
|
||||
];
|
||||
let execArgs: { cmd: string; args: string[] };
|
||||
let execArgs = null;
|
||||
try {
|
||||
const tscBin = require.resolve("typescript/bin/tsc");
|
||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||
} catch {
|
||||
// fallback to PATH tsc (reuse runnerArgs so --pretty false is preserved)
|
||||
execArgs = { cmd: "tsc", args: runnerArgs };
|
||||
// fallback to PATH tsc
|
||||
execArgs = {
|
||||
cmd: "tsc",
|
||||
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -186,21 +183,7 @@ function runTypeCheck(): { ok: boolean } {
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const execError = error as { stderr?: string; stdout?: string };
|
||||
const errorOutput = [execError.stdout, execError.stderr]
|
||||
.filter((chunk): chunk is string => Boolean(chunk))
|
||||
.join("\n");
|
||||
|
||||
// No compiler output = tsc never ran (e.g. binary missing). Don't let a
|
||||
// launch failure fall through to the "passed" branch and green-light CI.
|
||||
if (!errorOutput) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(
|
||||
`❌ ${colors.bold}TypeScript check failed to start${colors.reset} ${colors.gray}${message}${colors.reset}`,
|
||||
colors.red,
|
||||
);
|
||||
return { ok: false };
|
||||
}
|
||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
||||
|
||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||
// that generates a large volume of known type errors
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "اختر",
|
||||
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
||||
"video": "فيديو",
|
||||
"audio": "الصوت",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Vídeo",
|
||||
"audio": "Àudio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Vybrat",
|
||||
"no_trailer_available": "Přípojné vozidlo není k dispozici",
|
||||
"video": "Video",
|
||||
"audio": "Zvuk",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Vælg",
|
||||
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
|
||||
"video": "Video",
|
||||
"audio": "Lyd",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Auswählen",
|
||||
"no_trailer_available": "Kein Trailer verfügbar",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Sprache",
|
||||
"results": "Ergebnisse",
|
||||
"searching": "Suche ...",
|
||||
"search_failed": "Suche fehlgeschlagen",
|
||||
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
|
||||
"no_subtitles_found": "Keine Untertitel gefunden",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Επιλογή",
|
||||
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
|
||||
"video": "Βίντεο",
|
||||
"audio": "Ήχος",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -384,8 +384,6 @@
|
||||
"device_usage": "Device {{availableSpace}}%",
|
||||
"size_used": "{{used}} of {{total}} used",
|
||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||
"music_cache_title": "Music cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"clear_music_cache": "Clear music cache",
|
||||
@@ -486,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -622,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Seleccionar",
|
||||
"no_trailer_available": "No hay tráiler disponible",
|
||||
"video": "Vídeo",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Valitse",
|
||||
"no_trailer_available": "Perävaunua ei saatavilla",
|
||||
"video": "Video",
|
||||
"audio": "Ääni",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
"error_title": "Erreur",
|
||||
"login_title": "Se connecter",
|
||||
"login_to_title": "Se connecter à",
|
||||
"select_user": "Sélectionnez un utilisateur pour vous connecter",
|
||||
"add_user_to_login": "Ajouter un utilisateur pour se connecter",
|
||||
"add_user": "Ajouter un utilisateur",
|
||||
"select_user": "Select a user to log in",
|
||||
"add_user_to_login": "Add a user to log in",
|
||||
"add_user": "Add User",
|
||||
"username_placeholder": "Nom d'utilisateur",
|
||||
"password_placeholder": "Mot de passe",
|
||||
"login_button": "Se connecter",
|
||||
@@ -42,19 +42,19 @@
|
||||
"please_login_again": "Votre session enregistrée a expiré. Veuillez vous connecter à nouveau.",
|
||||
"remove_saved_login": "Supprimer l'identifiant enregistré",
|
||||
"remove_saved_login_description": "Cela supprimera vos identifiants enregistrés pour ce serveur. Vous devrez saisir à nouveau votre nom d’utilisateur et votre mot de passe la prochaine fois.",
|
||||
"accounts_count": "{{count}} comptes",
|
||||
"accounts_count": "Comptes {{count}}",
|
||||
"select_account": "Sélectionnez un compte",
|
||||
"add_account": "Ajouter un compte",
|
||||
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
|
||||
"remove_server": "Supprimer le serveur",
|
||||
"remove_server_description": "Ceci supprimera {{server}} et tous les comptes enregistrés de votre liste.",
|
||||
"select_your_server": "Sélectionnez votre serveur",
|
||||
"add_server_to_get_started": "Ajouter un serveur pour commencer",
|
||||
"add_server": "Ajouter un serveur",
|
||||
"change_server": "Changer de serveur"
|
||||
"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": {
|
||||
"title": "Enregistrer le compte",
|
||||
"title": "Sauvegarder le compte",
|
||||
"save_for_later": "Enregistrer ce compte",
|
||||
"security_option": "Options de sécurité",
|
||||
"no_protection": "Aucune protection",
|
||||
@@ -95,9 +95,9 @@
|
||||
"oops": "Oups !",
|
||||
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
|
||||
"continue_watching": "Continuer à regarder",
|
||||
"continue": "Continuer",
|
||||
"continue": "Continue",
|
||||
"next_up": "À suivre",
|
||||
"continue_and_next_up": "Continuer à regarder et à suivre",
|
||||
"continue_and_next_up": "Continuer de regarder et à suivre",
|
||||
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
|
||||
"suggested_movies": "Films suggérés",
|
||||
"suggested_episodes": "Épisodes suggérés",
|
||||
@@ -120,10 +120,10 @@
|
||||
"settings_title": "Paramètres",
|
||||
"log_out_button": "Déconnexion",
|
||||
"switch_user": {
|
||||
"title": "Changer d'utilisateur",
|
||||
"account": "Compte",
|
||||
"switch_user": "Changer d'utilisateur sur ce serveur",
|
||||
"current": "actuel"
|
||||
"title": "Switch User",
|
||||
"account": "Account",
|
||||
"switch_user": "Switch User on This Server",
|
||||
"current": "current"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Catégories"
|
||||
@@ -136,32 +136,32 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence",
|
||||
"merge_next_up_continue_watching": "Fusionner « Continuer à regarder » et « À suivre »",
|
||||
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
|
||||
"hide_remote_session_button": "Masquer le bouton de session distante",
|
||||
"show_home_backdrop": "Arrière-plan d'accueil dynamique",
|
||||
"show_hero_carousel": "Carrousel principal",
|
||||
"show_series_poster_on_episode": "Afficher l'affiche de la série sur les épisodes",
|
||||
"theme_music": "Musique de thème",
|
||||
"display_size": "Taille d'affichage",
|
||||
"display_size_small": "Petite",
|
||||
"display_size_default": "Par défaut",
|
||||
"display_size_large": "Grande",
|
||||
"display_size_extra_large": "Très grande"
|
||||
"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": {
|
||||
"title": "Réseau",
|
||||
"local_network": "Réseau local",
|
||||
"auto_switch_enabled": "Basculement automatique à la maison",
|
||||
"auto_switch_enabled": "Basculement automatique quand à la maison",
|
||||
"auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison",
|
||||
"local_url": "URL locale",
|
||||
"local_url_hint": "Entrez l'adresse de votre serveur local (par exemple http://192.168.1.100:8096)",
|
||||
"local_url_hint": "Entrez l'adresse de votre serveur local (exemple, http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Réseaux Wi-Fi domestiques",
|
||||
"add_current_network": "Ajouter \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Non connecté au Wi-Fi",
|
||||
"not_connected_to_wifi": "Non connecté au WiFi",
|
||||
"no_networks_configured": "Pas de réseau configuré",
|
||||
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer le basculement automatique",
|
||||
"current_wifi": "Wi-Fi actuel",
|
||||
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer la commutation automatique",
|
||||
"current_wifi": "WiFi actuel",
|
||||
"using_url": "Utilisant",
|
||||
"local": "URL locale",
|
||||
"remote": "URL à distance",
|
||||
@@ -172,9 +172,9 @@
|
||||
"not_configured": "Non configuré",
|
||||
"network_added": "Réseau ajouté",
|
||||
"network_already_added": "Réseau déjà ajouté",
|
||||
"no_wifi_connected": "Non connecté au Wi-Fi",
|
||||
"no_wifi_connected": "Non connecté au WiFi",
|
||||
"permission_denied": "Autorisation de localisation refusée",
|
||||
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wi-Fi afin de basculer automatiquement. Veuillez l'activer dans les paramètres."
|
||||
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wifi afin de changer automatiquement. Veuillez l’activer dans les paramètres."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Informations utilisateur",
|
||||
@@ -184,35 +184,35 @@
|
||||
"app_version": "Version de l'application"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Connexion rapide",
|
||||
"authorize_button": "Autoriser une connexion rapide",
|
||||
"enter_the_quick_connect_code": "Entrez le code de connexion rapide...",
|
||||
"quick_connect_title": "Connexion Rapide",
|
||||
"authorize_button": "Autoriser une Connexion Rapide",
|
||||
"enter_the_quick_connect_code": "Entrez le code de Connexion Rapide...",
|
||||
"success": "Succès",
|
||||
"quick_connect_autorized": "Connexion rapide autorisée",
|
||||
"quick_connect_autorized": "Connexion Rapide autorisé",
|
||||
"error": "Erreur",
|
||||
"invalid_code": "Code invalide",
|
||||
"authorize": "Autoriser"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Contrôles média",
|
||||
"media_controls_title": "Contrôles Média",
|
||||
"forward_skip_length": "Durée de saut en avant",
|
||||
"rewind_length": "Durée de retour en arrière",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"buffer": {
|
||||
"title": "Paramètres du tampon",
|
||||
"cache_mode": "Mode de cache",
|
||||
"title": "Buffer Settings",
|
||||
"cache_mode": "Cache Mode",
|
||||
"cache_auto": "Auto",
|
||||
"cache_yes": "Activé",
|
||||
"cache_no": "Désactivé",
|
||||
"buffer_duration": "Durée du tampon",
|
||||
"max_cache_size": "Taille maximale du cache",
|
||||
"max_backward_cache": "Cache arrière maximal"
|
||||
"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": "Sortie vidéo",
|
||||
"vo_mode": "Pilote VO",
|
||||
"gpu_next": "gpu-next (Recommandé)",
|
||||
"title": "Video Output",
|
||||
"vo_mode": "VO Driver",
|
||||
"gpu_next": "gpu-next (Recommended)",
|
||||
"gpu": "gpu"
|
||||
},
|
||||
"gesture_controls": {
|
||||
@@ -225,8 +225,8 @@
|
||||
"right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume",
|
||||
"hide_volume_slider": "Masquer le curseur de volume",
|
||||
"hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo",
|
||||
"hide_brightness_slider": "Masquer le curseur de luminosité",
|
||||
"hide_brightness_slider_description": "Masquer le curseur de luminosité dans le lecteur vidéo"
|
||||
"hide_brightness_slider": "Cacher le curseur de luminosité",
|
||||
"hide_brightness_slider_description": "Masquer le curseur de volume dans le lecteur vidéo"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
@@ -234,14 +234,14 @@
|
||||
"audio_language": "Langue audio",
|
||||
"audio_hint": "Choisissez une langue audio par défaut.",
|
||||
"none": "Aucune",
|
||||
"language": "Langue",
|
||||
"language": "Langage",
|
||||
"transcode_mode": {
|
||||
"title": "Transcodage audio",
|
||||
"description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)",
|
||||
"auto": "Auto",
|
||||
"stereo": "Forcer la stéréo",
|
||||
"5_1": "Autoriser 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
"passthrough": "Intercommunication"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
@@ -252,7 +252,7 @@
|
||||
"set_subtitle_track": "Piste de sous-titres de l'élément précédent",
|
||||
"subtitle_size": "Taille des sous-titres",
|
||||
"none": "Aucune",
|
||||
"language": "Langue",
|
||||
"language": "Langage",
|
||||
"loading": "Chargement",
|
||||
"modes": {
|
||||
"Default": "Par défaut",
|
||||
@@ -262,20 +262,20 @@
|
||||
"OnlyForced": "Forcés seulement"
|
||||
},
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Entrez votre clé API OpenSubtitles pour activer la recherche de sous-titres côté client comme solution de secours lorsque votre serveur Jellyfin n'a pas de fournisseur de sous-titres configuré.",
|
||||
"opensubtitles_api_key": "Clé API",
|
||||
"opensubtitles_api_key_placeholder": "Entrez la clé API...",
|
||||
"opensubtitles_get_key": "Obtenez votre clé API gratuite sur opensubtitles.com/fr/consumers",
|
||||
"mpv_subtitle_scale": "Échelle des sous-titres",
|
||||
"mpv_subtitle_margin_y": "Marge verticale",
|
||||
"mpv_subtitle_align_x": "Alignement horizontal",
|
||||
"mpv_subtitle_align_y": "Alignement vertical",
|
||||
"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": "Gauche",
|
||||
"center": "Centre",
|
||||
"right": "Droite",
|
||||
"top": "Haut",
|
||||
"bottom": "Bas"
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
@@ -283,38 +283,38 @@
|
||||
"video_orientation": "Orientation vidéo",
|
||||
"orientation": "Orientation",
|
||||
"orientations": {
|
||||
"DEFAULT": "Suivre l'orientation de l'appareil",
|
||||
"DEFAULT": "Par défaut",
|
||||
"ALL": "Toutes",
|
||||
"PORTRAIT": "Portrait auto",
|
||||
"PORTRAIT_UP": "Portrait haut",
|
||||
"PORTRAIT_DOWN": "Portrait bas",
|
||||
"LANDSCAPE": "Paysage auto",
|
||||
"LANDSCAPE_LEFT": "Paysage gauche",
|
||||
"LANDSCAPE_RIGHT": "Paysage droite",
|
||||
"PORTRAIT": "Portrait",
|
||||
"PORTRAIT_UP": "Portrait Haut",
|
||||
"PORTRAIT_DOWN": "Portrait Bas",
|
||||
"LANDSCAPE": "Paysage",
|
||||
"LANDSCAPE_LEFT": "Paysage Gauche",
|
||||
"LANDSCAPE_RIGHT": "Paysage Droite",
|
||||
"OTHER": "Autre",
|
||||
"UNKNOWN": "Inconnu"
|
||||
},
|
||||
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
||||
"show_custom_menu_links": "Afficher les liens personnalisés",
|
||||
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
||||
"hide_libraries": "Masquer les bibliothèques",
|
||||
"hide_libraries": "Cacher des bibliothèques",
|
||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
|
||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
||||
"default_quality": "Qualité par défaut",
|
||||
"default_playback_speed": "Vitesse de lecture par défaut",
|
||||
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
|
||||
"max_auto_play_episode_count": "Nombre max d'épisodes en lecture automatique",
|
||||
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||
"disabled": "Désactivé"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musique",
|
||||
"playback_title": "Lecture",
|
||||
"playback_description": "Configurer le mode de lecture de la musique.",
|
||||
"prefer_downloaded": "Préférer les musiques téléchargées",
|
||||
"prefer_downloaded": "Supprimer toutes les musiques téléchargées",
|
||||
"caching_title": "Mise en cache",
|
||||
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
|
||||
"lookahead_enabled": "Activer la mise en cache anticipée",
|
||||
"lookahead_count": "Pistes à mettre en cache à l'avance",
|
||||
"lookahead_enabled": "Activer la mise en cache guidée",
|
||||
"lookahead_count": "Pistes à pré-mettre en cache",
|
||||
"max_cache_size": "Taille max de cache"
|
||||
},
|
||||
"plugins": {
|
||||
@@ -333,7 +333,7 @@
|
||||
"tv_quota_days": "Jours de quota de séries",
|
||||
"reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr",
|
||||
"unlimited": "Illimité",
|
||||
"plus_n_more": "+{{n}} de plus",
|
||||
"plus_n_more": "+{{n}} Plus",
|
||||
"order_by": {
|
||||
"DEFAULT": "Par défaut",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne",
|
||||
@@ -341,7 +341,7 @@
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Activer la recherche Marlin",
|
||||
"enable_marlin_search": "Activer Marlin Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domaine.org:port",
|
||||
"marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
|
||||
@@ -362,10 +362,10 @@
|
||||
"features_title": "Fonctionnalités",
|
||||
"enable_movie_recommendations": "Recommandations de films",
|
||||
"enable_series_recommendations": "Recommandations de séries",
|
||||
"enable_promoted_watchlists": "Listes de suivi promues",
|
||||
"hide_watchlists_tab": "Masquer l'onglet des listes de suivi",
|
||||
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de suivi promues de Streamystats sur la page d’accueil.",
|
||||
"recommended_movies": "Films recommandés",
|
||||
"enable_promoted_watchlists": "Listes de lecture promues",
|
||||
"hide_watchlists_tab": "Masquer l'onglet des listes de lecture",
|
||||
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de lecture promues de Streamystats sur la page d’accueil.",
|
||||
"recommended_movies": "Films Recommandés",
|
||||
"recommended_series": "Séries recommandées",
|
||||
"toasts": {
|
||||
"saved": "Enregistré",
|
||||
@@ -375,7 +375,7 @@
|
||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Activer l'intégration de la liste de suivi"
|
||||
"watchlist_enabler": "Activer l'intégration de notre liste de lecture"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -392,10 +392,10 @@
|
||||
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
|
||||
"downloaded_songs_size": "{{size}} téléchargé",
|
||||
"downloaded_songs_deleted": "Chansons téléchargées supprimées",
|
||||
"clear_all_cache": "Effacer tout le cache",
|
||||
"clear_all_cache_confirm": "Effacer tout le cache ?",
|
||||
"clear_all_cache_confirm_desc": "Êtes-vous sûr de vouloir effacer toutes les données en cache ? Cela supprimera toutes les images, fichiers musicaux, sous-titres et caches de requêtes mis en cache. Vos paramètres et votre session de connexion seront conservés.",
|
||||
"clear_all_cache_error_desc": "Une erreur est survenue lors de l'effacement du cache."
|
||||
"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_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
"title": "Introduction",
|
||||
@@ -419,17 +419,17 @@
|
||||
"error_deleting_files": "Erreur lors de la suppression des fichiers"
|
||||
},
|
||||
"security": {
|
||||
"title": "Sécurité",
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Délai d'inactivité",
|
||||
"disabled": "Désactivé",
|
||||
"title": "Inactivity Timeout",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
"15_minutes": "15 minutes",
|
||||
"30_minutes": "30 minutes",
|
||||
"1_hour": "1 heure",
|
||||
"4_hours": "4 heures",
|
||||
"24_hours": "24 heures"
|
||||
"1_hour": "1 hour",
|
||||
"4_hours": "4 hours",
|
||||
"24_hours": "24 hours"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -446,7 +446,7 @@
|
||||
"delete_all_movies_button": "Supprimer tous les films",
|
||||
"delete_all_series_button": "Supprimer toutes les séries",
|
||||
"delete_all_button": "Supprimer tous les médias",
|
||||
"delete_all_other_media_button": "Supprimer les autres médias",
|
||||
"delete_all_other_media_button": "Supprimer un autre média",
|
||||
"active_download": "Téléchargement actif",
|
||||
"no_active_downloads": "Pas de téléchargements actifs",
|
||||
"active_downloads": "Téléchargements actifs",
|
||||
@@ -454,7 +454,7 @@
|
||||
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
|
||||
"back": "Retour",
|
||||
"delete": "Supprimer",
|
||||
"delete_download": "Supprimer le téléchargement",
|
||||
"delete_download": "Delete Download",
|
||||
"something_went_wrong": "Quelque chose s'est mal passé",
|
||||
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
@@ -465,7 +465,7 @@
|
||||
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès !",
|
||||
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
|
||||
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
||||
"failed_to_delete_media": "Échec de la suppression des autres médias",
|
||||
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
||||
"download_cancelled": "Téléchargement annulé",
|
||||
"could_not_delete_download": "Impossible de supprimer le téléchargement",
|
||||
"download_completed": "Téléchargement terminé",
|
||||
@@ -483,16 +483,17 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"no_results": "Aucun résultat",
|
||||
"no_results": "No Results",
|
||||
"select": "Sélectionner",
|
||||
"no_trailer_available": "Aucune bande-annonce disponible",
|
||||
"video": "Vidéo",
|
||||
"audio": "Audio",
|
||||
"subtitle": "Sous-titres",
|
||||
"play": "Lecture",
|
||||
"mark_as_played": "Marquer comme vu",
|
||||
"mark_as_not_played": "Marquer comme non vu",
|
||||
"mark_as_played": "Mark as Played",
|
||||
"mark_as_not_played": "Mark as not Played",
|
||||
"none": "Aucun",
|
||||
"track": "Piste",
|
||||
"track": "Suivre",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"ok": "Ok",
|
||||
@@ -500,15 +501,15 @@
|
||||
"back": "Précédent",
|
||||
"continue": "Continuer",
|
||||
"verifying": "Vérification...",
|
||||
"login": "Connexion",
|
||||
"episodes": "Épisodes",
|
||||
"movies": "Films",
|
||||
"loading": "Chargement…",
|
||||
"seeAll": "Tout afficher"
|
||||
"login": "Login",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Rechercher...",
|
||||
"x_items": "{{count}} médias",
|
||||
"x_items": "{{count}} Médias",
|
||||
"library": "Bibliothèque",
|
||||
"discover": "Découvrir",
|
||||
"no_results": "Aucun résultat",
|
||||
@@ -526,7 +527,7 @@
|
||||
"request_series": "Demander une série",
|
||||
"recently_added": "Ajoutés récemment",
|
||||
"recent_requests": "Demandes récentes",
|
||||
"plex_watchlist": "Liste de suivi Plex",
|
||||
"plex_watchlist": "Liste de lecture Plex",
|
||||
"trending": "Tendance",
|
||||
"popular_movies": "Films populaires",
|
||||
"movie_genres": "Genres de films",
|
||||
@@ -535,7 +536,7 @@
|
||||
"popular_tv": "Séries populaires",
|
||||
"tv_genres": "Genres des séries",
|
||||
"upcoming_tv": "Séries à venir",
|
||||
"networks": "Chaînes",
|
||||
"networks": "Studios",
|
||||
"tmdb_movie_keyword": "Mots-clés de film TMDB",
|
||||
"tmdb_movie_genre": "Genre de film TMDB",
|
||||
"tmdb_tv_keyword": "Mots-clés de séries TMDB",
|
||||
@@ -552,7 +553,7 @@
|
||||
"item_types": {
|
||||
"movies": "Films",
|
||||
"series": "Séries",
|
||||
"boxsets": "Coffrets",
|
||||
"boxsets": "Coffrets ",
|
||||
"playlists": "Playlists",
|
||||
"items": "Médias"
|
||||
},
|
||||
@@ -574,10 +575,10 @@
|
||||
"filter_by": "Filtrer par",
|
||||
"sort_order": "Ordre de tri",
|
||||
"tags": "Tags",
|
||||
"all": "Tout",
|
||||
"reset": "Réinitialiser",
|
||||
"asc": "Croissant",
|
||||
"desc": "Décroissant"
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
@@ -591,11 +592,11 @@
|
||||
"noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Aucun lien"
|
||||
"no_links": "Aucuns liens"
|
||||
},
|
||||
"player": {
|
||||
"live": "EN DIRECT",
|
||||
"mpv_player_title": "Lecteur MPV",
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Erreur",
|
||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
||||
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
||||
@@ -610,71 +611,72 @@
|
||||
"downloaded_file_yes": "Oui",
|
||||
"downloaded_file_no": "Non",
|
||||
"downloaded_file_cancel": "Annuler",
|
||||
"swipe_down_settings": "Balayez vers le bas pour les paramètres",
|
||||
"ends_at": "Se termine à {{time}}",
|
||||
"search_subtitles": "Rechercher des sous-titres",
|
||||
"subtitle_tracks": "Pistes",
|
||||
"subtitle_search": "Rechercher et télécharger",
|
||||
"download": "Télécharger",
|
||||
"subtitle_download_hint": "Les sous-titres téléchargés seront enregistrés dans votre bibliothèque",
|
||||
"using_jellyfin_server": "Utilisation du serveur Jellyfin",
|
||||
"language": "Langue",
|
||||
"results": "Résultats",
|
||||
"search_failed": "Recherche échouée",
|
||||
"no_subtitle_provider": "Aucun fournisseur de sous-titres configuré sur le serveur",
|
||||
"no_subtitles_found": "Aucun sous-titre trouvé",
|
||||
"add_opensubtitles_key_hint": "Ajoutez une clé API OpenSubtitles dans les paramètres pour une solution de secours côté client",
|
||||
"settings": "Paramètres",
|
||||
"skip_intro": "Passer l'intro",
|
||||
"skip_credits": "Passer le générique",
|
||||
"stopPlayback": "Arrêter la lecture",
|
||||
"stopPlayingTitle": "Arrêter de lire \"{{title}}\" ?",
|
||||
"stopPlayingConfirm": "Êtes-vous sûr de vouloir arrêter la lecture ?",
|
||||
"downloaded": "Téléchargé",
|
||||
"missing_parameters": "Paramètres de lecture manquants"
|
||||
"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",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapitres",
|
||||
"chapter_number": "Chapitre {{number}}",
|
||||
"open": "Ouvrir les chapitres",
|
||||
"close": "Fermer les chapitres"
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "À suivre",
|
||||
"no_items_to_display": "Aucun média à afficher",
|
||||
"no_items_to_display": "Aucuns médias à afficher",
|
||||
"cast_and_crew": "Distribution et équipe",
|
||||
"series": "Séries",
|
||||
"seasons": "Saisons",
|
||||
"season": "Saison",
|
||||
"from_this_series": "De cette série",
|
||||
"more_from_this_season": "Plus de cette saison",
|
||||
"view_series": "Voir la série",
|
||||
"view_season": "Voir la saison",
|
||||
"select_season": "Sélectionner une saison",
|
||||
"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": "Aucun épisode pour cette saison",
|
||||
"overview": "Aperçu",
|
||||
"more_with": "Plus avec {{name}}",
|
||||
"similar_items": "Médias similaires",
|
||||
"no_similar_items_found": "Aucun média similaire trouvé",
|
||||
"no_similar_items_found": "Aucuns médias similaires trouvés",
|
||||
"video": "Vidéo",
|
||||
"more_details": "Plus de détails",
|
||||
"media_options": "Options média",
|
||||
"quality": "Qualité",
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Sous-titres",
|
||||
"none": "Aucun",
|
||||
"tracks": "Pistes"
|
||||
"label": "Subtitle",
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
},
|
||||
"show_more": "Afficher plus",
|
||||
"show_less": "Afficher moins",
|
||||
"left": "restant",
|
||||
"director": "Réalisateur",
|
||||
"cast": "Distribution",
|
||||
"technical_details": "Détails techniques",
|
||||
"left": "left",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
"appeared_in": "Apparu dans",
|
||||
"movies": "Films",
|
||||
"shows": "Séries",
|
||||
"movies": "Movies",
|
||||
"shows": "Shows",
|
||||
"could_not_load_item": "Impossible de charger le média",
|
||||
"none": "Aucun",
|
||||
"download": {
|
||||
@@ -686,13 +688,13 @@
|
||||
"download_unwatched_only": "Non visionné uniquement",
|
||||
"download_button": "Télécharger"
|
||||
},
|
||||
"mark_played": "Marquer comme vu",
|
||||
"mark_unplayed": "Marquer comme non vu",
|
||||
"resume_playback": "Reprendre la lecture",
|
||||
"resume_playback_description": "Voulez-vous continuer où vous vous êtes arrêté ou commencer à partir du début ?",
|
||||
"play_from_start": "Lire depuis le début",
|
||||
"continue_from": "Continuer depuis {{time}}",
|
||||
"no_data_available": "Aucune donnée disponible"
|
||||
"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}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Suivant",
|
||||
@@ -704,16 +706,16 @@
|
||||
"sports": "Sports",
|
||||
"for_kids": "Pour enfants",
|
||||
"news": "Actualités",
|
||||
"page_of": "Page {{current}} sur {{total}}",
|
||||
"no_programs": "Aucun programme disponible",
|
||||
"no_channels": "Aucune chaîne disponible",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"tabs": {
|
||||
"programs": "Programmes",
|
||||
"programs": "Programs",
|
||||
"guide": "Guide",
|
||||
"channels": "Chaînes",
|
||||
"recordings": "Enregistrements",
|
||||
"schedule": "Programmation",
|
||||
"series": "Séries"
|
||||
"channels": "Channels",
|
||||
"recordings": "Recordings",
|
||||
"schedule": "Schedule",
|
||||
"series": "Series"
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
@@ -723,7 +725,7 @@
|
||||
"whats_wrong": "Quel est le problème ?",
|
||||
"issue_type": "Type de problème",
|
||||
"select_an_issue": "Sélectionnez un problème",
|
||||
"types": "Types",
|
||||
"types": "Types de fichiers",
|
||||
"describe_the_issue": "(optionnel) Décrivez le problème...",
|
||||
"submit_button": "Soumettre",
|
||||
"report_issue_button": "Signaler un problème",
|
||||
@@ -732,7 +734,7 @@
|
||||
"failed_to_login": "Échec de la connexion",
|
||||
"cast": "Distribution",
|
||||
"details": "Détails",
|
||||
"status": "Statut",
|
||||
"status": "Statuts",
|
||||
"original_title": "Titre original",
|
||||
"series_type": "Type de série",
|
||||
"release_dates": "Dates de sortie",
|
||||
@@ -750,32 +752,32 @@
|
||||
"tags": "Tags",
|
||||
"quality_profile": "Profil de qualité",
|
||||
"root_folder": "Dossier racine",
|
||||
"season_all": "Saison (Toutes)",
|
||||
"season_all": "Saison (Tous)",
|
||||
"season_number": "Saison {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} épisodes",
|
||||
"number_episodes": "{{episode_number}} Épisodes",
|
||||
"born": "Né(e) le",
|
||||
"appearances": "Apparitions",
|
||||
"appearances": "Apparences",
|
||||
"approve": "Valider",
|
||||
"decline": "Refuser",
|
||||
"requested_by": "Demandé par {{user}}",
|
||||
"unknown_user": "Utilisateur inconnu",
|
||||
"select": "Sélectionner",
|
||||
"request_all": "Tout demander",
|
||||
"request_seasons": "Demander des saisons",
|
||||
"select_seasons": "Sélectionner les saisons",
|
||||
"request_selected": "Demander la sélection",
|
||||
"n_selected": "{{count}} sélectionnés",
|
||||
"select": "Select",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selected",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences ! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
||||
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
|
||||
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr",
|
||||
"issue_submitted": "Problème soumis !",
|
||||
"requested_item": "{{item}} demandé !",
|
||||
"requested_item": "{{item}}} demandé !",
|
||||
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !",
|
||||
"something_went_wrong_requesting_media": "Une erreur s'est produite lors de la demande du média !",
|
||||
"something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média !",
|
||||
"request_approved": "Demande approuvée !",
|
||||
"request_declined": "Demande refusée !",
|
||||
"failed_to_approve_request": "Échec de l'approbation de la demande",
|
||||
"request_declined": "Demande déclinée !",
|
||||
"failed_to_approve_request": "Échec d'approbation de la demande",
|
||||
"failed_to_decline_request": "Échec du refus de la demande"
|
||||
}
|
||||
},
|
||||
@@ -785,7 +787,7 @@
|
||||
"library": "Bibliothèque",
|
||||
"custom_links": "Liens personnalisés",
|
||||
"favorites": "Favoris",
|
||||
"settings": "Réglages"
|
||||
"settings": "Settings"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musique",
|
||||
@@ -805,13 +807,13 @@
|
||||
"play_top_tracks": "Jouer les pistes les plus populaires",
|
||||
"no_suggestions": "Pas de suggestion disponible",
|
||||
"no_albums": "Pas d'albums trouvés",
|
||||
"no_artists": "Pas d'artistes trouvés",
|
||||
"no_artists": "Pas d'artistes trouvé",
|
||||
"no_playlists": "Pas de playlists trouvées",
|
||||
"album_not_found": "Album introuvable",
|
||||
"artist_not_found": "Artiste introuvable",
|
||||
"playlist_not_found": "Playlist introuvable",
|
||||
"track_options": {
|
||||
"play_next": "Lire ensuite",
|
||||
"play_next": "Lecture suivante",
|
||||
"add_to_queue": "Ajouter à la file d'attente",
|
||||
"add_to_playlist": "Ajouter à la playlist",
|
||||
"download": "Télécharger",
|
||||
@@ -820,15 +822,15 @@
|
||||
"cached": "En cache",
|
||||
"delete_download": "Supprimer un téléchargement",
|
||||
"delete_cache": "Supprimer du cache",
|
||||
"go_to_artist": "Aller à l'artiste",
|
||||
"go_to_album": "Aller à l'album",
|
||||
"go_to_artist": "Voir l'artiste",
|
||||
"go_to_album": "Aller à l’album",
|
||||
"add_to_favorites": "Ajouter aux favoris",
|
||||
"remove_from_favorites": "Retirer des favoris",
|
||||
"remove_from_playlist": "Retirer de la playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Créer une playlist",
|
||||
"playlist_name": "Nom de la playlist",
|
||||
"create_playlist": "Créer une Playlist",
|
||||
"playlist_name": "Nom de la Playlist",
|
||||
"enter_name": "Entrer le nom de la playlist",
|
||||
"create": "Créer",
|
||||
"search_playlists": "Rechercher des playlists...",
|
||||
@@ -839,10 +841,10 @@
|
||||
"created": "Playlist créée",
|
||||
"create_new": "Créer une nouvelle playlist",
|
||||
"failed_to_add": "Échec de l'ajout à la playlist",
|
||||
"failed_to_remove": "Échec du retrait de la playlist",
|
||||
"failed_to_create": "Échec de la création de la playlist",
|
||||
"failed_to_remove": "Échec de la suppression de la playlist",
|
||||
"failed_to_create": "Échec de la suppression de la playlist",
|
||||
"delete_playlist": "Supprimer la playlist",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{name}} » ? Cette action est irréversible.",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.",
|
||||
"deleted": "Playlist supprimée",
|
||||
"failed_to_delete": "Échec de la suppression de la playlist"
|
||||
},
|
||||
@@ -853,46 +855,46 @@
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Listes de suivi",
|
||||
"my_watchlists": "Mes listes de suivi",
|
||||
"public_watchlists": "Listes de suivi publiques",
|
||||
"create_title": "Créer une liste de suivi",
|
||||
"edit_title": "Modifier la liste de suivi",
|
||||
"create_button": "Créer une liste de suivi",
|
||||
"title": "Listes de lecture",
|
||||
"my_watchlists": "Mes listes de lecture",
|
||||
"public_watchlists": "Watchlist publique",
|
||||
"create_title": "Créer une Watchlist",
|
||||
"edit_title": "Modifier la Watchlist",
|
||||
"create_button": "Créer une Watchlist",
|
||||
"save_button": "Enregistrer les modifications",
|
||||
"delete_button": "Supprimer",
|
||||
"remove_button": "Retirer",
|
||||
"cancel_button": "Annuler",
|
||||
"name_label": "Nom",
|
||||
"name_placeholder": "Entrez le nom de la liste de suivi",
|
||||
"name_placeholder": "Entrer le nom de la playlist",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Entrez la description (facultatif)",
|
||||
"is_public_label": "Liste de suivi publique",
|
||||
"is_public_label": "Liste de lecture Publique",
|
||||
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
|
||||
"allowed_type_label": "Type de contenu",
|
||||
"sort_order_label": "Ordre de tri par défaut",
|
||||
"empty_title": "Aucune liste de suivi",
|
||||
"empty_title": "Pas de Watchlists",
|
||||
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
|
||||
"empty_watchlist": "Cette liste de suivi est vide",
|
||||
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
|
||||
"not_configured_title": "Streamystats non configuré",
|
||||
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
|
||||
"go_to_settings": "Accédez aux paramètres",
|
||||
"add_to_watchlist": "Ajouter à la liste de suivi",
|
||||
"remove_from_watchlist": "Retirer de la liste de suivi",
|
||||
"go_to_settings": "Accédez aux Paramètres",
|
||||
"add_to_watchlist": "Ajouter à la Watchlist",
|
||||
"remove_from_watchlist": "Retirer de la Watchlist",
|
||||
"select_watchlist": "Sélectionner la liste de suivi",
|
||||
"create_new": "Créer une nouvelle liste de suivi",
|
||||
"item": "élément",
|
||||
"items": "éléments",
|
||||
"create_new": "Créer une Watchlist",
|
||||
"item": "médias",
|
||||
"items": "élément",
|
||||
"public": "Publique",
|
||||
"private": "Privée",
|
||||
"you": "Vous",
|
||||
"you": "Vous-même",
|
||||
"by_owner": "Par un autre utilisateur",
|
||||
"not_found": "Liste de suivi introuvable",
|
||||
"delete_confirm_title": "Supprimer la liste de suivi",
|
||||
"delete_confirm_message": "Voulez-vous vraiment supprimer « {{name}} » ? Cette action est irréversible.",
|
||||
"remove_item_title": "Retirer de la liste de suivi",
|
||||
"remove_item_message": "Retirer « {{name}} » de cette liste de suivi ?",
|
||||
"not_found": "Playlist introuvable",
|
||||
"delete_confirm_title": "Supprimer la Watchlist",
|
||||
"delete_confirm_message": "Tous les médias (par défaut)",
|
||||
"remove_item_title": "Retirer de la Watchlist",
|
||||
"remove_item_message": "Retirer «{{name}}» de cette liste de suivi?",
|
||||
"loading": "Chargement des listes de suivi...",
|
||||
"no_compatible_watchlists": "Aucune liste de suivi compatible",
|
||||
"create_one_first": "Créer une liste de suivi qui accepte ce type de contenu"
|
||||
@@ -908,33 +910,33 @@
|
||||
}
|
||||
},
|
||||
"companion_login": {
|
||||
"title": "Associer à la TV",
|
||||
"align_qr": "Alignez le code QR dans le cadre",
|
||||
"enter_code_manually": "Saisir le code manuellement",
|
||||
"pairing_enter_credentials": "Entrez les identifiants pour le téléviseur",
|
||||
"pairing_code_label": "Code d'appairage",
|
||||
"server": "Serveur",
|
||||
"authorize_button": "Autoriser",
|
||||
"authorizing": "Autorisation en cours...",
|
||||
"scan_again": "Scanner à nouveau",
|
||||
"done": "Terminé",
|
||||
"success_title": "Autorisation envoyée",
|
||||
"pairing_tv_connecting": "La TV se connecte à votre compte",
|
||||
"error_title": "Échec de l'autorisation",
|
||||
"error_invalid_qr": "Code QR invalide. Veuillez scanner le code d'appairage du téléviseur.",
|
||||
"error_generic": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"error_permission_denied": "L'accès à la caméra est nécessaire pour scanner les QR codes.",
|
||||
"login_as": "Se connecter en tant que {{username}} ?",
|
||||
"on_server": "sur {{server}}",
|
||||
"use_different_user": "Utiliser un autre utilisateur",
|
||||
"open_settings": "Ouvrir les paramètres"
|
||||
"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": "Associer au téléphone",
|
||||
"pair_with_phone_title": "Se connecter sur la TV",
|
||||
"waiting_for_phone": "En attente du téléphone...",
|
||||
"scan_with_phone": "Scanner avec l'application Streamyfin sur votre téléphone",
|
||||
"logging_in": "Connexion en cours...",
|
||||
"logging_in_description": "Connexion à votre serveur"
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "בחר",
|
||||
"no_trailer_available": "אין טריילר זמין",
|
||||
"video": "וידאו",
|
||||
"audio": "שמע",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Videó",
|
||||
"audio": "Hang",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Nascondi il cursore della luminosità nel lettore video"
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
@@ -237,10 +237,10 @@
|
||||
"language": "Lingua",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controlla come viene gestito l'audio surround (7.1, TrueHD, DTS-HD)",
|
||||
"auto": "Automatico",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Consenti 5.1",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
@@ -262,20 +262,20 @@
|
||||
"OnlyForced": "Solo forzati"
|
||||
},
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Inserisci la tua chiave API OpenSubtitles per abilitare la ricerca dei sottotitoli quando il tuo server Jellyfin non ha un provider di sottotitoli configurato.",
|
||||
"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": "Inserisci la chiave API...",
|
||||
"opensubtitles_get_key": "Ottieni la tua chiave API gratuita su opensubtitles.com/en/consumers",
|
||||
"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": "Sinistra",
|
||||
"center": "Centro",
|
||||
"right": "Destra",
|
||||
"top": "Alto",
|
||||
"bottom": "Basso"
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
@@ -307,9 +307,9 @@
|
||||
"disabled": "Disabilitato"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musica",
|
||||
"playback_title": "Riproduzione",
|
||||
"playback_description": "Configura come viene riprodotta la musica.",
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
@@ -333,7 +333,7 @@
|
||||
"tv_quota_days": "Giorni di quota per le serie TV",
|
||||
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
|
||||
"unlimited": "Illimitato",
|
||||
"plus_n_more": "+{{n}} altro",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Predefinito",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media",
|
||||
@@ -352,25 +352,25 @@
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"disable_streamystats": "Disabilita Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Inserisci l'URL per il tuo server Streamystats. L'URL dovrebbe includere http o https ed eventualmente la porta.",
|
||||
"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": "Read More About Streamystats.",
|
||||
"save": "Salva",
|
||||
"features_title": "Funzionalità",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Mostra consigli personalizzati e watchlist promosse da Streamystats nella home page.",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Salvato",
|
||||
"refreshed": "Impostazioni aggiornate dal server",
|
||||
"disabled": "Streamystats disabilitato"
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
@@ -385,17 +385,17 @@
|
||||
"size_used": "{{used}} di {{total}} usato",
|
||||
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} nella cache",
|
||||
"music_cache_cleared": "Cache musicale cancellata",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} scaricato",
|
||||
"downloaded_songs_deleted": "Brani scaricati eliminati",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Sei sicuro di voler cancellare tutti i dati nella cache? Questo cancellerà tutte le immagini nella cache, i file musicali, i sottotitoli e le cache delle interrogazioni. Le impostazioni e la sessione di login verranno mantenute.",
|
||||
"clear_all_cache_error_desc": "Si è verificato un errore durante la cancellazione della 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_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro",
|
||||
@@ -404,8 +404,8 @@
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Log",
|
||||
"export_logs": "Esporta i logs",
|
||||
"click_for_more_info": "Clicca per maggiori informazioni",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Livello",
|
||||
"no_logs_available": "Nessun log disponibile",
|
||||
"delete_all_logs": "Cancella tutti i log"
|
||||
@@ -419,17 +419,17 @@
|
||||
"error_deleting_files": "Errore nella cancellazione dei file"
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicurezza",
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"disabled": "Disabilitato",
|
||||
"1_minute": "1 minuto",
|
||||
"5_minutes": "5 minuti",
|
||||
"15_minutes": "15 minuti",
|
||||
"30_minutes": "30 minuti",
|
||||
"1_hour": "1 ora",
|
||||
"4_hours": "4 ore",
|
||||
"24_hours": "24 ore"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Seleziona",
|
||||
"no_trailer_available": "Nessun trailer disponibile",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -493,18 +494,18 @@
|
||||
"mark_as_not_played": "Mark as not Played",
|
||||
"none": "Nulla",
|
||||
"track": "Traccia",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Cancella",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Rimuovi",
|
||||
"back": "Indietro",
|
||||
"continue": "Continua",
|
||||
"verifying": "Verifica in corso...",
|
||||
"login": "Accedi",
|
||||
"episodes": "Episodi",
|
||||
"movies": "Film",
|
||||
"loading": "Caricamento…",
|
||||
"seeAll": "Visualizza tutti"
|
||||
"remove": "Remove",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Cerca...",
|
||||
@@ -518,10 +519,10 @@
|
||||
"episodes": "Episodi",
|
||||
"collections": "Collezioni",
|
||||
"actors": "Attori",
|
||||
"artists": "Artisti",
|
||||
"albums": "Album",
|
||||
"songs": "Tracce",
|
||||
"playlists": "Playlist",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Film Richiesti",
|
||||
"request_series": "Serie Richieste",
|
||||
"recently_added": "Aggiunti di Recente",
|
||||
@@ -553,7 +554,7 @@
|
||||
"movies": "film",
|
||||
"series": "serie TV",
|
||||
"boxsets": "cofanetti",
|
||||
"playlists": "Playlist",
|
||||
"playlists": "Playlists",
|
||||
"items": "elementi"
|
||||
},
|
||||
"options": {
|
||||
@@ -565,7 +566,7 @@
|
||||
"cover": "Copertina",
|
||||
"show_titles": "Mostra titoli",
|
||||
"show_stats": "Mostra statistiche",
|
||||
"options_title": "Impostazioni"
|
||||
"options_title": "Options"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Generi",
|
||||
@@ -574,10 +575,10 @@
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Criterio di ordinamento",
|
||||
"tags": "Tag",
|
||||
"all": "Tutto",
|
||||
"reset": "Ripristina",
|
||||
"asc": "Crescente",
|
||||
"desc": "Decrescente"
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
@@ -594,7 +595,7 @@
|
||||
"no_links": "Nessun link"
|
||||
},
|
||||
"player": {
|
||||
"live": "IN DIRETTA",
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Errore",
|
||||
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
|
||||
@@ -605,39 +606,40 @@
|
||||
"next_episode": "Prossimo Episodio",
|
||||
"continue_watching": "Continua a guardare",
|
||||
"go_back": "Indietro",
|
||||
"downloaded_file_title": "Questo file è stato scaricato",
|
||||
"downloaded_file_message": "Vuoi riprodurre il file scaricato?",
|
||||
"downloaded_file_yes": "Si",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Annulla",
|
||||
"swipe_down_settings": "Scorri in basso per le impostazioni",
|
||||
"ends_at": "Termina alle {{time}}",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracce",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Scarica",
|
||||
"subtitle_download_hint": "I sottotitoli scaricati verranno salvati nella tua libreria",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Lingua",
|
||||
"results": "Risultati",
|
||||
"search_failed": "Ricerca fallita",
|
||||
"no_subtitle_provider": "Nessun provider di sottotitoli configurato sul server",
|
||||
"no_subtitles_found": "Nessun sottotitolo trovato",
|
||||
"add_opensubtitles_key_hint": "Aggiungi la chiave API OpenSubtitles nelle impostazioni",
|
||||
"settings": "Impostazioni",
|
||||
"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": "Interrompere la riproduzione \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Sei sicuro di voler interrompere la riproduzione?",
|
||||
"downloaded": "Scaricato",
|
||||
"missing_parameters": "Parametri di riproduzione mancanti"
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Capitoli",
|
||||
"chapter_number": "Capitolo {{number}}",
|
||||
"open": "Apri capitoli",
|
||||
"close": "Chiudi i capitoli"
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Il prossimo",
|
||||
@@ -662,19 +664,19 @@
|
||||
"quality": "Qualità",
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Sottotitoli",
|
||||
"none": "Vuoto",
|
||||
"tracks": "Tracce"
|
||||
"label": "Subtitle",
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
},
|
||||
"show_more": "Mostra di più",
|
||||
"show_less": "Mostra di meno",
|
||||
"left": "sinistra",
|
||||
"director": "Regista",
|
||||
"left": "left",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
"appeared_in": "Apparso in",
|
||||
"movies": "Film",
|
||||
"shows": "Serie",
|
||||
"movies": "Movies",
|
||||
"shows": "Shows",
|
||||
"could_not_load_item": "Impossibile caricare l'elemento",
|
||||
"none": "Nessuno",
|
||||
"download": {
|
||||
@@ -689,10 +691,10 @@
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched",
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Vuoi continuare da dove hai lasciato o riniziare da capo?",
|
||||
"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": "Continua da {{time}}",
|
||||
"no_data_available": "Nessun dato disponibile"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Prossimo",
|
||||
@@ -704,16 +706,16 @@
|
||||
"sports": "Sport",
|
||||
"for_kids": "Per Bambini",
|
||||
"news": "Notiziari",
|
||||
"page_of": "Pagina {{current}} di {{total}}",
|
||||
"no_programs": "Nessun programma disponibile",
|
||||
"no_channels": "Nessun canale disponibile",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"tabs": {
|
||||
"programs": "Programmi",
|
||||
"guide": "Guida",
|
||||
"channels": "Canali",
|
||||
"recordings": "Registrazioni",
|
||||
"schedule": "Pianifica",
|
||||
"series": "Serie Tv"
|
||||
"programs": "Programs",
|
||||
"guide": "Guide",
|
||||
"channels": "Channels",
|
||||
"recordings": "Recordings",
|
||||
"schedule": "Schedule",
|
||||
"series": "Series"
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
@@ -759,12 +761,12 @@
|
||||
"decline": "Rifiuta",
|
||||
"requested_by": "Richiesto da {{user}}",
|
||||
"unknown_user": "Utente Sconosciuto",
|
||||
"select": "Seleziona",
|
||||
"select": "Select",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selezionati",
|
||||
"n_selected": "{{count}} selected",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
|
||||
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
|
||||
@@ -785,39 +787,39 @@
|
||||
"library": "Libreria",
|
||||
"custom_links": "Collegamenti personalizzati",
|
||||
"favorites": "Preferiti",
|
||||
"settings": "Impostazioni"
|
||||
"settings": "Settings"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musica",
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggerimenti",
|
||||
"albums": "Album",
|
||||
"artists": "Artisti",
|
||||
"playlists": "Playlist",
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Riproduci",
|
||||
"shuffle": "Riproduzione casuale",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "Nessun suggerimento disponibile",
|
||||
"no_albums": "Nessun album trovato",
|
||||
"no_artists": "Artista non trovato",
|
||||
"no_playlists": "Nessuna playlist trovata",
|
||||
"album_not_found": "Album non trovato",
|
||||
"artist_not_found": "Artista non trovato",
|
||||
"playlist_not_found": "Playlist non trovata",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Scarica",
|
||||
"downloaded": "Scaricato",
|
||||
"downloading": "Scaricamento...",
|
||||
"cached": "Memorizzato nella cache",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
@@ -829,112 +831,112 @@
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Inserisci il nome della playlist",
|
||||
"create": "Crea",
|
||||
"search_playlists": "Cerca playlist...",
|
||||
"added_to": "Aggiunto a {{name}}",
|
||||
"added": "Aggiunto alla playlist",
|
||||
"removed_from": "Rimosso da {{name}}",
|
||||
"removed": "Rimosso dalla playlist",
|
||||
"created": "Playlist creata",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Impossibile aggiungere alla playlist",
|
||||
"failed_to_remove": "Impossibile rimuovere dalla playlist",
|
||||
"failed_to_create": "Impossibile creare la playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
||||
"deleted": "Playlist eliminata",
|
||||
"failed_to_delete": "Impossibile eliminare la playlist"
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alfabetico",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Da vedere",
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Cancella",
|
||||
"remove_button": "Rimuovi",
|
||||
"cancel_button": "Annulla",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "Inserisci il nome della lista \"Da vedere\"",
|
||||
"description_label": "Descrizione",
|
||||
"description_placeholder": "Inserisci descrizione (opzionale)",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Permetti ad altri di vedere questa lista",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Crea la tua prima lista \"Da vedere\" per iniziare a organizzare i tuoi media",
|
||||
"empty_watchlist": "Questa lista è vuota",
|
||||
"empty_watchlist_hint": "Aggiungi elementi dalla tua libreria a questa lista",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configura Streamystats nelle impostazioni per utilizzare le watchlist",
|
||||
"go_to_settings": "Vai alle impostazioni",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "elemento",
|
||||
"items": "elementi",
|
||||
"public": "Pubblico",
|
||||
"private": "Privato",
|
||||
"you": "Tu",
|
||||
"by_owner": "Da un altro utente",
|
||||
"not_found": "\"Da vedere\" non trovata",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Rimuovere \"{{name}}\" da questa lista?",
|
||||
"loading": "Caricamento liste...",
|
||||
"no_compatible_watchlists": "Nessuna lista compatibile",
|
||||
"create_one_first": "Crea una lista che accetti questo tipo di contenuto"
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Velocità",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "Solo questo media",
|
||||
"show": "Questo show",
|
||||
"all": "Tutti i media (predefinito)"
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
},
|
||||
"companion_login": {
|
||||
"title": "Associa con la TV",
|
||||
"align_qr": "Allinea il QR code all'interno del riquadro",
|
||||
"enter_code_manually": "Inserisci il codice manualmente",
|
||||
"pairing_enter_credentials": "Inserire le credenziali per la TV",
|
||||
"pairing_code_label": "Codice di associazione",
|
||||
"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": "Autorizza",
|
||||
"authorizing": "Autorizzando...",
|
||||
"authorize_button": "Authorize",
|
||||
"authorizing": "Authorizing...",
|
||||
"scan_again": "Scan Again",
|
||||
"done": "Fatto",
|
||||
"done": "Done",
|
||||
"success_title": "Authorization Sent",
|
||||
"pairing_tv_connecting": "La TV si sta collegando al tuo account",
|
||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||
"error_title": "Authorization Failed",
|
||||
"error_invalid_qr": "QR code non valido. Scansiona il codice di associazione della TV.",
|
||||
"error_generic": "Si è verificato un errore. Riprova.",
|
||||
"error_permission_denied": "Per scansionare i codici QR è necessaria l'autorizzazione della fotocamera.",
|
||||
"login_as": "Accedi come {{username}}?",
|
||||
"on_server": "su {{server}}",
|
||||
"use_different_user": "Usa un altro utente",
|
||||
"open_settings": "Apri le impostazioni"
|
||||
"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",
|
||||
"waiting_for_phone": "In attesa del telefono...",
|
||||
"scan_with_phone": "Scansiona con l'applicazione Streamyfin sul tuo telefono",
|
||||
"logging_in": "Accesso in corso...",
|
||||
"logging_in_description": "Sto connettendo al server"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "選択",
|
||||
"no_trailer_available": "トレーラーがありません",
|
||||
"video": "映像",
|
||||
"audio": "音声",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Selecteren",
|
||||
"no_trailer_available": "Geen trailer beschikbaar",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Velg",
|
||||
"no_trailer_available": "Ingen trailer tilgjengelig",
|
||||
"video": "Video",
|
||||
"audio": "Lyd",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Wybierz",
|
||||
"no_trailer_available": "Brak dostępnego zwiastunu",
|
||||
"video": "Wideo",
|
||||
"audio": "Dźwięk",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Selecionar",
|
||||
"no_trailer_available": "Nenhum trailer disponível",
|
||||
"video": "Vídeo",
|
||||
"audio": "Áudio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Selectare",
|
||||
"no_trailer_available": "Nicio remorcă disponibilă",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Выбрать",
|
||||
"no_trailer_available": "Трейлер недоступен",
|
||||
"video": "Видео",
|
||||
"audio": "Звук",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "Inga resultat",
|
||||
"select": "Välj",
|
||||
"no_trailer_available": "Ingen trailer tillgänglig",
|
||||
"video": "Video",
|
||||
"audio": "Ljud",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Använder Jellyfin-server",
|
||||
"language": "Språk",
|
||||
"results": "Resultat",
|
||||
"searching": "Söker...",
|
||||
"search_failed": "Sökningen misslyckades",
|
||||
"no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern",
|
||||
"no_subtitles_found": "Inga undertexter hittades",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "mu'tlhegh",
|
||||
"audio": "QoQ",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Seç",
|
||||
"no_trailer_available": "Fragman mevcut değil",
|
||||
"video": "Video",
|
||||
"audio": "Ses",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Âm thanh",
|
||||
@@ -620,6 +621,7 @@
|
||||
"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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user