mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 12:08:37 +01:00
Compare commits
4 Commits
feat/playe
...
cleanup/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947d2e4ff3 | ||
|
|
6b7ee0514f | ||
|
|
c663bd0413 | ||
|
|
52e6f56220 |
@@ -6,6 +6,7 @@ import {
|
|||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
@@ -76,7 +77,7 @@ const MobilePage: React.FC = () => {
|
|||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import {
|
import {
|
||||||
MeasuredTriggerHost,
|
type LayoutChangeEvent,
|
||||||
OptionGroupCard,
|
Platform,
|
||||||
ToggleSwitch,
|
StyleSheet,
|
||||||
} from "@/components/common/dropdownShared";
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
@@ -15,7 +16,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, Menu } = Platform.isTV
|
const { Button, Host, 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
|
||||||
@@ -71,6 +72,16 @@ interface PlatformDropdownProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||||
option,
|
option,
|
||||||
isLast,
|
isLast,
|
||||||
@@ -110,7 +121,19 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
||||||
<OptionGroupCard title={group.title}>
|
<View className='mb-6'>
|
||||||
|
{group.title && (
|
||||||
|
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
||||||
|
{group.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
className='bg-neutral-800 rounded-xl overflow-hidden'
|
||||||
|
>
|
||||||
{group.options.map((option, index) => (
|
{group.options.map((option, index) => (
|
||||||
<OptionItem
|
<OptionItem
|
||||||
key={index}
|
key={index}
|
||||||
@@ -118,7 +141,8 @@ const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
|||||||
isLast={index === group.options.length - 1}
|
isLast={index === group.options.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</OptionGroupCard>
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const BottomSheetContent: React.FC<{
|
const BottomSheetContent: React.FC<{
|
||||||
@@ -193,6 +217,24 @@ const PlatformDropdownComponent = ({
|
|||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
|
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
||||||
|
// `matchContents` doesn't help here: it reports the native Menu's size via
|
||||||
|
// setStyleSize and overrides any explicit size. Instead we measure the
|
||||||
|
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
||||||
|
const [triggerSize, setTriggerSize] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = e.nativeEvent.layout;
|
||||||
|
setTriggerSize((prev) =>
|
||||||
|
prev && prev.width === width && prev.height === height
|
||||||
|
? prev
|
||||||
|
: { width, height },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
@@ -223,11 +265,28 @@ 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 (
|
||||||
<MeasuredTriggerHost
|
<View style={triggerSize ?? { opacity: 0 }}>
|
||||||
trigger={trigger}
|
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||||
hostStyle={expoUIConfig?.hostStyle}
|
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||||
|
sizes to the trigger's content rather than to its parent. */}
|
||||||
|
<View
|
||||||
|
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||||
|
pointerEvents='none'
|
||||||
|
aria-hidden
|
||||||
|
onLayout={handleMeasureTrigger}
|
||||||
>
|
>
|
||||||
|
{trigger}
|
||||||
|
</View>
|
||||||
|
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||||
<Menu label={trigger}>
|
<Menu label={trigger}>
|
||||||
{groups.flatMap((group, groupIndex) => {
|
{groups.flatMap((group, groupIndex) => {
|
||||||
// Check if this group has radio options
|
// Check if this group has radio options
|
||||||
@@ -252,7 +311,9 @@ const PlatformDropdownComponent = ({
|
|||||||
// 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((opt) => opt.selected);
|
const selectedOption = radioOptions.find(
|
||||||
|
(opt) => opt.selected,
|
||||||
|
);
|
||||||
const displayTitle = selectedOption
|
const displayTitle = selectedOption
|
||||||
? `${group.title}: ${selectedOption.label}`
|
? `${group.title}: ${selectedOption.label}`
|
||||||
: group.title;
|
: group.title;
|
||||||
@@ -286,7 +347,9 @@ const PlatformDropdownComponent = ({
|
|||||||
systemImage={
|
systemImage={
|
||||||
option.selected ? "checkmark.circle.fill" : "circle"
|
option.selected ? "checkmark.circle.fill" : "circle"
|
||||||
}
|
}
|
||||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
modifiers={
|
||||||
|
option.disabled ? [disabled(true)] : undefined
|
||||||
|
}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
option.onPress();
|
option.onPress();
|
||||||
onOptionSelect?.(option.value);
|
onOptionSelect?.(option.value);
|
||||||
@@ -332,7 +395,8 @@ const PlatformDropdownComponent = ({
|
|||||||
return items;
|
return items;
|
||||||
})}
|
})}
|
||||||
</Menu>
|
</Menu>
|
||||||
</MeasuredTriggerHost>
|
</Host>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
|
|
||||||
const streams = useMemo(
|
const streams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||||
[source],
|
[source, streamType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSteam = useMemo(
|
const selectedSteam = useMemo(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import { type PropsWithChildren, useCallback } from "react";
|
import { type PropsWithChildren, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -149,6 +150,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
@@ -182,11 +184,13 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
const options: string[] = [
|
const options: string[] = [
|
||||||
"Mark as Played",
|
t("common.mark_as_played"),
|
||||||
"Mark as Not Played",
|
t("common.mark_as_not_played"),
|
||||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
isFavorite
|
||||||
...(isOffline ? ["Delete Download"] : []),
|
? t("music.track_options.remove_from_favorites")
|
||||||
"Cancel",
|
: t("music.track_options.add_to_favorites"),
|
||||||
|
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
||||||
|
t("common.cancel"),
|
||||||
];
|
];
|
||||||
const cancelButtonIndex = options.length - 1;
|
const cancelButtonIndex = options.length - 1;
|
||||||
const destructiveButtonIndex = isOffline
|
const destructiveButtonIndex = isOffline
|
||||||
@@ -219,6 +223,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
isOffline,
|
isOffline,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
item.Id,
|
item.Id,
|
||||||
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
// Shared internals for PlatformDropdown and PlayerSettingsPopover.
|
|
||||||
// Both components host SwiftUI content (Menu / Popover) inside @expo/ui's
|
|
||||||
// <Host>, both render an Android bottom-sheet card for the same three core
|
|
||||||
// option types (radio / toggle / action), and both wear the same wrapper
|
|
||||||
// boilerplate. This module is the single source of truth for those pieces.
|
|
||||||
//
|
|
||||||
// What lives here:
|
|
||||||
// - useTriggerSize() — measures the RN trigger's intrinsic size
|
|
||||||
// - MeasuredTriggerHost — pins <Host> to that measured size (workaround
|
|
||||||
// for @expo/ui SDK 55 sizing behaviour; see notes below)
|
|
||||||
// - ToggleSwitch — the small purple switch used in the Android sheet
|
|
||||||
// - OptionGroupCard — the rounded dark card with optional title that
|
|
||||||
// wraps a group's option rows on Android
|
|
||||||
//
|
|
||||||
// What deliberately doesn't live here:
|
|
||||||
// - The iOS rendering — PlatformDropdown uses a Menu, PlayerSettingsPopover
|
|
||||||
// uses a hand-styled Popover. Nothing meaningful to share.
|
|
||||||
// - The Android per-row renderers — PlatformDropdown handles 3 option types,
|
|
||||||
// PlayerSettingsPopover handles 6 (adds slider/stepper/subgroup). Forcing
|
|
||||||
// a shared abstraction would couple them. Each owns its own OptionItem.
|
|
||||||
|
|
||||||
import React, { useCallback, useState } from "react";
|
|
||||||
import {
|
|
||||||
type LayoutChangeEvent,
|
|
||||||
Platform,
|
|
||||||
StyleSheet,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
|
||||||
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
|
|
||||||
// load and crashes the entire route tree on tvOS. Load it lazily and only
|
|
||||||
// off-TV; both consumers also gate rendering on Platform.OS === "ios".
|
|
||||||
const { Host } = Platform.isTV
|
|
||||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
|
||||||
: require("@expo/ui/swift-ui");
|
|
||||||
|
|
||||||
type TriggerSize = { width: number; height: number };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measures and remembers the intrinsic size of a RN trigger view so the
|
|
||||||
* surrounding <Host> can be pinned to a concrete box.
|
|
||||||
*
|
|
||||||
* Returns `[size, handleLayout]` — pass `handleLayout` to a hidden,
|
|
||||||
* absolutely-positioned mirror of the trigger and use `size` as the
|
|
||||||
* wrapper's `style` once measured.
|
|
||||||
*/
|
|
||||||
export function useTriggerSize(): [
|
|
||||||
TriggerSize | null,
|
|
||||||
(e: LayoutChangeEvent) => void,
|
|
||||||
] {
|
|
||||||
const [size, setSize] = useState<TriggerSize | null>(null);
|
|
||||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
|
||||||
const { width, height } = e.nativeEvent.layout;
|
|
||||||
setSize((prev) =>
|
|
||||||
prev && prev.width === width && prev.height === height
|
|
||||||
? prev
|
|
||||||
: { width, height },
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
return [size, onLayout];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MeasuredTriggerHostProps {
|
|
||||||
trigger: React.ReactNode;
|
|
||||||
hostStyle?: any;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pins @expo/ui's <Host> to the trigger's measured size.
|
|
||||||
*
|
|
||||||
* @expo/ui's <Host> (SDK 55) fills its parent and reports its own size via
|
|
||||||
* `setStyleSize`, so it can't size itself to content. If the wrapper has no
|
|
||||||
* size, the Host's `flex: 1` height depends on the parent while the parent
|
|
||||||
* depends on the Host — a circular dependency that collapses to 0 for any
|
|
||||||
* dropdown nested more than one level deep (so only the first, shallowest
|
|
||||||
* dropdown on screen stays visible).
|
|
||||||
*
|
|
||||||
* Giving the wrapper the measured trigger size breaks the cycle; the Host
|
|
||||||
* then fills a concrete box.
|
|
||||||
*/
|
|
||||||
export const MeasuredTriggerHost: React.FC<MeasuredTriggerHostProps> = ({
|
|
||||||
trigger,
|
|
||||||
hostStyle,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const [size, handleMeasure] = useTriggerSize();
|
|
||||||
return (
|
|
||||||
<View style={size ?? { opacity: 0 }}>
|
|
||||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
|
||||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
|
||||||
sizes to the trigger's content rather than to its parent. */}
|
|
||||||
<View
|
|
||||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
|
||||||
pointerEvents='none'
|
|
||||||
aria-hidden
|
|
||||||
onLayout={handleMeasure}
|
|
||||||
>
|
|
||||||
{trigger}
|
|
||||||
</View>
|
|
||||||
<Host style={[StyleSheet.absoluteFill, hostStyle as any]}>
|
|
||||||
{children}
|
|
||||||
</Host>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Small pill switch used by Android sheet rows. */
|
|
||||||
export const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
|
||||||
<View
|
|
||||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rounded dark card with an optional title above it. Wraps a group's option
|
|
||||||
* rows in the Android bottom sheet.
|
|
||||||
*/
|
|
||||||
export const OptionGroupCard: React.FC<{
|
|
||||||
title?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ title, children }) => (
|
|
||||||
<View className='mb-6'>
|
|
||||||
{title && (
|
|
||||||
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{ borderRadius: 12, overflow: "hidden" }}
|
|
||||||
className='bg-neutral-800 rounded-xl overflow-hidden'
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
api,
|
api,
|
||||||
item: library,
|
item: library,
|
||||||
}),
|
}),
|
||||||
[library],
|
[api, library],
|
||||||
);
|
);
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
const itemType = useMemo(() => {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ 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";
|
||||||
@@ -10,10 +14,20 @@ 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 {
|
|
||||||
type OptionGroup,
|
// Subtitle scale presets (direct multiplier values)
|
||||||
PlayerSettingsPopover,
|
const SUBTITLE_SCALE_PRESETS = [
|
||||||
} from "./PlayerSettingsPopover";
|
{ label: "0.1x", value: 0.1 },
|
||||||
|
{ label: "0.25x", value: 0.25 },
|
||||||
|
{ label: "0.5x", value: 0.5 },
|
||||||
|
{ label: "0.75x", value: 0.75 },
|
||||||
|
{ label: "1.0x", value: 1.0 },
|
||||||
|
{ label: "1.25x", value: 1.25 },
|
||||||
|
{ label: "1.5x", value: 1.5 },
|
||||||
|
{ label: "2.0x", value: 2.0 },
|
||||||
|
{ label: "2.5x", value: 2.5 },
|
||||||
|
{ label: "3.0x", value: 3.0 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface DropdownViewProps {
|
interface DropdownViewProps {
|
||||||
playbackSpeed?: number;
|
playbackSpeed?: number;
|
||||||
@@ -88,7 +102,6 @@ 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,
|
||||||
@@ -100,18 +113,10 @@ const DropdownView = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subtitles section. iOS: tap the `...` opens a SwiftUI Popover with the
|
// Subtitle Section
|
||||||
// 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: [
|
|
||||||
{
|
|
||||||
type: "subgroup" as const,
|
|
||||||
label: "Track",
|
|
||||||
icon: "captions.bubble",
|
|
||||||
options: subtitleTracks.map((sub) => ({
|
options: subtitleTracks.map((sub) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: sub.name,
|
label: sub.name,
|
||||||
@@ -119,22 +124,18 @@ const DropdownView = ({
|
|||||||
selected: subtitleIndex === sub.index.toString(),
|
selected: subtitleIndex === sub.index.toString(),
|
||||||
onPress: () => sub.setTrack(),
|
onPress: () => sub.setTrack(),
|
||||||
})),
|
})),
|
||||||
},
|
});
|
||||||
{
|
|
||||||
type: "slider" as const,
|
// Subtitle Scale Section
|
||||||
label: "Size",
|
groups.push({
|
||||||
icon: "textformat.size",
|
title: "Subtitle Scale",
|
||||||
value: Math.round((settings.mpvSubtitleScale ?? 1.0) * 10) / 10,
|
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||||
step: 0.1,
|
type: "radio" as const,
|
||||||
min: 0.1,
|
label: preset.label,
|
||||||
max: 3.0,
|
value: preset.value.toString(),
|
||||||
format: (v: number) => `${v.toFixed(1)}x`,
|
selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value,
|
||||||
onValueChange: (value: number) =>
|
onPress: () => updateSettings({ mpvSubtitleScale: preset.value }),
|
||||||
updateSettings({
|
})),
|
||||||
mpvSubtitleScale: Math.round(value * 10) / 10,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +143,6 @@ 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,7 +157,6 @@ 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,
|
||||||
@@ -177,7 +176,6 @@ 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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -218,7 +216,7 @@ const DropdownView = ({
|
|||||||
if (Platform.isTV) return null;
|
if (Platform.isTV) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerSettingsPopover
|
<PlatformDropdown
|
||||||
title='Playback Options'
|
title='Playback Options'
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
|
|||||||
@@ -1,930 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import {
|
|
||||||
MeasuredTriggerHost,
|
|
||||||
OptionGroupCard,
|
|
||||||
ToggleSwitch,
|
|
||||||
} from "@/components/common/dropdownShared";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import type {
|
|
||||||
ActionOption as BaseActionOption,
|
|
||||||
RadioOption as BaseRadioOption,
|
|
||||||
ToggleOption as BaseToggleOption,
|
|
||||||
} from "@/components/PlatformDropdown";
|
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
||||||
|
|
||||||
// Player-only popover/sheet. Shares no rendering with `PlatformDropdown`:
|
|
||||||
// that component is used by ~20 callers (settings, season pickers,
|
|
||||||
// bitrate/audio/subtitle selectors, …) and must keep its small native
|
|
||||||
// Menu look. This one targets the in-player `...` button and is allowed to
|
|
||||||
// (a) host a real slider, (b) wear the Swift-mock visual style, and
|
|
||||||
// (c) carry SF Symbol icons per row.
|
|
||||||
//
|
|
||||||
// Common boilerplate (trigger measurement, ToggleSwitch, Android option-card
|
|
||||||
// shell) lives in @/components/common/dropdownShared and is reused with
|
|
||||||
// PlatformDropdown.
|
|
||||||
//
|
|
||||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
|
||||||
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
|
|
||||||
// load and crashes the entire route tree on tvOS (expo-router requires every
|
|
||||||
// route file). Load it lazily and only off-TV; TV never renders these.
|
|
||||||
const {
|
|
||||||
Button,
|
|
||||||
HStack,
|
|
||||||
Image: SwiftImage,
|
|
||||||
Menu,
|
|
||||||
Popover,
|
|
||||||
Rectangle: SwiftRectangle,
|
|
||||||
Slider: SwiftSlider,
|
|
||||||
Spacer,
|
|
||||||
Stepper,
|
|
||||||
Text: SwiftText,
|
|
||||||
Toggle: SwiftToggle,
|
|
||||||
VStack,
|
|
||||||
} = Platform.isTV
|
|
||||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
|
||||||
: require("@expo/ui/swift-ui");
|
|
||||||
const {
|
|
||||||
buttonStyle,
|
|
||||||
disabled,
|
|
||||||
font,
|
|
||||||
foregroundStyle,
|
|
||||||
frame,
|
|
||||||
opacity,
|
|
||||||
padding,
|
|
||||||
tint,
|
|
||||||
} = Platform.isTV
|
|
||||||
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
|
||||||
: require("@expo/ui/swift-ui/modifiers");
|
|
||||||
// Android-side Material 3 slider. Lives in @expo/ui/community/slider and is a
|
|
||||||
// drop-in for react-native-community/slider on Android (and SwiftUI Slider on
|
|
||||||
// iOS, but we use the swift-ui Slider directly inside the popover instead).
|
|
||||||
const { Slider: CommunitySlider } = Platform.isTV
|
|
||||||
? ({} as typeof import("@expo/ui/community/slider"))
|
|
||||||
: require("@expo/ui/community/slider");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Option model
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Reuses PlatformDropdown's three base option types (so the 20+ shared callers
|
|
||||||
// and the player popover stay in sync on shape), then adds:
|
|
||||||
// - `icon?: string` on every variant — SF Symbol shown in the iOS popover
|
|
||||||
// - Slider / Stepper / Subgroup variants for the player's extra controls
|
|
||||||
|
|
||||||
type WithIcon = { icon?: string };
|
|
||||||
|
|
||||||
export type RadioOption<T = any> = BaseRadioOption<T> & WithIcon;
|
|
||||||
export type ToggleOption = BaseToggleOption & WithIcon;
|
|
||||||
export type ActionOption = BaseActionOption & WithIcon;
|
|
||||||
|
|
||||||
export type StepperOption = {
|
|
||||||
type: "stepper";
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
step: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
onValueChange: (value: number) => void;
|
|
||||||
/** Optional value formatter for the displayed number. */
|
|
||||||
format?: (value: number) => string;
|
|
||||||
disabled?: boolean;
|
|
||||||
} & WithIcon;
|
|
||||||
|
|
||||||
export type SliderOption = {
|
|
||||||
type: "slider";
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
step: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
onValueChange: (value: number) => void;
|
|
||||||
/** Optional value formatter for the displayed number. */
|
|
||||||
format?: (value: number) => string;
|
|
||||||
disabled?: boolean;
|
|
||||||
} & WithIcon;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A row that itself opens a nested dropdown. On iOS this renders as a
|
|
||||||
* SwiftUI `Menu` inside the popover (label = subgroup name, value =
|
|
||||||
* currently-selected child); on Android the row expands inline to show its
|
|
||||||
* options when tapped (and collapses again on a second tap).
|
|
||||||
*/
|
|
||||||
export type SubgroupOption = {
|
|
||||||
type: "subgroup";
|
|
||||||
label: string;
|
|
||||||
options: Option[];
|
|
||||||
disabled?: boolean;
|
|
||||||
} & WithIcon;
|
|
||||||
|
|
||||||
export type Option =
|
|
||||||
| RadioOption
|
|
||||||
| ToggleOption
|
|
||||||
| ActionOption
|
|
||||||
| StepperOption
|
|
||||||
| SliderOption
|
|
||||||
| SubgroupOption;
|
|
||||||
|
|
||||||
export type OptionGroup = {
|
|
||||||
title?: string;
|
|
||||||
options: Option[];
|
|
||||||
/**
|
|
||||||
* Optional SF Symbol used for the group's row in the iOS popover when the
|
|
||||||
* entire group is compressed to a single Menu (e.g. radio-only groups).
|
|
||||||
*/
|
|
||||||
icon?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PlayerSettingsPopoverProps {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
title?: string;
|
|
||||||
groups: OptionGroup[];
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
onOptionSelect?: (value?: any) => void;
|
|
||||||
expoUIConfig?: {
|
|
||||||
hostStyle?: any;
|
|
||||||
};
|
|
||||||
bottomSheetConfig?: {
|
|
||||||
enableDynamicSizing?: boolean;
|
|
||||||
enablePanDownToClose?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Android bottom-sheet renderers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const StepperControl: React.FC<{
|
|
||||||
option: StepperOption;
|
|
||||||
}> = ({ option }) => {
|
|
||||||
const display = option.format
|
|
||||||
? option.format(option.value)
|
|
||||||
: option.value.toString();
|
|
||||||
const canDecrement = option.value > option.min;
|
|
||||||
const canIncrement = option.value < option.max;
|
|
||||||
|
|
||||||
const decrement = () => {
|
|
||||||
if (option.disabled) return;
|
|
||||||
const next = Math.max(option.min, option.value - option.step);
|
|
||||||
if (next !== option.value) option.onValueChange(next);
|
|
||||||
};
|
|
||||||
const increment = () => {
|
|
||||||
if (option.disabled) return;
|
|
||||||
const next = Math.min(option.max, option.value + option.step);
|
|
||||||
if (next !== option.value) option.onValueChange(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex flex-row items-center'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={decrement}
|
|
||||||
disabled={!canDecrement || option.disabled}
|
|
||||||
className={`w-8 h-8 bg-neutral-700 rounded-l-lg flex items-center justify-center ${!canDecrement || option.disabled ? "opacity-40" : ""}`}
|
|
||||||
>
|
|
||||||
<Text className='text-white'>-</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View className='h-8 px-3 bg-neutral-700 flex items-center justify-center'>
|
|
||||||
<Text className='text-white'>{display}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={increment}
|
|
||||||
disabled={!canIncrement || option.disabled}
|
|
||||||
className={`w-8 h-8 bg-neutral-700 rounded-r-lg flex items-center justify-center ${!canIncrement || option.disabled ? "opacity-40" : ""}`}
|
|
||||||
>
|
|
||||||
<Text className='text-white'>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android: full-width Material 3 slider inside the bottom sheet, with a
|
|
||||||
* label/value row above the track. The slider lives below the touch target so
|
|
||||||
* dragging it doesn't accidentally collapse the sheet.
|
|
||||||
*/
|
|
||||||
const SliderControl: React.FC<{
|
|
||||||
option: SliderOption;
|
|
||||||
}> = ({ option }) => {
|
|
||||||
const display = option.format
|
|
||||||
? option.format(option.value)
|
|
||||||
: option.value.toString();
|
|
||||||
return (
|
|
||||||
<View className='flex-1 px-4 py-3'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2'>
|
|
||||||
<Text className='text-white'>{option.label}</Text>
|
|
||||||
<Text className='text-neutral-400'>{display}</Text>
|
|
||||||
</View>
|
|
||||||
<CommunitySlider
|
|
||||||
value={option.value}
|
|
||||||
minimumValue={option.min}
|
|
||||||
maximumValue={option.max}
|
|
||||||
step={option.step}
|
|
||||||
onValueChange={option.onValueChange}
|
|
||||||
disabled={option.disabled}
|
|
||||||
style={{ width: "100%", height: 40 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
|
||||||
option,
|
|
||||||
isLast,
|
|
||||||
}) => {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
const isToggle = option.type === "toggle";
|
|
||||||
const isAction = option.type === "action";
|
|
||||||
const isStepper = option.type === "stepper";
|
|
||||||
const isSlider = option.type === "slider";
|
|
||||||
const isSubgroup = option.type === "subgroup";
|
|
||||||
|
|
||||||
if (isSlider) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SliderControl option={option} />
|
|
||||||
{!isLast && (
|
|
||||||
<View
|
|
||||||
style={{ height: StyleSheet.hairlineWidth }}
|
|
||||||
className='bg-neutral-700 mx-4'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePress = isToggle
|
|
||||||
? option.onToggle
|
|
||||||
: isSubgroup
|
|
||||||
? () => setExpanded((v) => !v)
|
|
||||||
: isStepper
|
|
||||||
? undefined
|
|
||||||
: (option as RadioOption | ActionOption).onPress;
|
|
||||||
|
|
||||||
const selectedChild = isSubgroup
|
|
||||||
? (option.options.find(
|
|
||||||
(o): o is RadioOption => o.type === "radio" && o.selected,
|
|
||||||
) ?? undefined)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePress}
|
|
||||||
disabled={option.disabled || isStepper}
|
|
||||||
activeOpacity={isStepper ? 1 : 0.2}
|
|
||||||
className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
|
|
||||||
>
|
|
||||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
|
||||||
{isToggle ? (
|
|
||||||
<ToggleSwitch value={option.value} />
|
|
||||||
) : isStepper ? (
|
|
||||||
<StepperControl option={option} />
|
|
||||||
) : isSubgroup ? (
|
|
||||||
<View className='flex flex-row items-center'>
|
|
||||||
{selectedChild && (
|
|
||||||
<Text className='text-neutral-400 mr-2'>
|
|
||||||
{selectedChild.label}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Ionicons
|
|
||||||
name={expanded ? "chevron-up" : "chevron-down"}
|
|
||||||
size={20}
|
|
||||||
color='#9ca3af'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : isAction ? null : (option as RadioOption).selected ? (
|
|
||||||
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
|
||||||
) : (
|
|
||||||
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{isSubgroup && expanded && (
|
|
||||||
<View className='pl-4 bg-neutral-900'>
|
|
||||||
{option.options.map((child, childIndex) => (
|
|
||||||
<OptionItem
|
|
||||||
key={childIndex}
|
|
||||||
option={child}
|
|
||||||
isLast={childIndex === option.options.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLast && (
|
|
||||||
<View
|
|
||||||
style={{ height: StyleSheet.hairlineWidth }}
|
|
||||||
className='bg-neutral-700 mx-4'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
|
||||||
<OptionGroupCard title={group.title}>
|
|
||||||
{group.options.map((option, index) => (
|
|
||||||
<OptionItem
|
|
||||||
key={index}
|
|
||||||
option={option}
|
|
||||||
isLast={index === group.options.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</OptionGroupCard>
|
|
||||||
);
|
|
||||||
|
|
||||||
const BottomSheetContent: React.FC<{
|
|
||||||
title?: string;
|
|
||||||
groups: OptionGroup[];
|
|
||||||
onOptionSelect?: (value?: any) => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}> = ({ title, groups, onOptionSelect, onClose }) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
// Recursively wrap options so radio/action presses also call
|
|
||||||
// onOptionSelect/onClose, including options nested inside subgroups.
|
|
||||||
const wrapOption = (option: Option): Option => {
|
|
||||||
if (option.type === "radio") {
|
|
||||||
return {
|
|
||||||
...option,
|
|
||||||
onPress: () => {
|
|
||||||
option.onPress();
|
|
||||||
onOptionSelect?.(option.value);
|
|
||||||
onClose?.();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (option.type === "toggle") {
|
|
||||||
return {
|
|
||||||
...option,
|
|
||||||
onToggle: () => {
|
|
||||||
option.onToggle();
|
|
||||||
onOptionSelect?.(option.value);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (option.type === "action") {
|
|
||||||
return {
|
|
||||||
...option,
|
|
||||||
onPress: () => {
|
|
||||||
option.onPress();
|
|
||||||
onClose?.();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (option.type === "subgroup") {
|
|
||||||
return { ...option, options: option.options.map(wrapOption) };
|
|
||||||
}
|
|
||||||
return option;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrappedGroups = groups.map((group) => ({
|
|
||||||
...group,
|
|
||||||
options: group.options.map(wrapOption),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomSheetScrollView
|
|
||||||
className='px-4 pb-8 pt-2'
|
|
||||||
style={{
|
|
||||||
paddingLeft: Math.max(16, insets.left),
|
|
||||||
paddingRight: Math.max(16, insets.right),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
|
|
||||||
{wrappedGroups.map((group, index) => (
|
|
||||||
<OptionGroupComponent key={index} group={group} />
|
|
||||||
))}
|
|
||||||
</BottomSheetScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Component
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const PlayerSettingsPopoverComponent = ({
|
|
||||||
trigger,
|
|
||||||
title,
|
|
||||||
groups,
|
|
||||||
open: controlledOpen,
|
|
||||||
onOpenChange: controlledOnOpenChange,
|
|
||||||
onOptionSelect,
|
|
||||||
expoUIConfig,
|
|
||||||
bottomSheetConfig,
|
|
||||||
}: PlayerSettingsPopoverProps) => {
|
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
|
||||||
|
|
||||||
// Android: controlled open routes through the global bottom-sheet modal.
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
|
||||||
showModal(
|
|
||||||
<BottomSheetContent
|
|
||||||
title={title}
|
|
||||||
groups={groups}
|
|
||||||
onOptionSelect={onOptionSelect}
|
|
||||||
onClose={() => {
|
|
||||||
hideModal();
|
|
||||||
controlledOnOpenChange?.(false);
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
{
|
|
||||||
snapPoints: ["90%"],
|
|
||||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [controlledOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
|
||||||
controlledOnOpenChange?.(false);
|
|
||||||
}
|
|
||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
|
||||||
|
|
||||||
// Internal open state for the iOS popover. Synced both ways with
|
|
||||||
// `controlledOpen` when controlled.
|
|
||||||
const [iosOpen, setIosOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.OS === "ios" && controlledOpen !== undefined) {
|
|
||||||
setIosOpen(controlledOpen);
|
|
||||||
}
|
|
||||||
}, [controlledOpen]);
|
|
||||||
|
|
||||||
const handleIosOpenChange = (value: boolean) => {
|
|
||||||
setIosOpen(value);
|
|
||||||
controlledOnOpenChange?.(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
|
||||||
const closePopover = () => handleIosOpenChange(false);
|
|
||||||
|
|
||||||
// ---- Swift-mock styled popover body ----
|
|
||||||
// Mirrors the reference Swift `PlayerSettingsViewController` design:
|
|
||||||
// - small-caps section headers with a hairline rule to the trailing edge
|
|
||||||
// - 44pt rows with leading SF Symbol, 15pt title, trailing value + glyph
|
|
||||||
// - real native Slider rows for slider options
|
|
||||||
// Radio-only titled groups (Quality/Audio/Speed) are compressed to a
|
|
||||||
// single Menu row whose label is a styled HStack — tapping opens the
|
|
||||||
// selection menu without changing the panel's height.
|
|
||||||
|
|
||||||
type IconName = string | undefined;
|
|
||||||
|
|
||||||
const MENU_CHEVRON = "chevron.up.chevron.down" as const;
|
|
||||||
const TERTIARY = {
|
|
||||||
type: "hierarchical" as const,
|
|
||||||
style: "tertiary" as const,
|
|
||||||
};
|
|
||||||
const SECONDARY = {
|
|
||||||
type: "hierarchical" as const,
|
|
||||||
style: "secondary" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 24pt-wide leading icon slot. Renders a transparent placeholder when
|
|
||||||
* no icon is set so titles stay aligned across rows. */
|
|
||||||
const renderIcon = (icon: IconName) => (
|
|
||||||
<SwiftImage
|
|
||||||
systemName={(icon ?? "circle") as any}
|
|
||||||
size={18}
|
|
||||||
modifiers={[
|
|
||||||
frame({ width: 24, alignment: "leading" }),
|
|
||||||
foregroundStyle(SECONDARY),
|
|
||||||
...(icon ? [] : [opacity(0)]),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Small-caps section header + thin separator that fills the row width. */
|
|
||||||
const renderSectionHeader = (sectionTitle: string, key: string) => (
|
|
||||||
<HStack
|
|
||||||
key={key}
|
|
||||||
spacing={10}
|
|
||||||
alignment='center'
|
|
||||||
modifiers={[frame({ height: 28 })]}
|
|
||||||
>
|
|
||||||
<SwiftText
|
|
||||||
modifiers={[
|
|
||||||
font({ size: 11, weight: "semibold" }),
|
|
||||||
foregroundStyle(TERTIARY),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{sectionTitle.toUpperCase()}
|
|
||||||
</SwiftText>
|
|
||||||
<SwiftRectangle
|
|
||||||
modifiers={[frame({ height: 1 }), foregroundStyle(TERTIARY)]}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Bare hairline used to close out a multi-row titled section. */
|
|
||||||
const renderDivider = (key: string) => (
|
|
||||||
<SwiftRectangle
|
|
||||||
key={key}
|
|
||||||
modifiers={[
|
|
||||||
frame({ height: 1 }),
|
|
||||||
foregroundStyle(TERTIARY),
|
|
||||||
padding({ vertical: 2 }),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Render menu-safe children (radio/action) inside a SwiftUI Menu. */
|
|
||||||
const renderMenuChild = (option: Option, key: string): any => {
|
|
||||||
if (option.type === "radio") {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
label={option.label}
|
|
||||||
systemImage={
|
|
||||||
(option.selected ? "checkmark.circle.fill" : "circle") as any
|
|
||||||
}
|
|
||||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
|
||||||
onPress={() => {
|
|
||||||
option.onPress();
|
|
||||||
onOptionSelect?.(option.value);
|
|
||||||
closePopover();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (option.type === "action") {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
label={option.label}
|
|
||||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
|
||||||
onPress={() => {
|
|
||||||
option.onPress();
|
|
||||||
closePopover();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Row that opens a SwiftUI Menu on tap. Used for compressed radio
|
|
||||||
* groups and for subgroup options inside a multi-row section. */
|
|
||||||
const renderMenuRow = ({
|
|
||||||
key,
|
|
||||||
icon,
|
|
||||||
title: rowTitle,
|
|
||||||
valueLabel,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
key: string;
|
|
||||||
icon: IconName;
|
|
||||||
title: string;
|
|
||||||
valueLabel?: string;
|
|
||||||
children: any;
|
|
||||||
}) => (
|
|
||||||
<Menu
|
|
||||||
key={key}
|
|
||||||
label={
|
|
||||||
<HStack
|
|
||||||
spacing={10}
|
|
||||||
alignment='center'
|
|
||||||
modifiers={[frame({ height: 44 })]}
|
|
||||||
>
|
|
||||||
{renderIcon(icon)}
|
|
||||||
<SwiftText modifiers={[font({ size: 15 })]}>{rowTitle}</SwiftText>
|
|
||||||
<Spacer />
|
|
||||||
{valueLabel ? (
|
|
||||||
<SwiftText
|
|
||||||
modifiers={[font({ size: 13 }), foregroundStyle(SECONDARY)]}
|
|
||||||
>
|
|
||||||
{valueLabel}
|
|
||||||
</SwiftText>
|
|
||||||
) : null}
|
|
||||||
<SwiftImage
|
|
||||||
systemName={MENU_CHEVRON as any}
|
|
||||||
size={12}
|
|
||||||
modifiers={[foregroundStyle(TERTIARY)]}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderSliderRow = (option: SliderOption, key: string) => {
|
|
||||||
const display = option.format
|
|
||||||
? option.format(option.value)
|
|
||||||
: option.value.toString();
|
|
||||||
return (
|
|
||||||
<HStack
|
|
||||||
key={key}
|
|
||||||
spacing={10}
|
|
||||||
alignment='center'
|
|
||||||
modifiers={[frame({ height: 44 })]}
|
|
||||||
>
|
|
||||||
{renderIcon(option.icon)}
|
|
||||||
<SwiftText
|
|
||||||
modifiers={[
|
|
||||||
font({ size: 15 }),
|
|
||||||
frame({ width: 64, alignment: "leading" }),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</SwiftText>
|
|
||||||
<SwiftSlider
|
|
||||||
value={option.value}
|
|
||||||
min={option.min}
|
|
||||||
max={option.max}
|
|
||||||
step={option.step}
|
|
||||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
|
||||||
onValueChange={option.onValueChange}
|
|
||||||
/>
|
|
||||||
<SwiftText
|
|
||||||
modifiers={[
|
|
||||||
font({ size: 13, design: "monospaced" }),
|
|
||||||
foregroundStyle(SECONDARY),
|
|
||||||
frame({ width: 44, alignment: "trailing" }),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{display}
|
|
||||||
</SwiftText>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderStepperRow = (option: StepperOption, key: string) => {
|
|
||||||
const display = option.format
|
|
||||||
? option.format(option.value)
|
|
||||||
: option.value.toString();
|
|
||||||
return (
|
|
||||||
<HStack
|
|
||||||
key={key}
|
|
||||||
spacing={10}
|
|
||||||
alignment='center'
|
|
||||||
modifiers={[frame({ height: 44 })]}
|
|
||||||
>
|
|
||||||
{renderIcon(option.icon)}
|
|
||||||
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
|
|
||||||
<Spacer />
|
|
||||||
<Stepper
|
|
||||||
label={display}
|
|
||||||
value={option.value}
|
|
||||||
step={option.step}
|
|
||||||
min={option.min}
|
|
||||||
max={option.max}
|
|
||||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
|
||||||
onValueChange={option.onValueChange}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderToggleRow = (option: ToggleOption, key: string) => (
|
|
||||||
<HStack
|
|
||||||
key={key}
|
|
||||||
spacing={10}
|
|
||||||
alignment='center'
|
|
||||||
modifiers={[frame({ height: 44 })]}
|
|
||||||
>
|
|
||||||
{renderIcon(option.icon)}
|
|
||||||
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
|
|
||||||
<Spacer />
|
|
||||||
<SwiftToggle
|
|
||||||
label=''
|
|
||||||
value={option.value}
|
|
||||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
|
||||||
onValueChange={() => {
|
|
||||||
option.onToggle();
|
|
||||||
onOptionSelect?.(option.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderActionRow = (option: ActionOption, key: string) => (
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
modifiers={[
|
|
||||||
buttonStyle("plain"),
|
|
||||||
...(option.disabled ? [disabled(true)] : []),
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
option.onPress();
|
|
||||||
closePopover();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HStack
|
|
||||||
spacing={10}
|
|
||||||
alignment='center'
|
|
||||||
modifiers={[frame({ height: 44 })]}
|
|
||||||
>
|
|
||||||
{renderIcon(option.icon)}
|
|
||||||
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
|
|
||||||
<Spacer />
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Render one Option as its own row inside a mixed (non-compressed)
|
|
||||||
* section. */
|
|
||||||
const renderOptionRow = (option: Option, key: string): any => {
|
|
||||||
if (option.type === "slider") return renderSliderRow(option, key);
|
|
||||||
if (option.type === "stepper") return renderStepperRow(option, key);
|
|
||||||
if (option.type === "toggle") return renderToggleRow(option, key);
|
|
||||||
if (option.type === "action") return renderActionRow(option, key);
|
|
||||||
if (option.type === "subgroup") {
|
|
||||||
const selectedChild = option.options.find(
|
|
||||||
(o): o is RadioOption => o.type === "radio" && o.selected,
|
|
||||||
);
|
|
||||||
return renderMenuRow({
|
|
||||||
key,
|
|
||||||
icon: option.icon,
|
|
||||||
title: option.label,
|
|
||||||
valueLabel: selectedChild?.label,
|
|
||||||
children: option.options.map((child, idx) =>
|
|
||||||
renderMenuChild(child, `${key}-c${idx}`),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (option.type === "radio") {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={key}
|
|
||||||
modifiers={[
|
|
||||||
buttonStyle("plain"),
|
|
||||||
...(option.disabled ? [disabled(true)] : []),
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
option.onPress();
|
|
||||||
onOptionSelect?.(option.value);
|
|
||||||
closePopover();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HStack
|
|
||||||
spacing={10}
|
|
||||||
alignment='center'
|
|
||||||
modifiers={[frame({ height: 44 })]}
|
|
||||||
>
|
|
||||||
{renderIcon(option.icon)}
|
|
||||||
<SwiftText modifiers={[font({ size: 15 })]}>
|
|
||||||
{option.label}
|
|
||||||
</SwiftText>
|
|
||||||
<Spacer />
|
|
||||||
{option.selected ? (
|
|
||||||
<SwiftImage
|
|
||||||
systemName={"checkmark" as any}
|
|
||||||
size={14}
|
|
||||||
modifiers={[foregroundStyle(SECONDARY)]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</HStack>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render an entire OptionGroup.
|
|
||||||
* - Titled group with only radio (or radio + action) options →
|
|
||||||
* compressed to a single Menu row.
|
|
||||||
* - Titled group containing slider/toggle/stepper/subgroup →
|
|
||||||
* section header + individual rows.
|
|
||||||
* - Untitled group → individual rows, no header.
|
|
||||||
*/
|
|
||||||
const renderGroup = (group: OptionGroup, groupIndex: number): any[] => {
|
|
||||||
if (group.options.length === 0) return [];
|
|
||||||
|
|
||||||
const onlyMenuSafe = group.options.every(
|
|
||||||
(o) => o.type === "radio" || o.type === "action",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (group.title && onlyMenuSafe) {
|
|
||||||
const selectedRadio = group.options.find(
|
|
||||||
(o): o is RadioOption => o.type === "radio" && o.selected,
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
renderMenuRow({
|
|
||||||
key: `group-${groupIndex}`,
|
|
||||||
icon: group.icon,
|
|
||||||
title: group.title,
|
|
||||||
valueLabel: selectedRadio?.label,
|
|
||||||
children: group.options.map((opt, idx) =>
|
|
||||||
renderMenuChild(opt, `g${groupIndex}-c${idx}`),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: any[] = [];
|
|
||||||
if (group.title) {
|
|
||||||
rows.push(renderSectionHeader(group.title, `header-${groupIndex}`));
|
|
||||||
}
|
|
||||||
group.options.forEach((opt, idx) => {
|
|
||||||
rows.push(renderOptionRow(opt, `g${groupIndex}-o${idx}`));
|
|
||||||
});
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MeasuredTriggerHost
|
|
||||||
trigger={trigger}
|
|
||||||
hostStyle={expoUIConfig?.hostStyle}
|
|
||||||
>
|
|
||||||
<Popover
|
|
||||||
isPresented={iosOpen}
|
|
||||||
onIsPresentedChange={handleIosOpenChange}
|
|
||||||
arrowEdge='top'
|
|
||||||
>
|
|
||||||
<Popover.Trigger>
|
|
||||||
{/* Wrap the RN trigger view in a SwiftUI Button so tap handling
|
|
||||||
is captured at the SwiftUI layer (matches the codebase
|
|
||||||
pattern in SearchTabButtons.tsx). */}
|
|
||||||
<Button
|
|
||||||
modifiers={[buttonStyle("plain")]}
|
|
||||||
onPress={() => handleIosOpenChange(true)}
|
|
||||||
>
|
|
||||||
{trigger}
|
|
||||||
</Button>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content>
|
|
||||||
{/* Bare VStack — no Form/List chrome — so the panel reads as
|
|
||||||
the Swift mock's floating glass card. The popover itself
|
|
||||||
supplies the material background; we just stack rows
|
|
||||||
inside. Width pinned to ~320pt; height >= 480pt. */}
|
|
||||||
<VStack
|
|
||||||
spacing={0}
|
|
||||||
alignment='leading'
|
|
||||||
modifiers={[
|
|
||||||
padding({ horizontal: 18, top: 12, bottom: 12 }),
|
|
||||||
frame({
|
|
||||||
minWidth: 300,
|
|
||||||
idealWidth: 320,
|
|
||||||
maxWidth: 360,
|
|
||||||
minHeight: 480,
|
|
||||||
idealHeight: 520,
|
|
||||||
}),
|
|
||||||
// Tint cascades to all child controls — Slider track, Menu
|
|
||||||
// checkmark, Stepper ± buttons, Toggle — so one modifier
|
|
||||||
// paints the whole popover white instead of system blue.
|
|
||||||
tint("white"),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{groups.flatMap((group, groupIndex) => {
|
|
||||||
const rows = renderGroup(group, groupIndex);
|
|
||||||
if (rows.length === 0) return [];
|
|
||||||
// After a multi-row titled section (Subtitles), append a
|
|
||||||
// bare hairline divider so it's clearly separated from
|
|
||||||
// the next group below.
|
|
||||||
const isMultiRow =
|
|
||||||
!!group.title &&
|
|
||||||
!group.options.every(
|
|
||||||
(o) => o.type === "radio" || o.type === "action",
|
|
||||||
);
|
|
||||||
const hasNext = groupIndex < groups.length - 1;
|
|
||||||
return isMultiRow && hasNext
|
|
||||||
? [...rows, renderDivider(`footer-${groupIndex}`)]
|
|
||||||
: rows;
|
|
||||||
})}
|
|
||||||
</VStack>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover>
|
|
||||||
</MeasuredTriggerHost>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android: open the bottom sheet directly on press (uncontrolled mode).
|
|
||||||
const handlePress = () => {
|
|
||||||
showModal(
|
|
||||||
<BottomSheetContent
|
|
||||||
title={title}
|
|
||||||
groups={groups}
|
|
||||||
onOptionSelect={onOptionSelect}
|
|
||||||
onClose={hideModal}
|
|
||||||
/>,
|
|
||||||
{
|
|
||||||
snapPoints: ["90%"],
|
|
||||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Memoize to prevent unnecessary re-renders when parent re-renders.
|
|
||||||
export const PlayerSettingsPopover = React.memo(
|
|
||||||
PlayerSettingsPopoverComponent,
|
|
||||||
(prevProps, nextProps) =>
|
|
||||||
prevProps.title === nextProps.title &&
|
|
||||||
prevProps.open === nextProps.open &&
|
|
||||||
prevProps.groups === nextProps.groups &&
|
|
||||||
prevProps.trigger === nextProps.trigger,
|
|
||||||
);
|
|
||||||
@@ -69,6 +69,13 @@ const initialApi = (() => {
|
|||||||
|
|
||||||
const initialUser = (() => {
|
const initialUser = (() => {
|
||||||
try {
|
try {
|
||||||
|
// Only return a stored user if we also have a token. Otherwise the
|
||||||
|
// user atom would be populated while the api atom is null (e.g. after
|
||||||
|
// a logout that left stale user JSON in storage), which causes
|
||||||
|
// useProtectedRoute to keep us inside the (auth) group instead of
|
||||||
|
// redirecting to /login.
|
||||||
|
const token = storage.getString("token");
|
||||||
|
if (!token) return null;
|
||||||
const userStr = storage.getString("user");
|
const userStr = storage.getString("user");
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
return JSON.parse(userStr) as UserDto;
|
return JSON.parse(userStr) as UserDto;
|
||||||
@@ -402,6 +409,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
storage.remove("token");
|
storage.remove("token");
|
||||||
|
storage.remove("user");
|
||||||
clearTVDiscoverySafely();
|
clearTVDiscoverySafely();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setApi(null);
|
setApi(null);
|
||||||
|
|||||||
@@ -456,6 +456,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
|
"new_app_version_requires_re_download_description": "Die neue App-Version erfordert das erneute Herunterladen von Filmen und Serien. Bitte lösche alle heruntergeladenen Elemente und starte den Download erneut.",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
"delete_download": "Download löschen",
|
||||||
"something_went_wrong": "Etwas ist schiefgelaufen",
|
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
|
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -498,6 +499,8 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Untertitel",
|
"subtitle": "Untertitel",
|
||||||
"play": "Abspielen",
|
"play": "Abspielen",
|
||||||
|
"mark_as_played": "Als gesehen markieren",
|
||||||
|
"mark_as_not_played": "Als ungesehen markieren",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"track": "Spur",
|
"track": "Spur",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
|||||||
@@ -534,6 +534,7 @@
|
|||||||
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"delete_download": "Delete Download",
|
||||||
"something_went_wrong": "Something Went Wrong",
|
"something_went_wrong": "Something Went Wrong",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
@@ -577,6 +578,8 @@
|
|||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitle": "Subtitle",
|
"subtitle": "Subtitle",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
|
"mark_as_played": "Mark as Played",
|
||||||
|
"mark_as_not_played": "Mark as not Played",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
Reference in New Issue
Block a user