Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Kim
e7cb31c685 fix(auth): clear stored user on logout to prevent empty home on relaunch
On logout, only the token was removed from MMKV while the user JSON was left behind. On next app launch, initialUser rehydrated userAtom from the stale storage while apiAtom was correctly null, so useProtectedRoute treated the session as authenticated and the user landed on an empty (auth)/(tabs)/(home) instead of /login.

- Remove "user" from storage in logoutMutation alongside "token".

- Make initialUser defensive: return null when no token is present so the user/api atoms stay consistent and any pre-existing stale state self-corrects on next launch.
2026-06-01 00:53:10 +10:00
5 changed files with 218 additions and 1220 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,13 @@ const initialApi = (() => {
const initialUser = (() => {
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");
if (userStr) {
return JSON.parse(userStr) as UserDto;
@@ -402,6 +409,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely();
setUser(null);
setApi(null);