Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
1b8478512e chore(deps): Update dependency xcode to v26.5 2026-05-31 09:33:21 +00:00
15 changed files with 231 additions and 1468 deletions

View File

@@ -219,7 +219,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
@@ -284,7 +284,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🚀 Build iOS app
env:
@@ -344,7 +344,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
@@ -412,7 +412,7 @@ jobs:
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.5"
- name: 🚀 Build iOS app
env:

View File

@@ -1,132 +0,0 @@
name: 🚀 Release (EAS Build + Submit)
# Cloud EAS build + auto-submit for iOS, tvOS and Android on merge to main.
# A manual approval gate (the `production` GitHub Environment) pauses the run
# before any build/submit starts. Configure required reviewers on that
# environment in repo Settings → Environments → production.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
on:
push:
branches: [main]
workflow_dispatch:
jobs:
approve:
name: 🔐 Approve release
runs-on: ubuntu-24.04
environment: production
steps:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
release:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: 🍎 iOS
platform: ios
profile: production
- name: 📺 tvOS
platform: ios
profile: production_tv
- name: 🤖 Android
platform: android
profile: production
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
# tvOS uses local credentials (EAS can't manage tvOS provisioning
# remotely, including the TopShelf extension target). Restore the
# gitignored credentials.json + cert + profiles from secrets so the
# cloud build can sign with `credentialsSource: local`.
- name: 🔐 Restore tvOS signing credentials
if: matrix.profile == 'production_tv'
env:
EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
run: |
mkdir -p certs profiles
printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
# iOS + tvOS submit upload to App Store Connect with an ASC API key.
# EAS reads it from EXPO_ASC_API_KEY_PATH / EXPO_ASC_KEY_ID /
# EXPO_ASC_ISSUER_ID (set on the build step below). Write the .p8,
# tolerating either raw-PEM or base64-encoded secret content.
- name: 🔐 Restore App Store Connect API key
if: matrix.platform == 'ios'
env:
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
run: |
if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
else
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
fi
# Android submit needs a Google Play service account JSON. eas.json's
# submit.production.android.serviceAccountKeyPath points at this file.
- name: 🔐 Restore Google Play service account
if: matrix.platform == 'android'
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
- name: 🚀 Build & submit (${{ matrix.name }})
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
# Consumed by eas submit for iOS/tvOS; ignored for Android.
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
run: |
eas build \
--platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \
--auto-submit \
--non-interactive

6
.gitignore vendored
View File

@@ -18,9 +18,6 @@ web-build/
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Module-specific Builds
modules/mpv-player/android/build
modules/player/android
@@ -79,6 +76,3 @@ build/
.claude/
.agents/skills/**
skills-lock.json
# CI-injected Google Play service account key (written at build time)
google-service-account.json

View File

@@ -1,9 +1,4 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -12,7 +7,6 @@ import { Alert, Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -107,7 +101,7 @@ export default function DownloadsPage() {
navigation.setOptions({
headerRight: () => (
<Pressable
onPress={() => bottomSheetModalRef.current?.present()}
onPress={bottomSheetModalRef.current?.present}
className='px-2'
>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
@@ -122,7 +116,7 @@ export default function DownloadsPage() {
}
}, [showMigration]);
const deleteMovies = () =>
const _deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -133,7 +127,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const deleteShows = () =>
const _deleteShows = () =>
deleteFileByType("Episode")
.then(() =>
toast.success(
@@ -144,7 +138,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const deleteOtherMedia = () =>
const _deleteOtherMedia = () =>
Promise.all(
otherMedia
.filter((item) => item.item.Type)
@@ -168,9 +162,6 @@ export default function DownloadsPage() {
),
);
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return (
<OfflineModeProvider isOffline={true}>
<ScrollView
@@ -265,42 +256,6 @@ export default function DownloadsPage() {
)}
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>
{t("home.downloads.delete_all_other_media_button")}
</Button>
)}
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</OfflineModeProvider>
);
}

View File

@@ -11,10 +11,9 @@
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.14",
"@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0",
"@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.2",
"@tanstack/query-sync-storage-persister": "^5.100.14",
@@ -84,7 +83,6 @@
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-tab-view": "4.3.0",
"react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7",
@@ -365,7 +363,7 @@
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.8", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA=="],
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
@@ -539,10 +537,6 @@
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
"@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="],
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
@@ -1595,8 +1589,6 @@
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
"react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
@@ -2009,10 +2001,6 @@
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
@@ -2231,14 +2219,6 @@
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
@@ -2351,14 +2331,6 @@
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],

View File

@@ -1,13 +1,14 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import React, { useEffect, useState } from "react";
import {
MeasuredTriggerHost,
OptionGroupCard,
ToggleSwitch,
} from "@/components/common/dropdownShared";
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
@@ -15,7 +16,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these.
const { Button, Menu } = Platform.isTV
const { Button, Host, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { disabled } = Platform.isTV
@@ -71,6 +72,16 @@ interface PlatformDropdownProps {
};
}
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
/>
</View>
);
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
@@ -110,15 +121,28 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<OptionGroupCard title={group.title}>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</OptionGroupCard>
<View className='mb-6'>
{group.title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{group.title}
</Text>
)}
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</View>
</View>
);
const BottomSheetContent: React.FC<{
@@ -193,6 +217,24 @@ const PlatformDropdownComponent = ({
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
// `matchContents` doesn't help here: it reports the native Menu's size via
// setStyleSize and overrides any explicit size. Instead we measure the
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
const [triggerSize, setTriggerSize] = useState<{
width: number;
height: number;
} | null>(null);
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
};
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
@@ -223,42 +265,82 @@ const PlatformDropdownComponent = ({
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) {
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// fills its parent and reports its own size via setStyleSize, so it can't
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
return (
<MeasuredTriggerHost
trigger={trigger}
hostStyle={expoUIConfig?.hostStyle}
>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
(opt) => opt.type === "radio",
) as RadioOption[];
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
<View style={triggerSize ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
(opt) => opt.type === "radio",
) as RadioOption[];
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const actionOptions = group.options.filter(
(opt) => opt.type === "action",
) as ActionOption[];
const items = [];
const items = [];
// Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find((opt) => opt.selected);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
// Group radio options under a submenu ONLY if there's a title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find(
(opt) => opt.selected,
);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>
))}
</Menu>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
@@ -272,67 +354,49 @@ const PlatformDropdownComponent = ({
option.onPress();
onOptionSelect?.(option.value);
}}
/>
))}
</Menu>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>,
);
});
/>,
);
});
}
}
}
// Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
/>,
);
});
// Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
/>,
);
});
// Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
}}
/>,
);
});
// Add Buttons for action options (no icon)
actionOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`action-${groupIndex}-${optionIndex}`}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
}}
/>,
);
});
return items;
})}
</Menu>
</MeasuredTriggerHost>
return items;
})}
</Menu>
</Host>
</View>
);
}

View File

@@ -37,12 +37,11 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
}
/>
<View
style={
Platform.isTV
? { width: `${progress}%`, backgroundColor: "#ffffff" }
: { width: `${progress}%` }
}
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
style={{
width: `${progress}%`,
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
}}
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
/>
</>
);

View File

@@ -1,142 +0,0 @@
// Shared internals for PlatformDropdown and PlayerSettingsPopover.
// Both components host SwiftUI content (Menu / Popover) inside @expo/ui's
// <Host>, both render an Android bottom-sheet card for the same three core
// option types (radio / toggle / action), and both wear the same wrapper
// boilerplate. This module is the single source of truth for those pieces.
//
// What lives here:
// - useTriggerSize() — measures the RN trigger's intrinsic size
// - MeasuredTriggerHost — pins <Host> to that measured size (workaround
// for @expo/ui SDK 55 sizing behaviour; see notes below)
// - ToggleSwitch — the small purple switch used in the Android sheet
// - OptionGroupCard — the rounded dark card with optional title that
// wraps a group's option rows on Android
//
// What deliberately doesn't live here:
// - The iOS rendering — PlatformDropdown uses a Menu, PlayerSettingsPopover
// uses a hand-styled Popover. Nothing meaningful to share.
// - The Android per-row renderers — PlatformDropdown handles 3 option types,
// PlayerSettingsPopover handles 6 (adds slider/stepper/subgroup). Forcing
// a shared abstraction would couple them. Each owns its own OptionItem.
import React, { useCallback, useState } from "react";
import {
type LayoutChangeEvent,
Platform,
StyleSheet,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS. Load it lazily and only
// off-TV; both consumers also gate rendering on Platform.OS === "ios".
const { Host } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
type TriggerSize = { width: number; height: number };
/**
* Measures and remembers the intrinsic size of a RN trigger view so the
* surrounding <Host> can be pinned to a concrete box.
*
* Returns `[size, handleLayout]` — pass `handleLayout` to a hidden,
* absolutely-positioned mirror of the trigger and use `size` as the
* wrapper's `style` once measured.
*/
export function useTriggerSize(): [
TriggerSize | null,
(e: LayoutChangeEvent) => void,
] {
const [size, setSize] = useState<TriggerSize | null>(null);
const onLayout = useCallback((e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setSize((prev) =>
prev && prev.width === width && prev.height === height
? prev
: { width, height },
);
}, []);
return [size, onLayout];
}
interface MeasuredTriggerHostProps {
trigger: React.ReactNode;
hostStyle?: any;
children: React.ReactNode;
}
/**
* Pins @expo/ui's <Host> to the trigger's measured size.
*
* @expo/ui's <Host> (SDK 55) fills its parent and reports its own size via
* `setStyleSize`, so it can't size itself to content. If the wrapper has no
* size, the Host's `flex: 1` height depends on the parent while the parent
* depends on the Host — a circular dependency that collapses to 0 for any
* dropdown nested more than one level deep (so only the first, shallowest
* dropdown on screen stays visible).
*
* Giving the wrapper the measured trigger size breaks the cycle; the Host
* then fills a concrete box.
*/
export const MeasuredTriggerHost: React.FC<MeasuredTriggerHostProps> = ({
trigger,
hostStyle,
children,
}) => {
const [size, handleMeasure] = useTriggerSize();
return (
<View style={size ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasure}
>
{trigger}
</View>
<Host style={[StyleSheet.absoluteFill, hostStyle as any]}>
{children}
</Host>
</View>
);
};
/** Small pill switch used by Android sheet rows. */
export const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
/>
</View>
);
/**
* Rounded dark card with an optional title above it. Wraps a group's option
* rows in the Android bottom sheet.
*/
export const OptionGroupCard: React.FC<{
title?: string;
children: React.ReactNode;
}> = ({ title, children }) => (
<View className='mb-6'>
{title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{title}
</Text>
)}
<View
style={{ borderRadius: 12, overflow: "hidden" }}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{children}
</View>
</View>
);

View File

@@ -1,5 +1,4 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { formatTimeString } from "@/utils/time";
@@ -17,8 +16,6 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
currentTime,
remainingTime,
}) => {
const { t } = useTranslation();
const getFinishTime = () => {
const now = new Date();
// remainingTime is in ms
@@ -40,7 +37,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text className='text-[10px] text-neutral-500 opacity-70'>
{t("player.ends_at", { time: getFinishTime() })}
ends at {getFinishTime()}
</Text>
</View>
</View>

View File

@@ -3,6 +3,10 @@ import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
import useRouter from "@/hooks/useAppRouter";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
@@ -10,10 +14,20 @@ import { useSettings } from "@/utils/atoms/settings";
import { usePlayerContext } from "../contexts/PlayerContext";
import { useVideoContext } from "../contexts/VideoContext";
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
import {
type OptionGroup,
PlayerSettingsPopover,
} from "./PlayerSettingsPopover";
// Subtitle scale presets (direct multiplier values)
const SUBTITLE_SCALE_PRESETS = [
{ label: "0.1x", value: 0.1 },
{ label: "0.25x", value: 0.25 },
{ label: "0.5x", value: 0.5 },
{ label: "0.75x", value: 0.75 },
{ label: "1.0x", value: 1.0 },
{ label: "1.25x", value: 1.25 },
{ label: "1.5x", value: 1.5 },
{ label: "2.0x", value: 2.0 },
{ label: "2.5x", value: 2.5 },
{ label: "3.0x", value: 3.0 },
] as const;
interface DropdownViewProps {
playbackSpeed?: number;
@@ -88,7 +102,6 @@ const DropdownView = ({
if (!isOffline) {
groups.push({
title: "Quality",
icon: "gauge.with.dots.needle.50percent",
options:
BITRATES?.map((bitrate) => ({
type: "radio" as const,
@@ -100,41 +113,29 @@ const DropdownView = ({
});
}
// Subtitles section. iOS: tap the `...` opens a SwiftUI Popover with the
// section header "SUBTITLES" + a Track row (Menu) + a Size row (native
// Slider). Android: same shape in a bottom-sheet — tap the "Track" row to
// expand the list inline, Size shows a Material 3 Slider.
// Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({
title: "Subtitles",
options: [
{
type: "subgroup" as const,
label: "Track",
icon: "captions.bubble",
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
value: sub.index.toString(),
selected: subtitleIndex === sub.index.toString(),
onPress: () => sub.setTrack(),
})),
},
{
type: "slider" as const,
label: "Size",
icon: "textformat.size",
value: Math.round((settings.mpvSubtitleScale ?? 1.0) * 10) / 10,
step: 0.1,
min: 0.1,
max: 3.0,
format: (v: number) => `${v.toFixed(1)}x`,
onValueChange: (value: number) =>
updateSettings({
mpvSubtitleScale: Math.round(value * 10) / 10,
}),
},
],
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
value: sub.index.toString(),
selected: subtitleIndex === sub.index.toString(),
onPress: () => sub.setTrack(),
})),
});
// Subtitle Scale Section
groups.push({
title: "Subtitle Scale",
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
type: "radio" as const,
label: preset.label,
value: preset.value.toString(),
selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value,
onPress: () => updateSettings({ mpvSubtitleScale: preset.value }),
})),
});
}
@@ -142,7 +143,6 @@ const DropdownView = ({
if (audioTracks && audioTracks.length > 0) {
groups.push({
title: "Audio",
icon: "speaker.wave.2",
options: audioTracks.map((track) => ({
type: "radio" as const,
label: track.name,
@@ -157,7 +157,6 @@ const DropdownView = ({
if (setPlaybackSpeed) {
groups.push({
title: "Speed",
icon: "speedometer",
options: PLAYBACK_SPEEDS.map((speed) => ({
type: "radio" as const,
label: speed.label,
@@ -177,7 +176,6 @@ const DropdownView = ({
label: showTechnicalInfo
? "Hide Technical Info"
: "Show Technical Info",
icon: "info.circle",
onPress: onToggleTechnicalInfo,
},
],
@@ -218,7 +216,7 @@ const DropdownView = ({
if (Platform.isTV) return null;
return (
<PlayerSettingsPopover
<PlatformDropdown
title='Playback Options'
groups={optionGroups}
trigger={trigger}

View File

@@ -1,930 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
MeasuredTriggerHost,
OptionGroupCard,
ToggleSwitch,
} from "@/components/common/dropdownShared";
import { Text } from "@/components/common/Text";
import type {
ActionOption as BaseActionOption,
RadioOption as BaseRadioOption,
ToggleOption as BaseToggleOption,
} from "@/components/PlatformDropdown";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
// Player-only popover/sheet. Shares no rendering with `PlatformDropdown`:
// that component is used by ~20 callers (settings, season pickers,
// bitrate/audio/subtitle selectors, …) and must keep its small native
// Menu look. This one targets the in-player `...` button and is allowed to
// (a) host a real slider, (b) wear the Swift-mock visual style, and
// (c) carry SF Symbol icons per row.
//
// Common boilerplate (trigger measurement, ToggleSwitch, Android option-card
// shell) lives in @/components/common/dropdownShared and is reused with
// PlatformDropdown.
//
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these.
const {
Button,
HStack,
Image: SwiftImage,
Menu,
Popover,
Rectangle: SwiftRectangle,
Slider: SwiftSlider,
Spacer,
Stepper,
Text: SwiftText,
Toggle: SwiftToggle,
VStack,
} = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const {
buttonStyle,
disabled,
font,
foregroundStyle,
frame,
opacity,
padding,
tint,
} = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
// Android-side Material 3 slider. Lives in @expo/ui/community/slider and is a
// drop-in for react-native-community/slider on Android (and SwiftUI Slider on
// iOS, but we use the swift-ui Slider directly inside the popover instead).
const { Slider: CommunitySlider } = Platform.isTV
? ({} as typeof import("@expo/ui/community/slider"))
: require("@expo/ui/community/slider");
// ---------------------------------------------------------------------------
// Option model
// ---------------------------------------------------------------------------
// Reuses PlatformDropdown's three base option types (so the 20+ shared callers
// and the player popover stay in sync on shape), then adds:
// - `icon?: string` on every variant — SF Symbol shown in the iOS popover
// - Slider / Stepper / Subgroup variants for the player's extra controls
type WithIcon = { icon?: string };
export type RadioOption<T = any> = BaseRadioOption<T> & WithIcon;
export type ToggleOption = BaseToggleOption & WithIcon;
export type ActionOption = BaseActionOption & WithIcon;
export type StepperOption = {
type: "stepper";
label: string;
value: number;
step: number;
min: number;
max: number;
onValueChange: (value: number) => void;
/** Optional value formatter for the displayed number. */
format?: (value: number) => string;
disabled?: boolean;
} & WithIcon;
export type SliderOption = {
type: "slider";
label: string;
value: number;
step: number;
min: number;
max: number;
onValueChange: (value: number) => void;
/** Optional value formatter for the displayed number. */
format?: (value: number) => string;
disabled?: boolean;
} & WithIcon;
/**
* A row that itself opens a nested dropdown. On iOS this renders as a
* SwiftUI `Menu` inside the popover (label = subgroup name, value =
* currently-selected child); on Android the row expands inline to show its
* options when tapped (and collapses again on a second tap).
*/
export type SubgroupOption = {
type: "subgroup";
label: string;
options: Option[];
disabled?: boolean;
} & WithIcon;
export type Option =
| RadioOption
| ToggleOption
| ActionOption
| StepperOption
| SliderOption
| SubgroupOption;
export type OptionGroup = {
title?: string;
options: Option[];
/**
* Optional SF Symbol used for the group's row in the iOS popover when the
* entire group is compressed to a single Menu (e.g. radio-only groups).
*/
icon?: string;
};
interface PlayerSettingsPopoverProps {
trigger?: React.ReactNode;
title?: string;
groups: OptionGroup[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
expoUIConfig?: {
hostStyle?: any;
};
bottomSheetConfig?: {
enableDynamicSizing?: boolean;
enablePanDownToClose?: boolean;
};
}
// ---------------------------------------------------------------------------
// Android bottom-sheet renderers
// ---------------------------------------------------------------------------
const StepperControl: React.FC<{
option: StepperOption;
}> = ({ option }) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
const canDecrement = option.value > option.min;
const canIncrement = option.value < option.max;
const decrement = () => {
if (option.disabled) return;
const next = Math.max(option.min, option.value - option.step);
if (next !== option.value) option.onValueChange(next);
};
const increment = () => {
if (option.disabled) return;
const next = Math.min(option.max, option.value + option.step);
if (next !== option.value) option.onValueChange(next);
};
return (
<View className='flex flex-row items-center'>
<TouchableOpacity
onPress={decrement}
disabled={!canDecrement || option.disabled}
className={`w-8 h-8 bg-neutral-700 rounded-l-lg flex items-center justify-center ${!canDecrement || option.disabled ? "opacity-40" : ""}`}
>
<Text className='text-white'>-</Text>
</TouchableOpacity>
<View className='h-8 px-3 bg-neutral-700 flex items-center justify-center'>
<Text className='text-white'>{display}</Text>
</View>
<TouchableOpacity
onPress={increment}
disabled={!canIncrement || option.disabled}
className={`w-8 h-8 bg-neutral-700 rounded-r-lg flex items-center justify-center ${!canIncrement || option.disabled ? "opacity-40" : ""}`}
>
<Text className='text-white'>+</Text>
</TouchableOpacity>
</View>
);
};
/**
* Android: full-width Material 3 slider inside the bottom sheet, with a
* label/value row above the track. The slider lives below the touch target so
* dragging it doesn't accidentally collapse the sheet.
*/
const SliderControl: React.FC<{
option: SliderOption;
}> = ({ option }) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
return (
<View className='flex-1 px-4 py-3'>
<View className='flex flex-row items-center justify-between mb-2'>
<Text className='text-white'>{option.label}</Text>
<Text className='text-neutral-400'>{display}</Text>
</View>
<CommunitySlider
value={option.value}
minimumValue={option.min}
maximumValue={option.max}
step={option.step}
onValueChange={option.onValueChange}
disabled={option.disabled}
style={{ width: "100%", height: 40 }}
/>
</View>
);
};
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
}) => {
const [expanded, setExpanded] = useState(false);
const isToggle = option.type === "toggle";
const isAction = option.type === "action";
const isStepper = option.type === "stepper";
const isSlider = option.type === "slider";
const isSubgroup = option.type === "subgroup";
if (isSlider) {
return (
<>
<SliderControl option={option} />
{!isLast && (
<View
style={{ height: StyleSheet.hairlineWidth }}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
}
const handlePress = isToggle
? option.onToggle
: isSubgroup
? () => setExpanded((v) => !v)
: isStepper
? undefined
: (option as RadioOption | ActionOption).onPress;
const selectedChild = isSubgroup
? (option.options.find(
(o): o is RadioOption => o.type === "radio" && o.selected,
) ?? undefined)
: undefined;
return (
<>
<TouchableOpacity
onPress={handlePress}
disabled={option.disabled || isStepper}
activeOpacity={isStepper ? 1 : 0.2}
className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
>
<Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? (
<ToggleSwitch value={option.value} />
) : isStepper ? (
<StepperControl option={option} />
) : isSubgroup ? (
<View className='flex flex-row items-center'>
{selectedChild && (
<Text className='text-neutral-400 mr-2'>
{selectedChild.label}
</Text>
)}
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={20}
color='#9ca3af'
/>
</View>
) : isAction ? null : (option as RadioOption).selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{isSubgroup && expanded && (
<View className='pl-4 bg-neutral-900'>
{option.options.map((child, childIndex) => (
<OptionItem
key={childIndex}
option={child}
isLast={childIndex === option.options.length - 1}
/>
))}
</View>
)}
{!isLast && (
<View
style={{ height: StyleSheet.hairlineWidth }}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<OptionGroupCard title={group.title}>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</OptionGroupCard>
);
const BottomSheetContent: React.FC<{
title?: string;
groups: OptionGroup[];
onOptionSelect?: (value?: any) => void;
onClose?: () => void;
}> = ({ title, groups, onOptionSelect, onClose }) => {
const insets = useSafeAreaInsets();
// Recursively wrap options so radio/action presses also call
// onOptionSelect/onClose, including options nested inside subgroups.
const wrapOption = (option: Option): Option => {
if (option.type === "radio") {
return {
...option,
onPress: () => {
option.onPress();
onOptionSelect?.(option.value);
onClose?.();
},
};
}
if (option.type === "toggle") {
return {
...option,
onToggle: () => {
option.onToggle();
onOptionSelect?.(option.value);
},
};
}
if (option.type === "action") {
return {
...option,
onPress: () => {
option.onPress();
onClose?.();
},
};
}
if (option.type === "subgroup") {
return { ...option, options: option.options.map(wrapOption) };
}
return option;
};
const wrappedGroups = groups.map((group) => ({
...group,
options: group.options.map(wrapOption),
}));
return (
<BottomSheetScrollView
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
{wrappedGroups.map((group, index) => (
<OptionGroupComponent key={index} group={group} />
))}
</BottomSheetScrollView>
);
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PlayerSettingsPopoverComponent = ({
trigger,
title,
groups,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
onOptionSelect,
expoUIConfig,
bottomSheetConfig,
}: PlayerSettingsPopoverProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
// Android: controlled open routes through the global bottom-sheet modal.
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={() => {
hideModal();
controlledOnOpenChange?.(false);
}}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlledOpen]);
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
controlledOnOpenChange?.(false);
}
}, [isVisible, controlledOpen, controlledOnOpenChange]);
// Internal open state for the iOS popover. Synced both ways with
// `controlledOpen` when controlled.
const [iosOpen, setIosOpen] = useState(false);
useEffect(() => {
if (Platform.OS === "ios" && controlledOpen !== undefined) {
setIosOpen(controlledOpen);
}
}, [controlledOpen]);
const handleIosOpenChange = (value: boolean) => {
setIosOpen(value);
controlledOnOpenChange?.(value);
};
if (Platform.OS === "ios" && !Platform.isTV) {
const closePopover = () => handleIosOpenChange(false);
// ---- Swift-mock styled popover body ----
// Mirrors the reference Swift `PlayerSettingsViewController` design:
// - small-caps section headers with a hairline rule to the trailing edge
// - 44pt rows with leading SF Symbol, 15pt title, trailing value + glyph
// - real native Slider rows for slider options
// Radio-only titled groups (Quality/Audio/Speed) are compressed to a
// single Menu row whose label is a styled HStack — tapping opens the
// selection menu without changing the panel's height.
type IconName = string | undefined;
const MENU_CHEVRON = "chevron.up.chevron.down" as const;
const TERTIARY = {
type: "hierarchical" as const,
style: "tertiary" as const,
};
const SECONDARY = {
type: "hierarchical" as const,
style: "secondary" as const,
};
/** 24pt-wide leading icon slot. Renders a transparent placeholder when
* no icon is set so titles stay aligned across rows. */
const renderIcon = (icon: IconName) => (
<SwiftImage
systemName={(icon ?? "circle") as any}
size={18}
modifiers={[
frame({ width: 24, alignment: "leading" }),
foregroundStyle(SECONDARY),
...(icon ? [] : [opacity(0)]),
]}
/>
);
/** Small-caps section header + thin separator that fills the row width. */
const renderSectionHeader = (sectionTitle: string, key: string) => (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 28 })]}
>
<SwiftText
modifiers={[
font({ size: 11, weight: "semibold" }),
foregroundStyle(TERTIARY),
]}
>
{sectionTitle.toUpperCase()}
</SwiftText>
<SwiftRectangle
modifiers={[frame({ height: 1 }), foregroundStyle(TERTIARY)]}
/>
</HStack>
);
/** Bare hairline used to close out a multi-row titled section. */
const renderDivider = (key: string) => (
<SwiftRectangle
key={key}
modifiers={[
frame({ height: 1 }),
foregroundStyle(TERTIARY),
padding({ vertical: 2 }),
]}
/>
);
/** Render menu-safe children (radio/action) inside a SwiftUI Menu. */
const renderMenuChild = (option: Option, key: string): any => {
if (option.type === "radio") {
return (
<Button
key={key}
label={option.label}
systemImage={
(option.selected ? "checkmark.circle.fill" : "circle") as any
}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
closePopover();
}}
/>
);
}
if (option.type === "action") {
return (
<Button
key={key}
label={option.label}
modifiers={option.disabled ? [disabled(true)] : undefined}
onPress={() => {
option.onPress();
closePopover();
}}
/>
);
}
return null;
};
/** Row that opens a SwiftUI Menu on tap. Used for compressed radio
* groups and for subgroup options inside a multi-row section. */
const renderMenuRow = ({
key,
icon,
title: rowTitle,
valueLabel,
children,
}: {
key: string;
icon: IconName;
title: string;
valueLabel?: string;
children: any;
}) => (
<Menu
key={key}
label={
<HStack
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{rowTitle}</SwiftText>
<Spacer />
{valueLabel ? (
<SwiftText
modifiers={[font({ size: 13 }), foregroundStyle(SECONDARY)]}
>
{valueLabel}
</SwiftText>
) : null}
<SwiftImage
systemName={MENU_CHEVRON as any}
size={12}
modifiers={[foregroundStyle(TERTIARY)]}
/>
</HStack>
}
>
{children}
</Menu>
);
const renderSliderRow = (option: SliderOption, key: string) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
return (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText
modifiers={[
font({ size: 15 }),
frame({ width: 64, alignment: "leading" }),
]}
>
{option.label}
</SwiftText>
<SwiftSlider
value={option.value}
min={option.min}
max={option.max}
step={option.step}
modifiers={option.disabled ? [disabled(true)] : undefined}
onValueChange={option.onValueChange}
/>
<SwiftText
modifiers={[
font({ size: 13, design: "monospaced" }),
foregroundStyle(SECONDARY),
frame({ width: 44, alignment: "trailing" }),
]}
>
{display}
</SwiftText>
</HStack>
);
};
const renderStepperRow = (option: StepperOption, key: string) => {
const display = option.format
? option.format(option.value)
: option.value.toString();
return (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
<Spacer />
<Stepper
label={display}
value={option.value}
step={option.step}
min={option.min}
max={option.max}
modifiers={option.disabled ? [disabled(true)] : undefined}
onValueChange={option.onValueChange}
/>
</HStack>
);
};
const renderToggleRow = (option: ToggleOption, key: string) => (
<HStack
key={key}
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
<Spacer />
<SwiftToggle
label=''
value={option.value}
modifiers={option.disabled ? [disabled(true)] : undefined}
onValueChange={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
/>
</HStack>
);
const renderActionRow = (option: ActionOption, key: string) => (
<Button
key={key}
modifiers={[
buttonStyle("plain"),
...(option.disabled ? [disabled(true)] : []),
]}
onPress={() => {
option.onPress();
closePopover();
}}
>
<HStack
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
<Spacer />
</HStack>
</Button>
);
/** Render one Option as its own row inside a mixed (non-compressed)
* section. */
const renderOptionRow = (option: Option, key: string): any => {
if (option.type === "slider") return renderSliderRow(option, key);
if (option.type === "stepper") return renderStepperRow(option, key);
if (option.type === "toggle") return renderToggleRow(option, key);
if (option.type === "action") return renderActionRow(option, key);
if (option.type === "subgroup") {
const selectedChild = option.options.find(
(o): o is RadioOption => o.type === "radio" && o.selected,
);
return renderMenuRow({
key,
icon: option.icon,
title: option.label,
valueLabel: selectedChild?.label,
children: option.options.map((child, idx) =>
renderMenuChild(child, `${key}-c${idx}`),
),
});
}
if (option.type === "radio") {
return (
<Button
key={key}
modifiers={[
buttonStyle("plain"),
...(option.disabled ? [disabled(true)] : []),
]}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
closePopover();
}}
>
<HStack
spacing={10}
alignment='center'
modifiers={[frame({ height: 44 })]}
>
{renderIcon(option.icon)}
<SwiftText modifiers={[font({ size: 15 })]}>
{option.label}
</SwiftText>
<Spacer />
{option.selected ? (
<SwiftImage
systemName={"checkmark" as any}
size={14}
modifiers={[foregroundStyle(SECONDARY)]}
/>
) : null}
</HStack>
</Button>
);
}
return null;
};
/**
* Render an entire OptionGroup.
* - Titled group with only radio (or radio + action) options →
* compressed to a single Menu row.
* - Titled group containing slider/toggle/stepper/subgroup →
* section header + individual rows.
* - Untitled group → individual rows, no header.
*/
const renderGroup = (group: OptionGroup, groupIndex: number): any[] => {
if (group.options.length === 0) return [];
const onlyMenuSafe = group.options.every(
(o) => o.type === "radio" || o.type === "action",
);
if (group.title && onlyMenuSafe) {
const selectedRadio = group.options.find(
(o): o is RadioOption => o.type === "radio" && o.selected,
);
return [
renderMenuRow({
key: `group-${groupIndex}`,
icon: group.icon,
title: group.title,
valueLabel: selectedRadio?.label,
children: group.options.map((opt, idx) =>
renderMenuChild(opt, `g${groupIndex}-c${idx}`),
),
}),
];
}
const rows: any[] = [];
if (group.title) {
rows.push(renderSectionHeader(group.title, `header-${groupIndex}`));
}
group.options.forEach((opt, idx) => {
rows.push(renderOptionRow(opt, `g${groupIndex}-o${idx}`));
});
return rows;
};
return (
<MeasuredTriggerHost
trigger={trigger}
hostStyle={expoUIConfig?.hostStyle}
>
<Popover
isPresented={iosOpen}
onIsPresentedChange={handleIosOpenChange}
arrowEdge='top'
>
<Popover.Trigger>
{/* Wrap the RN trigger view in a SwiftUI Button so tap handling
is captured at the SwiftUI layer (matches the codebase
pattern in SearchTabButtons.tsx). */}
<Button
modifiers={[buttonStyle("plain")]}
onPress={() => handleIosOpenChange(true)}
>
{trigger}
</Button>
</Popover.Trigger>
<Popover.Content>
{/* Bare VStack — no Form/List chrome — so the panel reads as
the Swift mock's floating glass card. The popover itself
supplies the material background; we just stack rows
inside. Width pinned to ~320pt; height >= 480pt. */}
<VStack
spacing={0}
alignment='leading'
modifiers={[
padding({ horizontal: 18, top: 12, bottom: 12 }),
frame({
minWidth: 300,
idealWidth: 320,
maxWidth: 360,
minHeight: 480,
idealHeight: 520,
}),
// Tint cascades to all child controls — Slider track, Menu
// checkmark, Stepper ± buttons, Toggle — so one modifier
// paints the whole popover white instead of system blue.
tint("white"),
]}
>
{groups.flatMap((group, groupIndex) => {
const rows = renderGroup(group, groupIndex);
if (rows.length === 0) return [];
// After a multi-row titled section (Subtitles), append a
// bare hairline divider so it's clearly separated from
// the next group below.
const isMultiRow =
!!group.title &&
!group.options.every(
(o) => o.type === "radio" || o.type === "action",
);
const hasNext = groupIndex < groups.length - 1;
return isMultiRow && hasNext
? [...rows, renderDivider(`footer-${groupIndex}`)]
: rows;
})}
</VStack>
</Popover.Content>
</Popover>
</MeasuredTriggerHost>
);
}
// Android: open the bottom sheet directly on press (uncontrolled mode).
const handlePress = () => {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={hideModal}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
};
return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity>
);
};
// Memoize to prevent unnecessary re-renders when parent re-renders.
export const PlayerSettingsPopover = React.memo(
PlayerSettingsPopoverComponent,
(prevProps, nextProps) =>
prevProps.title === nextProps.title &&
prevProps.open === nextProps.open &&
prevProps.groups === nextProps.groups &&
prevProps.trigger === nextProps.trigger,
);

View File

@@ -1,6 +1,6 @@
{
"cli": {
"version": ">= 16.0.0",
"version": ">= 9.1.0",
"appVersionSource": "remote"
},
"build": {
@@ -52,7 +52,6 @@
}
},
"production": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -60,7 +59,6 @@
}
},
"production-apk": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -69,7 +67,6 @@
}
},
"production-apk-tv": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"android": {
@@ -81,7 +78,6 @@
}
},
"production_tv": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"env": {
@@ -97,11 +93,6 @@
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal",
"releaseStatus": "completed"
}
},
"production_tv": {

View File

@@ -32,10 +32,9 @@
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.14",
"@gorhom/bottom-sheet": "5.2.8",
"@jellyfin/sdk": "^0.13.0",
"@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.2",
"@tanstack/query-sync-storage-persister": "^5.100.14",
@@ -105,7 +104,6 @@
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-tab-view": "4.3.0",
"react-native-text-ticker": "^1.15.0",
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
"react-native-udp": "^4.1.7",

View File

@@ -608,8 +608,7 @@
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
"downloaded_file_yes": "Ja",
"downloaded_file_no": "Nein",
"downloaded_file_cancel": "Abbrechen",
"ends_at": "Endet um {{time}}"
"downloaded_file_cancel": "Abbrechen"
},
"item_card": {
"next_up": "Als Nächstes",

View File

@@ -698,7 +698,7 @@
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel",
"swipe_down_settings": "Swipe down for settings",
"ends_at": "Ends at {{time}}",
"ends_at": "ends at",
"search_subtitles": "Search Subtitles",
"subtitle_tracks": "Tracks",
"subtitle_search": "Search & Download",