mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
Compare commits
16 Commits
renovate/r
...
feat/playe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef1e7f122 | ||
|
|
c981f59a50 | ||
|
|
62fc6f9a70 | ||
|
|
eb8dd51b4e | ||
|
|
ea5a999f21 | ||
|
|
dffcdef945 | ||
|
|
fa1c3f3947 | ||
|
|
2761de5a74 | ||
|
|
feca1d7e9c | ||
|
|
6b6bfd1a89 | ||
|
|
d585b20f49 | ||
|
|
692ccfdb2c | ||
|
|
86e39c444c | ||
|
|
ed7928b4d3 | ||
|
|
27dc7b5664 | ||
|
|
a205c75895 |
132
.github/workflows/release.yml
vendored
Normal file
132
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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
6
.gitignore
vendored
@@ -18,6 +18,9 @@ web-build/
|
|||||||
/androidmobile
|
/androidmobile
|
||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
|
# Gradle caches (top-level + per-module native projects)
|
||||||
|
**/.gradle/
|
||||||
|
|
||||||
# Module-specific Builds
|
# Module-specific Builds
|
||||||
modules/mpv-player/android/build
|
modules/mpv-player/android/build
|
||||||
modules/player/android
|
modules/player/android
|
||||||
@@ -76,3 +79,6 @@ build/
|
|||||||
.claude/
|
.claude/
|
||||||
.agents/skills/**
|
.agents/skills/**
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|
||||||
|
# CI-injected Google Play service account key (written at build time)
|
||||||
|
google-service-account.json
|
||||||
|
|||||||
7
app.json
7
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.54.0",
|
"version": "0.54.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"versionCode": 93,
|
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -144,8 +143,8 @@
|
|||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit-GPL",
|
"podName": "MPVKit",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -7,6 +12,7 @@ import { Alert, Platform, ScrollView, View } from "react-native";
|
|||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -101,7 +107,7 @@ export default function DownloadsPage() {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={bottomSheetModalRef.current?.present}
|
onPress={() => bottomSheetModalRef.current?.present()}
|
||||||
className='px-2'
|
className='px-2'
|
||||||
>
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
@@ -116,7 +122,7 @@ export default function DownloadsPage() {
|
|||||||
}
|
}
|
||||||
}, [showMigration]);
|
}, [showMigration]);
|
||||||
|
|
||||||
const _deleteMovies = () =>
|
const deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -127,7 +133,7 @@ export default function DownloadsPage() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const _deleteShows = () =>
|
const deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -138,7 +144,7 @@ export default function DownloadsPage() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const _deleteOtherMedia = () =>
|
const deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
otherMedia
|
otherMedia
|
||||||
.filter((item) => item.item.Type)
|
.filter((item) => item.item.Type)
|
||||||
@@ -162,6 +168,9 @@ export default function DownloadsPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteAllMedia = async () =>
|
||||||
|
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={true}>
|
<OfflineModeProvider isOffline={true}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -256,6 +265,42 @@ export default function DownloadsPage() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='p-4 space-y-4 mb-4'>
|
||||||
|
<Button color='purple' onPress={deleteMovies}>
|
||||||
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
|
</Button>
|
||||||
|
<Button color='purple' onPress={deleteShows}>
|
||||||
|
{t("home.downloads.delete_all_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>
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -825,12 +825,10 @@ export default function DirectPlayerPage() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** PiP handler for MPV */
|
|
||||||
const _onPictureInPictureChange = useCallback(
|
const _onPictureInPictureChange = useCallback(
|
||||||
(e: { nativeEvent: { isActive: boolean } }) => {
|
(e: { nativeEvent: { isActive: boolean } }) => {
|
||||||
const { isActive } = e.nativeEvent;
|
const { isActive } = e.nativeEvent;
|
||||||
setIsPipMode(isActive);
|
setIsPipMode(isActive);
|
||||||
// Hide controls when entering PiP
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
_setShowControls(false);
|
_setShowControls(false);
|
||||||
}
|
}
|
||||||
@@ -848,6 +846,9 @@ export default function DirectPlayerPage() {
|
|||||||
|
|
||||||
// Memoize video ref functions to prevent unnecessary re-renders
|
// Memoize video ref functions to prevent unnecessary re-renders
|
||||||
const startPictureInPicture = useCallback(async () => {
|
const startPictureInPicture = useCallback(async () => {
|
||||||
|
// Hide controls BEFORE entering PiP so the window captures a clean view
|
||||||
|
_setShowControls(false);
|
||||||
|
setIsPipMode(true);
|
||||||
return videoRef.current?.startPictureInPicture?.();
|
return videoRef.current?.startPictureInPicture?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1253,6 +1254,7 @@ export default function DirectPlayerPage() {
|
|||||||
nowPlayingMetadata={nowPlayingMetadata}
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
|
onPictureInPictureChange={_onPictureInPictureChange}
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
|
|||||||
65
bun.lock
65
bun.lock
@@ -11,9 +11,10 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.14",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.4",
|
||||||
|
"react-native-tab-view": "4.3.0",
|
||||||
"react-native-text-ticker": "^1.15.0",
|
"react-native-text-ticker": "^1.15.0",
|
||||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
"react-native-uuid": "^2.0.3",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-worklets": "0.9.1",
|
"react-native-worklets": "0.8.3",
|
||||||
"sonner-native": "0.21.2",
|
"sonner-native": "0.21.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
@@ -114,11 +116,6 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
|
||||||
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch",
|
|
||||||
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
|
|
||||||
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
|
|
||||||
},
|
|
||||||
"packages": {
|
"packages": {
|
||||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||||
|
|
||||||
@@ -200,11 +197,11 @@
|
|||||||
|
|
||||||
"@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="],
|
"@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="],
|
"@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="],
|
"@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="],
|
"@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="],
|
"@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="],
|
||||||
|
|
||||||
@@ -220,13 +217,13 @@
|
|||||||
|
|
||||||
"@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="],
|
"@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="],
|
"@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="],
|
"@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="],
|
"@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="],
|
"@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="],
|
"@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="],
|
||||||
|
|
||||||
@@ -368,7 +365,7 @@
|
|||||||
|
|
||||||
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
"@expo/xcpretty": ["@expo/xcpretty@4.4.4", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw=="],
|
||||||
|
|
||||||
"@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.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/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/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
"@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
|
||||||
|
|
||||||
@@ -542,6 +539,10 @@
|
|||||||
|
|
||||||
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
|
"@react-navigation/core": ["@react-navigation/core@7.17.5", "", { "dependencies": { "@react-navigation/routers": "^7.5.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-6fDCwDTWC7DJn0SDb9DJGRlipaygHIc+2elpZBJI6Crl/2Pu+Z1d6W4jMJ2gZO6iHKf+Pe5sUiQ/uwepGprZtg=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements": ["@react-navigation/elements@2.9.19", "", { "dependencies": { "color": "^4.2.3", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" }, "optionalPeers": ["@react-native-masked-view/masked-view"] }, "sha512-gBUvCZuUkOGw1KpLQEZIkByUz8RYPwXeoA6mZFJy9K1mxd8GdqHDMFCIoB0lfPz9rgrHj99RvtdlGZ/ZzkZv2A=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs": ["@react-navigation/material-top-tabs@7.4.28", "", { "dependencies": { "@react-navigation/elements": "^2.9.19", "color": "^4.2.3", "react-native-tab-view": "^4.3.0" }, "peerDependencies": { "@react-navigation/native": "^7.2.5", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", "react-native-safe-area-context": ">= 4.0.0" } }, "sha512-WZHJSGV2PQOD2Vr9LF8apGvcsbDKukzF3Fhh8xVNIesqaSi9TPProv4dRw6YkenUkjvFVZYkOjvwAJOToePVpA=="],
|
||||||
|
|
||||||
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
|
"@react-navigation/native": ["@react-navigation/native@7.2.5", "", { "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*" } }, "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg=="],
|
||||||
|
|
||||||
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
|
"@react-navigation/routers": ["@react-navigation/routers@7.5.5", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-9/hhMte12Kgu+pMnLfA4EWJ0OQmIEAMVMX06FPH2yGkEQSQ3JhhCN/GkcRikzQhtEi97VYYQA15umptBUShcOQ=="],
|
||||||
@@ -1594,6 +1595,8 @@
|
|||||||
|
|
||||||
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
|
"react-native-svg": ["react-native-svg@15.15.4", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", "warn-once": "0.1.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A=="],
|
||||||
|
|
||||||
|
"react-native-tab-view": ["react-native-tab-view@4.3.0", "", { "dependencies": { "use-latest-callback": "^0.2.4" }, "peerDependencies": { "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0" } }, "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg=="],
|
||||||
|
|
||||||
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
||||||
|
|
||||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
||||||
@@ -1608,7 +1611,7 @@
|
|||||||
|
|
||||||
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
|
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
|
||||||
|
|
||||||
"react-native-worklets": ["react-native-worklets@0.9.1", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", "@babel/plugin-transform-classes": "^7.28.6", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-optional-chaining": "^7.28.6", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.28.5", "convert-source-map": "^2.0.0", "semver": "^7.7.4" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.83 - 0.86" } }, "sha512-kb6lGtBI5Ap41tvBPM09Np472r2GXuJ+jRApIFy1eXBk699eChG3U+lyqRC2/wz/VDpaJAy6i5XPcceNOoH3mA=="],
|
"react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="],
|
||||||
|
|
||||||
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
|
||||||
|
|
||||||
@@ -2000,20 +2003,16 @@
|
|||||||
|
|
||||||
"@react-native/babel-preset/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
"@react-native/babel-preset/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
||||||
|
|
||||||
"@react-native/babel-preset/@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="],
|
|
||||||
|
|
||||||
"@react-native/babel-preset/@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="],
|
|
||||||
|
|
||||||
"@react-native/babel-preset/@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="],
|
|
||||||
|
|
||||||
"@react-native/babel-preset/@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="],
|
|
||||||
|
|
||||||
"@react-native/codegen/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
"@react-native/codegen/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
||||||
|
|
||||||
"@react-native/community-cli-plugin/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
"@react-native/community-cli-plugin/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||||
|
|
||||||
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
"@react-native/metro-babel-transformer/@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||||
|
|
||||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||||
|
|
||||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||||
@@ -2030,14 +2029,6 @@
|
|||||||
|
|
||||||
"babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
"babel-preset-expo/@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||||
|
|
||||||
"babel-preset-expo/@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.27.1", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA=="],
|
|
||||||
|
|
||||||
"babel-preset-expo/@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.28.4", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", "@babel/traverse": "^7.28.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA=="],
|
|
||||||
|
|
||||||
"babel-preset-expo/@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA=="],
|
|
||||||
|
|
||||||
"babel-preset-expo/@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg=="],
|
|
||||||
|
|
||||||
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
@@ -2240,6 +2231,14 @@
|
|||||||
|
|
||||||
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
"@react-native-community/cli-server-api/open/is-wsl": ["is-wsl@1.1.0", "", {}, "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color/color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||||
|
|
||||||
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
"@testing-library/dom/pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
|
||||||
@@ -2352,6 +2351,14 @@
|
|||||||
|
|
||||||
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
"@expo/package-manager/ora/strip-ansi/ansi-regex": ["ansi-regex@4.1.1", "", {}, "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"@react-navigation/elements/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"@react-navigation/material-top-tabs/color/color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
|
|
||||||
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
import {
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
type LayoutChangeEvent,
|
|
||||||
Platform,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import {
|
||||||
|
MeasuredTriggerHost,
|
||||||
|
OptionGroupCard,
|
||||||
|
ToggleSwitch,
|
||||||
|
} from "@/components/common/dropdownShared";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
@@ -16,7 +15,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|||||||
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
|
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
|
||||||
// load and crashes the entire route tree on tvOS (expo-router requires every
|
// 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.
|
// route file). Load it lazily and only off-TV; TV never renders these.
|
||||||
const { Button, Host, Menu } = Platform.isTV
|
const { Button, Menu } = Platform.isTV
|
||||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||||
: require("@expo/ui/swift-ui");
|
: require("@expo/ui/swift-ui");
|
||||||
const { disabled } = Platform.isTV
|
const { disabled } = Platform.isTV
|
||||||
@@ -72,16 +71,6 @@ 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 }> = ({
|
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||||
option,
|
option,
|
||||||
isLast,
|
isLast,
|
||||||
@@ -121,28 +110,15 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
||||||
<View className='mb-6'>
|
<OptionGroupCard title={group.title}>
|
||||||
{group.title && (
|
{group.options.map((option, index) => (
|
||||||
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
<OptionItem
|
||||||
{group.title}
|
key={index}
|
||||||
</Text>
|
option={option}
|
||||||
)}
|
isLast={index === group.options.length - 1}
|
||||||
<View
|
/>
|
||||||
style={{
|
))}
|
||||||
borderRadius: 12,
|
</OptionGroupCard>
|
||||||
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<{
|
const BottomSheetContent: React.FC<{
|
||||||
@@ -217,24 +193,6 @@ const PlatformDropdownComponent = ({
|
|||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
|
||||||
// `matchContents` doesn't help here: it reports the native Menu's size via
|
|
||||||
// setStyleSize and overrides any explicit size. Instead we measure the
|
|
||||||
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
|
||||||
const [triggerSize, setTriggerSize] = useState<{
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
|
||||||
const { width, height } = e.nativeEvent.layout;
|
|
||||||
setTriggerSize((prev) =>
|
|
||||||
prev && prev.width === width && prev.height === height
|
|
||||||
? prev
|
|
||||||
: { width, height },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
@@ -265,82 +223,42 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
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 (
|
return (
|
||||||
<View style={triggerSize ?? { opacity: 0 }}>
|
<MeasuredTriggerHost
|
||||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
trigger={trigger}
|
||||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
hostStyle={expoUIConfig?.hostStyle}
|
||||||
sizes to the trigger's content rather than to its parent. */}
|
>
|
||||||
<View
|
<Menu label={trigger}>
|
||||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
{groups.flatMap((group, groupIndex) => {
|
||||||
pointerEvents='none'
|
// Check if this group has radio options
|
||||||
aria-hidden
|
const radioOptions = group.options.filter(
|
||||||
onLayout={handleMeasureTrigger}
|
(opt) => opt.type === "radio",
|
||||||
>
|
) as RadioOption[];
|
||||||
{trigger}
|
const toggleOptions = group.options.filter(
|
||||||
</View>
|
(opt) => opt.type === "toggle",
|
||||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
) as ToggleOption[];
|
||||||
<Menu label={trigger}>
|
const actionOptions = group.options.filter(
|
||||||
{groups.flatMap((group, groupIndex) => {
|
(opt) => opt.type === "action",
|
||||||
// Check if this group has radio options
|
) as ActionOption[];
|
||||||
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
|
// Group radio options under a submenu ONLY if there's a title
|
||||||
// Otherwise render as individual buttons
|
// Otherwise render as individual buttons
|
||||||
if (radioOptions.length > 0) {
|
if (radioOptions.length > 0) {
|
||||||
if (group.title) {
|
if (group.title) {
|
||||||
// Use a nested Menu as a submenu for grouped options. This
|
// Use a nested Menu as a submenu for grouped options. This
|
||||||
// reads as "Title: Selected" and expands to the choices on
|
// reads as "Title: Selected" and expands to the choices on
|
||||||
// tap, keeping the nested look while staying a dropdown.
|
// tap, keeping the nested look while staying a dropdown.
|
||||||
// (Menu opens on a single tap and nests cleanly; ContextMenu
|
// (Menu opens on a single tap and nests cleanly; ContextMenu
|
||||||
// would require a long-press and read as a context menu.)
|
// would require a long-press and read as a context menu.)
|
||||||
const selectedOption = radioOptions.find(
|
const selectedOption = radioOptions.find((opt) => opt.selected);
|
||||||
(opt) => opt.selected,
|
const displayTitle = selectedOption
|
||||||
);
|
? `${group.title}: ${selectedOption.label}`
|
||||||
const displayTitle = selectedOption
|
: group.title;
|
||||||
? `${group.title}: ${selectedOption.label}`
|
items.push(
|
||||||
: group.title;
|
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
|
||||||
items.push(
|
{radioOptions.map((option, optionIndex) => (
|
||||||
<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
|
<Button
|
||||||
key={`radio-${groupIndex}-${optionIndex}`}
|
key={`radio-${groupIndex}-${optionIndex}`}
|
||||||
label={option.label}
|
label={option.label}
|
||||||
@@ -354,49 +272,67 @@ const PlatformDropdownComponent = ({
|
|||||||
option.onPress();
|
option.onPress();
|
||||||
onOptionSelect?.(option.value);
|
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
|
// Add Buttons for toggle options
|
||||||
toggleOptions.forEach((option, optionIndex) => {
|
toggleOptions.forEach((option, optionIndex) => {
|
||||||
items.push(
|
items.push(
|
||||||
<Button
|
<Button
|
||||||
key={`toggle-${groupIndex}-${optionIndex}`}
|
key={`toggle-${groupIndex}-${optionIndex}`}
|
||||||
label={option.label}
|
label={option.label}
|
||||||
systemImage={
|
systemImage={
|
||||||
option.value ? "checkmark.circle.fill" : "circle"
|
option.value ? "checkmark.circle.fill" : "circle"
|
||||||
}
|
}
|
||||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
option.onToggle();
|
option.onToggle();
|
||||||
onOptionSelect?.(option.value);
|
onOptionSelect?.(option.value);
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Buttons for action options (no icon)
|
// Add Buttons for action options (no icon)
|
||||||
actionOptions.forEach((option, optionIndex) => {
|
actionOptions.forEach((option, optionIndex) => {
|
||||||
items.push(
|
items.push(
|
||||||
<Button
|
<Button
|
||||||
key={`action-${groupIndex}-${optionIndex}`}
|
key={`action-${groupIndex}-${optionIndex}`}
|
||||||
label={option.label}
|
label={option.label}
|
||||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
option.onPress();
|
option.onPress();
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
})}
|
})}
|
||||||
</Menu>
|
</Menu>
|
||||||
</Host>
|
</MeasuredTriggerHost>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={
|
||||||
width: `${progress}%`,
|
Platform.isTV
|
||||||
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
|
? { width: `${progress}%`, backgroundColor: "#ffffff" }
|
||||||
}}
|
: { width: `${progress}%` }
|
||||||
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
|
}
|
||||||
|
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
142
components/common/dropdownShared.tsx
Normal file
142
components/common/dropdownShared.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
|||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
thumbWidth={0}
|
thumbWidth={0}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
|
renderBubble={() => null}
|
||||||
|
renderThumb={() => null}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
}}
|
}}
|
||||||
theme={{
|
theme={{
|
||||||
minimumTrackTintColor: "#FDFDFD",
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
maximumTrackTintColor: "#5A5A5A",
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
|
||||||
bubbleTextColor: "transparent", // Hide the value text
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
|
|||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
thumbWidth={0}
|
thumbWidth={0}
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
|
renderBubble={() => null}
|
||||||
|
renderThumb={() => null}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
}}
|
}}
|
||||||
theme={{
|
theme={{
|
||||||
minimumTrackTintColor: "#FDFDFD",
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
maximumTrackTintColor: "#5A5A5A",
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
|
||||||
bubbleTextColor: "transparent", // Hide the value text
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
@@ -16,6 +17,8 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getFinishTime = () => {
|
const getFinishTime = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// remainingTime is in ms
|
// remainingTime is in ms
|
||||||
@@ -37,7 +40,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
|||||||
-{formatTimeString(remainingTime, "ms")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||||
ends at {getFinishTime()}
|
{t("player.ends_at", { time: getFinishTime() })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ import { useLocalSearchParams } from "expo-router";
|
|||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
|
||||||
type OptionGroup,
|
|
||||||
PlatformDropdown,
|
|
||||||
} from "@/components/PlatformDropdown";
|
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
@@ -14,20 +10,10 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import { usePlayerContext } from "../contexts/PlayerContext";
|
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
|
||||||
|
import {
|
||||||
// Subtitle scale presets (direct multiplier values)
|
type OptionGroup,
|
||||||
const SUBTITLE_SCALE_PRESETS = [
|
PlayerSettingsPopover,
|
||||||
{ label: "0.1x", value: 0.1 },
|
} from "./PlayerSettingsPopover";
|
||||||
{ 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 {
|
interface DropdownViewProps {
|
||||||
playbackSpeed?: number;
|
playbackSpeed?: number;
|
||||||
@@ -102,6 +88,7 @@ const DropdownView = ({
|
|||||||
if (!isOffline) {
|
if (!isOffline) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Quality",
|
title: "Quality",
|
||||||
|
icon: "gauge.with.dots.needle.50percent",
|
||||||
options:
|
options:
|
||||||
BITRATES?.map((bitrate) => ({
|
BITRATES?.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
@@ -113,29 +100,41 @@ const DropdownView = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subtitle Section
|
// 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.
|
||||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Subtitles",
|
title: "Subtitles",
|
||||||
options: subtitleTracks.map((sub) => ({
|
options: [
|
||||||
type: "radio" as const,
|
{
|
||||||
label: sub.name,
|
type: "subgroup" as const,
|
||||||
value: sub.index.toString(),
|
label: "Track",
|
||||||
selected: subtitleIndex === sub.index.toString(),
|
icon: "captions.bubble",
|
||||||
onPress: () => sub.setTrack(),
|
options: subtitleTracks.map((sub) => ({
|
||||||
})),
|
type: "radio" as const,
|
||||||
});
|
label: sub.name,
|
||||||
|
value: sub.index.toString(),
|
||||||
// Subtitle Scale Section
|
selected: subtitleIndex === sub.index.toString(),
|
||||||
groups.push({
|
onPress: () => sub.setTrack(),
|
||||||
title: "Subtitle Scale",
|
})),
|
||||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
},
|
||||||
type: "radio" as const,
|
{
|
||||||
label: preset.label,
|
type: "slider" as const,
|
||||||
value: preset.value.toString(),
|
label: "Size",
|
||||||
selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value,
|
icon: "textformat.size",
|
||||||
onPress: () => updateSettings({ mpvSubtitleScale: preset.value }),
|
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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +142,7 @@ const DropdownView = ({
|
|||||||
if (audioTracks && audioTracks.length > 0) {
|
if (audioTracks && audioTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Audio",
|
title: "Audio",
|
||||||
|
icon: "speaker.wave.2",
|
||||||
options: audioTracks.map((track) => ({
|
options: audioTracks.map((track) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: track.name,
|
label: track.name,
|
||||||
@@ -157,6 +157,7 @@ const DropdownView = ({
|
|||||||
if (setPlaybackSpeed) {
|
if (setPlaybackSpeed) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Speed",
|
title: "Speed",
|
||||||
|
icon: "speedometer",
|
||||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: speed.label,
|
label: speed.label,
|
||||||
@@ -176,6 +177,7 @@ const DropdownView = ({
|
|||||||
label: showTechnicalInfo
|
label: showTechnicalInfo
|
||||||
? "Hide Technical Info"
|
? "Hide Technical Info"
|
||||||
: "Show Technical Info",
|
: "Show Technical Info",
|
||||||
|
icon: "info.circle",
|
||||||
onPress: onToggleTechnicalInfo,
|
onPress: onToggleTechnicalInfo,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -216,7 +218,7 @@ const DropdownView = ({
|
|||||||
if (Platform.isTV) return null;
|
if (Platform.isTV) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlayerSettingsPopover
|
||||||
title='Playback Options'
|
title='Playback Options'
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
|
|||||||
@@ -0,0 +1,930 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
34
eas.json
34
eas.json
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": ">= 9.1.0"
|
"version": ">= 16.0.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
@@ -51,23 +52,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
@@ -77,8 +81,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production_tv": {
|
"production_tv": {
|
||||||
|
"bun": "1.3.5",
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.54.0",
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_TV": "1"
|
"EXPO_TV": "1"
|
||||||
},
|
},
|
||||||
@@ -88,7 +93,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
"production": {},
|
"production": {
|
||||||
"production_tv": {}
|
"ios": {
|
||||||
|
"appleTeamId": "MWD5K362T8",
|
||||||
|
"ascAppId": "6593660679"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-service-account.json",
|
||||||
|
"track": "internal",
|
||||||
|
"releaseStatus": "completed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production_tv": {
|
||||||
|
"ios": {
|
||||||
|
"appleTeamId": "MWD5K362T8",
|
||||||
|
"ascAppId": "6593660679"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach surface and re-enable video output.
|
* Attach surface and ensure video output is active.
|
||||||
* Based on Findroid's implementation.
|
*
|
||||||
|
* During PiP transitions, the surface is destroyed and recreated by Android.
|
||||||
|
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
|
||||||
|
* resumes immediately when the new surface is attached — avoiding the black
|
||||||
|
* screen that occurs when the VO is fully re-initialized via setOptionString.
|
||||||
*/
|
*/
|
||||||
fun attachSurface(surface: Surface) {
|
fun attachSurface(surface: Surface) {
|
||||||
this.surface = surface
|
this.surface = surface
|
||||||
|
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.attachSurface(surface)
|
MPVLib.attachSurface(surface)
|
||||||
// Re-enable video output after attaching surface (Findroid approach)
|
|
||||||
MPVLib.setOptionString("force-window", "yes")
|
MPVLib.setOptionString("force-window", "yes")
|
||||||
MPVLib.setOptionString("vo", voDriver)
|
// Read back vo to confirm it's still active
|
||||||
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
|
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
|
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detach surface and disable video output.
|
* Detach surface without killing the VO pipeline.
|
||||||
* Based on Findroid's implementation.
|
*
|
||||||
|
* The previous approach (vo=null / force-window=no) destroyed the entire video
|
||||||
|
* output pipeline on every surface transition. During PiP mode, the rapid
|
||||||
|
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
|
||||||
|
* did not properly re-initialize rendering into the new PiP surface.
|
||||||
|
*
|
||||||
|
* By keeping the VO alive, frames are simply dropped while no surface is
|
||||||
|
* attached, and rendering resumes immediately when the new surface arrives.
|
||||||
*/
|
*/
|
||||||
fun detachSurface() {
|
fun detachSurface() {
|
||||||
this.surface = null
|
this.surface = null
|
||||||
|
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
try {
|
|
||||||
// Disable video output before detaching surface (Findroid approach)
|
|
||||||
MPVLib.setOptionString("vo", "null")
|
|
||||||
MPVLib.setOptionString("force-window", "no")
|
|
||||||
Log.i(TAG, "Video output disabled before surface detach")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to disable video output: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
MPVLib.detachSurface()
|
MPVLib.detachSurface()
|
||||||
|
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
|
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +283,24 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun updateSurfaceSize(width: Int, height: Int) {
|
fun updateSurfaceSize(width: Int, height: Int) {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
||||||
Log.i(TAG, "Surface size updated: ${width}x$height")
|
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force mpv to render a frame to the current surface.
|
||||||
|
* Steps forward one frame then seeks back to the original position.
|
||||||
|
* Used after PiP entry to work around mpv stopping pixel output.
|
||||||
|
*/
|
||||||
|
fun forceRedraw() {
|
||||||
|
if (!isRunning) return
|
||||||
|
val pos = cachedPosition
|
||||||
|
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||||
|
MPVLib.command(arrayOf("frame-step"))
|
||||||
|
if (pos > 0) {
|
||||||
|
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,9 +715,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// dropped), so we (re)apply here for embedded and external alike.
|
// dropped), so we (re)apply here for embedded and external alike.
|
||||||
// This is what makes a carried-over subtitle show up on the next
|
// This is what makes a carried-over subtitle show up on the next
|
||||||
// episode without a manual re-selection.
|
// episode without a manual re-selection.
|
||||||
if (initialAudioId != null && initialAudioId > 0) {
|
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
||||||
setAudioTrack(initialAudioId)
|
|
||||||
}
|
|
||||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||||
|
|
||||||
if (!isReadyToSeek) {
|
if (!isReadyToSeek) {
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ class MpvPlayerModule : Module() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Defines events that the view can send to JavaScript
|
// Defines events that the view can send to JavaScript
|
||||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package expo.modules.mpvplayer
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Build
|
import android.graphics.Rect
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.SurfaceHolder
|
import android.view.TextureView
|
||||||
import android.view.SurfaceView
|
import android.view.View
|
||||||
import android.widget.FrameLayout
|
import android.view.ViewGroup
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
import expo.modules.kotlin.views.ExpoView
|
import expo.modules.kotlin.views.ExpoView
|
||||||
@@ -28,26 +31,27 @@ data class VideoLoadConfig(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||||
* This mirrors the iOS MpvPlayerView implementation.
|
* Uses TextureView for reliable Picture-in-Picture support.
|
||||||
*/
|
*/
|
||||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||||
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MpvPlayerView"
|
private const val TAG = "MpvPlayerView"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event dispatchers
|
// Event dispatchers
|
||||||
val onLoad by EventDispatcher()
|
val onLoad by EventDispatcher()
|
||||||
val onPlaybackStateChange by EventDispatcher()
|
val onPlaybackStateChange by EventDispatcher()
|
||||||
val onProgress by EventDispatcher()
|
val onProgress by EventDispatcher()
|
||||||
val onError by EventDispatcher()
|
val onError by EventDispatcher()
|
||||||
val onTracksReady by EventDispatcher()
|
val onTracksReady by EventDispatcher()
|
||||||
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
private var surfaceView: SurfaceView
|
|
||||||
|
private var textureView: TextureView
|
||||||
private var renderer: MPVLayerRenderer? = null
|
private var renderer: MPVLayerRenderer? = null
|
||||||
private var pipController: PiPController? = null
|
private var pipController: PiPController? = null
|
||||||
|
|
||||||
private var currentUrl: String? = null
|
private var currentUrl: String? = null
|
||||||
private var cachedPosition: Double = 0.0
|
private var cachedPosition: Double = 0.0
|
||||||
private var cachedDuration: Double = 0.0
|
private var cachedDuration: Double = 0.0
|
||||||
@@ -56,23 +60,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private var pendingConfig: VideoLoadConfig? = null
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
private var rendererStarted: Boolean = false
|
private var rendererStarted: Boolean = false
|
||||||
private var pendingSurface: Surface? = null
|
private var pendingSurface: Surface? = null
|
||||||
|
private var surfaceTexture: SurfaceTexture? = null
|
||||||
|
|
||||||
|
// PiP state tracking
|
||||||
|
private var isWaitingForPiPTransition: Boolean = false
|
||||||
|
private var isPiPSurfaceForced: Boolean = false
|
||||||
|
private val pipHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
// Create SurfaceView for video rendering
|
// Create TextureView for video rendering (composites into app window for PiP support)
|
||||||
surfaceView = SurfaceView(context).apply {
|
textureView = TextureView(context).apply {
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
FrameLayout.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
holder.addCallback(this@MpvPlayerView)
|
surfaceTextureListener = this@MpvPlayerView
|
||||||
}
|
}
|
||||||
addView(surfaceView)
|
addView(textureView)
|
||||||
|
|
||||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||||
pipController = PiPController(context, appContext)
|
pipController = PiPController(context, appContext)
|
||||||
pipController?.setPlayerView(surfaceView)
|
pipController?.setPlayerView(textureView)
|
||||||
pipController?.delegate = object : PiPController.Delegate {
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
play()
|
play()
|
||||||
@@ -85,6 +95,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
override fun onSeekBy(seconds: Double) {
|
override fun onSeekBy(seconds: Double) {
|
||||||
seekBy(seconds)
|
seekBy(seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||||
|
if (isInPiP) {
|
||||||
|
if (!isWaitingForPiPTransition) {
|
||||||
|
isWaitingForPiPTransition = true
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||||
|
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
restoreFromPiP()
|
||||||
|
}
|
||||||
|
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||||
@@ -102,32 +129,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
try {
|
try {
|
||||||
renderer?.start(voDriver ?: "gpu-next")
|
renderer?.start(voDriver ?: "gpu-next")
|
||||||
rendererStarted = true
|
rendererStarted = true
|
||||||
Log.i(TAG, "Renderer started with vo=$voDriver")
|
|
||||||
|
|
||||||
// If surface was created before renderer started, attach it now
|
|
||||||
pendingSurface?.let { surface ->
|
pendingSurface?.let { surface ->
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
pendingSurface = null
|
pendingSurface = null
|
||||||
Log.i(TAG, "Attached pending surface after renderer start")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||||
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
|
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SurfaceHolder.Callback
|
// MARK: - TextureView.SurfaceTextureListener
|
||||||
|
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||||
Log.i(TAG, "Surface created")
|
this.surfaceTexture = surfaceTexture
|
||||||
|
val surface = Surface(surfaceTexture)
|
||||||
|
surfaceTexture.setDefaultBufferSize(width, height)
|
||||||
surfaceReady = true
|
surfaceReady = true
|
||||||
|
|
||||||
if (rendererStarted) {
|
if (rendererStarted) {
|
||||||
renderer?.attachSurface(holder.surface)
|
renderer?.attachSurface(surface)
|
||||||
} else {
|
} else {
|
||||||
// Renderer not started yet - store surface to attach after start
|
pendingSurface = surface
|
||||||
pendingSurface = holder.surface
|
|
||||||
Log.i(TAG, "Surface created before renderer started, storing as pending")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a pending load, execute it now
|
// If we have a pending load, execute it now
|
||||||
@@ -137,19 +161,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
pendingConfig = null
|
pendingConfig = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||||
Log.i(TAG, "Surface changed: ${width}x${height}")
|
surfaceTexture.setDefaultBufferSize(width, height)
|
||||||
// Update MPV with the new surface size (Findroid approach)
|
|
||||||
renderer?.updateSurfaceSize(width, height)
|
renderer?.updateSurfaceSize(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||||
Log.i(TAG, "Surface destroyed")
|
this.surfaceTexture = null
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
renderer?.detachSurface()
|
renderer?.detachSurface()
|
||||||
|
return false // mpv manages the SurfaceTexture
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||||
|
// Called every frame — no action needed, mpv drives rendering directly
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Video Loading
|
// MARK: - Video Loading
|
||||||
|
|
||||||
fun loadVideo(config: VideoLoadConfig) {
|
fun loadVideo(config: VideoLoadConfig) {
|
||||||
@@ -169,10 +197,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
loadVideoInternal(config)
|
loadVideoInternal(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadVideoInternal(config: VideoLoadConfig) {
|
private fun loadVideoInternal(config: VideoLoadConfig) {
|
||||||
currentUrl = config.url
|
currentUrl = config.url
|
||||||
|
|
||||||
renderer?.load(
|
renderer?.load(
|
||||||
url = config.url,
|
url = config.url,
|
||||||
headers = config.headers,
|
headers = config.headers,
|
||||||
@@ -181,124 +209,173 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
initialSubtitleId = config.initialSubtitleId,
|
initialSubtitleId = config.initialSubtitleId,
|
||||||
initialAudioId = config.initialAudioId
|
initialAudioId = config.initialAudioId
|
||||||
)
|
)
|
||||||
|
|
||||||
if (config.autoplay) {
|
if (config.autoplay) {
|
||||||
play()
|
play()
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad(mapOf("url" to config.url))
|
onLoad(mapOf("url" to config.url))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience method for simple loads
|
// Convenience method for simple loads
|
||||||
fun loadVideo(url: String, headers: Map<String, String>? = null) {
|
fun loadVideo(url: String, headers: Map<String, String>? = null) {
|
||||||
loadVideo(VideoLoadConfig(url = url, headers = headers))
|
loadVideo(VideoLoadConfig(url = url, headers = headers))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Playback Controls
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
fun play() {
|
fun play() {
|
||||||
intendedPlayState = true
|
intendedPlayState = true
|
||||||
renderer?.play()
|
renderer?.play()
|
||||||
pipController?.setPlaybackRate(1.0)
|
pipController?.setPlaybackRate(1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
intendedPlayState = false
|
intendedPlayState = false
|
||||||
renderer?.pause()
|
renderer?.pause()
|
||||||
pipController?.setPlaybackRate(0.0)
|
pipController?.setPlaybackRate(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekTo(position: Double) {
|
fun seekTo(position: Double) {
|
||||||
renderer?.seekTo(position)
|
renderer?.seekTo(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekBy(offset: Double) {
|
fun seekBy(offset: Double) {
|
||||||
renderer?.seekBy(offset)
|
renderer?.seekBy(offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSpeed(speed: Double) {
|
fun setSpeed(speed: Double) {
|
||||||
renderer?.setSpeed(speed)
|
renderer?.setSpeed(speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSpeed(): Double {
|
fun getSpeed(): Double {
|
||||||
return renderer?.getSpeed() ?: 1.0
|
return renderer?.getSpeed() ?: 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isPaused(): Boolean {
|
fun isPaused(): Boolean {
|
||||||
return renderer?.isPausedState ?: true
|
return renderer?.isPausedState ?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentPosition(): Double {
|
fun getCurrentPosition(): Double {
|
||||||
return cachedPosition
|
return cachedPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDuration(): Double {
|
fun getDuration(): Double {
|
||||||
return cachedDuration
|
return cachedDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Picture in Picture
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
Log.i(TAG, "startPictureInPicture called")
|
isWaitingForPiPTransition = true
|
||||||
pipController?.startPictureInPicture()
|
pipController?.startPictureInPicture()
|
||||||
|
|
||||||
|
// Resize buffer to match PiP window after animation settles
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||||
|
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
||||||
|
* visible rect so mpv renders at the PiP window's actual dimensions.
|
||||||
|
*/
|
||||||
|
private fun forcePiPBufferSize() {
|
||||||
|
if (!isWaitingForPiPTransition || !surfaceReady) return
|
||||||
|
|
||||||
|
val rect = Rect()
|
||||||
|
textureView.getGlobalVisibleRect(rect)
|
||||||
|
val visW = rect.width()
|
||||||
|
val visH = rect.height()
|
||||||
|
val vw = textureView.width
|
||||||
|
val vh = textureView.height
|
||||||
|
|
||||||
|
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
||||||
|
|
||||||
|
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
||||||
|
renderer?.updateSurfaceSize(visW, visH)
|
||||||
|
|
||||||
|
// Force TextureView layout to match PiP visible area.
|
||||||
|
// layoutParams alone doesn't work during PiP because the parent
|
||||||
|
// never re-lays out its children.
|
||||||
|
textureView.measure(
|
||||||
|
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
||||||
|
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
||||||
|
)
|
||||||
|
textureView.layout(0, 0, visW, visH)
|
||||||
|
isPiPSurfaceForced = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreFromPiP() {
|
||||||
|
if (!isPiPSurfaceForced) return
|
||||||
|
isPiPSurfaceForced = false
|
||||||
|
|
||||||
|
val lp = textureView.layoutParams
|
||||||
|
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
textureView.layoutParams = lp
|
||||||
|
textureView.requestLayout()
|
||||||
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isPictureInPictureSupported(): Boolean {
|
fun isPictureInPictureSupported(): Boolean {
|
||||||
return pipController?.isPictureInPictureSupported() ?: false
|
return pipController?.isPictureInPictureSupported() ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isPictureInPictureActive(): Boolean {
|
fun isPictureInPictureActive(): Boolean {
|
||||||
return pipController?.isPictureInPictureActive() ?: false
|
return pipController?.isPictureInPictureActive() ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Controls
|
// MARK: - Subtitle Controls
|
||||||
|
|
||||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
return renderer?.getSubtitleTracks() ?: emptyList()
|
return renderer?.getSubtitleTracks() ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleTrack(trackId: Int) {
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
renderer?.setSubtitleTrack(trackId)
|
renderer?.setSubtitleTrack(trackId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disableSubtitles() {
|
fun disableSubtitles() {
|
||||||
renderer?.disableSubtitles()
|
renderer?.disableSubtitles()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentSubtitleTrack(): Int {
|
fun getCurrentSubtitleTrack(): Int {
|
||||||
return renderer?.getCurrentSubtitleTrack() ?: 0
|
return renderer?.getCurrentSubtitleTrack() ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||||
renderer?.addSubtitleFile(url, select)
|
renderer?.addSubtitleFile(url, select)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Positioning
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
fun setSubtitlePosition(position: Int) {
|
fun setSubtitlePosition(position: Int) {
|
||||||
renderer?.setSubtitlePosition(position)
|
renderer?.setSubtitlePosition(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleScale(scale: Double) {
|
fun setSubtitleScale(scale: Double) {
|
||||||
renderer?.setSubtitleScale(scale)
|
renderer?.setSubtitleScale(scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleMarginY(margin: Int) {
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
renderer?.setSubtitleMarginY(margin)
|
renderer?.setSubtitleMarginY(margin)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignX(alignment: String) {
|
fun setSubtitleAlignX(alignment: String) {
|
||||||
renderer?.setSubtitleAlignX(alignment)
|
renderer?.setSubtitleAlignX(alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignY(alignment: String) {
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
renderer?.setSubtitleAlignY(alignment)
|
renderer?.setSubtitleAlignY(alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleFontSize(size: Int) {
|
fun setSubtitleFontSize(size: Int) {
|
||||||
renderer?.setSubtitleFontSize(size)
|
renderer?.setSubtitleFontSize(size)
|
||||||
}
|
}
|
||||||
@@ -316,15 +393,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Track Controls
|
// MARK: - Audio Track Controls
|
||||||
|
|
||||||
fun getAudioTracks(): List<Map<String, Any>> {
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
return renderer?.getAudioTracks() ?: emptyList()
|
return renderer?.getAudioTracks() ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAudioTrack(trackId: Int) {
|
fun setAudioTrack(trackId: Int) {
|
||||||
renderer?.setAudioTrack(trackId)
|
renderer?.setAudioTrack(trackId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentAudioTrack(): Int {
|
fun getCurrentAudioTrack(): Int {
|
||||||
return renderer?.getCurrentAudioTrack() ?: 0
|
return renderer?.getCurrentAudioTrack() ?: 0
|
||||||
}
|
}
|
||||||
@@ -349,16 +426,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MPVLayerRenderer.Delegate
|
// MARK: - MPVLayerRenderer.Delegate
|
||||||
|
|
||||||
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
|
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
cachedDuration = duration
|
cachedDuration = duration
|
||||||
|
|
||||||
// Update PiP progress
|
// Update PiP progress
|
||||||
if (pipController?.isPictureInPictureActive() == true) {
|
if (pipController?.isPictureInPictureActive() == true) {
|
||||||
pipController?.setCurrentTime(position, duration)
|
pipController?.setCurrentTime(position, duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress(mapOf(
|
onProgress(mapOf(
|
||||||
"position" to position,
|
"position" to position,
|
||||||
"duration" to duration,
|
"duration" to duration,
|
||||||
@@ -366,50 +443,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
"cacheSeconds" to cacheSeconds
|
"cacheSeconds" to cacheSeconds
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPauseChanged(isPaused: Boolean) {
|
override fun onPauseChanged(isPaused: Boolean) {
|
||||||
// Sync PiP playback rate
|
|
||||||
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
|
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
|
||||||
|
|
||||||
onPlaybackStateChange(mapOf(
|
onPlaybackStateChange(mapOf(
|
||||||
"isPaused" to isPaused,
|
"isPaused" to isPaused,
|
||||||
"isPlaying" to !isPaused
|
"isPlaying" to !isPaused
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLoadingChanged(isLoading: Boolean) {
|
override fun onLoadingChanged(isLoading: Boolean) {
|
||||||
onPlaybackStateChange(mapOf(
|
onPlaybackStateChange(mapOf(
|
||||||
"isLoading" to isLoading
|
"isLoading" to isLoading
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReadyToSeek() {
|
override fun onReadyToSeek() {
|
||||||
onPlaybackStateChange(mapOf(
|
onPlaybackStateChange(mapOf(
|
||||||
"isReadyToSeek" to true
|
"isReadyToSeek" to true
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTracksReady() {
|
override fun onTracksReady() {
|
||||||
onTracksReady(emptyMap<String, Any>())
|
onTracksReady(emptyMap<String, Any>())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVideoDimensionsChanged(width: Int, height: Int) {
|
override fun onVideoDimensionsChanged(width: Int, height: Int) {
|
||||||
// Update PiP controller with video dimensions for proper aspect ratio
|
|
||||||
pipController?.setVideoDimensions(width, height)
|
pipController?.setVideoDimensions(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(message: String) {
|
override fun onError(message: String) {
|
||||||
onError(mapOf("error" to message))
|
onError(mapOf("error" to message))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
surfaceView.holder.removeCallback(this)
|
surfaceTexture = null
|
||||||
|
surfaceReady = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
override fun onDetachedFromWindow() {
|
||||||
super.onDetachedFromWindow()
|
super.onDetachedFromWindow()
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|||||||
@@ -1,51 +1,62 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
import android.app.PictureInPictureParams
|
import android.app.PictureInPictureParams
|
||||||
|
import android.app.RemoteAction
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
|
|
||||||
/**
|
|
||||||
* Picture-in-Picture controller for Android.
|
|
||||||
* This mirrors the iOS PiPController implementation.
|
|
||||||
*/
|
|
||||||
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
|
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PiPController"
|
private const val TAG = "PiPController"
|
||||||
private const val DEFAULT_ASPECT_WIDTH = 16
|
private const val DEFAULT_ASPECT_WIDTH = 16
|
||||||
private const val DEFAULT_ASPECT_HEIGHT = 9
|
private const val DEFAULT_ASPECT_HEIGHT = 9
|
||||||
|
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
|
||||||
|
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
|
||||||
|
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Delegate {
|
interface Delegate {
|
||||||
fun onPlay()
|
fun onPlay()
|
||||||
fun onPause()
|
fun onPause()
|
||||||
fun onSeekBy(seconds: Double)
|
fun onSeekBy(seconds: Double)
|
||||||
|
fun onPictureInPictureModeChanged(isInPiP: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
var delegate: Delegate? = null
|
var delegate: Delegate? = null
|
||||||
|
|
||||||
private var currentPosition: Double = 0.0
|
private var currentPosition: Double = 0.0
|
||||||
private var currentDuration: Double = 0.0
|
private var currentDuration: Double = 0.0
|
||||||
private var playbackRate: Double = 1.0
|
private var playbackRate: Double = 1.0
|
||||||
|
|
||||||
// Video dimensions for proper aspect ratio
|
|
||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
|
|
||||||
// Reference to the player view for source rect
|
|
||||||
private var playerView: View? = null
|
private var playerView: View? = null
|
||||||
|
|
||||||
/**
|
// PiP state tracking
|
||||||
* Check if Picture-in-Picture is supported on this device
|
private var isInPiPMode: Boolean = false
|
||||||
*/
|
private var pipEntryNotified: Boolean = false
|
||||||
|
private val pipHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||||
|
private var lifecycleRegistered = false
|
||||||
|
private var pipBroadcastReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
fun isPictureInPictureSupported(): Boolean {
|
fun isPictureInPictureSupported(): Boolean {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||||
@@ -53,10 +64,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Picture-in-Picture is currently active
|
|
||||||
*/
|
|
||||||
fun isPictureInPictureActive(): Boolean {
|
fun isPictureInPictureActive(): Boolean {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
@@ -64,69 +72,69 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Start Picture-in-Picture mode
|
|
||||||
*/
|
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val activity = getActivity()
|
|
||||||
if (activity == null) {
|
val activity = getActivity() ?: run {
|
||||||
Log.e(TAG, "Cannot start PiP: no activity found")
|
Log.e(TAG, "Cannot start PiP: no activity")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPictureInPictureSupported()) {
|
||||||
|
Log.e(TAG, "PiP not supported on this device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val params = buildPiPParams(forEntering = true)
|
||||||
|
val result = activity.enterPictureInPictureMode(params)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
Log.e(TAG, "enterPictureInPictureMode rejected by system")
|
||||||
|
isInPiPMode = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPictureInPictureSupported()) {
|
isInPiPMode = true
|
||||||
Log.e(TAG, "PiP not supported on this device")
|
pipEntryNotified = true
|
||||||
return
|
delegate?.onPictureInPictureModeChanged(true)
|
||||||
}
|
registerLifecycleCallbacks()
|
||||||
|
} catch (e: Exception) {
|
||||||
try {
|
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
||||||
val params = buildPiPParams(forEntering = true)
|
|
||||||
activity.enterPictureInPictureMode(params)
|
|
||||||
Log.i(TAG, "Entered PiP mode")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "PiP requires Android O or higher")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop Picture-in-Picture mode
|
|
||||||
*/
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
// On Android, exiting PiP is typically done by the user
|
isInPiPMode = false
|
||||||
// or by finishing the activity. We can request to move task to back.
|
pipEntryNotified = false
|
||||||
|
unregisterLifecycleCallbacks()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
if (activity?.isInPictureInPictureMode == true) {
|
if (activity?.isInPictureInPictureMode == true) {
|
||||||
// Move task to back which will exit PiP
|
|
||||||
activity.moveTaskToBack(false)
|
activity.moveTaskToBack(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||||
* Update the current playback position and duration
|
|
||||||
* Note: We don't update PiP params here as we're not using progress in PiP controls
|
|
||||||
*/
|
|
||||||
fun setCurrentTime(position: Double, duration: Double) {
|
fun setCurrentTime(position: Double, duration: Double) {
|
||||||
currentPosition = position
|
currentPosition = position
|
||||||
currentDuration = duration
|
currentDuration = duration
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the playback rate (0.0 for paused, 1.0 for playing)
|
|
||||||
*/
|
|
||||||
fun setPlaybackRate(rate: Double) {
|
fun setPlaybackRate(rate: Double) {
|
||||||
playbackRate = rate
|
playbackRate = rate
|
||||||
|
|
||||||
// Update PiP params to reflect play/pause state
|
if (rate > 0) {
|
||||||
|
registerLifecycleCallbacks()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update PiP params so autoEnterEnabled and action icons track play/pause state
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
if (activity?.isInPictureInPictureMode == true) {
|
if (activity != null) {
|
||||||
try {
|
try {
|
||||||
activity.setPictureInPictureParams(buildPiPParams())
|
activity.setPictureInPictureParams(buildPiPParams())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -135,28 +143,19 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the video dimensions for proper aspect ratio calculation
|
|
||||||
*/
|
|
||||||
fun setVideoDimensions(width: Int, height: Int) {
|
fun setVideoDimensions(width: Int, height: Int) {
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
videoWidth = width
|
videoWidth = width
|
||||||
videoHeight = height
|
videoHeight = height
|
||||||
Log.i(TAG, "Video dimensions set: ${width}x${height}")
|
|
||||||
|
|
||||||
// Update PiP params if active
|
|
||||||
updatePiPParamsIfNeeded()
|
updatePiPParamsIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the player view reference for source rect hint
|
|
||||||
*/
|
|
||||||
fun setPlayerView(view: View?) {
|
fun setPlayerView(view: View?) {
|
||||||
playerView = view
|
playerView = view
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePiPParamsIfNeeded() {
|
private fun updatePiPParamsIfNeeded() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity()
|
val activity = getActivity()
|
||||||
@@ -169,23 +168,16 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build Picture-in-Picture params for the current player state.
|
|
||||||
* Calculates proper aspect ratio and source rect based on video and view dimensions.
|
|
||||||
*/
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
|
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
|
||||||
val view = playerView
|
val view = playerView
|
||||||
val viewWidth = view?.width ?: 0
|
val viewWidth = view?.width ?: 0
|
||||||
val viewHeight = view?.height ?: 0
|
val viewHeight = view?.height ?: 0
|
||||||
|
|
||||||
// Display aspect ratio from view (exactly like Findroid)
|
|
||||||
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
|
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
|
||||||
|
|
||||||
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
|
// Video aspect ratio with 2.39:1 clamping
|
||||||
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
|
|
||||||
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
|
|
||||||
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
||||||
Rational(
|
Rational(
|
||||||
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
||||||
@@ -194,70 +186,235 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
} else {
|
} else {
|
||||||
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
|
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source rect hint calculation (exactly like Findroid)
|
|
||||||
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
|
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
|
||||||
if (displayAspectRatio < aspectRatio) {
|
if (displayAspectRatio < aspectRatio) {
|
||||||
// Letterboxing - black bars top/bottom
|
|
||||||
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
|
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
|
||||||
Rect(
|
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
|
||||||
0,
|
|
||||||
space,
|
|
||||||
viewWidth,
|
|
||||||
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Pillarboxing - black bars left/right
|
|
||||||
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
|
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
|
||||||
Rect(
|
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
|
||||||
space,
|
|
||||||
0,
|
|
||||||
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
|
|
||||||
viewHeight
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val builder = PictureInPictureParams.Builder()
|
val builder = PictureInPictureParams.Builder()
|
||||||
.setAspectRatio(aspectRatio)
|
.setAspectRatio(aspectRatio)
|
||||||
|
|
||||||
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
||||||
|
|
||||||
// On Android 12+, enable auto-enter (like Findroid)
|
ensurePiPReceiverRegistered()
|
||||||
|
builder.setActions(buildPiPActions())
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
builder.setAutoEnterEnabled(true)
|
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getActivity(): Activity? {
|
private fun getActivity(): Activity? {
|
||||||
// First try Expo's AppContext (preferred in React Native)
|
|
||||||
appContext?.currentActivity?.let { return it }
|
appContext?.currentActivity?.let { return it }
|
||||||
|
|
||||||
// Fallback: Try to get from context wrapper chain
|
|
||||||
var ctx = context
|
var ctx = context
|
||||||
while (ctx is android.content.ContextWrapper) {
|
while (ctx is android.content.ContextWrapper) {
|
||||||
if (ctx is Activity) {
|
if (ctx is Activity) return ctx
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
ctx = ctx.baseContext
|
ctx = ctx.baseContext
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// MARK: - Lifecycle-based PiP Detection
|
||||||
* Handle PiP action (called from activity when user taps PiP controls)
|
|
||||||
*/
|
private fun registerLifecycleCallbacks() {
|
||||||
fun handlePiPAction(action: String) {
|
if (lifecycleRegistered) return
|
||||||
when (action) {
|
|
||||||
"play" -> delegate?.onPlay()
|
val app = context.applicationContext as? Application ?: run {
|
||||||
"pause" -> delegate?.onPause()
|
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
|
||||||
"skip_forward" -> delegate?.onSeekBy(10.0)
|
startFallbackPolling()
|
||||||
"skip_backward" -> delegate?.onSeekBy(-10.0)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||||
|
override fun onActivityStarted(activity: Activity) {}
|
||||||
|
|
||||||
|
override fun onActivityResumed(activity: Activity) {
|
||||||
|
if (!isInPiPMode) return
|
||||||
|
if (!activity.isInPictureInPictureMode) {
|
||||||
|
isInPiPMode = false
|
||||||
|
pipEntryNotified = false
|
||||||
|
delegate?.onPictureInPictureModeChanged(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityPaused(activity: Activity) {
|
||||||
|
// Proactively hide controls when user leaves while playing,
|
||||||
|
// before the PiP window captures the UI. onActivityStopped
|
||||||
|
// will restore if PiP didn't actually enter.
|
||||||
|
if (playbackRate > 0 && !isInPiPMode) {
|
||||||
|
isInPiPMode = true
|
||||||
|
pipEntryNotified = true
|
||||||
|
delegate?.onPictureInPictureModeChanged(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
pipHandler.postDelayed({
|
||||||
|
val inPip = activity.isInPictureInPictureMode
|
||||||
|
|
||||||
|
if (inPip && !isInPiPMode) {
|
||||||
|
isInPiPMode = true
|
||||||
|
pipEntryNotified = true
|
||||||
|
delegate?.onPictureInPictureModeChanged(true)
|
||||||
|
return@postDelayed
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInPiPMode) return@postDelayed
|
||||||
|
if (inPip) return@postDelayed
|
||||||
|
|
||||||
|
// Not in PiP after 1s — check again to avoid false positive during transition
|
||||||
|
pipHandler.postDelayed({
|
||||||
|
if (!isInPiPMode) return@postDelayed
|
||||||
|
if (!activity.isInPictureInPictureMode) {
|
||||||
|
isInPiPMode = false
|
||||||
|
pipEntryNotified = false
|
||||||
|
delegate?.onPictureInPictureModeChanged(false)
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||||
|
|
||||||
|
override fun onActivityDestroyed(activity: Activity) {
|
||||||
|
isInPiPMode = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
||||||
|
lifecycleRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterLifecycleCallbacks() {
|
||||||
|
if (!lifecycleRegistered) return
|
||||||
|
lifecycleCallbacks?.let {
|
||||||
|
(context.applicationContext as? Application)
|
||||||
|
?.unregisterActivityLifecycleCallbacks(it)
|
||||||
|
}
|
||||||
|
lifecycleCallbacks = null
|
||||||
|
lifecycleRegistered = false
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
unregisterPiPBroadcastReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startFallbackPolling() {
|
||||||
|
var falseReadCount = 0
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
pipHandler.postDelayed(object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
if (!isInPiPMode) return
|
||||||
|
|
||||||
|
var ctx = context
|
||||||
|
var activity: Activity? = null
|
||||||
|
while (ctx is android.content.ContextWrapper) {
|
||||||
|
if (ctx is Activity) { activity = ctx; break }
|
||||||
|
ctx = ctx.baseContext
|
||||||
|
}
|
||||||
|
|
||||||
|
val stillInPip = activity?.isInPictureInPictureMode == true
|
||||||
|
|
||||||
|
if (!stillInPip) {
|
||||||
|
falseReadCount++
|
||||||
|
if (falseReadCount >= 3) {
|
||||||
|
isInPiPMode = false
|
||||||
|
delegate?.onPictureInPictureModeChanged(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pipHandler.postDelayed(this, 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
falseReadCount = 0
|
||||||
|
pipHandler.postDelayed(this, 1000)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PiP Remote Actions
|
||||||
|
|
||||||
|
private fun ensurePiPReceiverRegistered() {
|
||||||
|
if (pipBroadcastReceiver != null) return
|
||||||
|
|
||||||
|
pipBroadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_PIP_PLAY_PAUSE -> {
|
||||||
|
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
|
||||||
|
}
|
||||||
|
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
|
||||||
|
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(ACTION_PIP_PLAY_PAUSE)
|
||||||
|
addAction(ACTION_PIP_SKIP_FORWARD)
|
||||||
|
addAction(ACTION_PIP_SKIP_BACKWARD)
|
||||||
|
}
|
||||||
|
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Context.RECEIVER_EXPORTED
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterPiPBroadcastReceiver() {
|
||||||
|
pipBroadcastReceiver?.let {
|
||||||
|
try {
|
||||||
|
context.applicationContext.unregisterReceiver(it)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
pipBroadcastReceiver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPiPActions(): List<RemoteAction> {
|
||||||
|
val isPlaying = playbackRate > 0
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
|
||||||
|
"Rewind", "Skip backward 10 seconds",
|
||||||
|
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
|
||||||
|
),
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(
|
||||||
|
context,
|
||||||
|
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
|
||||||
|
),
|
||||||
|
if (isPlaying) "Pause" else "Play",
|
||||||
|
if (isPlaying) "Pause playback" else "Resume playback",
|
||||||
|
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
|
||||||
|
),
|
||||||
|
RemoteAction(
|
||||||
|
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
|
||||||
|
"Fast Forward", "Skip forward 10 seconds",
|
||||||
|
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
return android.app.PendingIntent.getBroadcast(
|
||||||
|
context.applicationContext, 0, Intent(action), flags
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,19 @@
|
|||||||
Pod::Spec.new do |s|
|
Pod::Spec.new do |s|
|
||||||
s.name = 'MpvPlayer'
|
s.name = 'MpvPlayer'
|
||||||
s.version = '1.0.0'
|
s.version = '1.0.0'
|
||||||
s.summary = 'MPVKit for Expo'
|
s.summary = 'MPV-based video player for Streamyfin (Expo module)'
|
||||||
s.description = 'MPVKit for Expo'
|
s.author = 'Streamyfin'
|
||||||
s.author = 'mpvkit'
|
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||||
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
s.platforms = { :ios => '15.1', :tvos => '15.1' }
|
||||||
s.platforms = {
|
s.source = { git: '' }
|
||||||
:ios => '15.1',
|
|
||||||
:tvos => '15.1'
|
|
||||||
}
|
|
||||||
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
|
||||||
s.static_framework = true
|
s.static_framework = true
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
s.dependency 'ExpoModulesCore'
|
||||||
s.dependency 'MPVKit-GPL'
|
s.dependency 'MPVKit'
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
|
||||||
s.pod_target_xcconfig = {
|
s.pod_target_xcconfig = {
|
||||||
'DEFINES_MODULE' => 'YES',
|
'DEFINES_MODULE' => 'YES',
|
||||||
'VALID_ARCHS' => 'arm64',
|
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
|
|
||||||
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
|
||||||
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
|
||||||
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
|
||||||
}
|
|
||||||
|
|
||||||
s.user_target_xcconfig = {
|
|
||||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export type OnErrorEventPayload = {
|
|||||||
|
|
||||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||||
|
|
||||||
|
export type OnPictureInPictureChangePayload = {
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type NowPlayingMetadata = {
|
export type NowPlayingMetadata = {
|
||||||
title?: string;
|
title?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
@@ -77,6 +81,9 @@ export type MpvPlayerViewProps = {
|
|||||||
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
||||||
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
||||||
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
||||||
|
onPictureInPictureChange?: (event: {
|
||||||
|
nativeEvent: OnPictureInPictureChangePayload;
|
||||||
|
}) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MpvPlayerViewRef {
|
export interface MpvPlayerViewRef {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
|
|||||||
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||||
requireNativeView("MpvPlayer");
|
requireNativeView("MpvPlayer");
|
||||||
|
|
||||||
|
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
|
||||||
|
|
||||||
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||||
function MpvPlayerView(props, ref) {
|
function MpvPlayerView(props, ref) {
|
||||||
const nativeRef = useRef<any>(null);
|
const nativeRef = useRef<any>(null);
|
||||||
@@ -40,16 +42,24 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
|||||||
return await nativeRef.current?.getDuration();
|
return await nativeRef.current?.getDuration();
|
||||||
},
|
},
|
||||||
startPictureInPicture: async () => {
|
startPictureInPicture: async () => {
|
||||||
|
console.log(PIP_LOG, "startPictureInPicture → native");
|
||||||
await nativeRef.current?.startPictureInPicture();
|
await nativeRef.current?.startPictureInPicture();
|
||||||
|
console.log(PIP_LOG, "startPictureInPicture ← native returned");
|
||||||
},
|
},
|
||||||
stopPictureInPicture: async () => {
|
stopPictureInPicture: async () => {
|
||||||
|
console.log(PIP_LOG, "stopPictureInPicture → native");
|
||||||
await nativeRef.current?.stopPictureInPicture();
|
await nativeRef.current?.stopPictureInPicture();
|
||||||
|
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
|
||||||
},
|
},
|
||||||
isPictureInPictureSupported: async () => {
|
isPictureInPictureSupported: async () => {
|
||||||
return await nativeRef.current?.isPictureInPictureSupported();
|
const result = await nativeRef.current?.isPictureInPictureSupported();
|
||||||
|
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
isPictureInPictureActive: async () => {
|
isPictureInPictureActive: async () => {
|
||||||
return await nativeRef.current?.isPictureInPictureActive();
|
const result = await nativeRef.current?.isPictureInPictureActive();
|
||||||
|
console.log(PIP_LOG, "isPictureInPictureActive =", result);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
getSubtitleTracks: async () => {
|
getSubtitleTracks: async () => {
|
||||||
return await nativeRef.current?.getSubtitleTracks();
|
return await nativeRef.current?.getSubtitleTracks();
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -32,9 +32,10 @@
|
|||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~56.0.14",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.14",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "4.25.2",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.4",
|
||||||
|
"react-native-tab-view": "4.3.0",
|
||||||
"react-native-text-ticker": "^1.15.0",
|
"react-native-text-ticker": "^1.15.0",
|
||||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -111,7 +113,7 @@
|
|||||||
"react-native-uuid": "^2.0.3",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-worklets": "0.9.1",
|
"react-native-worklets": "0.8.3",
|
||||||
"sonner-native": "0.21.2",
|
"sonner-native": "0.21.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
@@ -162,10 +164,5 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
],
|
]
|
||||||
"patchedDependencies": {
|
|
||||||
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
|
|
||||||
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
|
|
||||||
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
|
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
||||||
new file mode 100644
|
|
||||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
|
||||||
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
|
|
||||||
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
|
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
|
||||||
--- a/ios/BottomAccessoryProvider.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
||||||
+++ b/ios/BottomAccessoryProvider.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
||||||
@@ -8,7 +8,7 @@ import SwiftUI
|
@@ -8,7 +8,7 @@ import SwiftUI
|
||||||
self.delegate = delegate
|
self.delegate = delegate
|
||||||
}
|
}
|
||||||
@@ -14,10 +11,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
|
|||||||
@available(iOS 26.0, *)
|
@available(iOS 26.0, *)
|
||||||
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
|
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
|
||||||
var placementValue = "none"
|
var placementValue = "none"
|
||||||
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
|
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
||||||
--- a/ios/TabView/NewTabView.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
+++ b/ios/TabView/NewTabView.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +53,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
+
|
+
|
||||||
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
|
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
||||||
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
||||||
--- a/ios/TabViewImpl.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
||||||
+++ b/ios/TabViewImpl.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
||||||
@@ -281,7 +281,7 @@ extension View {
|
@@ -281,7 +281,7 @@ extension View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -69,10 +66,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
|
|||||||
if #available(iOS 26.0, macOS 26.0, *) {
|
if #available(iOS 26.0, macOS 26.0, *) {
|
||||||
if let behavior {
|
if let behavior {
|
||||||
self.tabBarMinimizeBehavior(behavior.convert())
|
self.tabBarMinimizeBehavior(behavior.convert())
|
||||||
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
|
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
||||||
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
||||||
--- a/ios/TabViewProps.swift
|
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
||||||
+++ b/ios/TabViewProps.swift
|
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
||||||
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
|
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
|
||||||
case onScrollUp
|
case onScrollUp
|
||||||
case onScrollDown
|
case onScrollDown
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
diff --git a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
||||||
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
--- a/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
+++ b/node_modules/react-native-ios-utilities/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
||||||
@@ -25,15 +25,14 @@ public extension RCTView {
|
@@ -25,15 +25,14 @@ public extension RCTView {
|
||||||
return rootView.recursivelyFindSubview(whereType: targetType);
|
return rootView.recursivelyFindSubview(whereType: targetType);
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
|
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
|
||||||
new file mode 100644
|
|
||||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
|
||||||
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
|
|
||||||
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
|
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
|
||||||
--- a/react-native-udp.podspec
|
--- a/node_modules/react-native-udp/react-native-udp.podspec
|
||||||
+++ b/react-native-udp.podspec
|
+++ b/node_modules/react-native-udp/react-native-udp.podspec
|
||||||
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
|
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
|
||||||
s.homepage = package_json["homepage"]
|
s.homepage = package_json["homepage"]
|
||||||
s.license = package_json["license"]
|
s.license = package_json["license"]
|
||||||
@@ -39,6 +39,28 @@ function buildPatch() {
|
|||||||
" end",
|
" end",
|
||||||
" end",
|
" end",
|
||||||
"",
|
"",
|
||||||
|
" # iOS 26 / Xcode 26: the APP target itself compiles ExpoModulesProvider.swift,",
|
||||||
|
" # which imports SwiftUI-based modules (ExpoUI, ExpoGlassEffect, GlassPoster, ExpoBlur, …).",
|
||||||
|
" # That emits a `-framework SwiftUICore` autolink into the app executable's OWN object",
|
||||||
|
" # files, so the pods-only flag above is not enough — the app's link still fails with",
|
||||||
|
" # `cannot link directly with 'SwiftUICore'`. Drop the autolink on the user app target",
|
||||||
|
" # too. Phone-only — tvOS has no SwiftUICore split and must stay untouched.",
|
||||||
|
" if ENV['EXPO_TV'] != '1'",
|
||||||
|
" installer.aggregate_targets.each do |agg|",
|
||||||
|
" next unless agg.user_project",
|
||||||
|
" agg.user_project.native_targets.each do |target|",
|
||||||
|
" target.build_configurations.each do |cfg|",
|
||||||
|
" existing = cfg.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'",
|
||||||
|
" existing = existing.join(' ') if existing.is_a?(Array)",
|
||||||
|
" unless existing.include?('-disable-autolink-framework -Xfrontend SwiftUICore')",
|
||||||
|
" cfg.build_settings['OTHER_SWIFT_FLAGS'] = existing + ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
|
||||||
|
" end",
|
||||||
|
" end",
|
||||||
|
" end",
|
||||||
|
" agg.user_project.save",
|
||||||
|
" end",
|
||||||
|
" end",
|
||||||
|
"",
|
||||||
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
|
" # Safely patch RCTThirdPartyComponentsProvider.mm to avoid startup crash on unlinked Fabric components",
|
||||||
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
|
' filepath = "#{installer.sandbox.root}/../build/generated/ios/ReactCodegen/RCTThirdPartyComponentsProvider.mm"',
|
||||||
" if File.exist?(filepath)",
|
" if File.exist?(filepath)",
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const initialApi = (() => {
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
const jellyfinInstance = new Jellyfin({
|
const jellyfinInstance = new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.0" },
|
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -128,7 +128,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const id = getOrSetDeviceId();
|
const id = getOrSetDeviceId();
|
||||||
const deviceName = getDeviceNameSync();
|
const deviceName = getDeviceNameSync();
|
||||||
return new Jellyfin({
|
return new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.54.0" },
|
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -162,7 +162,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.54.0"`,
|
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@
|
|||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>arm64</string>
|
||||||
|
</array>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@@ -608,7 +608,8 @@
|
|||||||
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
|
"downloaded_file_message": "Heruntergeladene Datei abspielen?",
|
||||||
"downloaded_file_yes": "Ja",
|
"downloaded_file_yes": "Ja",
|
||||||
"downloaded_file_no": "Nein",
|
"downloaded_file_no": "Nein",
|
||||||
"downloaded_file_cancel": "Abbrechen"
|
"downloaded_file_cancel": "Abbrechen",
|
||||||
|
"ends_at": "Endet um {{time}}"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Als Nächstes",
|
"next_up": "Als Nächstes",
|
||||||
|
|||||||
@@ -698,7 +698,7 @@
|
|||||||
"downloaded_file_no": "No",
|
"downloaded_file_no": "No",
|
||||||
"downloaded_file_cancel": "Cancel",
|
"downloaded_file_cancel": "Cancel",
|
||||||
"swipe_down_settings": "Swipe down for settings",
|
"swipe_down_settings": "Swipe down for settings",
|
||||||
"ends_at": "ends at",
|
"ends_at": "Ends at {{time}}",
|
||||||
"search_subtitles": "Search Subtitles",
|
"search_subtitles": "Search Subtitles",
|
||||||
"subtitle_tracks": "Tracks",
|
"subtitle_tracks": "Tracks",
|
||||||
"subtitle_search": "Search & Download",
|
"subtitle_search": "Search & Download",
|
||||||
|
|||||||
Reference in New Issue
Block a user