mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 10:44:18 +01:00
Compare commits
96 Commits
renovate/p
...
fix/skip-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9e5dc35da | ||
|
|
5de017612d | ||
|
|
c00d2f050e | ||
|
|
ca9e9f1486 | ||
|
|
41e2dc3b81 | ||
|
|
9b2d289908 | ||
|
|
3cc4fd1a1e | ||
|
|
22739277b7 | ||
|
|
5623a5cddb | ||
|
|
e481f22e10 | ||
|
|
4487682412 | ||
|
|
994056d5f0 | ||
|
|
31cf9e8dff | ||
|
|
3a77ffc63c | ||
|
|
e6200a09ad | ||
|
|
fe36e2e0fc | ||
|
|
a1ba5d8c1d | ||
|
|
299d80ce03 | ||
|
|
1eaca76e97 | ||
|
|
9d9da134c0 | ||
|
|
c3275cda34 | ||
|
|
cd60c0ca07 | ||
|
|
6f6e3ed49f | ||
|
|
fd4215407d | ||
|
|
71b82a75ee | ||
|
|
e0cf1559bc | ||
|
|
610158efbd | ||
|
|
a2802b4570 | ||
|
|
9f08b51e1e | ||
|
|
06076b1a85 | ||
|
|
7d8014f686 | ||
|
|
4813fdab81 | ||
|
|
980245f19d | ||
|
|
d5d0976ff9 | ||
|
|
228e847a4e | ||
|
|
15c66bc714 | ||
|
|
c4209cbf8e | ||
|
|
fd504cef14 | ||
|
|
dbdf4767a5 | ||
|
|
1860863c60 | ||
|
|
69651cdfa3 | ||
|
|
25ee7dad85 | ||
|
|
34d9b43dcb | ||
|
|
039d7cfaec | ||
|
|
9cdfebe908 | ||
|
|
bee8323110 | ||
|
|
8b02cfcb65 | ||
|
|
3020bf5903 | ||
|
|
1f76119082 | ||
|
|
e0017b8092 | ||
|
|
4c5a3dc658 | ||
|
|
b39eb35795 | ||
|
|
c4b6390a5e | ||
|
|
3ff3847d80 | ||
|
|
8cf141e03b | ||
|
|
34f01e2c13 | ||
|
|
b7bdd99cdd | ||
|
|
6ba1111794 | ||
|
|
9fea9015eb | ||
|
|
500da94583 | ||
|
|
98dfefbdd0 | ||
|
|
fb8f11460b | ||
|
|
112d9bef96 | ||
|
|
692e02e42f | ||
|
|
49a903f48a | ||
|
|
8618e2d6c3 | ||
|
|
c5d39d40b8 | ||
|
|
fbeb025d73 | ||
|
|
64b9224b7a | ||
|
|
b59759638c | ||
|
|
8219e44b5b | ||
|
|
376ccd1f93 | ||
|
|
5221ed1963 | ||
|
|
461d055c7a | ||
|
|
58f3646477 | ||
|
|
33ca07ca82 | ||
|
|
e5d56d6ad1 | ||
|
|
80602ecd23 | ||
|
|
14ea18e0d2 | ||
|
|
28fcb303c6 | ||
|
|
d9677cbece | ||
|
|
90e9084949 | ||
|
|
115c163aeb | ||
|
|
a58a4da4f3 | ||
|
|
c02baf2831 | ||
|
|
3848877021 | ||
|
|
1f54ccc52c | ||
|
|
08efa1b0f7 | ||
|
|
90ea934548 | ||
|
|
1c158dea4e | ||
|
|
9a7b9c9de2 | ||
|
|
ceeacda7f9 | ||
|
|
b8780f34ec | ||
|
|
97ef9b5ee7 | ||
|
|
ff708ec832 | ||
|
|
d6980cfc8e |
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.54.1",
|
"version": "0.55.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
@@ -158,7 +157,6 @@ export default function IndexLayout() {
|
|||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => _router.back()}
|
onPress={() => _router.back()}
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
|||||||
@@ -324,32 +324,44 @@ export default function SettingsTV() {
|
|||||||
// MPV alignment options
|
// MPV alignment options
|
||||||
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: "Left", value: "left", selected: currentAlignX === "left" },
|
|
||||||
{
|
{
|
||||||
label: "Center",
|
label: t("home.settings.subtitles.align.left"),
|
||||||
|
value: "left",
|
||||||
|
selected: currentAlignX === "left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.align.center"),
|
||||||
value: "center",
|
value: "center",
|
||||||
selected: currentAlignX === "center",
|
selected: currentAlignX === "center",
|
||||||
},
|
},
|
||||||
{ label: "Right", value: "right", selected: currentAlignX === "right" },
|
{
|
||||||
|
label: t("home.settings.subtitles.align.right"),
|
||||||
|
value: "right",
|
||||||
|
selected: currentAlignX === "right",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[currentAlignX],
|
[currentAlignX, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: "Top", value: "top", selected: currentAlignY === "top" },
|
|
||||||
{
|
{
|
||||||
label: "Center",
|
label: t("home.settings.subtitles.align.top"),
|
||||||
|
value: "top",
|
||||||
|
selected: currentAlignY === "top",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.align.center"),
|
||||||
value: "center",
|
value: "center",
|
||||||
selected: currentAlignY === "center",
|
selected: currentAlignY === "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Bottom",
|
label: t("home.settings.subtitles.align.bottom"),
|
||||||
value: "bottom",
|
value: "bottom",
|
||||||
selected: currentAlignY === "bottom",
|
selected: currentAlignY === "bottom",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[currentAlignY],
|
[currentAlignY, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cache mode options
|
// Cache mode options
|
||||||
@@ -499,13 +511,13 @@ export default function SettingsTV() {
|
|||||||
|
|
||||||
const alignXLabel = useMemo(() => {
|
const alignXLabel = useMemo(() => {
|
||||||
const option = alignXOptions.find((o) => o.selected);
|
const option = alignXOptions.find((o) => o.selected);
|
||||||
return option?.label || "Center";
|
return option?.label || t("home.settings.subtitles.align.center");
|
||||||
}, [alignXOptions]);
|
}, [alignXOptions, t]);
|
||||||
|
|
||||||
const alignYLabel = useMemo(() => {
|
const alignYLabel = useMemo(() => {
|
||||||
const option = alignYOptions.find((o) => o.selected);
|
const option = alignYOptions.find((o) => o.selected);
|
||||||
return option?.label || "Bottom";
|
return option?.label || t("home.settings.subtitles.align.bottom");
|
||||||
}, [alignYOptions]);
|
}, [alignYOptions, t]);
|
||||||
|
|
||||||
const typographyScaleLabel = useMemo(() => {
|
const typographyScaleLabel = useMemo(() => {
|
||||||
const option = typographyScaleOptions.find((o) => o.selected);
|
const option = typographyScaleOptions.find((o) => o.selected);
|
||||||
@@ -645,7 +657,7 @@ export default function SettingsTV() {
|
|||||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
/>
|
/>
|
||||||
<TVSettingsStepper
|
<TVSettingsStepper
|
||||||
label='Vertical Margin'
|
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
onDecrease={() => {
|
onDecrease={() => {
|
||||||
const newValue = Math.max(
|
const newValue = Math.max(
|
||||||
@@ -663,11 +675,11 @@ export default function SettingsTV() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label='Horizontal Alignment'
|
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||||
value={alignXLabel}
|
value={alignXLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: "Horizontal Alignment",
|
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||||
options: alignXOptions,
|
options: alignXOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
@@ -677,11 +689,11 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TVSettingsOptionButton
|
<TVSettingsOptionButton
|
||||||
label='Vertical Alignment'
|
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||||
value={alignYLabel}
|
value={alignYLabel}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
showOptions({
|
showOptions({
|
||||||
title: "Vertical Alignment",
|
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
||||||
options: alignYOptions,
|
options: alignYOptions,
|
||||||
onSelect: (value) =>
|
onSelect: (value) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, Switch, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -50,12 +51,12 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
>
|
>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
className='px-4'
|
className='px-4 pt-4'
|
||||||
>
|
>
|
||||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
@@ -46,7 +47,7 @@ export default function HideLibrariesPage() {
|
|||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { File, Paths } from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import type * as SharingType from "expo-sharing";
|
import type * as SharingType from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
@@ -6,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -72,6 +75,25 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [filteredLogs, Sharing]);
|
}, [filteredLogs, Sharing]);
|
||||||
|
|
||||||
|
const copyLog = useCallback(
|
||||||
|
async (log: NonNullable<typeof logs>[number]) => {
|
||||||
|
// Skip on builds that don't ship the expo-clipboard native module
|
||||||
|
// (probe returns null instead of throwing); same guard as Quick Connect.
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
const text = [
|
||||||
|
`[${log.level}] ${new Date(log.timestamp).toLocaleString()}`,
|
||||||
|
log.message,
|
||||||
|
log.data ? JSON.stringify(log.data, null, 2) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
await Clipboard.setStringAsync(text);
|
||||||
|
toast.success(t("home.settings.logs.copied"));
|
||||||
|
},
|
||||||
|
[logs, t],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
@@ -88,8 +110,15 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-1'>
|
<ScrollView
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
// Like the sibling settings pages, let iOS auto-inset the content below the
|
||||||
|
// transparent header (no manual header-height math). The filter bar is a
|
||||||
|
// sticky header so it stays pinned just under the header while logs scroll.
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
stickyHeaderIndices={[0]}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||||
|
>
|
||||||
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2 bg-black'>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
queryKey='log'
|
queryKey='log'
|
||||||
@@ -98,7 +127,6 @@ export default function Page() {
|
|||||||
values={[order]}
|
values={[order]}
|
||||||
title={t("library.filters.sort_order")}
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
||||||
disableSearch={true}
|
|
||||||
/>
|
/>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={levelsFilterId}
|
id={levelsFilterId}
|
||||||
@@ -108,15 +136,10 @@ export default function Page() {
|
|||||||
values={levels}
|
values={levels}
|
||||||
title={t("home.settings.logs.level")}
|
title={t("home.settings.logs.level")}
|
||||||
renderItemLabel={(level) => level}
|
renderItemLabel={(level) => level}
|
||||||
disableSearch={true}
|
|
||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<ScrollView
|
<View className='flex flex-col space-y-2 px-4'>
|
||||||
className='pb-4 px-4'
|
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col space-y-2'>
|
|
||||||
{filteredLogs?.map((log, index) => (
|
{filteredLogs?.map((log, index) => (
|
||||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -143,26 +166,41 @@ export default function Page() {
|
|||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text selectable className='text-xs'>
|
<Text className='text-xs'>{log.message}</Text>
|
||||||
{log.message}
|
{/* Keep the whole collapsed row tappable: the hint lives inside
|
||||||
</Text>
|
the toggle so tapping it expands too. */}
|
||||||
</TouchableOpacity>
|
{log.data && !state[log.timestamp] && (
|
||||||
|
|
||||||
{log.data && (
|
|
||||||
<>
|
|
||||||
{!state[log.timestamp] && (
|
|
||||||
<Text className='text-xs mt-0.5'>
|
<Text className='text-xs mt-0.5'>
|
||||||
{t("home.settings.logs.click_for_more_info")}
|
{t("home.settings.logs.click_for_more_info")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{log.data && (
|
||||||
<Collapsible collapsed={!state[log.timestamp]}>
|
<Collapsible collapsed={!state[log.timestamp]}>
|
||||||
<View className='mt-2 flex flex-col space-y-2'>
|
<View className='mt-2 flex flex-col space-y-2'>
|
||||||
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
<ScrollView
|
||||||
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
className='rounded-xl'
|
||||||
|
style={codeBlockStyle}
|
||||||
|
nestedScrollEnabled
|
||||||
|
>
|
||||||
|
{/* Only the raw payload is selectable (per request); the
|
||||||
|
header/message stay tap-to-toggle. */}
|
||||||
|
<Text selectable>{JSON.stringify(log.data, null, 2)}</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => copyLog(log)}
|
||||||
|
className='flex flex-row items-center self-end px-2 py-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='copy-outline' size={16} color='white' />
|
||||||
|
<Text className='text-xs ml-1'>
|
||||||
|
{t("home.settings.logs.copy")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -173,6 +211,5 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.prefer_downloaded")}
|
title={t("home.settings.music.prefer_downloaded")}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.preferLocalAudio}
|
value={settings.preferLocalAudio}
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
|
|||||||
title={t("home.settings.music.lookahead_enabled")}
|
title={t("home.settings.music.lookahead_enabled")}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.audioLookaheadEnabled}
|
value={settings.audioLookaheadEnabled}
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<ListGroup>
|
<ListGroup className='mt-4'>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteDownloadedSongsClicked}
|
onPress={onDeleteDownloadedSongsClicked}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ export default function PlaybackControlsPage() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4 flex flex-col'
|
className='p-4 flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<View className='mb-4'>
|
<View>
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
<MediaToggles className='mb-4' />
|
<MediaToggles className='mb-4' />
|
||||||
<GestureControls className='mb-4' />
|
<GestureControls className='mb-4' />
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { ScrollView } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function JellyseerrPluginPage() {
|
export default function JellyseerrPluginPage() {
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting
|
<View className='p-4'>
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
|
||||||
className='p-4'
|
|
||||||
>
|
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { ScrollView } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function KefinTweaksPage() {
|
export default function KefinTweaksPage() {
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting
|
<View className='px-4'>
|
||||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
|
||||||
className='p-4'
|
|
||||||
>
|
|
||||||
<KefinTweaksSettings />
|
<KefinTweaksSettings />
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function MarlinSearchPage() {
|
export default function MarlinSearchPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
|
||||||
|
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
|
||||||
|
// Effective (user/admin merged) URL, same source the search screen uses —
|
||||||
|
// the raw plugin value misses a user-configured Streamystats.
|
||||||
|
const hasStreamystats = !!settings?.streamyStatsServerUrl;
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
const onSave = (val: string) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
@@ -41,15 +43,8 @@ export default function MarlinSearchPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
};
|
};
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
|
||||||
return (
|
|
||||||
pluginSettings?.searchEngine?.locked === true &&
|
|
||||||
pluginSettings?.marlinServerUrl?.locked === true
|
|
||||||
);
|
|
||||||
}, [pluginSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
if (!marlinUrlLocked) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||||
@@ -60,7 +55,7 @@ export default function MarlinSearchPage() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
}, [navigation, value, marlinUrlLocked, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -72,52 +67,42 @@ export default function MarlinSearchPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DisabledSetting disabled={disabled} className='px-4'>
|
<View className='px-4 pt-4'>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<DisabledSetting
|
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
||||||
disabled={
|
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
||||||
pluginSettings?.searchEngine?.locked === true ||
|
|
||||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
|
||||||
}
|
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
|
||||||
>
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
)}
|
)}
|
||||||
|
disabledByAdmin={searchEngineLocked}
|
||||||
|
// Streamystats owns the search engine while configured — block the
|
||||||
|
// row tap too, not just the Switch, so it can't force "Jellyfin".
|
||||||
|
disabled={hasStreamystats}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.searchEngine === "Marlin"}
|
value={settings.searchEngine === "Marlin"}
|
||||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
disabled={searchEngineLocked || hasStreamystats}
|
||||||
onValueChange={(value) => {
|
onValueChange={(val) => {
|
||||||
updateSettings({
|
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
||||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</DisabledSetting>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<DisabledSetting
|
<ListGroup className='mt-2'>
|
||||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
<ListItem
|
||||||
showText={!pluginSettings?.searchEngine?.locked}
|
title={t("home.settings.plugins.marlin_search.url")}
|
||||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
disabledByAdmin={marlinUrlLocked}
|
||||||
>
|
>
|
||||||
<View
|
|
||||||
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
|
||||||
>
|
|
||||||
<Text className='mr-4'>
|
|
||||||
{t("home.settings.plugins.marlin_search.url")}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
editable={settings.searchEngine === "Marlin"}
|
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
||||||
className='text-white'
|
className='text-white text-right flex-1'
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
@@ -128,15 +113,16 @@ export default function MarlinSearchPage() {
|
|||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={(text) => setValue(text)}
|
onChangeText={(text) => setValue(text)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</ListItem>
|
||||||
</DisabledSetting>
|
</ListGroup>
|
||||||
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
import { Platform, ScrollView, View } from "react-native";
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function PluginsPage() {
|
export default function PluginsPage() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { refreshStreamyfinPluginSettings } = useSettings();
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
// Returns undefined when the API call fails (handled internally).
|
||||||
|
const refreshed = await refreshStreamyfinPluginSettings();
|
||||||
|
if (refreshed) {
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
t("home.settings.plugins.streamystats.toasts.refresh_failed"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -18,6 +37,17 @@ export default function PluginsPage() {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
|
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
|
||||||
|
so it lives on the plugins index rather than inside Streamystats. */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Switch,
|
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const {
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
pluginSettings,
|
|
||||||
refreshStreamyfinPluginSettings,
|
|
||||||
} = useSettings();
|
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
|
|
||||||
// Local state for all editable fields
|
// Local state for all editable fields
|
||||||
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
const isStreamystatsEnabled = !!url;
|
const searchLocked = pluginSettings?.searchEngine?.locked === true;
|
||||||
|
const movieRecsLocked =
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
|
||||||
|
const seriesRecsLocked =
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
|
||||||
|
const promotedWatchlistsLocked =
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
|
||||||
|
const hideWatchlistsTabLocked =
|
||||||
|
pluginSettings?.hideWatchlistsTab?.locked === true;
|
||||||
|
// The input renders the locked admin URL; enablement must follow the same
|
||||||
|
// effective value or every toggle stays disabled until local state syncs.
|
||||||
|
const effectiveUrl = isUrlLocked
|
||||||
|
? (settings?.streamyStatsServerUrl ?? "")
|
||||||
|
: url;
|
||||||
|
const isStreamystatsEnabled = !!effectiveUrl;
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
|
||||||
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
|
||||||
// Update local state with new values
|
|
||||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
|
||||||
setUrl(newUrl);
|
|
||||||
if (newUrl) {
|
|
||||||
setUseForSearch(true);
|
|
||||||
}
|
|
||||||
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
|
||||||
}, [refreshStreamyfinPluginSettings, t]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,7 +132,7 @@ export default function StreamystatsPage() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='px-4'>
|
<View className='px-4 pt-4'>
|
||||||
<ListGroup className='flex-1'>
|
<ListGroup className='flex-1'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.url")}
|
title={t("home.settings.plugins.streamystats.url")}
|
||||||
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={url}
|
value={effectiveUrl}
|
||||||
keyboardType='url'
|
keyboardType='url'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
disabledByAdmin={searchLocked}
|
||||||
>
|
>
|
||||||
<Switch
|
{/* Locked controls show the live admin value and can't be toggled —
|
||||||
value={useForSearch}
|
local form state would let the switch flip while the write guard
|
||||||
disabled={!isStreamystatsEnabled}
|
drops the change. */}
|
||||||
|
<SettingSwitch
|
||||||
|
value={
|
||||||
|
searchLocked
|
||||||
|
? settings?.searchEngine === "Streamystats"
|
||||||
|
: useForSearch
|
||||||
|
}
|
||||||
|
disabled={!isStreamystatsEnabled || searchLocked}
|
||||||
onValueChange={setUseForSearch}
|
onValueChange={setUseForSearch}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
|
|||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={movieRecsLocked}
|
||||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={movieRecs}
|
value={
|
||||||
|
movieRecsLocked
|
||||||
|
? (settings?.streamyStatsMovieRecommendations ?? false)
|
||||||
|
: movieRecs
|
||||||
|
}
|
||||||
onValueChange={setMovieRecs}
|
onValueChange={setMovieRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={seriesRecsLocked}
|
||||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={seriesRecs}
|
value={
|
||||||
|
seriesRecsLocked
|
||||||
|
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
||||||
|
: seriesRecs
|
||||||
|
}
|
||||||
onValueChange={setSeriesRecs}
|
onValueChange={setSeriesRecs}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
)}
|
)}
|
||||||
disabledByAdmin={
|
disabledByAdmin={promotedWatchlistsLocked}
|
||||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={promotedWatchlists}
|
value={
|
||||||
|
promotedWatchlistsLocked
|
||||||
|
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
||||||
|
: promotedWatchlists
|
||||||
|
}
|
||||||
onValueChange={setPromotedWatchlists}
|
onValueChange={setPromotedWatchlists}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
disabledByAdmin={hideWatchlistsTabLocked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={hideWatchlistsTab}
|
value={
|
||||||
|
hideWatchlistsTabLocked
|
||||||
|
? (settings?.hideWatchlistsTab ?? false)
|
||||||
|
: hideWatchlistsTab
|
||||||
|
}
|
||||||
onValueChange={setHideWatchlistsTab}
|
onValueChange={setHideWatchlistsTab}
|
||||||
disabled={!isStreamystatsEnabled}
|
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
|
|||||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleRefreshFromServer}
|
|
||||||
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
|
||||||
>
|
|
||||||
<Text className='text-center text-blue-500'>
|
|
||||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||||
{!isUrlLocked && isStreamystatsEnabled && (
|
{!isUrlLocked && isStreamystatsEnabled && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -349,9 +349,6 @@ const page: React.FC = () => {
|
|||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title={t("library.filters.genres")}
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -376,7 +373,6 @@ const page: React.FC = () => {
|
|||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title={t("library.filters.years")}
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -401,9 +397,6 @@ const page: React.FC = () => {
|
|||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title={t("library.filters.tags")}
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -421,9 +414,6 @@ const page: React.FC = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -441,9 +431,6 @@ const page: React.FC = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -568,9 +568,6 @@ const Page = () => {
|
|||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title={t("library.filters.genres")}
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -595,7 +592,6 @@ const Page = () => {
|
|||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title={t("library.filters.years")}
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -620,9 +616,6 @@ const Page = () => {
|
|||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title={t("library.filters.tags")}
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -640,9 +633,6 @@ const Page = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -660,9 +650,6 @@ const Page = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -680,9 +667,6 @@ const Page = () => {
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
generalFilters.find((i) => i.key === item)?.value || ""
|
generalFilters.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
searchFilter={(item, search) =>
|
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
Missing music library id.
|
{t("music.missing_library_id")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
Missing music library id.
|
{t("music.missing_library_id")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
|||||||
return (
|
return (
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
<Text className='text-neutral-500 text-center'>
|
<Text className='text-neutral-500 text-center'>
|
||||||
Missing music library id.
|
{t("music.missing_library_id")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
@@ -72,6 +73,7 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
|||||||
type ViewMode = "player" | "queue";
|
type ViewMode = "player" | "queue";
|
||||||
|
|
||||||
export default function NowPlayingScreen() {
|
export default function NowPlayingScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
|
|||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-neutral-500'>No track playing</Text>
|
<Text className='text-neutral-500'>
|
||||||
|
{t("music.no_track_playing")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
);
|
);
|
||||||
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
|
|||||||
: "text-neutral-500"
|
: "text-neutral-500"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Now Playing
|
{t("music.now_playing")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -718,6 +722,7 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onReorderQueue,
|
onReorderQueue,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const renderQueueItem = useCallback(
|
const renderQueueItem = useCallback(
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
const index = getIndex() ?? 0;
|
const index = getIndex() ?? 0;
|
||||||
@@ -831,13 +836,15 @@ const QueueView: React.FC<QueueViewProps> = ({
|
|||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View className='px-4 py-2'>
|
<View className='px-4 py-2'>
|
||||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
{history.length > 0 ? "Playing from queue" : "Up next"}
|
{history.length > 0
|
||||||
|
? t("music.playing_from_queue")
|
||||||
|
: t("music.up_next")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className='flex-1 items-center justify-center py-20'>
|
<View className='flex-1 items-center justify-center py-20'>
|
||||||
<Text className='text-neutral-500'>Queue is empty</Text>
|
<Text className='text-neutral-500'>{t("music.queue_empty")}</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import { Controls } from "@/components/video-player/controls/Controls";
|
|||||||
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
|
||||||
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||||
|
import {
|
||||||
|
LOCAL_SUBTITLE_INDEX_START,
|
||||||
|
toServerSubtitleIndex,
|
||||||
|
} from "@/components/video-player/controls/types";
|
||||||
import {
|
import {
|
||||||
PlaybackSpeedScope,
|
PlaybackSpeedScope,
|
||||||
updatePlaybackSpeedSettings,
|
updatePlaybackSpeedSettings,
|
||||||
@@ -50,15 +54,16 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useInactivity } from "@/providers/InactivityProvider";
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import {
|
import {
|
||||||
|
applyMpvSubtitleSelection,
|
||||||
|
getExternalSubtitleUrl,
|
||||||
getMpvAudioId,
|
getMpvAudioId,
|
||||||
getMpvSubtitleId,
|
isImageBasedSubtitle,
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
@@ -657,32 +662,20 @@ export default function DirectPlayerPage() {
|
|||||||
const mediaSource = stream.mediaSource;
|
const mediaSource = stream.mediaSource;
|
||||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||||
|
|
||||||
// Get external subtitle URLs
|
// Get external subtitle URLs — getExternalSubtitleUrl is the shared source
|
||||||
// - Online: prepend API base path to server URLs
|
// of truth with identity matching (online: basePath + DeliveryUrl unless
|
||||||
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
// IsExternalUrl; offline: local file path stored in DeliveryUrl).
|
||||||
let externalSubs: string[] | undefined;
|
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||||
if (!offline && api?.basePath) {
|
(s) => s.Type === "Subtitle" && s.DeliveryMethod === "External",
|
||||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
)
|
||||||
(s) =>
|
.map((s) =>
|
||||||
s.Type === "Subtitle" &&
|
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||||
s.DeliveryMethod === "External" &&
|
)
|
||||||
s.DeliveryUrl,
|
.filter((u): u is string => !!u);
|
||||||
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
|
|
||||||
} else if (offline) {
|
|
||||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
|
||||||
(s) =>
|
|
||||||
s.Type === "Subtitle" &&
|
|
||||||
s.DeliveryMethod === "External" &&
|
|
||||||
s.DeliveryUrl,
|
|
||||||
).map((s) => s.DeliveryUrl!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate track IDs for initial selection
|
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
||||||
const initialSubtitleId = getMpvSubtitleId(
|
// subtitles). The subtitle selection is applied later, once MPV's real track
|
||||||
mediaSource,
|
// list is known — see applySubtitleSelection / onTracksReady.
|
||||||
subtitleIndex,
|
|
||||||
isTranscoding,
|
|
||||||
);
|
|
||||||
const initialAudioId = getMpvAudioId(
|
const initialAudioId = getMpvAudioId(
|
||||||
mediaSource,
|
mediaSource,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
@@ -700,7 +693,6 @@ export default function DirectPlayerPage() {
|
|||||||
url: stream.url,
|
url: stream.url,
|
||||||
startPosition: startPos,
|
startPosition: startPos,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
initialSubtitleId,
|
|
||||||
initialAudioId,
|
initialAudioId,
|
||||||
// Pass cache/buffer settings from user preferences
|
// Pass cache/buffer settings from user preferences
|
||||||
cacheConfig: {
|
cacheConfig: {
|
||||||
@@ -748,7 +740,6 @@ export default function DirectPlayerPage() {
|
|||||||
playbackPositionFromUrl,
|
playbackPositionFromUrl,
|
||||||
api?.basePath,
|
api?.basePath,
|
||||||
api?.accessToken,
|
api?.accessToken,
|
||||||
subtitleIndex,
|
|
||||||
audioIndex,
|
audioIndex,
|
||||||
offline,
|
offline,
|
||||||
settings.mpvCacheEnabled,
|
settings.mpvCacheEnabled,
|
||||||
@@ -938,7 +929,9 @@ export default function DirectPlayerPage() {
|
|||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item?.Id ?? "",
|
itemId: item?.Id ?? "",
|
||||||
audioIndex: String(index),
|
audioIndex: String(index),
|
||||||
subtitleIndex: String(currentSubtitleIndex),
|
// A local (client-downloaded) sub only exists in the dying mpv
|
||||||
|
// instance — the server must be asked for "none" (-1) instead.
|
||||||
|
subtitleIndex: String(toServerSubtitleIndex(currentSubtitleIndex)),
|
||||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrateValue?.toString() ?? "",
|
bitrateValue: bitrateValue?.toString() ?? "",
|
||||||
playbackPosition: msToTicks(progress.get()).toString(),
|
playbackPosition: msToTicks(progress.get()).toString(),
|
||||||
@@ -974,30 +967,103 @@ export default function DirectPlayerPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// TV subtitle track change handler
|
// TV subtitle track change handler
|
||||||
const handleSubtitleIndexChange = useCallback(
|
/**
|
||||||
async (index: number) => {
|
* Resolve a Jellyfin subtitle index against MPV's *real* track list and apply
|
||||||
setCurrentSubtitleIndex(index);
|
* it. Identity-based (external by filename, embedded by language/title) so it
|
||||||
|
* stays correct across external/embedded reordering and server-hidden embedded
|
||||||
// Check if we're transcoding
|
* subs — unlike positional mapping. Reused for initial selection (onTracksReady,
|
||||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
* fired again after each external sub-add) and runtime changes.
|
||||||
|
*/
|
||||||
if (index === -1) {
|
const applySubtitleSelection = useCallback(
|
||||||
// Disable subtitles
|
async (jellyfinSubtitleIndex: number) => {
|
||||||
await videoRef.current?.disableSubtitles?.();
|
const subtitleStreams = stream?.mediaSource?.MediaStreams?.filter(
|
||||||
} else {
|
(s) => s.Type === "Subtitle",
|
||||||
// Convert Jellyfin index to MPV track ID
|
);
|
||||||
const mpvTrackId = getMpvSubtitleId(
|
return applyMpvSubtitleSelection(videoRef.current, {
|
||||||
stream?.mediaSource,
|
subtitleStreams,
|
||||||
index,
|
jellyfinSubtitleIndex,
|
||||||
isTranscoding,
|
getExpectedExternalUrl: (s) =>
|
||||||
|
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[stream?.mediaSource, offline, api?.basePath],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
|
// Re-negotiate the stream with new track params (server re-processes it,
|
||||||
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
|
// e.g. to burn an image sub in or out). Same-item mirror of VideoContext's
|
||||||
|
// replacePlayer, resuming at the live position.
|
||||||
|
const replaceWithTrackSelection = useCallback(
|
||||||
|
(params: { subtitleIndex?: string; audioIndex?: string }) => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item?.Id ?? "",
|
||||||
|
audioIndex: params.audioIndex ?? String(currentAudioIndex ?? ""),
|
||||||
|
subtitleIndex:
|
||||||
|
params.subtitleIndex ??
|
||||||
|
String(toServerSubtitleIndex(currentSubtitleIndex)),
|
||||||
|
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrateValue?.toString() ?? "",
|
||||||
|
playbackPosition: msToTicks(progress.get()).toString(),
|
||||||
|
}).toString();
|
||||||
|
// Destroy the current mpv instance before re-navigating, same rationale as
|
||||||
|
// goToNextItem: Expo Router briefly holds two players during the
|
||||||
|
// transition and two decoders/surfaces OOM-kill low-RAM devices.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
item?.Id,
|
||||||
|
currentAudioIndex,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
stream?.mediaSource?.Id,
|
||||||
|
bitrateValue,
|
||||||
|
router,
|
||||||
|
progress,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV/mobile subtitle track change handler
|
||||||
|
const handleSubtitleIndexChange = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
|
// Local (client-downloaded) subs are loaded via addSubtitleFile, not
|
||||||
|
// resolvable against server streams — just track the live index.
|
||||||
|
if (index <= LOCAL_SUBTITLE_INDEX_START) {
|
||||||
|
setCurrentSubtitleIndex(index);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subs = stream?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
);
|
||||||
|
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||||
|
const target = subs?.find((s) => s.Index === index);
|
||||||
|
const current = subs?.find((s) => s.Index === currentSubtitleIndex);
|
||||||
|
// Burned-in subs are pixels, not tracks: switching TO one and switching
|
||||||
|
// AWAY from an active one both need a server re-process (same guard as
|
||||||
|
// VideoContext's needsReplace on the mobile menu path).
|
||||||
|
const needsReplace =
|
||||||
|
isTranscoding &&
|
||||||
|
((target && isImageBasedSubtitle(target)) ||
|
||||||
|
(current && isImageBasedSubtitle(current)));
|
||||||
|
if (needsReplace) {
|
||||||
|
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentSubtitleIndex(index);
|
||||||
|
const result = await applySubtitleSelection(index);
|
||||||
|
// Safety net: a menu-listed sub the player can't select (server-burned
|
||||||
|
// Encode, sidecar never sub-added) needs the server to re-process the
|
||||||
|
// stream with it.
|
||||||
|
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||||
|
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[stream?.mediaSource],
|
[
|
||||||
|
applySubtitleSelection,
|
||||||
|
replaceWithTrackSelection,
|
||||||
|
stream?.mediaSource,
|
||||||
|
currentSubtitleIndex,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Technical info toggle handler
|
// Technical info toggle handler
|
||||||
@@ -1116,6 +1182,10 @@ export default function DirectPlayerPage() {
|
|||||||
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
|
// Free the current mpv instance before navigating, matching goToNextItem —
|
||||||
|
// otherwise two decoders/surfaces overlap during the transition and can
|
||||||
|
// OOM-kill low-RAM devices.
|
||||||
|
videoRef.current?.destroy().catch(() => {});
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
}, [
|
}, [
|
||||||
previousItem,
|
previousItem,
|
||||||
@@ -1128,9 +1198,24 @@ export default function DirectPlayerPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||||
const addSubtitleFile = useCallback(async (path: string) => {
|
const addSubtitleFile = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
// Set the live index to the new local sub's REAL index BEFORE the add.
|
||||||
|
// Local subs are keyed LOCAL_SUBTITLE_INDEX_START - position, so use the
|
||||||
|
// downloaded path's position (not a blanket sentinel, which would collide
|
||||||
|
// with the first local sub at -100 and mis-record the selection). Any
|
||||||
|
// local index resolves to notFound on the onTracksReady re-apply, so it
|
||||||
|
// still doesn't clobber the freshly selected track; carry-over now keeps
|
||||||
|
// the correct local sub.
|
||||||
|
const locals = itemId ? getSubtitlesForItem(itemId) : [];
|
||||||
|
const pos = locals.findIndex((s) => s.filePath === path);
|
||||||
|
setCurrentSubtitleIndex(
|
||||||
|
LOCAL_SUBTITLE_INDEX_START - (pos >= 0 ? pos : 0),
|
||||||
|
);
|
||||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||||
}, []);
|
},
|
||||||
|
[itemId],
|
||||||
|
);
|
||||||
|
|
||||||
// TV: Refresh subtitle tracks after server-side subtitle download
|
// TV: Refresh subtitle tracks after server-side subtitle download
|
||||||
// Re-fetches the media source to pick up newly downloaded subtitles
|
// Re-fetches the media source to pick up newly downloaded subtitles
|
||||||
@@ -1362,6 +1447,10 @@ export default function DirectPlayerPage() {
|
|||||||
}}
|
}}
|
||||||
onTracksReady={() => {
|
onTracksReady={() => {
|
||||||
setTracksReady(true);
|
setTracksReady(true);
|
||||||
|
// Fired after embedded tracks enumerate and again after each
|
||||||
|
// external sub-add; re-resolve so the final fire (full track
|
||||||
|
// list) selects the right track by identity.
|
||||||
|
void applySubtitleSelection(currentSubtitleIndex);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!hasPlaybackStarted && (
|
{!hasPlaybackStarted && (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
Easing,
|
Easing,
|
||||||
|
InteractionManager,
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -192,6 +193,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -328,7 +330,7 @@ const SubtitleResultCard = React.forwardRef<
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||||
Hash Match
|
{t("player.hash_match")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -645,10 +647,23 @@ export default function TVSubtitleModal() {
|
|||||||
|
|
||||||
const handleTrackSelect = useCallback(
|
const handleTrackSelect = useCallback(
|
||||||
(option: { setTrack?: () => void }) => {
|
(option: { setTrack?: () => void }) => {
|
||||||
|
if (modalState?.deferApplyUntilDismissed) {
|
||||||
|
// Player: setTrack can navigate (replacePlayer for a burn-in switch
|
||||||
|
// while transcoding); a router.replace fired while this modal is the
|
||||||
|
// active route targets the MODAL and is swallowed. Close FIRST, apply
|
||||||
|
// after dismissal.
|
||||||
|
handleClose();
|
||||||
|
InteractionManager.runAfterInteractions(() => option.setTrack?.());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Detail page: setTrack only updates state. Run it BEFORE closing so the
|
||||||
|
// re-render happens while the modal is up; deferring it until after
|
||||||
|
// dismissal re-renders the detail page after focus returns and yanks TV
|
||||||
|
// focus, leaving navigation stuck.
|
||||||
option.setTrack?.();
|
option.setTrack?.();
|
||||||
handleClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
[handleClose],
|
[handleClose, modalState?.deferApplyUntilDismissed],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDownload = useCallback(
|
const handleDownload = useCallback(
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: t("home.oops") }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
<ThemedText type='title'>{t("not_found.title")}</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Image } from "expo-image";
|
|||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
|
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
@@ -27,6 +28,7 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
|||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
|
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
@@ -425,6 +427,7 @@ function Layout() {
|
|||||||
>
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<InactivityProvider>
|
<InactivityProvider>
|
||||||
|
<WifiSsidProvider>
|
||||||
<ServerUrlProvider>
|
<ServerUrlProvider>
|
||||||
<NetworkStatusProvider>
|
<NetworkStatusProvider>
|
||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
@@ -467,7 +470,8 @@ function Layout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
title: "",
|
title: "",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent:
|
||||||
|
Platform.OS === "ios",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name='+not-found' />
|
<Stack.Screen name='+not-found' />
|
||||||
@@ -551,6 +555,9 @@ function Layout() {
|
|||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
{!Platform.isTV && <GlobalModal />}
|
{!Platform.isTV && <GlobalModal />}
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<PendingAccountSaveModal />
|
||||||
|
)}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
@@ -562,6 +569,7 @@ function Layout() {
|
|||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
</NetworkStatusProvider>
|
</NetworkStatusProvider>
|
||||||
</ServerUrlProvider>
|
</ServerUrlProvider>
|
||||||
|
</WifiSsidProvider>
|
||||||
</InactivityProvider>
|
</InactivityProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
|
|||||||
16
bun.lock
16
bun.lock
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "1.2.0",
|
"@bottom-tabs/react-navigation": "1.4.0",
|
||||||
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
||||||
"@expo/metro-runtime": "~56.0.15",
|
"@expo/metro-runtime": "~56.0.15",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
|
"expo-clipboard": "~56.0.4",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"expo-font": "~56.0.6",
|
"expo-font": "~56.0.6",
|
||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
"expo-image": "~56.0.11",
|
"expo-image": "~56.0.11",
|
||||||
|
"expo-key-event": "^1.9.0",
|
||||||
"expo-linear-gradient": "~56.0.4",
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-localization": "~56.0.6",
|
"expo-localization": "~56.0.6",
|
||||||
@@ -62,7 +64,7 @@
|
|||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-native": "npm:react-native-tvos@0.85.3-0",
|
"react-native": "npm:react-native-tvos@0.85.3-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.2.0",
|
"react-native-bottom-tabs": "1.4.0",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
@@ -285,7 +287,7 @@
|
|||||||
|
|
||||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
|
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
|
||||||
|
|
||||||
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.2.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-gEnLP7q9Iai0KlVxHDIdlrDgkvJ5vwPzL2+2ucz5BdPWd++Cf5GO1jPq92R4/85PrioviCZnlAD91Wx8WxPOjA=="],
|
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@1.4.0", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-EeOCrecZ4YiVmFASKtoLyQFFFBjbmkV6FBD4Oxyjs9aSexuX4jYbC8yapfnevqyMnYINz0qYTvpUEILciNL+nQ=="],
|
||||||
|
|
||||||
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
|
"@dominicstop/ts-event-emitter": ["@dominicstop/ts-event-emitter@1.1.0", "", {}, "sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw=="],
|
||||||
|
|
||||||
@@ -1001,6 +1003,8 @@
|
|||||||
|
|
||||||
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
"expo-camera": ["expo-camera@56.0.8", "", { "dependencies": { "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ=="],
|
||||||
|
|
||||||
|
"expo-clipboard": ["expo-clipboard@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-qb4DYlkiowHYHaUYVT2FN9nk/nI1xShXOUYsI7J9dVpQCOHcGFjCBPX1VAvEW4Ye4/Aagd6IuhOVAq/+scBOiA=="],
|
||||||
|
|
||||||
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
"expo-constants": ["expo-constants@56.0.18", "", { "dependencies": { "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-8AMtbDGl/WVPnWlmbpGmvcdnNCy9E4PFnwdVwj600vljkMDPSxcAcjw8GVXEPk3PpZ+ngTqsrkltWyj0UKYAxw=="],
|
||||||
|
|
||||||
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
"expo-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||||
@@ -1031,6 +1035,8 @@
|
|||||||
|
|
||||||
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
|
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
|
||||||
|
|
||||||
|
"expo-key-event": ["expo-key-event@1.9.0", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZV/+iAz/0WeACFG4IR+KOTtm/7PTIsl+GaD/PYBM89PhvueQ6zYuvjPb5A47sWX2WjDunzVN0R1rOd/HIjA8aA=="],
|
||||||
|
|
||||||
"expo-linear-gradient": ["expo-linear-gradient@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw=="],
|
"expo-linear-gradient": ["expo-linear-gradient@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw=="],
|
||||||
|
|
||||||
"expo-linking": ["expo-linking@56.0.14", "", { "dependencies": { "expo-constants": "~56.0.18", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ=="],
|
"expo-linking": ["expo-linking@56.0.14", "", { "dependencies": { "expo-constants": "~56.0.18", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-IvVQHWC+Cj4fK5qD3iEVYqpU2a4rLW0IpAAlGJ4MH+H1fyZiHh3eN6qg2WmoclOEPfYATSuEa+dQT6wfgVpXlQ=="],
|
||||||
@@ -1589,7 +1595,7 @@
|
|||||||
|
|
||||||
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
|
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
|
||||||
|
|
||||||
"react-native-bottom-tabs": ["react-native-bottom-tabs@1.2.0", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ScVPko86ts+m6JMNtI24MCSYJCOZc1aZkn9qwS9ly3o0ubajRWDpCzgRJfRFysi08bKrcqAXKVCHZNHvNb2PTA=="],
|
"react-native-bottom-tabs": ["react-native-bottom-tabs@1.4.0", "", { "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-XGuKHGU2FH1j/Ii7HzFE0+6ejiLs4mrcBRQRJff2g94hRjDZnx+trB+cOFG6z1BHGPLv6CD833S0m+nQuZB9nA=="],
|
||||||
|
|
||||||
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
|
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
|
||||||
|
|
||||||
@@ -1641,7 +1647,7 @@
|
|||||||
|
|
||||||
"react-native-text-ticker": ["react-native-text-ticker@1.16.0", "", {}, "sha512-UHZcIrXzNQ3CnGSA6d5foR9weqSCEk5w+eJGnUf5OQkKbuq/xtt1ZaNVnsgcG5+3f9EnRrNCt52/a46LXVKGGw=="],
|
"react-native-text-ticker": ["react-native-text-ticker@1.16.0", "", {}, "sha512-UHZcIrXzNQ3CnGSA6d5foR9weqSCEk5w+eJGnUf5OQkKbuq/xtt1ZaNVnsgcG5+3f9EnRrNCt52/a46LXVKGGw=="],
|
||||||
|
|
||||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
|
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
||||||
|
|
||||||
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { FilterSheet } from "./filters/FilterSheet";
|
|
||||||
|
|
||||||
export type Bitrate = {
|
|
||||||
key: string;
|
|
||||||
value: number | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BITRATES: Bitrate[] = [
|
|
||||||
{
|
|
||||||
key: "Max",
|
|
||||||
value: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "8 Mb/s",
|
|
||||||
value: 8000000,
|
|
||||||
height: 1080,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "4 Mb/s",
|
|
||||||
value: 4000000,
|
|
||||||
height: 1080,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "2 Mb/s",
|
|
||||||
value: 2000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "1 Mb/s",
|
|
||||||
value: 1000000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "500 Kb/s",
|
|
||||||
value: 500000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "250 Kb/s",
|
|
||||||
value: 250000,
|
|
||||||
},
|
|
||||||
].sort(
|
|
||||||
(a, b) =>
|
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
|
||||||
onChange: (value: Bitrate) => void;
|
|
||||||
selected?: Bitrate | null;
|
|
||||||
inverted?: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BitrateSheet: React.FC<Props> = ({
|
|
||||||
onChange,
|
|
||||||
selected,
|
|
||||||
inverted,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
|
||||||
if (inverted)
|
|
||||||
return BITRATES.slice().sort(
|
|
||||||
(a, b) =>
|
|
||||||
(a.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(b.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
return BITRATES.slice().sort(
|
|
||||||
(a, b) =>
|
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
|
||||||
(a.value || Number.POSITIVE_INFINITY),
|
|
||||||
);
|
|
||||||
}, [inverted]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex shrink'
|
|
||||||
style={{
|
|
||||||
minWidth: 60,
|
|
||||||
maxWidth: 200,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col' {...props}>
|
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.quality")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
|
||||||
onPress={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<Text numberOfLines={1}>
|
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FilterSheet
|
|
||||||
open={open}
|
|
||||||
setOpen={setOpen}
|
|
||||||
title={t("item_card.quality")}
|
|
||||||
data={sorted}
|
|
||||||
values={selected ? [selected] : []}
|
|
||||||
multiple={false}
|
|
||||||
searchFilter={(item, query) => {
|
|
||||||
const label = (item as any).key || "";
|
|
||||||
return label.toLowerCase().includes(query.toLowerCase());
|
|
||||||
}}
|
|
||||||
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
|
||||||
set={(vals) => {
|
|
||||||
const chosen = vals[0] as Bitrate | undefined;
|
|
||||||
if (chosen) onChange(chosen);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -40,7 +40,10 @@ import {
|
|||||||
TVSeriesNavigation,
|
TVSeriesNavigation,
|
||||||
TVTechnicalDetails,
|
TVTechnicalDetails,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import type { Track } from "@/components/video-player/controls/types";
|
import {
|
||||||
|
LOCAL_SUBTITLE_INDEX_START,
|
||||||
|
type Track,
|
||||||
|
} from "@/components/video-player/controls/types";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
@@ -56,6 +59,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
@@ -232,12 +236,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
return streams ?? [];
|
return streams ?? [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Get available subtitle tracks (raw MediaStream[] for label lookup)
|
// Get available subtitle tracks (raw MediaStream[] for label lookup),
|
||||||
|
// ordered like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
(s) => s.Type === "Subtitle",
|
(s) => s.Type === "Subtitle",
|
||||||
);
|
);
|
||||||
return streams ?? [];
|
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
||||||
}, [selectedOptions?.mediaSource]);
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
// Store handleSubtitleChange in a ref for stable callback reference
|
// Store handleSubtitleChange in a ref for stable callback reference
|
||||||
@@ -248,9 +253,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
// State to trigger refresh of local subtitles list
|
// State to trigger refresh of local subtitles list
|
||||||
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
const [localSubtitlesRefreshKey, setLocalSubtitlesRefreshKey] = useState(0);
|
||||||
|
|
||||||
// Starting index for local (client-downloaded) subtitles
|
|
||||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
|
||||||
|
|
||||||
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||||
// Also includes locally downloaded subtitles from OpenSubtitles
|
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||||
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)
|
)
|
||||||
: freshItem.MediaSources?.[0];
|
: freshItem.MediaSources?.[0];
|
||||||
|
|
||||||
// Get subtitle streams from the fresh data
|
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
||||||
const streams =
|
// (embedded first, externals last) — same as the initial list.
|
||||||
mediaSource?.MediaStreams?.filter(
|
const streams = [
|
||||||
|
...(mediaSource?.MediaStreams?.filter(
|
||||||
(s: MediaStream) => s.Type === "Subtitle",
|
(s: MediaStream) => s.Type === "Subtitle",
|
||||||
) ?? [];
|
) ?? []),
|
||||||
|
].sort(compareTracksForMenu);
|
||||||
|
|
||||||
// Convert to Track[] with setTrack callbacks
|
// Convert to Track[] with setTrack callbacks
|
||||||
const tracks: Track[] = streams.map((stream) => ({
|
const tracks: Track[] = streams.map((stream) => ({
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { BITRATES } from "./BitRateSheet";
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
|
import { BITRATES } from "./BitrateSelector";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
[
|
||||||
|
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
(x) => x.Type === "Subtitle",
|
(x) => x.Type === "Subtitle",
|
||||||
) || [],
|
) || []),
|
||||||
|
].sort(compareTracksForMenu),
|
||||||
[selectedOptions.mediaSource],
|
[selectedOptions.mediaSource],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { FilterSheet } from "./filters/FilterSheet";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
|
||||||
item: BaseItemDto;
|
|
||||||
onChange: (value: MediaSourceInfo) => void;
|
|
||||||
selected?: MediaSourceInfo | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MediaSourceSheet: React.FC<Props> = ({
|
|
||||||
item,
|
|
||||||
onChange,
|
|
||||||
selected,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
|
||||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
|
||||||
if (source.Name) return source.Name;
|
|
||||||
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
|
||||||
return `Source ${source.Id}`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectedName = useMemo(() => {
|
|
||||||
if (!selected) return "";
|
|
||||||
return getDisplayName(selected);
|
|
||||||
}, [selected, getDisplayName]);
|
|
||||||
|
|
||||||
if (isTv || (item.MediaSources && item.MediaSources.length <= 1)) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex shrink' style={{ minWidth: 75 }}>
|
|
||||||
<View className='flex flex-col' {...props}>
|
|
||||||
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
|
||||||
onPress={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FilterSheet
|
|
||||||
open={open}
|
|
||||||
setOpen={setOpen}
|
|
||||||
title={t("item_card.video")}
|
|
||||||
data={item.MediaSources || []}
|
|
||||||
values={selected ? [selected] : []}
|
|
||||||
multiple={false}
|
|
||||||
searchFilter={(src, query) =>
|
|
||||||
getDisplayName(src as MediaSourceInfo)
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(query.toLowerCase())
|
|
||||||
}
|
|
||||||
renderItemLabel={(src) => (
|
|
||||||
<Text>{getDisplayName(src as MediaSourceInfo)}</Text>
|
|
||||||
)}
|
|
||||||
set={(vals) => {
|
|
||||||
const chosen = vals[0] as MediaSourceInfo | undefined;
|
|
||||||
if (chosen) onChange(chosen);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
52
components/PendingAccountSaveModal.tsx
Normal file
52
components/PendingAccountSaveModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
|
import {
|
||||||
|
pendingAccountSaveAtom,
|
||||||
|
useJellyfin,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import { writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-login save-account prompt. Login flows (password or Quick Connect)
|
||||||
|
* only flag the intent via pendingAccountSaveAtom; the protection picker
|
||||||
|
* shows here, AFTER the session is authorized — the login screen itself
|
||||||
|
* unmounts as soon as the user is set, so it can't host the modal.
|
||||||
|
*/
|
||||||
|
export const PendingAccountSaveModal: React.FC = () => {
|
||||||
|
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { saveCurrentAccount } = useJellyfin();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// A logout before answering drops the intent — it must not resurface on
|
||||||
|
// the next (possibly different) login.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user && pending) setPending(null);
|
||||||
|
}, [user, pending, setPending]);
|
||||||
|
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={!!pending && !!user}
|
||||||
|
username={user?.Name ?? ""}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
onSave={(securityType, pinCode) => {
|
||||||
|
const serverName = pending?.serverName;
|
||||||
|
setPending(null);
|
||||||
|
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
|
||||||
|
(error) => {
|
||||||
|
writeErrorLog(`Failed to save account: ${error?.message ?? error}`);
|
||||||
|
toast.error(t("save_account.not_saved"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -209,6 +210,7 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
{trigger || <Text className='text-white'>{t("common.open_menu")}</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
accessibilityLabel='Play button'
|
accessibilityLabel={t("accessibility.play_button")}
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint={t("accessibility.play_hint")}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative flex-1"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -36,6 +37,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
colors,
|
colors,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
// Use colors prop if provided, otherwise fallback to global atom
|
||||||
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityLabel='Play button'
|
accessibilityLabel={t("accessibility.play_button")}
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint={t("accessibility.play_hint")}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
FlatList,
|
FlatList,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -31,6 +32,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||||
|
const { t } = useTranslation();
|
||||||
const handlePlayInSession = async (sessionId: string) => {
|
const handlePlayInSession = async (sessionId: string) => {
|
||||||
if (!api || !item.Id) return;
|
if (!api || !item.Id) return;
|
||||||
|
|
||||||
@@ -65,7 +67,9 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
<View style={styles.centeredView}>
|
<View style={styles.centeredView}>
|
||||||
<View style={styles.modalView}>
|
<View style={styles.modalView}>
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>Select Session</Text>
|
<Text style={styles.modalTitle}>
|
||||||
|
{t("home.sessions.select_session")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||||
<Ionicons name='close' size={24} color='white' />
|
<Ionicons name='close' size={24} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -78,7 +82,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
) : !sessions || sessions.length === 0 ? (
|
) : !sessions || sessions.length === 0 ? (
|
||||||
<Text style={styles.noSessionsText}>
|
<Text style={styles.noSessionsText}>
|
||||||
No active sessions found
|
{t("home.sessions.no_active_sessions")}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -98,7 +102,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{session.NowPlayingItem && (
|
{session.NowPlayingItem && (
|
||||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||||
Now playing:{" "}
|
{t("home.sessions.now_playing")}{" "}
|
||||||
{session.NowPlayingItem.SeriesName
|
{session.NowPlayingItem.SeriesName
|
||||||
? `${session.NowPlayingItem.SeriesName} :`
|
? `${session.NowPlayingItem.SeriesName} :`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
const subs = source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
|
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
return subs ? [...subs].sort(compareTracksForMenu) : subs;
|
||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||||
@@ -23,7 +23,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
className='flex items-center justify-center w-9 h-9'
|
className='flex items-center justify-center w-9 h-9'
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
>
|
>
|
||||||
<Ionicons name='arrow-back' size={24} color='white' />
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,10 +36,10 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
intensity={100}
|
intensity={100}
|
||||||
className='overflow-hidden rounded-full p-2'
|
className='overflow-hidden rounded-full p-2'
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Feather
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='arrow-back'
|
name='chevron-left'
|
||||||
size={24}
|
size={28}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
@@ -49,13 +49,18 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className=' rounded-full p-2'
|
// Match the Settings page back button: chevron flush to the edge with a
|
||||||
|
// 16px gap before the title (the old `p-2` pushed both arrow and title
|
||||||
|
// too far right). drop-shadow keeps it readable over images. hitSlop
|
||||||
|
// keeps the touch area at ~44dp now that the padding is gone.
|
||||||
|
style={{ marginRight: 16 }}
|
||||||
{...pressableProps}
|
{...pressableProps}
|
||||||
|
hitSlop={8}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Feather
|
||||||
className='drop-shadow-2xl'
|
className='drop-shadow-2xl'
|
||||||
name='arrow-back'
|
name='chevron-left'
|
||||||
size={24}
|
size={28}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
40
components/common/SettingSwitch.tsx
Normal file
40
components/common/SettingSwitch.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Platform, Switch, type SwitchProps, View } from "react-native";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings toggle. Android's native Switch lays out ~40px tall / ~56px wide and
|
||||||
|
* inflates list rows (iOS renders it ~31px). A plain `transform: scale` is
|
||||||
|
* visual-only and does NOT shrink the layout box, so we pin the Switch inside a
|
||||||
|
* FIXED-SIZE box (overflow hidden) and center it:
|
||||||
|
* - the fixed height caps the row height (compact, uniform rows),
|
||||||
|
* - the fixed width + centering keep the switch in the exact same spot in the
|
||||||
|
* on/off states (a non-fixed wrapper let its width fluctuate between states,
|
||||||
|
* which shifted the switch sideways on toggle).
|
||||||
|
* iOS renders the switch untouched.
|
||||||
|
*
|
||||||
|
* Tunables: BOX_H drives the row height; SCALE shrinks the visual to fit the
|
||||||
|
* box; keep BOX_W >= scaled visual width to avoid clipping the switch sideways.
|
||||||
|
*/
|
||||||
|
const BOX_W = 40;
|
||||||
|
const BOX_H = 30;
|
||||||
|
const SCALE = 0.9;
|
||||||
|
|
||||||
|
export const SettingSwitch: React.FC<SwitchProps> = (props) => {
|
||||||
|
if (Platform.OS !== "android") return <Switch {...props} />;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: BOX_W,
|
||||||
|
height: BOX_H,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
{...props}
|
||||||
|
style={[props.style, { transform: [{ scale: SCALE }] }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { t } from "i18next";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -35,6 +35,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { cancelDownload } = useDownload();
|
const { cancelDownload } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useNetworkAwareQueryClient();
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
@@ -173,7 +174,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
{isTranscoding && (
|
{isTranscoding && (
|
||||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
<Text className='text-xs text-purple-400'>
|
||||||
|
{t("home.downloads.transcoding")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
import { FilterSheetContent } from "./FilterSheetContent";
|
||||||
|
|
||||||
interface FilterButtonProps<T> extends ViewProps {
|
interface FilterButtonProps<T> extends ViewProps {
|
||||||
id: string;
|
id: string;
|
||||||
disableSearch?: boolean;
|
|
||||||
queryKey: string;
|
queryKey: string;
|
||||||
values: T[];
|
values: T[];
|
||||||
title: string;
|
title: string;
|
||||||
set: (value: T[]) => void;
|
set: (value: T[]) => void;
|
||||||
queryFn: (params: any) => Promise<any>;
|
queryFn: (params: any) => Promise<any>;
|
||||||
searchFilter?: (item: T, query: string) => boolean;
|
renderItemLabel: (item: T) => string;
|
||||||
renderItemLabel: (item: T) => React.ReactNode;
|
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
icon?: "filter" | "sort";
|
icon?: "filter" | "sort";
|
||||||
}
|
}
|
||||||
@@ -27,13 +25,11 @@ export const FilterButton = <T,>({
|
|||||||
values, // selected values
|
values, // selected values
|
||||||
title,
|
title,
|
||||||
renderItemLabel,
|
renderItemLabel,
|
||||||
searchFilter,
|
|
||||||
disableSearch = false,
|
|
||||||
multiple = false,
|
multiple = false,
|
||||||
icon = "filter",
|
icon = "filter",
|
||||||
...props
|
...props
|
||||||
}: FilterButtonProps<T>) => {
|
}: FilterButtonProps<T>) => {
|
||||||
const [open, setOpen] = useState(false);
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
const { data: filters } = useQuery<T[]>({
|
const { data: filters } = useQuery<T[]>({
|
||||||
queryKey: ["filters", title, queryKey, id],
|
queryKey: ["filters", title, queryKey, id],
|
||||||
@@ -42,13 +38,24 @@ export const FilterButton = <T,>({
|
|||||||
enabled: !!id && !!queryFn && !!queryKey,
|
enabled: !!id && !!queryFn && !!queryKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openSheet = () => {
|
||||||
|
if (!filters?.length) return;
|
||||||
|
showModal(
|
||||||
|
<FilterSheetContent<T>
|
||||||
|
title={title}
|
||||||
|
data={filters}
|
||||||
|
initialValues={values}
|
||||||
|
set={set}
|
||||||
|
renderItemLabel={renderItemLabel}
|
||||||
|
multiple={multiple}
|
||||||
|
onClose={hideModal}
|
||||||
|
/>,
|
||||||
|
{ snapPoints: ["85%"] },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TouchableOpacity onPress={openSheet}>
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
filters?.length && setOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||||
@@ -57,7 +64,7 @@ export const FilterButton = <T,>({
|
|||||||
? "bg-purple-600 border border-purple-700"
|
? "bg-purple-600 border border-purple-700"
|
||||||
: "bg-neutral-900 border border-neutral-900"
|
: "bg-neutral-900 border border-neutral-900"
|
||||||
}
|
}
|
||||||
${filters?.length === 0 && "opacity-50"}
|
${filters?.length === 0 ? "opacity-50" : ""}
|
||||||
`}
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -85,18 +92,5 @@ export const FilterButton = <T,>({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<FilterSheet<T>
|
|
||||||
title={title}
|
|
||||||
open={open}
|
|
||||||
setOpen={setOpen}
|
|
||||||
data={filters}
|
|
||||||
values={values}
|
|
||||||
set={set}
|
|
||||||
renderItemLabel={renderItemLabel}
|
|
||||||
searchFilter={searchFilter}
|
|
||||||
disableSearch={disableSearch}
|
|
||||||
multiple={multiple}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,257 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { isEqual } from "lodash";
|
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Input } from "../common/Input";
|
|
||||||
|
|
||||||
interface Props<T> extends ViewProps {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
data?: T[] | null;
|
|
||||||
values: T[];
|
|
||||||
set: (value: T[]) => void;
|
|
||||||
title: string;
|
|
||||||
searchFilter?: (item: T, query: string) => boolean;
|
|
||||||
renderItemLabel: (item: T) => React.ReactNode;
|
|
||||||
disableSearch?: boolean;
|
|
||||||
multiple?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIMIT = 100;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FilterSheet Component
|
|
||||||
*
|
|
||||||
* This component creates a bottom sheet modal for filtering and selecting items from a list.
|
|
||||||
*
|
|
||||||
* @template T - The type of items in the list
|
|
||||||
*
|
|
||||||
* @param {Object} props - The component props
|
|
||||||
* @param {boolean} props.open - Whether the bottom sheet is open
|
|
||||||
* @param {function} props.setOpen - Function to set the open state
|
|
||||||
* @param {T[] | null} [props.data] - The full list of items to filter from
|
|
||||||
* @param {T[]} props.values - The currently selected items
|
|
||||||
* @param {function} props.set - Function to update the selected items
|
|
||||||
* @param {string} props.title - The title of the bottom sheet
|
|
||||||
* @param {function} props.searchFilter - Function to filter items based on search query
|
|
||||||
* @param {function} props.renderItemLabel - Function to render the label for each item
|
|
||||||
* @param {boolean} [props.disableSearch=false] - Whether to disable the search input
|
|
||||||
*
|
|
||||||
* @returns {React.ReactElement} The FilterSheet component
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Displays a list of items in a bottom sheet
|
|
||||||
* - Allows searching and filtering of items
|
|
||||||
* - Supports single selection of items
|
|
||||||
* - Loads items in batches for performance optimization
|
|
||||||
* - Customizable item rendering
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const FilterSheet = <T,>({
|
|
||||||
values,
|
|
||||||
data: _data,
|
|
||||||
open,
|
|
||||||
set,
|
|
||||||
setOpen,
|
|
||||||
title,
|
|
||||||
searchFilter,
|
|
||||||
renderItemLabel,
|
|
||||||
disableSearch = false,
|
|
||||||
multiple = false,
|
|
||||||
}: Props<T>) => {
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
const snapPoints = useMemo(() => ["85%"], []);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const [data, setData] = useState<T[]>([]);
|
|
||||||
const [offset, setOffset] = useState<number>(0);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState<string>("");
|
|
||||||
|
|
||||||
const [showSearch, setShowSearch] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
if (!search) return _data;
|
|
||||||
const results = [];
|
|
||||||
for (let i = 0; i < (_data?.length || 0); i++) {
|
|
||||||
if (_data && searchFilter?.(_data[i], search)) {
|
|
||||||
results.push(_data[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results.slice(0, 100);
|
|
||||||
}, [search, _data, searchFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!data || data.length === 0 || disableSearch) return;
|
|
||||||
if (data.length > 15) {
|
|
||||||
setShowSearch(true);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
// Loads data in batches of LIMIT size, starting from offset,
|
|
||||||
// to implement efficient "load more" functionality
|
|
||||||
useEffect(() => {
|
|
||||||
if (!_data || _data.length === 0) return;
|
|
||||||
|
|
||||||
const newData = [...data];
|
|
||||||
|
|
||||||
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
|
||||||
const item = _data[i];
|
|
||||||
// Check if this item already exists in our data array
|
|
||||||
// some dups happened with re-renders during testing
|
|
||||||
const exists = newData.some((existingItem) =>
|
|
||||||
isEqual(existingItem, item),
|
|
||||||
);
|
|
||||||
if (!exists) {
|
|
||||||
newData.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(newData);
|
|
||||||
}, [offset, _data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) bottomSheetModalRef.current?.present();
|
|
||||||
else bottomSheetModalRef.current?.dismiss();
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderData = useMemo(() => {
|
|
||||||
if (search.length > 0 && showSearch) return filteredData;
|
|
||||||
return data;
|
|
||||||
}, [search, filteredData, data]);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
index={0}
|
|
||||||
snapPoints={snapPoints}
|
|
||||||
onChange={handleSheetChanges}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
style={{}}
|
|
||||||
>
|
|
||||||
<BottomSheetScrollView
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='mt-2 mb-8'
|
|
||||||
style={{
|
|
||||||
paddingLeft: Math.max(16, insets.left),
|
|
||||||
paddingRight: Math.max(16, insets.right),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='font-bold text-2xl'>{title}</Text>
|
|
||||||
<Text className='mb-2 text-neutral-500'>
|
|
||||||
{t("search.x_items", { count: _data?.length })}
|
|
||||||
</Text>
|
|
||||||
{showSearch && (
|
|
||||||
<Input
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
className='my-2 border-neutral-800 border'
|
|
||||||
value={search}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
setSearch(text);
|
|
||||||
}}
|
|
||||||
returnKeyType='done'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
borderRadius: 20,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
className='mb-4 flex flex-col rounded-xl overflow-hidden'
|
|
||||||
>
|
|
||||||
{renderData?.map((item, index) => (
|
|
||||||
<View key={index}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (multiple) {
|
|
||||||
if (!values.includes(item)) set(values.concat(item));
|
|
||||||
else set(values.filter((v) => v !== item));
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen(false);
|
|
||||||
}, 250);
|
|
||||||
} else {
|
|
||||||
if (!values.includes(item)) {
|
|
||||||
set([item]);
|
|
||||||
setTimeout(() => {
|
|
||||||
setOpen(false);
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
|
||||||
>
|
|
||||||
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
|
||||||
{values.some((i) => isEqual(i, item)) ? (
|
|
||||||
<Ionicons name='radio-button-on' size={24} color='white' />
|
|
||||||
) : (
|
|
||||||
<Ionicons name='radio-button-off' size={24} color='white' />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='h-1 divide-neutral-700 '
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
{data.length < (_data?.length || 0) && (
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
setOffset(offset + LIMIT);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Load more
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</BottomSheetScrollView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
134
components/filters/FilterSheetContent.tsx
Normal file
134
components/filters/FilterSheetContent.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||||
|
import { isEqual } from "lodash";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
title: string;
|
||||||
|
data: T[];
|
||||||
|
initialValues: T[];
|
||||||
|
set: (value: T[]) => void;
|
||||||
|
renderItemLabel: (item: T) => string;
|
||||||
|
multiple?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_THRESHOLD = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sheet content for FilterButton, rendered inside the GlobalModal bottom
|
||||||
|
* sheet. The GlobalModal stores its content as a static snapshot, so this
|
||||||
|
* component owns the selection state locally and mirrors changes back to the
|
||||||
|
* caller through `set`.
|
||||||
|
*
|
||||||
|
* Uses a virtualized BottomSheetFlatList — filter lists (genres, tags, years)
|
||||||
|
* can contain thousands of entries.
|
||||||
|
*/
|
||||||
|
export const FilterSheetContent = <T,>({
|
||||||
|
title,
|
||||||
|
data,
|
||||||
|
initialValues,
|
||||||
|
set,
|
||||||
|
renderItemLabel,
|
||||||
|
multiple = false,
|
||||||
|
onClose,
|
||||||
|
}: Props<T>) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [values, setValues] = useState<T[]>(initialValues);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const showSearch = data.length > SEARCH_THRESHOLD;
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!search || !showSearch) return data;
|
||||||
|
const query = search.toLowerCase();
|
||||||
|
return data.filter((item) =>
|
||||||
|
renderItemLabel(item).toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
}, [data, search, showSearch, renderItemLabel]);
|
||||||
|
|
||||||
|
const select = (item: T) => {
|
||||||
|
const selected = values.some((v) => isEqual(v, item));
|
||||||
|
if (multiple) {
|
||||||
|
const next = selected
|
||||||
|
? values.filter((v) => !isEqual(v, item))
|
||||||
|
: values.concat(item);
|
||||||
|
setValues(next);
|
||||||
|
set(next);
|
||||||
|
} else {
|
||||||
|
if (!selected) {
|
||||||
|
setValues([item]);
|
||||||
|
set([item]);
|
||||||
|
}
|
||||||
|
setTimeout(() => onClose(), 250);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='font-bold text-2xl mt-2'>{title}</Text>
|
||||||
|
<Text className='mb-2 text-neutral-500'>
|
||||||
|
{t("search.x_items", { count: data.length })}
|
||||||
|
</Text>
|
||||||
|
{showSearch && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
className='my-2 border-neutral-800 border'
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
returnKeyType='done'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
marginBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BottomSheetFlatList
|
||||||
|
data={filteredData}
|
||||||
|
keyExtractor={(item, index) => `${renderItemLabel(item)}-${index}`}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
initialNumToRender={20}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{ height: StyleSheet.hairlineWidth }}
|
||||||
|
className='bg-neutral-700'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const selected = values.some((v) => isEqual(v, item));
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => select(item)}
|
||||||
|
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
|
||||||
|
>
|
||||||
|
<Text className='flex shrink'>{renderItemLabel(item)}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={selected ? "radio-button-on" : "radio-button-off"}
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -39,7 +39,11 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import {
|
||||||
|
apiAtom,
|
||||||
|
pendingAccountSaveAtom,
|
||||||
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -89,6 +93,9 @@ const HomeMobile = () => {
|
|||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
const { showIntro } = useIntroSheet();
|
const { showIntro } = useIntroSheet();
|
||||||
|
// Gate the intro so it can't steal presentation from the post-login
|
||||||
|
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
|
||||||
|
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
|
||||||
|
|
||||||
// Fallback refresh for newly added content when returning to the home screen
|
// Fallback refresh for newly added content when returning to the home screen
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
@@ -97,7 +104,9 @@ const HomeMobile = () => {
|
|||||||
// Show intro modal on first launch
|
// Show intro modal on first launch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
if (!hasShownIntro) {
|
// Defer while the save-account sheet is up; this effect re-runs and schedules
|
||||||
|
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
|
||||||
|
if (!hasShownIntro && !pendingAccountSave) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
showIntro();
|
showIntro();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -106,7 +115,7 @@ const HomeMobile = () => {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [showIntro]);
|
}, [showIntro, pendingAccountSave]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
@@ -221,22 +230,33 @@ const HomeMobile = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
// Use getItems (not getLatestMedia) so we get item-level results
|
||||||
const allData =
|
// filtered by type from a specific library. getLatestMedia is
|
||||||
(
|
// episode-oriented and groups results, which drops Series when
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
// combined with a parentId + includeItemTypes filter.
|
||||||
|
//
|
||||||
|
// The specific reason for this is jellyfin 12.0 returns episodes, seasons, or shows,
|
||||||
|
// but we only handle shows in our recently added in [shows] section. So we need to filter by type at the item level.
|
||||||
|
//
|
||||||
|
// For Series we sort by DateLastContentAdded so shows bubble up when
|
||||||
|
// a new episode is added (series cards for new episodes, matching how
|
||||||
|
// Jellyfin's "Latest" row worked pre-12.0). Movies use DateCreated.
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 10,
|
parentId,
|
||||||
|
includeItemTypes,
|
||||||
|
recursive: true,
|
||||||
|
sortBy: includeItemTypes.includes("Series")
|
||||||
|
? ["DateLastContentAdded"]
|
||||||
|
: ["DateCreated"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes,
|
});
|
||||||
parentId,
|
return response.data.Items || [];
|
||||||
})
|
|
||||||
).data || [];
|
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -250,9 +270,7 @@ const HomeMobile = () => {
|
|||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
? []
|
|
||||||
: ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey: string[] = [
|
const queryKey: string[] = [
|
||||||
"home",
|
"home",
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
|
{title ? (
|
||||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
<View
|
<View
|
||||||
style={[]}
|
style={[]}
|
||||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -32,14 +33,22 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
disabledByAdmin = false,
|
disabledByAdmin = false,
|
||||||
...viewProps
|
...viewProps
|
||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const { t } = useTranslation();
|
||||||
|
const effectiveSubtitle = disabledByAdmin
|
||||||
|
? t("home.settings.disabled_by_admin")
|
||||||
|
: subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
|
// Keep the row floor uniform; Android trims padding slightly (its native
|
||||||
|
// controls sit taller). Switch height is capped via SettingSwitch so toggle
|
||||||
|
// rows match non-toggle rows.
|
||||||
|
const rowSizing =
|
||||||
|
Platform.OS === "android" ? "min-h-[42px] py-1.5" : "min-h-[42px] py-2";
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -58,7 +67,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 ${rowSizing} pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -22,6 +23,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||||
scaleAmount: 1,
|
scaleAmount: 1,
|
||||||
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||||
>
|
>
|
||||||
LIVE
|
{t("player.live")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
|||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Live TV
|
{t("live_tv.title")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Tab Bar */}
|
{/* Tab Bar */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -20,14 +20,16 @@ import { Button } from "@/components/Button";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import {
|
||||||
import type {
|
apiAtom,
|
||||||
AccountSecurityType,
|
pendingAccountSaveAtom,
|
||||||
SavedServer,
|
useJellyfin,
|
||||||
} from "@/utils/secureCredentials";
|
userAtom,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import type { SavedServer } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
@@ -35,6 +37,7 @@ const CredentialsSchema = z.object({
|
|||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const {
|
const {
|
||||||
@@ -42,9 +45,11 @@ export const Login: React.FC = () => {
|
|||||||
login,
|
login,
|
||||||
removeServer,
|
removeServer,
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
|
stopQuickConnectPolling,
|
||||||
loginWithSavedCredential,
|
loginWithSavedCredential,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
} = useJellyfin();
|
} = useJellyfin();
|
||||||
|
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -64,13 +69,50 @@ export const Login: React.FC = () => {
|
|||||||
password: _password || "",
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save account state
|
// Quick Connect code shown in the in-app sheet while polling for authorization
|
||||||
|
const [quickConnectCode, setQuickConnectCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Close the code sheet as soon as the session is authorized — the native
|
||||||
|
// Alert used before had no programmatic dismiss and stayed open after login.
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) setQuickConnectCode(null);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Stop Quick Connect polling when leaving the login page (parity with TVLogin)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopQuickConnectPolling();
|
||||||
|
};
|
||||||
|
}, [stopQuickConnectPolling]);
|
||||||
|
|
||||||
|
// Going back to server selection keeps this component mounted (same screen,
|
||||||
|
// different state), so the unmount cleanup above doesn't run. Without this a
|
||||||
|
// code authorized after leaving would silently log the user in later.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api?.basePath) {
|
||||||
|
stopQuickConnectPolling();
|
||||||
|
setQuickConnectCode(null);
|
||||||
|
}
|
||||||
|
}, [api?.basePath, stopQuickConnectPolling]);
|
||||||
|
|
||||||
|
// Save account state — only the intent lives here; the protection picker is
|
||||||
|
// the global PendingAccountSaveModal, shown after the login succeeds.
|
||||||
const [saveAccount, setSaveAccount] = useState(false);
|
const [saveAccount, setSaveAccount] = useState(false);
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
||||||
const [pendingLogin, setPendingLogin] = useState<{
|
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
||||||
username: string;
|
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
||||||
password: string;
|
|
||||||
} | null>(null);
|
// A Quick Connect login with "save account" on flags the post-login save:
|
||||||
|
// the protection picker shows globally once the session exists (this screen
|
||||||
|
// unmounts on login, so it can't host the modal).
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
if (quickConnectActive && saveAccount) {
|
||||||
|
setPendingAccountSave({ serverName });
|
||||||
|
}
|
||||||
|
setQuickConnectActive(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// Handle URL params for server connection
|
// Handle URL params for server connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,29 +159,22 @@ export const Login: React.FC = () => {
|
|||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
if (!result.success) return;
|
if (!result.success) return;
|
||||||
|
|
||||||
if (saveAccount) {
|
const ok = await performLogin(credentials.username, credentials.password);
|
||||||
setPendingLogin({
|
// The protection picker shows AFTER a successful login (global modal) —
|
||||||
username: credentials.username,
|
// never for a failed one.
|
||||||
password: credentials.password,
|
if (ok && saveAccount) {
|
||||||
});
|
setPendingAccountSave({ serverName });
|
||||||
setShowSaveModal(true);
|
|
||||||
} else {
|
|
||||||
await performLogin(credentials.username, credentials.password);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const performLogin = async (
|
const performLogin = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
options?: {
|
): Promise<boolean> => {
|
||||||
saveAccount?: boolean;
|
|
||||||
securityType?: AccountSecurityType;
|
|
||||||
pinCode?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await login(username, password, serverName, options);
|
await login(username, password, serverName);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
@@ -149,23 +184,9 @@ export const Login: React.FC = () => {
|
|||||||
t("login.an_unexpected_error_occurred"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPendingLogin(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAccountConfirm = async (
|
|
||||||
securityType: AccountSecurityType,
|
|
||||||
pinCode?: string,
|
|
||||||
) => {
|
|
||||||
setShowSaveModal(false);
|
|
||||||
if (pendingLogin) {
|
|
||||||
await performLogin(pendingLogin.username, pendingLogin.password, {
|
|
||||||
saveAccount: true,
|
|
||||||
securityType,
|
|
||||||
pinCode,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,15 +280,8 @@ export const Login: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert(
|
setQuickConnectActive(true);
|
||||||
t("login.quick_connect"),
|
setQuickConnectCode(code);
|
||||||
t("login.enter_code_to_login", { code: code }),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("login.got_it"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -402,7 +416,7 @@ export const Login: React.FC = () => {
|
|||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
aria-label='Server URL'
|
aria-label={t("server.server_url")}
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
onChangeText={setServerURL}
|
onChangeText={setServerURL}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -444,14 +458,11 @@ export const Login: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
<SaveAccountModal
|
{/* Dismissing only hides the code — polling continues so the login still
|
||||||
visible={showSaveModal}
|
completes if the code is authorized from another device afterwards. */}
|
||||||
onClose={() => {
|
<QuickConnectCodeModal
|
||||||
setShowSaveModal(false);
|
code={quickConnectCode}
|
||||||
setPendingLogin(null);
|
onClose={() => setQuickConnectCode(null)}
|
||||||
}}
|
|
||||||
onSave={handleSaveAccountConfirm}
|
|
||||||
username={pendingLogin?.username || credentials.username}
|
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
137
components/login/QuickConnectCodeModal.tsx
Normal file
137
components/login/QuickConnectCodeModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The Quick Connect code to display, or null when hidden. */
|
||||||
|
code: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the Quick Connect code while the app polls for authorization.
|
||||||
|
* In-app sheet instead of a native Alert so it can dismiss itself once the
|
||||||
|
* session is authorized — a native alert has no programmatic dismiss and
|
||||||
|
* lingers over the app after login completes.
|
||||||
|
*/
|
||||||
|
export const QuickConnectCodeModal: React.FC<Props> = ({ code, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const snapPoints = useMemo(() => ["50%"], []);
|
||||||
|
const isPresentedRef = useRef(false);
|
||||||
|
|
||||||
|
// Keep the last code around so the dismiss animation doesn't flash empty
|
||||||
|
// when the parent clears the code to close the sheet.
|
||||||
|
const lastCodeRef = useRef<string | null>(null);
|
||||||
|
if (code) lastCodeRef.current = code;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (code) {
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else if (isPresentedRef.current) {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
}
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index >= 0) {
|
||||||
|
isPresentedRef.current = true;
|
||||||
|
} else if (index === -1 && isPresentedRef.current) {
|
||||||
|
isPresentedRef.current = false;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyCode = useCallback(async () => {
|
||||||
|
const value = code ?? lastCodeRef.current;
|
||||||
|
if (!value) return;
|
||||||
|
// Builds that don't ship the expo-clipboard native module yet: probe with
|
||||||
|
// requireOptionalNativeModule (returns null instead of throwing/logging)
|
||||||
|
// and skip — importing the JS wrapper there would error out.
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
toast.success(t("login.code_copied"));
|
||||||
|
}, [code, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||||
|
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: Math.max(16, insets.bottom),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("login.quick_connect")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='mt-6 p-6 border border-neutral-800 rounded-xl bg-neutral-900 flex flex-row items-center justify-center'
|
||||||
|
onPress={copyCode}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className='text-center font-bold text-5xl text-neutral-100'
|
||||||
|
style={{ letterSpacing: 10 }}
|
||||||
|
>
|
||||||
|
{code ?? lastCodeRef.current}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='copy-outline'
|
||||||
|
size={22}
|
||||||
|
color='white'
|
||||||
|
style={{ opacity: 0.4, marginLeft: 16 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className='mt-2 text-neutral-500 text-center text-xs'>
|
||||||
|
{t("login.tap_code_to_copy")}
|
||||||
|
</Text>
|
||||||
|
<Text className='mt-3 mb-5 text-neutral-400 text-center px-4'>
|
||||||
|
{t("login.quick_connect_instructions")}
|
||||||
|
</Text>
|
||||||
|
<Button className='mt-auto' color='purple' onPress={onClose}>
|
||||||
|
{t("login.got_it")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -118,7 +118,6 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||||
}
|
}
|
||||||
disableSearch={true}
|
|
||||||
/>
|
/>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
@@ -128,7 +127,6 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
values={[jellyseerrSortOrder]}
|
values={[jellyseerrSortOrder]}
|
||||||
title={t("library.filters.sort_order")}
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||||
disableSearch={true}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
@@ -88,6 +89,8 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
showDiscover,
|
showDiscover,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!showDiscover) {
|
if (!showDiscover) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -101,13 +104,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label='Library'
|
label={t("search.library")}
|
||||||
isSelected={searchType === "Library"}
|
isSelected={searchType === "Library"}
|
||||||
onPress={() => setSearchType("Library")}
|
onPress={() => setSearchType("Library")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<TVSearchTabBadge
|
<TVSearchTabBadge
|
||||||
label='Discover'
|
label={t("search.discover")}
|
||||||
isSelected={searchType === "Discover"}
|
isSelected={searchType === "Discover"}
|
||||||
onPress={() => setSearchType("Discover")}
|
onPress={() => setSearchType("Discover")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Switch } from "react-native";
|
import { Linking } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -35,7 +36,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -49,7 +50,7 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
"home.settings.appearance.merge_next_up_continue_watching_hint",
|
"home.settings.appearance.merge_next_up_continue_watching_hint",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.mergeNextUpAndContinueWatching}
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
@@ -62,13 +63,26 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
"home.settings.appearance.use_episode_images_next_up_hint",
|
"home.settings.appearance.use_episode_images_next_up_hint",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.useEpisodeImagesForNextUp}
|
value={settings.useEpisodeImagesForNextUp}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ useEpisodeImagesForNextUp: value })
|
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||||
|
subtitle={t(
|
||||||
|
"home.settings.appearance.hide_remote_session_button_hint",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SettingSwitch
|
||||||
|
value={settings.hideRemoteSessionButton}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ hideRemoteSessionButton: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
router.push("/settings/appearance/hide-libraries/page")
|
router.push("/settings/appearance/hide-libraries/page")
|
||||||
@@ -77,19 +91,6 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
|
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
|
||||||
subtitle={t(
|
|
||||||
"home.settings.appearance.hide_remote_session_button_hint",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.hideRemoteSessionButton}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ hideRemoteSessionButton: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -135,7 +135,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.audio.set_audio_track")}
|
title={t("home.settings.audio.set_audio_track")}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.rememberAudioSelections}
|
value={settings.rememberAudioSelections}
|
||||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
@@ -9,7 +10,7 @@ export const ChromecastSettings: React.FC = ({ ...props }) => {
|
|||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<ListItem title={"Enable H265 for Chromecast"}>
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -39,7 +39,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
@@ -55,7 +55,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
@@ -71,7 +71,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
@@ -87,7 +87,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hideVolumeSlider}
|
value={settings.hideVolumeSlider}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
onValueChange={(hideVolumeSlider) =>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
@@ -103,7 +103,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.hideBrightnessSlider}
|
value={settings.hideBrightnessSlider}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export const JellyseerrSettings = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
// Only the server URL is admin-lockable — the password stays editable so
|
||||||
|
// the user can still sign in to the admin-pinned Jellyseerr server.
|
||||||
|
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
@@ -30,22 +33,25 @@ export const JellyseerrSettings = () => {
|
|||||||
string | undefined
|
string | undefined
|
||||||
>(settings?.jellyseerrServerUrl || undefined);
|
>(settings?.jellyseerrServerUrl || undefined);
|
||||||
|
|
||||||
|
// When the URL is admin-pinned, ignore any locally-typed value so the login
|
||||||
|
// targets the same server the (read-only) input displays.
|
||||||
|
const effectiveServerUrl = urlLocked
|
||||||
|
? settings?.jellyseerrServerUrl
|
||||||
|
: jellyseerrServerUrl || settings?.jellyseerrServerUrl;
|
||||||
|
|
||||||
const loginToJellyseerrMutation = useMutation({
|
const loginToJellyseerrMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
if (!effectiveServerUrl) throw new Error("Missing server url");
|
||||||
throw new Error("Missing server url");
|
|
||||||
if (!user?.Name)
|
if (!user?.Name)
|
||||||
throw new Error("Missing required information for login");
|
throw new Error("Missing required information for login");
|
||||||
const jellyseerrTempApi = new JellyseerrApi(
|
const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
|
||||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
|
||||||
);
|
|
||||||
const testResult = await jellyseerrTempApi.test();
|
const testResult = await jellyseerrTempApi.test();
|
||||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||||
},
|
},
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
setJellyseerrUser(user);
|
setJellyseerrUser(user);
|
||||||
updateSettings({ jellyseerrServerUrl });
|
updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t("jellyseerr.failed_to_login"));
|
toast.error(t("jellyseerr.failed_to_login"));
|
||||||
@@ -115,6 +121,7 @@ export const JellyseerrSettings = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||||
|
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
|
||||||
<Text className='font-bold mb-1'>
|
<Text className='font-bold mb-1'>
|
||||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -128,7 +135,11 @@ export const JellyseerrSettings = () => {
|
|||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
value={
|
||||||
|
urlLocked
|
||||||
|
? settings?.jellyseerrServerUrl
|
||||||
|
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
|
||||||
|
}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
}
|
}
|
||||||
@@ -137,8 +148,14 @@ export const JellyseerrSettings = () => {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
onChangeText={setjellyseerrServerUrl}
|
onChangeText={setjellyseerrServerUrl}
|
||||||
editable={!loginToJellyseerrMutation.isPending}
|
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
{urlLocked && (
|
||||||
|
<Text className='text-xs text-red-600 mb-2'>
|
||||||
|
{t("home.settings.disabled_by_admin")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-bold mb-2'>
|
<Text className='font-bold mb-2'>
|
||||||
{t("home.settings.plugins.jellyseerr.password")}
|
{t("home.settings.plugins.jellyseerr.password")}
|
||||||
|
|||||||
@@ -1,33 +1,28 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, Text, View } from "react-native";
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const KefinTweaksSettings = () => {
|
export const KefinTweaksSettings = () => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||||
|
const locked = pluginSettings?.useKefinTweaks?.locked === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className=''>
|
<ListGroup>
|
||||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
<ListItem
|
||||||
<Text className='text-xs text-red-600 mb-2'>
|
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||||
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
disabledByAdmin={locked}
|
||||||
</Text>
|
>
|
||||||
|
<SettingSwitch
|
||||||
<View className='flex flex-row items-center justify-between mt-2'>
|
|
||||||
<Text className='text-white'>
|
|
||||||
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
value={isEnabled}
|
value={isEnabled}
|
||||||
|
disabled={locked}
|
||||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||||
trackColor={{ false: "#555", true: "purple" }}
|
|
||||||
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</ListItem>
|
||||||
</View>
|
</ListGroup>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||||
|
import { openLocationSettings } from "@/modules/wifi-ssid";
|
||||||
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import {
|
import {
|
||||||
@@ -26,16 +28,26 @@ const DEFAULT_CONFIG: LocalNetworkConfig = {
|
|||||||
|
|
||||||
interface StatusDisplayProps {
|
interface StatusDisplayProps {
|
||||||
currentSSID: string | null;
|
currentSSID: string | null;
|
||||||
|
connectedToWifi: boolean;
|
||||||
isUsingLocalUrl: boolean;
|
isUsingLocalUrl: boolean;
|
||||||
|
locationBlocked: boolean;
|
||||||
|
onOpenLocationSettings: () => void;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDisplay({
|
function StatusDisplay({
|
||||||
currentSSID,
|
currentSSID,
|
||||||
|
connectedToWifi,
|
||||||
isUsingLocalUrl,
|
isUsingLocalUrl,
|
||||||
|
locationBlocked,
|
||||||
|
onOpenLocationSettings,
|
||||||
t,
|
t,
|
||||||
}: StatusDisplayProps): React.ReactElement {
|
}: StatusDisplayProps): React.ReactElement {
|
||||||
const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
|
const wifiStatus = currentSSID
|
||||||
|
? currentSSID
|
||||||
|
: connectedToWifi
|
||||||
|
? t("home.settings.network.ssid_hidden")
|
||||||
|
: t("home.settings.network.not_connected");
|
||||||
const urlType = isUsingLocalUrl
|
const urlType = isUsingLocalUrl
|
||||||
? t("home.settings.network.local")
|
? t("home.settings.network.local")
|
||||||
: t("home.settings.network.remote");
|
: t("home.settings.network.remote");
|
||||||
@@ -55,6 +67,23 @@ function StatusDisplay({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text className={urlTypeColor}>{urlType}</Text>
|
<Text className={urlTypeColor}>{urlType}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{locationBlocked && (
|
||||||
|
<View className='mt-2 pt-2 border-t border-neutral-800'>
|
||||||
|
<Text className='text-xs text-amber-400'>
|
||||||
|
{t("home.settings.network.location_off_description")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onOpenLocationSettings}
|
||||||
|
className='mt-2 self-start'
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Text className='text-xs text-blue-400 font-semibold'>
|
||||||
|
{t("home.settings.network.open_location_settings")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -62,7 +91,17 @@ function StatusDisplay({
|
|||||||
export function LocalNetworkSettings(): React.ReactElement | null {
|
export function LocalNetworkSettings(): React.ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { permissionStatus, requestPermission } = useWifiSSID();
|
const { permissionStatus, requestPermission } = useWifiSSID();
|
||||||
const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
|
const { isUsingLocalUrl, currentSSID, connectedToWifi, refreshUrlState } =
|
||||||
|
useServerUrl();
|
||||||
|
|
||||||
|
// Connected to Wi-Fi and have permission, but the OS won't reveal the SSID —
|
||||||
|
// on Android this means device Location services are off.
|
||||||
|
const locationBlocked =
|
||||||
|
permissionStatus === "granted" && connectedToWifi && !currentSSID;
|
||||||
|
|
||||||
|
const handleOpenLocationSettings = useCallback(() => {
|
||||||
|
openLocationSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const remoteUrl = storage.getString("serverUrl");
|
const remoteUrl = storage.getString("serverUrl");
|
||||||
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
|
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
|
||||||
@@ -147,7 +186,10 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
title={t("home.settings.network.auto_switch_enabled")}
|
title={t("home.settings.network.auto_switch_enabled")}
|
||||||
subtitle={t("home.settings.network.auto_switch_description")}
|
subtitle={t("home.settings.network.auto_switch_description")}
|
||||||
>
|
>
|
||||||
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
|
<SettingSwitch
|
||||||
|
value={config.enabled}
|
||||||
|
onValueChange={handleToggleEnabled}
|
||||||
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
@@ -195,6 +237,7 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
|
{!locationBlocked && (
|
||||||
<View className='py-2'>
|
<View className='py-2'>
|
||||||
<Button
|
<Button
|
||||||
onPress={handleAddCurrentNetwork}
|
onPress={handleAddCurrentNetwork}
|
||||||
@@ -203,10 +246,14 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
|||||||
{addNetworkButtonText}
|
{addNetworkButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<StatusDisplay
|
<StatusDisplay
|
||||||
currentSSID={currentSSID}
|
currentSSID={currentSSID}
|
||||||
|
connectedToWifi={connectedToWifi}
|
||||||
isUsingLocalUrl={isUsingLocalUrl}
|
isUsingLocalUrl={isUsingLocalUrl}
|
||||||
|
locationBlocked={locationBlocked}
|
||||||
|
onOpenLocationSettings={handleOpenLocationSettings}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Platform, Switch, View, type ViewProps } from "react-native";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -17,20 +19,21 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||||
|
|
||||||
const alignXLabels: Record<AlignX, string> = {
|
const alignXLabels: Record<AlignX, string> = {
|
||||||
left: "Left",
|
left: t("home.settings.subtitles.align.left"),
|
||||||
center: "Center",
|
center: t("home.settings.subtitles.align.center"),
|
||||||
right: "Right",
|
right: t("home.settings.subtitles.align.right"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignYLabels: Record<AlignY, string> = {
|
const alignYLabels: Record<AlignY, string> = {
|
||||||
top: "Top",
|
top: t("home.settings.subtitles.align.top"),
|
||||||
center: "Center",
|
center: t("home.settings.subtitles.align.center"),
|
||||||
bottom: "Bottom",
|
bottom: t("home.settings.subtitles.align.bottom"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignXOptionGroups = useMemo(() => {
|
const alignXOptionGroups = useMemo(() => {
|
||||||
@@ -60,16 +63,18 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
title='MPV Subtitle Settings'
|
title={t("home.settings.subtitles.mpv_settings_title")}
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
Advanced subtitle customization for MPV player
|
{t("home.settings.subtitles.mpv_settings_description")}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isTv && (
|
{!isTv && (
|
||||||
<>
|
<>
|
||||||
<ListItem title='Vertical Margin'>
|
<ListItem
|
||||||
|
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||||
|
>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleMarginY ?? 0}
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -81,7 +86,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title='Horizontal Alignment'>
|
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignXOptionGroups}
|
groups={alignXOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -96,11 +101,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title='Horizontal Alignment'
|
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title='Vertical Alignment'>
|
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={alignYOptionGroups}
|
groups={alignYOptionGroups}
|
||||||
trigger={
|
trigger={
|
||||||
@@ -115,14 +120,14 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
title='Vertical Alignment'
|
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ListItem title='Opaque Background'>
|
<ListItem title={t("home.settings.subtitles.opaque_background")}>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
||||||
@@ -131,7 +136,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{settings.mpvSubtitleBackgroundEnabled && (
|
{settings.mpvSubtitleBackgroundEnabled && (
|
||||||
<ListItem title='Background Opacity'>
|
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||||
step={5}
|
step={5}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, Switch, View } from "react-native";
|
import { Linking, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
@@ -132,7 +133,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -150,7 +151,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.showCustomMenuLinks}
|
value={settings.showCustomMenuLinks}
|
||||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -188,7 +189,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Switch, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
@@ -115,7 +116,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
<ListGroup title={t("home.settings.other.other_title")} className='mb-4'>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
@@ -146,7 +147,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.safeAreaInControlsEnabled}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -205,7 +206,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.disableHapticFeedback}
|
value={settings.disableHapticFeedback}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
onValueChange={(disableHapticFeedback) =>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
@@ -218,7 +219,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.auto_play_next_episode")}
|
title={t("home.settings.other.auto_play_next_episode")}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.autoPlayNextEpisode}
|
value={settings.autoPlayNextEpisode}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
onValueChange={(autoPlayNextEpisode) =>
|
onValueChange={(autoPlayNextEpisode) =>
|
||||||
|
|||||||
@@ -20,12 +20,7 @@ export const PluginSettings = () => {
|
|||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||||
title={"Jellyseerr"}
|
title='Jellyseerr'
|
||||||
showArrow
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
|
||||||
title='Marlin Search'
|
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
|||||||
title='Streamystats'
|
title='Streamystats'
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||||
|
title='Marlin Search'
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||||
title='KefinTweaks'
|
title='KefinTweaks'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
@@ -5,11 +6,13 @@ import {
|
|||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
@@ -79,6 +82,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
}, [api, user, quickConnectCode]);
|
}, [api, user, quickConnectCode]);
|
||||||
|
|
||||||
|
const pasteCode = useCallback(async () => {
|
||||||
|
// Builds without the expo-clipboard native module: probe first (no-op).
|
||||||
|
if (!requireOptionalNativeModule("ExpoClipboard")) return;
|
||||||
|
const Clipboard = await import("expo-clipboard");
|
||||||
|
const text = await Clipboard.getStringAsync();
|
||||||
|
const digits = (text || "").replace(/\D/g, "").slice(0, 6);
|
||||||
|
if (digits) setQuickConnectCode(digits);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,6 +142,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
style={{ paddingHorizontal: 16 }}
|
style={{ paddingHorizontal: 16 }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={pasteCode}
|
||||||
|
className='flex-row items-center justify-center self-center'
|
||||||
|
>
|
||||||
|
<Feather name='clipboard' size={15} color='#a3a3a3' />
|
||||||
|
<Text className='text-neutral-400 ml-2'>
|
||||||
|
{t("home.settings.quick_connect.paste_code")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const StorageSettings = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<ListGroup>
|
<ListGroup className={Platform.OS === "android" ? "mt-4" : undefined}>
|
||||||
<ListItem
|
<ListItem
|
||||||
textColor='red'
|
textColor='red'
|
||||||
onPress={onDeleteClicked}
|
onPress={onDeleteClicked}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -98,6 +98,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup
|
<ListGroup
|
||||||
|
className='mb-4'
|
||||||
title={t("home.settings.subtitles.subtitle_title")}
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
description={
|
description={
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
@@ -152,7 +153,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={t("home.settings.subtitles.set_subtitle_track")}
|
title={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
>
|
>
|
||||||
<Switch
|
<SettingSwitch
|
||||||
value={settings.rememberSubtitleSelections}
|
value={settings.rememberSubtitleSelections}
|
||||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
@@ -28,6 +29,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
const styles = createStyles(typography);
|
const styles = createStyles(typography);
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -152,7 +154,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.flagText}>Hash Match</Text>
|
<Text style={styles.flagText}>{t("player.hash_match")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{result.hearingImpaired && (
|
{result.hearingImpaired && (
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
|
||||||
import { TimeDisplay } from "./TimeDisplay";
|
import { TimeDisplay } from "./TimeDisplay";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
|
|
||||||
@@ -34,11 +33,8 @@ interface BottomControlsProps {
|
|||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
showSkipButton: boolean;
|
|
||||||
showSkipCreditButton: boolean;
|
showSkipCreditButton: boolean;
|
||||||
hasContentAfterCredits: boolean;
|
hasContentAfterCredits: boolean;
|
||||||
skipIntro: () => void;
|
|
||||||
skipCredit: () => void;
|
|
||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
handleNextEpisodeAutoPlay: () => void;
|
handleNextEpisodeAutoPlay: () => void;
|
||||||
handleNextEpisodeManual: () => void;
|
handleNextEpisodeManual: () => void;
|
||||||
@@ -86,11 +82,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipButton,
|
|
||||||
showSkipCreditButton,
|
showSkipCreditButton,
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
skipIntro,
|
|
||||||
skipCredit,
|
|
||||||
nextItem,
|
nextItem,
|
||||||
handleNextEpisodeAutoPlay,
|
handleNextEpisodeAutoPlay,
|
||||||
handleNextEpisodeManual,
|
handleNextEpisodeManual,
|
||||||
@@ -180,21 +173,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||||
<SkipButton
|
|
||||||
showButton={showSkipButton}
|
|
||||||
onPress={skipIntro}
|
|
||||||
buttonText='Skip Intro'
|
|
||||||
/>
|
|
||||||
{/* Smart Skip Credits behavior:
|
|
||||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
|
||||||
- Show "Next Episode" if credits extend to video end AND next episode exists */}
|
|
||||||
<SkipButton
|
|
||||||
showButton={
|
|
||||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
|
||||||
}
|
|
||||||
onPress={skipCredit}
|
|
||||||
buttonText='Skip Credits'
|
|
||||||
/>
|
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
settings.autoPlayEpisodeCount <
|
settings.autoPlayEpisodeCount <
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text className='text-2xl font-bold text-white py-4 '>
|
<Text className='text-2xl font-bold text-white py-4 '>
|
||||||
Are you still watching ?
|
{t("player.still_watching")}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useKeyEventListener } from "expo-key-event";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { type FC, useCallback, useEffect, useState } from "react";
|
import { type FC, useCallback, useEffect, useState } from "react";
|
||||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
import { Platform, StyleSheet, useWindowDimensions, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
@@ -38,6 +39,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
|||||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||||
import { useVideoTime } from "./hooks/useVideoTime";
|
import { useVideoTime } from "./hooks/useVideoTime";
|
||||||
|
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
|
||||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
@@ -205,6 +207,22 @@ export const Controls: FC<Props> = ({
|
|||||||
play,
|
play,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useKeyEventListener((e) => {
|
||||||
|
if (episodeView || showAudioSlider) return;
|
||||||
|
if (e?.eventType !== "press") return;
|
||||||
|
const key = e.key;
|
||||||
|
|
||||||
|
if (key === " " || key === "Spacebar" || key === "Space") {
|
||||||
|
togglePlay();
|
||||||
|
} else if (!Platform.isTV && key === "ArrowLeft") {
|
||||||
|
// Exclude TV platforms to prevent conflicts with the remote control,
|
||||||
|
// which uses the same arrow keys for directional UI navigation.
|
||||||
|
handleSkipBackward();
|
||||||
|
} else if (!Platform.isTV && key === "ArrowRight") {
|
||||||
|
handleSkipForward();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Time management hook
|
// Time management hook
|
||||||
const { currentTime, remainingTime } = useVideoTime({
|
const { currentTime, remainingTime } = useVideoTime({
|
||||||
progress,
|
progress,
|
||||||
@@ -338,6 +356,16 @@ export const Controls: FC<Props> = ({
|
|||||||
maxMs,
|
maxMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Whether the "Next Episode" countdown will actually be rendered. The Skip
|
||||||
|
// Credits button yields to it only when this is true; if autoplay is
|
||||||
|
// disabled or its episode limit is reached, Skip Credits must stay available
|
||||||
|
// (mirrors the NextEpisodeCountDownButton mount gate in BottomControls).
|
||||||
|
const willShowNextEpisode =
|
||||||
|
!!nextItem &&
|
||||||
|
settings.autoPlayNextEpisode !== false &&
|
||||||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
|
||||||
|
|
||||||
const goToItemCommon = useCallback(
|
const goToItemCommon = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
if (!item || !settings) {
|
if (!item || !settings) {
|
||||||
@@ -570,11 +598,8 @@ export const Controls: FC<Props> = ({
|
|||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
showSkipButton={showSkipButton}
|
|
||||||
showSkipCreditButton={showSkipCreditButton}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
hasContentAfterCredits={hasContentAfterCredits}
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
skipIntro={skipIntro}
|
|
||||||
skipCredit={skipCredit}
|
|
||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||||
@@ -594,6 +619,17 @@ export const Controls: FC<Props> = ({
|
|||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
{/* Skip Intro / Skip Credits float independently of the controls so
|
||||||
|
they're visible (and tappable) without summoning the controls. */}
|
||||||
|
<SkipSegmentOverlay
|
||||||
|
showSkipButton={showSkipButton}
|
||||||
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
|
willShowNextEpisode={willShowNextEpisode}
|
||||||
|
skipIntro={skipIntro}
|
||||||
|
skipCredit={skipCredit}
|
||||||
|
controlsVisible={showControls}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { useVideoContext } from "./contexts/VideoContext";
|
import { useVideoContext } from "./contexts/VideoContext";
|
||||||
@@ -317,8 +318,10 @@ export const Controls: FC<Props> = ({
|
|||||||
try {
|
try {
|
||||||
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
||||||
// Skip streams without a real index: `?? -1` would alias them to the
|
// Skip streams without a real index: `?? -1` would alias them to the
|
||||||
// "disable subtitles" sentinel and mis-route selection.
|
// "disable subtitles" sentinel and mis-route selection. Order like
|
||||||
return streams
|
// jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
|
return [...streams]
|
||||||
|
.sort(compareTracksForMenu)
|
||||||
.filter((stream) => typeof stream.Index === "number")
|
.filter((stream) => typeof stream.Index === "number")
|
||||||
.map((stream) => {
|
.map((stream) => {
|
||||||
const index = stream.Index as number;
|
const index = stream.Index as number;
|
||||||
@@ -601,6 +604,9 @@ export const Controls: FC<Props> = ({
|
|||||||
mediaSourceId: mediaSource?.Id,
|
mediaSourceId: mediaSource?.Id,
|
||||||
subtitleTracks: tracksWithoutDisable,
|
subtitleTracks: tracksWithoutDisable,
|
||||||
currentSubtitleIndex: subtitleIndex ?? -1,
|
currentSubtitleIndex: subtitleIndex ?? -1,
|
||||||
|
// In-player selection can navigate (replacePlayer for burn-in switches);
|
||||||
|
// apply it after the modal route is dismissed so it isn't swallowed.
|
||||||
|
deferApplyUntilDismissed: true,
|
||||||
onDisableSubtitles: () => {
|
onDisableSubtitles: () => {
|
||||||
// Find and call the "Disable" track's setTrack from VideoContext
|
// Find and call the "Disable" track's setTrack from VideoContext
|
||||||
const disableTrack = videoContextSubtitleTracks?.find(
|
const disableTrack = videoContextSubtitleTracks?.find(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { type FC, useCallback, useState } from "react";
|
import { type FC, useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
@@ -57,6 +58,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
showTechnicalInfo = false,
|
showTechnicalInfo = false,
|
||||||
onToggleTechnicalInfo,
|
onToggleTechnicalInfo,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useControlsSafeAreaInsets();
|
const insets = useControlsSafeAreaInsets();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -127,8 +129,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
|||||||
onPress={toggleOrientation}
|
onPress={toggleOrientation}
|
||||||
disabled={isTogglingOrientation}
|
disabled={isTogglingOrientation}
|
||||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||||
accessibilityLabel='Toggle screen orientation'
|
accessibilityLabel={t("accessibility.toggle_orientation")}
|
||||||
accessibilityHint='Toggles the screen orientation between portrait and landscape'
|
accessibilityHint={t("accessibility.toggle_orientation_hint")}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name='screen-rotation'
|
name='screen-rotation'
|
||||||
|
|||||||
125
components/video-player/controls/SkipSegmentOverlay.tsx
Normal file
125
components/video-player/controls/SkipSegmentOverlay.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||||
|
import SkipButton from "./SkipButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showSkipButton: boolean;
|
||||||
|
showSkipCreditButton: boolean;
|
||||||
|
hasContentAfterCredits: boolean;
|
||||||
|
willShowNextEpisode: boolean;
|
||||||
|
skipIntro: () => void;
|
||||||
|
skipCredit: () => void;
|
||||||
|
controlsVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offsets are relative to the safe-area insets so they hold in both portrait
|
||||||
|
// and landscape (the insets move with the notch / home indicator).
|
||||||
|
//
|
||||||
|
// Hidden: low, far-right — nothing else is drawn there, this is the familiar
|
||||||
|
// spot and was working fine.
|
||||||
|
// Visible: shifted up (clear of the horizontal progress bar) and left (clear
|
||||||
|
// of the chapters icon), while staying below the vertical volume
|
||||||
|
// slider which sits at the vertical middle of the screen.
|
||||||
|
const HIDDEN_BOTTOM = 24;
|
||||||
|
const HIDDEN_RIGHT = 12;
|
||||||
|
const VISIBLE_BOTTOM = 65;
|
||||||
|
const VISIBLE_RIGHT = 42;
|
||||||
|
const ANIM_DURATION = 250;
|
||||||
|
|
||||||
|
// Keeps `value` true for `duration` ms after it turns false. SkipButton hides
|
||||||
|
// itself instantly via a `hidden` (display:none) class, which would preempt
|
||||||
|
// the parent's opacity fade-out — lagging the flag keeps the button rendered
|
||||||
|
// (and visible) while the fade plays out.
|
||||||
|
const useDelayedHide = (value: boolean, duration: number): boolean => {
|
||||||
|
const [display, setDisplay] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
setDisplay(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = setTimeout(() => setDisplay(false), duration);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, duration]);
|
||||||
|
return value || display;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floating Skip Intro / Skip Credits buttons shown independently of the
|
||||||
|
* player controls. They appear on their own during an intro or credits segment
|
||||||
|
* without the user having to summon the controls.
|
||||||
|
*/
|
||||||
|
export const SkipSegmentOverlay: FC<Props> = ({
|
||||||
|
showSkipButton,
|
||||||
|
showSkipCreditButton,
|
||||||
|
hasContentAfterCredits,
|
||||||
|
willShowNextEpisode,
|
||||||
|
skipIntro,
|
||||||
|
skipCredit,
|
||||||
|
controlsVisible,
|
||||||
|
}) => {
|
||||||
|
const insets = useControlsSafeAreaInsets();
|
||||||
|
|
||||||
|
const showCredit =
|
||||||
|
showSkipCreditButton && (hasContentAfterCredits || !willShowNextEpisode);
|
||||||
|
const visible = showSkipButton || showCredit;
|
||||||
|
|
||||||
|
// Drive each SkipButton with a lagged flag so it stays visible while the
|
||||||
|
// opacity fade-out plays, instead of disappearing the instant its segment
|
||||||
|
// ends. `visible` above still drives opacity/pointerEvents immediately.
|
||||||
|
const renderSkip = useDelayedHide(showSkipButton, ANIM_DURATION);
|
||||||
|
const renderCredit = useDelayedHide(showCredit, ANIM_DURATION);
|
||||||
|
|
||||||
|
const opacity = useSharedValue(visible ? 1 : 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.value = withTiming(visible ? 1 : 0, {
|
||||||
|
duration: ANIM_DURATION,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
});
|
||||||
|
}, [visible, opacity]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Position is recomputed on render (no slide animation) so the button never
|
||||||
|
// sweeps through an overlap zone while the controls toggle.
|
||||||
|
const bottom =
|
||||||
|
insets.bottom + (controlsVisible ? VISIBLE_BOTTOM : HIDDEN_BOTTOM);
|
||||||
|
const right = insets.right + (controlsVisible ? VISIBLE_RIGHT : HIDDEN_RIGHT);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.container, { right, bottom }, animatedStyle]}
|
||||||
|
pointerEvents={visible ? "box-none" : "none"}
|
||||||
|
>
|
||||||
|
<SkipButton
|
||||||
|
showButton={renderSkip}
|
||||||
|
onPress={skipIntro}
|
||||||
|
buttonText='Skip Intro'
|
||||||
|
/>
|
||||||
|
<SkipButton
|
||||||
|
showButton={renderCredit}
|
||||||
|
onPress={skipCredit}
|
||||||
|
buttonText='Skip Credits'
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
zIndex: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -184,6 +185,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const safeInsets = useControlsSafeAreaInsets();
|
const safeInsets = useControlsSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
@@ -312,13 +314,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.videoCodec && (
|
{info?.videoCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Video: {formatCodec(info.videoCodec)}
|
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
|
||||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.audioCodec && (
|
{info?.audioCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Audio: {formatCodec(info.audioCodec)}
|
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
||||||
{streamInfo?.audioChannels
|
{streamInfo?.audioChannels
|
||||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -326,12 +328,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{streamInfo?.subtitleCodec && (
|
{streamInfo?.subtitleCodec && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
{t("player.technical_info.subtitle")}{" "}
|
||||||
|
{formatCodec(streamInfo.subtitleCodec)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Bitrate:{" "}
|
{t("player.technical_info.bitrate")}{" "}
|
||||||
{info.videoBitrate
|
{info.videoBitrate
|
||||||
? formatBitrate(info.videoBitrate)
|
? formatBitrate(info.videoBitrate)
|
||||||
: info.audioBitrate
|
: info.audioBitrate
|
||||||
@@ -341,7 +344,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
{t("player.technical_info.buffer_seconds", {
|
||||||
|
seconds: info.cacheSeconds.toFixed(1),
|
||||||
|
})}
|
||||||
{info?.demuxerMaxBytes !== undefined
|
{info?.demuxerMaxBytes !== undefined
|
||||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||||
@@ -352,7 +357,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
VO: {info.voDriver}
|
{t("player.technical_info.vo")} {info.voDriver}
|
||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -364,10 +369,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
)}
|
)}
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
Dropped: {info.droppedFrames} frames
|
{t("player.technical_info.dropped_frames", {
|
||||||
|
count: info.droppedFrames,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
{!info && !playMethod && (
|
||||||
|
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
type OptionGroup,
|
type OptionGroup,
|
||||||
@@ -54,6 +55,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
onRatioChange,
|
onRatioChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||||
@@ -66,7 +68,10 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
{
|
{
|
||||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: option.label,
|
label:
|
||||||
|
option.id === "default"
|
||||||
|
? t("player.aspect_ratio_original")
|
||||||
|
: option.label,
|
||||||
value: option.id,
|
value: option.id,
|
||||||
selected: option.id === currentRatio,
|
selected: option.id === currentRatio,
|
||||||
onPress: () => handleRatioSelect(option.id),
|
onPress: () => handleRatioSelect(option.id),
|
||||||
@@ -94,7 +99,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title='Aspect Ratio'
|
title={t("player.aspect_ratio")}
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
bottomSheetConfig={{
|
bottomSheetConfig={{
|
||||||
|
|||||||
@@ -23,32 +23,29 @@
|
|||||||
* - Used to report playback state to Jellyfin server
|
* - Used to report playback state to Jellyfin server
|
||||||
* - Value of -1 means disabled/none
|
* - Value of -1 means disabled/none
|
||||||
*
|
*
|
||||||
* 2. MPV INDEX (track.mpvIndex)
|
* 2. PLAYER TRACK (selected by IDENTITY, not position)
|
||||||
* - MPV's internal track ID
|
* - Selection resolves the server Index against MPV's REAL track list via
|
||||||
* - MPV orders tracks as: [all embedded, then all external]
|
* applyMpvSubtitleSelection: externals matched by external-filename,
|
||||||
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
|
* embedded by language/title. `track.mpvIndex` is no longer used to select
|
||||||
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
|
* (kept -1) — positional mapping mis-selected when externals/embedded were
|
||||||
|
* reordered or the server hid embedded subs (#954 et al.).
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* SUBTITLE HANDLING
|
* SUBTITLE HANDLING
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*
|
*
|
||||||
* Embedded (DeliveryMethod.Embed):
|
* Embedded & External:
|
||||||
* - Already in MPV's track list
|
* - Selected via applyMpvSubtitleSelection (identity match against the live
|
||||||
* - Select via setSubtitleTrack(mpvId)
|
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
|
||||||
*
|
* embedded first, externals last, forced/default float up).
|
||||||
* External (DeliveryMethod.External):
|
|
||||||
* - Loaded into MPV on video start
|
|
||||||
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
|
|
||||||
*
|
*
|
||||||
* Image-based during transcoding:
|
* Image-based during transcoding:
|
||||||
* - Burned into video by Jellyfin, not in MPV
|
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
|
||||||
* - Requires replacePlayer() to change
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { File } from "expo-file-system";
|
import { File } from "expo-file-system";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -61,16 +58,18 @@ import {
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import type { MpvAudioTrack } from "@/modules";
|
import type { MpvAudioTrack } from "@/modules";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
import {
|
||||||
import type { Track } from "../types";
|
applyMpvSubtitleSelection,
|
||||||
|
compareTracksForMenu,
|
||||||
|
getExternalSubtitleUrl,
|
||||||
|
isImageBasedSubtitle,
|
||||||
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
|
import { LOCAL_SUBTITLE_INDEX_START, type Track } from "../types";
|
||||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||||
|
|
||||||
// Starting index for local (client-downloaded) subtitles
|
|
||||||
// Uses negative indices to avoid collision with Jellyfin indices
|
|
||||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
|
||||||
|
|
||||||
interface VideoContextProps {
|
interface VideoContextProps {
|
||||||
subtitleTracks: Track[] | null;
|
subtitleTracks: Track[] | null;
|
||||||
audioTracks: Track[] | null;
|
audioTracks: Track[] | null;
|
||||||
@@ -87,6 +86,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
||||||
const playerControls = usePlayerControls();
|
const playerControls = usePlayerControls();
|
||||||
const offline = useOfflineMode();
|
const offline = useOfflineMode();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||||
@@ -126,10 +126,17 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
audioIndex?: string;
|
audioIndex?: string;
|
||||||
subtitleIndex?: string;
|
subtitleIndex?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
// The URL param can hold a local-sub sentinel (selected via setParams) that
|
||||||
|
// only exists in the dying player — the server must get "none" (-1) instead.
|
||||||
|
// NaN (missing/blank param) fails the comparison and passes through as-is.
|
||||||
|
const fallbackSubtitleIndex =
|
||||||
|
Number.parseInt(subtitleIndex, 10) <= LOCAL_SUBTITLE_INDEX_START
|
||||||
|
? "-1"
|
||||||
|
: subtitleIndex;
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: itemId ?? "",
|
itemId: itemId ?? "",
|
||||||
audioIndex: params.audioIndex ?? audioIndex,
|
audioIndex: params.audioIndex ?? audioIndex,
|
||||||
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
|
subtitleIndex: params.subtitleIndex ?? fallbackSubtitleIndex,
|
||||||
mediaSourceId: mediaSource?.Id ?? "",
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrateValue,
|
bitrateValue: bitrateValue,
|
||||||
playbackPosition: playbackPosition,
|
playbackPosition: playbackPosition,
|
||||||
@@ -141,6 +148,19 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tracksReady) return;
|
if (!tracksReady) return;
|
||||||
|
|
||||||
|
// Guard every state commit against stale runs: api?.basePath /
|
||||||
|
// isCurrentSubImageBased can flip mid-run and restart this effect, and an
|
||||||
|
// earlier async run (which captured an old `api`) must not finish later and
|
||||||
|
// overwrite the fresh track list with callbacks bound to stale closures.
|
||||||
|
// The cleanup flips `cancelled`, so any late commit from a dead run is dropped.
|
||||||
|
let cancelled = false;
|
||||||
|
const commitSubtitleTracks = (next: Track[]) => {
|
||||||
|
if (!cancelled) setSubtitleTracks(next);
|
||||||
|
};
|
||||||
|
const commitAudioTracks = (next: Track[]) => {
|
||||||
|
if (!cancelled) setAudioTracks(next);
|
||||||
|
};
|
||||||
|
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
// Check if this is offline transcoded content
|
// Check if this is offline transcoded content
|
||||||
// For transcoded offline content, only ONE audio track exists in the file
|
// For transcoded offline content, only ONE audio track exists in the file
|
||||||
@@ -166,10 +186,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
setAudioTracks(audio);
|
commitAudioTracks(audio);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: show no audio tracks if the stored track wasn't found
|
// Fallback: show no audio tracks if the stored track wasn't found
|
||||||
setAudioTracks([]);
|
commitAudioTracks([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For subtitles in transcoded offline content:
|
// For subtitles in transcoded offline content:
|
||||||
@@ -179,6 +199,24 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
downloadedItem.userData.subtitleStreamIndex;
|
downloadedItem.userData.subtitleStreamIndex;
|
||||||
const subs: Track[] = [];
|
const subs: Track[] = [];
|
||||||
|
|
||||||
|
// If an IMAGE subtitle was burned into the transcoded download it's in the
|
||||||
|
// video pixels — it can't be turned off or swapped. Show only that entry
|
||||||
|
// instead of advertising "Disable"/text controls that can't affect it.
|
||||||
|
const burnedInSub = allSubs.find(
|
||||||
|
(s) => s.Index === downloadedSubtitleIndex,
|
||||||
|
);
|
||||||
|
if (burnedInSub && isImageBasedSubtitle(burnedInSub)) {
|
||||||
|
commitSubtitleTracks([
|
||||||
|
{
|
||||||
|
name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`,
|
||||||
|
index: burnedInSub.Index ?? -1,
|
||||||
|
mpvIndex: -1,
|
||||||
|
setTrack: () => {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add "Disable" option
|
// Add "Disable" option
|
||||||
subs.push({
|
subs.push({
|
||||||
name: "Disable",
|
name: "Disable",
|
||||||
@@ -190,123 +228,84 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// For text-based subs, they should still be available in the file
|
// Text subs are muxed into the transcoded file and switchable; resolve by
|
||||||
let subIdx = 1;
|
// identity against MPV's real track list (same as online). Order matches web.
|
||||||
for (const sub of allSubs) {
|
// Image subs aren't in the transcoded file (only the burned one was, handled
|
||||||
if (sub.IsTextSubtitleStream) {
|
// above), so skip them here.
|
||||||
subs.push({
|
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||||
name: sub.DisplayTitle || "Unknown",
|
if (!isImageBasedSubtitle(sub)) {
|
||||||
index: sub.Index ?? -1,
|
|
||||||
mpvIndex: subIdx,
|
|
||||||
setTrack: () => {
|
|
||||||
playerControls.setSubtitleTrack(subIdx);
|
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
subIdx++;
|
|
||||||
} else if (sub.Index === downloadedSubtitleIndex) {
|
|
||||||
// This image-based sub was burned in - show it but indicate it's active
|
|
||||||
subs.push({
|
|
||||||
name: `${sub.DisplayTitle || "Unknown"} (burned in)`,
|
|
||||||
index: sub.Index ?? -1,
|
|
||||||
mpvIndex: -1, // Can't be changed
|
|
||||||
setTrack: () => {
|
|
||||||
// Already burned in, just update params
|
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MPV track handling
|
|
||||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
|
||||||
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
|
||||||
|
|
||||||
// Separate embedded vs external subtitles from Jellyfin's list
|
|
||||||
// MPV orders tracks as: [all embedded, then all external]
|
|
||||||
const embeddedSubs = allSubs.filter(
|
|
||||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
|
|
||||||
);
|
|
||||||
const externalSubs = allSubs.filter(
|
|
||||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Count embedded subs that will be in MPV
|
|
||||||
// (excludes image-based subs during transcoding as they're burned in)
|
|
||||||
const embeddedInPlayer = embeddedSubs.filter(
|
|
||||||
(s) => !isTranscoding || !isImageBasedSubtitle(s),
|
|
||||||
);
|
|
||||||
|
|
||||||
const subs: Track[] = [];
|
|
||||||
|
|
||||||
// Process all Jellyfin subtitles
|
|
||||||
for (const sub of allSubs) {
|
|
||||||
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
|
|
||||||
const isExternal =
|
|
||||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
|
||||||
|
|
||||||
// For image-based subs during transcoding, need to refresh player
|
|
||||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
|
||||||
subs.push({
|
subs.push({
|
||||||
name: sub.DisplayTitle || "Unknown",
|
name: sub.DisplayTitle || "Unknown",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
mpvIndex: -1,
|
mpvIndex: -1,
|
||||||
setTrack: () => {
|
setTrack: () => {
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
|
void applyMpvSubtitleSelection(playerControls, {
|
||||||
|
subtitleStreams: allSubs,
|
||||||
|
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||||
|
getExpectedExternalUrl: (s) => {
|
||||||
|
if (!s.DeliveryUrl) return undefined;
|
||||||
|
if (offline) return s.DeliveryUrl;
|
||||||
|
return api?.basePath
|
||||||
|
? `${api.basePath}${s.DeliveryUrl}`
|
||||||
|
: undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
continue;
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate MPV track ID based on type
|
commitSubtitleTracks(subs);
|
||||||
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
|
return;
|
||||||
let mpvId = -1;
|
}
|
||||||
|
|
||||||
if (isEmbedded) {
|
// MPV track handling
|
||||||
// Find position among embedded subs that are in player
|
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||||
const embeddedPosition = embeddedInPlayer.findIndex(
|
if (cancelled) return;
|
||||||
(s) => s.Index === sub.Index,
|
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
||||||
);
|
|
||||||
if (embeddedPosition !== -1) {
|
const subs: Track[] = [];
|
||||||
mpvId = embeddedPosition + 1; // 1-based ID
|
|
||||||
}
|
// Process all Jellyfin subtitles. Selection resolves against MPV's real
|
||||||
} else if (isExternal) {
|
// track list by identity (applyMpvSubtitleSelection) — never positional
|
||||||
// Find position among external subs, offset by embedded count
|
// index math, which mis-selects across external/embedded reordering and
|
||||||
const externalPosition = externalSubs.findIndex(
|
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
|
||||||
(s) => s.Index === sub.Index,
|
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
|
||||||
);
|
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||||
if (externalPosition !== -1) {
|
// Image-based subs during transcoding are burned into the video by the
|
||||||
mpvId = embeddedInPlayer.length + externalPosition + 1;
|
// server; both switching TO one and switching AWAY from a currently
|
||||||
}
|
// active one require a player refresh (re-transcode), not a track change.
|
||||||
}
|
const needsReplace =
|
||||||
|
isTranscoding &&
|
||||||
|
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
|
||||||
|
|
||||||
subs.push({
|
subs.push({
|
||||||
name: sub.DisplayTitle || "Unknown",
|
name: sub.DisplayTitle || "Unknown",
|
||||||
index: sub.Index ?? -1,
|
index: sub.Index ?? -1,
|
||||||
mpvIndex: mpvId,
|
mpvIndex: -1,
|
||||||
setTrack: () => {
|
setTrack: () => {
|
||||||
// Transcoding + switching to/from image-based sub
|
if (needsReplace) {
|
||||||
if (
|
|
||||||
isTranscoding &&
|
|
||||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
|
||||||
) {
|
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct switch in player
|
|
||||||
if (mpvId !== -1) {
|
|
||||||
playerControls.setSubtitleTrack(mpvId);
|
|
||||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||||
return;
|
void applyMpvSubtitleSelection(playerControls, {
|
||||||
}
|
subtitleStreams: allSubs,
|
||||||
|
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||||
// Fallback - refresh player
|
// Mirror how external subs are loaded into MPV (online: basePath +
|
||||||
|
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
|
||||||
|
// external-filename lines up.
|
||||||
|
getExpectedExternalUrl: (s) =>
|
||||||
|
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||||
|
}).then((result) => {
|
||||||
|
// Safety net: a menu-listed sub the player can't select (server-
|
||||||
|
// burned Encode, sidecar never sub-added) only shows up after the
|
||||||
|
// server re-processes the stream with it.
|
||||||
|
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -374,12 +373,29 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
|
||||||
setAudioTracks(audio);
|
// at the front (unshifted), local downloaded subs at the end.
|
||||||
|
commitSubtitleTracks(subs);
|
||||||
|
commitAudioTracks(audio);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
|
||||||
|
// API is ready so online externals don't resolve with undefined.
|
||||||
|
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer
|
||||||
|
// decision — rebuild when it flips so we refresh the stream when we should.
|
||||||
|
}, [
|
||||||
|
tracksReady,
|
||||||
|
mediaSource,
|
||||||
|
offline,
|
||||||
|
downloadedItem,
|
||||||
|
itemId,
|
||||||
|
api?.basePath,
|
||||||
|
isCurrentSubImageBased,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,7 @@ const DropdownView = ({
|
|||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||||
useLocalSearchParams<{
|
useLocalSearchParams<{
|
||||||
@@ -101,7 +103,7 @@ const DropdownView = ({
|
|||||||
// Quality Section
|
// Quality Section
|
||||||
if (!isOffline) {
|
if (!isOffline) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Quality",
|
title: t("player.menu.quality"),
|
||||||
options:
|
options:
|
||||||
BITRATES?.map((bitrate) => ({
|
BITRATES?.map((bitrate) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
@@ -116,7 +118,7 @@ const DropdownView = ({
|
|||||||
// Subtitle Section
|
// Subtitle Section
|
||||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Subtitles",
|
title: t("player.menu.subtitles"),
|
||||||
options: subtitleTracks.map((sub) => ({
|
options: subtitleTracks.map((sub) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: sub.name,
|
label: sub.name,
|
||||||
@@ -128,7 +130,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
// Subtitle Scale Section
|
// Subtitle Scale Section
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Subtitle Scale",
|
title: t("player.menu.subtitle_scale"),
|
||||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: preset.label,
|
label: preset.label,
|
||||||
@@ -142,7 +144,7 @@ const DropdownView = ({
|
|||||||
// Audio Section
|
// Audio Section
|
||||||
if (audioTracks && audioTracks.length > 0) {
|
if (audioTracks && audioTracks.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Audio",
|
title: t("player.menu.audio"),
|
||||||
options: audioTracks.map((track) => ({
|
options: audioTracks.map((track) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: track.name,
|
label: track.name,
|
||||||
@@ -156,7 +158,7 @@ const DropdownView = ({
|
|||||||
// Speed Section
|
// Speed Section
|
||||||
if (setPlaybackSpeed) {
|
if (setPlaybackSpeed) {
|
||||||
groups.push({
|
groups.push({
|
||||||
title: "Speed",
|
title: t("player.menu.speed"),
|
||||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
type: "radio" as const,
|
type: "radio" as const,
|
||||||
label: speed.label,
|
label: speed.label,
|
||||||
@@ -174,8 +176,8 @@ const DropdownView = ({
|
|||||||
{
|
{
|
||||||
type: "action" as const,
|
type: "action" as const,
|
||||||
label: showTechnicalInfo
|
label: showTechnicalInfo
|
||||||
? "Hide Technical Info"
|
? t("player.menu.hide_technical_info")
|
||||||
: "Show Technical Info",
|
: t("player.menu.show_technical_info"),
|
||||||
onPress: onToggleTechnicalInfo,
|
onPress: onToggleTechnicalInfo,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -185,6 +187,7 @@ const DropdownView = ({
|
|||||||
return groups;
|
return groups;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
|
t,
|
||||||
isOffline,
|
isOffline,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
changeBitrate,
|
changeBitrate,
|
||||||
@@ -217,7 +220,7 @@ const DropdownView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
title='Playback Options'
|
title={t("player.menu.playback_options")}
|
||||||
groups={optionGroups}
|
groups={optionGroups}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
expoUIConfig={{}}
|
expoUIConfig={{}}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Alert } from "react-native";
|
|||||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
import { useTVEventHandler } from "@/hooks/useTVEventHandler";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
|
||||||
interface UseRemoteControlProps {
|
interface UseRemoteControlProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
@@ -124,17 +125,23 @@ export function useRemoteControl({
|
|||||||
|
|
||||||
// Controls are hidden, so confirm before leaving playback.
|
// Controls are hidden, so confirm before leaving playback.
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Stop Playback",
|
i18n.t("player.stopPlayback"),
|
||||||
videoTitleRef.current
|
videoTitleRef.current
|
||||||
? `Stop playing "${videoTitleRef.current}"?`
|
? i18n.t("player.stopPlayingTitle", {
|
||||||
: "Are you sure you want to stop playback?",
|
title: videoTitleRef.current,
|
||||||
|
})
|
||||||
|
: i18n.t("player.stopPlayingConfirm"),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: "Cancel",
|
text: i18n.t("common.cancel"),
|
||||||
style: "cancel",
|
style: "cancel",
|
||||||
onPress: () => onCancelExitRef.current?.(),
|
onPress: () => onCancelExitRef.current?.(),
|
||||||
},
|
},
|
||||||
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
{
|
||||||
|
text: i18n.t("common.stop"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: onBackRef.current,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -28,4 +28,20 @@ type Track = {
|
|||||||
localPath?: string;
|
localPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthetic `Track.index` base for client-side downloaded subtitles (loaded via
|
||||||
|
* `addSubtitleFile`, no matching Jellyfin MediaStream). Local sub k gets
|
||||||
|
* `LOCAL_SUBTITLE_INDEX_START - k`, so `index <= LOCAL_SUBTITLE_INDEX_START`
|
||||||
|
* identifies a local track.
|
||||||
|
*/
|
||||||
|
export const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a client-side subtitle index to what the Jellyfin stream API understands.
|
||||||
|
* Local sentinel indexes have no matching MediaStream on the server, which
|
||||||
|
* expects -1 ("no subtitles") when re-negotiating a stream.
|
||||||
|
*/
|
||||||
|
export const toServerSubtitleIndex = (index: number): number =>
|
||||||
|
index <= LOCAL_SUBTITLE_INDEX_START ? -1 : index;
|
||||||
|
|
||||||
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };
|
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class JellyseerrApi {
|
|||||||
if (inRange(status, 200, 299)) {
|
if (inRange(status, 200, 299)) {
|
||||||
if (data.version < "2.0.0") {
|
if (data.version < "2.0.0") {
|
||||||
const error = t(
|
const error = t(
|
||||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
"jellyseerr.toasts.jellyseerr_does_not_meet_requirements",
|
||||||
);
|
);
|
||||||
toast.error(error);
|
toast.error(error);
|
||||||
throw Error(error);
|
throw Error(error);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface ShowSubtitleModalParams {
|
|||||||
onServerSubtitleDownloaded?: () => void;
|
onServerSubtitleDownloaded?: () => void;
|
||||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||||
refreshSubtitleTracks?: () => Promise<Track[]>;
|
refreshSubtitleTracks?: () => Promise<Track[]>;
|
||||||
|
deferApplyUntilDismissed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTVSubtitleModal = () => {
|
export const useTVSubtitleModal = () => {
|
||||||
@@ -30,6 +31,7 @@ export const useTVSubtitleModal = () => {
|
|||||||
onServerSubtitleDownloaded: params.onServerSubtitleDownloaded,
|
onServerSubtitleDownloaded: params.onServerSubtitleDownloaded,
|
||||||
onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded,
|
onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded,
|
||||||
refreshSubtitleTracks: params.refreshSubtitleTracks,
|
refreshSubtitleTracks: params.refreshSubtitleTracks,
|
||||||
|
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
||||||
});
|
});
|
||||||
router.push("/(auth)/tv-subtitle-modal");
|
router.push("/(auth)/tv-subtitle-modal");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,125 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
export {
|
||||||
import { Platform } from "react-native";
|
type PermissionStatus,
|
||||||
import { getSSID } from "@/modules/wifi-ssid";
|
type UseWifiSSIDReturn,
|
||||||
|
useWifiSsid as useWifiSSID,
|
||||||
export type PermissionStatus =
|
} from "@/providers/WifiSsidProvider";
|
||||||
| "granted"
|
|
||||||
| "denied"
|
|
||||||
| "undetermined"
|
|
||||||
| "unavailable";
|
|
||||||
|
|
||||||
export interface UseWifiSSIDReturn {
|
|
||||||
ssid: string | null;
|
|
||||||
permissionStatus: PermissionStatus;
|
|
||||||
requestPermission: () => Promise<boolean>;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// WiFi SSID is not available on tvOS
|
|
||||||
if (Platform.isTV) {
|
|
||||||
// Export a stub hook for tvOS
|
|
||||||
module.exports = {
|
|
||||||
useWifiSSID: (): UseWifiSSIDReturn => ({
|
|
||||||
ssid: null,
|
|
||||||
permissionStatus: "unavailable" as PermissionStatus,
|
|
||||||
requestPermission: async () => false,
|
|
||||||
isLoading: false,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only import Location on non-TV platforms
|
|
||||||
const Location = Platform.isTV ? null : require("expo-location");
|
|
||||||
|
|
||||||
function mapLocationStatus(status: number | undefined): PermissionStatus {
|
|
||||||
if (!Location) return "unavailable";
|
|
||||||
switch (status) {
|
|
||||||
case Location.PermissionStatus?.GRANTED:
|
|
||||||
return "granted";
|
|
||||||
case Location.PermissionStatus?.DENIED:
|
|
||||||
return "denied";
|
|
||||||
default:
|
|
||||||
return "undetermined";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWifiSSID(): UseWifiSSIDReturn {
|
|
||||||
const [ssid, setSSID] = useState<string | null>(null);
|
|
||||||
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
|
|
||||||
Platform.isTV ? "unavailable" : "undetermined",
|
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(!Platform.isTV);
|
|
||||||
|
|
||||||
const fetchSSID = useCallback(async () => {
|
|
||||||
if (Platform.isTV) return;
|
|
||||||
const result = await getSSID();
|
|
||||||
setSSID(result);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestPermission = useCallback(async (): Promise<boolean> => {
|
|
||||||
if (Platform.isTV || !Location) {
|
|
||||||
setPermissionStatus("unavailable");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
|
||||||
const newStatus = mapLocationStatus(status);
|
|
||||||
setPermissionStatus(newStatus);
|
|
||||||
|
|
||||||
if (newStatus === "granted") {
|
|
||||||
await fetchSSID();
|
|
||||||
}
|
|
||||||
|
|
||||||
return newStatus === "granted";
|
|
||||||
} catch {
|
|
||||||
setPermissionStatus("unavailable");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [fetchSSID]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV || !Location) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initialize() {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const { status } = await Location.getForegroundPermissionsAsync();
|
|
||||||
const mappedStatus = mapLocationStatus(status);
|
|
||||||
setPermissionStatus(mappedStatus);
|
|
||||||
|
|
||||||
if (mappedStatus === "granted") {
|
|
||||||
await fetchSSID();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setPermissionStatus("unavailable");
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize();
|
|
||||||
}, [fetchSSID]);
|
|
||||||
|
|
||||||
// Refresh SSID when permission status changes to granted
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
if (permissionStatus === "granted") {
|
|
||||||
fetchSSID();
|
|
||||||
|
|
||||||
// Also set up an interval to periodically check SSID
|
|
||||||
const interval = setInterval(fetchSSID, 10000); // Check every 10 seconds
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [permissionStatus, fetchSSID]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ssid,
|
|
||||||
permissionStatus,
|
|
||||||
requestPermission,
|
|
||||||
isLoading,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -535,6 +535,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
|
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||||
|
|
||||||
|
// Identity fields used to map a Jellyfin subtitle to the real track
|
||||||
|
// (instead of fragile positional counting). `external` + `external-filename`
|
||||||
|
// uniquely identify a sub-added sidecar. `ff-index` is exposed for
|
||||||
|
// diagnostics / potential future exact-index matching; the current
|
||||||
|
// resolver matches embedded tracks by language/title, not ff-index.
|
||||||
|
val external = mpv?.getPropertyBoolean("track-list/$i/external") ?: false
|
||||||
|
track["external"] = external
|
||||||
|
mpv?.getPropertyString("track-list/$i/external-filename")?.let {
|
||||||
|
track["externalFilename"] = it
|
||||||
|
}
|
||||||
|
mpv?.getPropertyInt("track-list/$i/ff-index")?.let { track["ffIndex"] = it }
|
||||||
|
|
||||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
@@ -840,6 +853,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
||||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||||
|
|
||||||
|
// The disable above can race a JS-side identity selection that
|
||||||
|
// landed before FILE_LOADED (JS no longer passes an initial sid).
|
||||||
|
// Re-emit tracksReady so the idempotent JS re-apply always runs
|
||||||
|
// after it — for embedded-only files this is the only
|
||||||
|
// post-FILE_LOADED fire.
|
||||||
|
mainHandler.post { delegate?.onTracksReady() }
|
||||||
|
|
||||||
if (!isReadyToSeek) {
|
if (!isReadyToSeek) {
|
||||||
isReadyToSeek = true
|
isReadyToSeek = true
|
||||||
mainHandler.post { delegate?.onReadyToSeek() }
|
mainHandler.post { delegate?.onReadyToSeek() }
|
||||||
|
|||||||
@@ -508,6 +508,15 @@ final class MPVLayerRenderer {
|
|||||||
} else {
|
} else {
|
||||||
disableSubtitles()
|
disableSubtitles()
|
||||||
}
|
}
|
||||||
|
// The disable above can race a JS-side identity selection that
|
||||||
|
// landed before FILE_LOADED (JS no longer passes an initial sid).
|
||||||
|
// Re-emit tracksReady so the idempotent JS re-apply always runs
|
||||||
|
// after it — for embedded-only files this is the only
|
||||||
|
// post-FILE_LOADED fire.
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.delegate?.renderer(self, didBecomeTracksReady: true)
|
||||||
|
}
|
||||||
if !isReadyToSeek {
|
if !isReadyToSeek {
|
||||||
isReadyToSeek = true
|
isReadyToSeek = true
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
@@ -759,7 +768,7 @@ final class MPVLayerRenderer {
|
|||||||
trackType == "sub" else { continue }
|
trackType == "sub" else { continue }
|
||||||
|
|
||||||
var trackId: Int64 = 0
|
var trackId: Int64 = 0
|
||||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue }
|
||||||
|
|
||||||
var track: [String: Any] = ["id": Int(trackId)]
|
var track: [String: Any] = ["id": Int(trackId)]
|
||||||
|
|
||||||
@@ -771,11 +780,33 @@ final class MPVLayerRenderer {
|
|||||||
track["lang"] = lang
|
track["lang"] = lang
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let codec = getStringProperty(handle: handle, name: "track-list/\(i)/codec") {
|
||||||
|
track["codec"] = codec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity fields used to map a Jellyfin subtitle to the real track
|
||||||
|
// (instead of fragile positional counting). `external` + `external-filename`
|
||||||
|
// uniquely identify a sub-added sidecar. `ff-index` is exposed for
|
||||||
|
// diagnostics / potential future exact-index matching; the current
|
||||||
|
// resolver matches embedded tracks by language/title, not ff-index.
|
||||||
|
var external: Int32 = 0
|
||||||
|
getProperty(handle: handle, name: "track-list/\(i)/external", format: MPV_FORMAT_FLAG, value: &external)
|
||||||
|
track["external"] = external != 0
|
||||||
|
|
||||||
|
if let extFilename = getStringProperty(handle: handle, name: "track-list/\(i)/external-filename") {
|
||||||
|
track["externalFilename"] = extFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
var ffIndex: Int64 = 0
|
||||||
|
if getProperty(handle: handle, name: "track-list/\(i)/ff-index", format: MPV_FORMAT_INT64, value: &ffIndex) >= 0 {
|
||||||
|
track["ffIndex"] = Int(ffIndex)
|
||||||
|
}
|
||||||
|
|
||||||
var selected: Int32 = 0
|
var selected: Int32 = 0
|
||||||
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
|
||||||
track["selected"] = selected != 0
|
track["selected"] = selected != 0
|
||||||
|
|
||||||
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none")", type: "Info")
|
Logger.shared.log("getSubtitleTracks: found sub track id=\(trackId), title=\(track["title"] ?? "none"), lang=\(track["lang"] ?? "none"), external=\(external != 0)", type: "Info")
|
||||||
tracks.append(track)
|
tracks.append(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,7 +903,7 @@ final class MPVLayerRenderer {
|
|||||||
trackType == "audio" else { continue }
|
trackType == "audio" else { continue }
|
||||||
|
|
||||||
var trackId: Int64 = 0
|
var trackId: Int64 = 0
|
||||||
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
|
guard getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId) >= 0 else { continue }
|
||||||
|
|
||||||
var track: [String: Any] = ["id": Int(trackId)]
|
var track: [String: Any] = ["id": Int(trackId)]
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,14 @@ export type SubtitleTrack = {
|
|||||||
id: number;
|
id: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
|
/** Subtitle codec (mpv `codec`), e.g. "subrip", "ass", "hdmv_pgs_subtitle". */
|
||||||
|
codec?: string;
|
||||||
|
/** True if loaded from a separate file via `sub-add` (mpv `external`). */
|
||||||
|
external?: boolean;
|
||||||
|
/** For external tracks: the exact URL/path it was loaded from (mpv `external-filename`). */
|
||||||
|
externalFilename?: string;
|
||||||
|
/** FFmpeg stream index (mpv `ff-index`); not guaranteed for non-lavf demuxers. */
|
||||||
|
ffIndex?: number;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
19
modules/wifi-ssid/android/build.gradle
Normal file
19
modules/wifi-ssid/android/build.gradle
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
apply plugin: 'expo-module-gradle-plugin'
|
||||||
|
|
||||||
|
group = 'expo.modules.wifissid'
|
||||||
|
version = '1.0.0'
|
||||||
|
|
||||||
|
expoModule {
|
||||||
|
canBePublished false
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.wifissid"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
}
|
||||||
9
modules/wifi-ssid/android/src/main/AndroidManifest.xml
Normal file
9
modules/wifi-ssid/android/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Reading the connected Wi-Fi SSID requires ACCESS_WIFI_STATE, and (since
|
||||||
|
Android 8) ACCESS_FINE_LOCATION granted at runtime. Location is requested
|
||||||
|
at runtime by the app via expo-location; the manifest declares the
|
||||||
|
Wi-Fi/network permissions this module needs directly. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package expo.modules.wifissid
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.wifi.WifiInfo
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
|
class WifiSsidModule : Module() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "WifiSsid"
|
||||||
|
private const val UNKNOWN_SSID = "<unknown ssid>"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val context
|
||||||
|
get() = requireNotNull(appContext.reactContext)
|
||||||
|
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("WifiSsid")
|
||||||
|
|
||||||
|
AsyncFunction("getSSID") {
|
||||||
|
val (ssid, connectedToWifi) = readWifi()
|
||||||
|
mapOf(
|
||||||
|
"ssid" to ssid,
|
||||||
|
"connectedToWifi" to connectedToWifi,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("getSSIDSync") {
|
||||||
|
val (ssid, connectedToWifi) = readWifi()
|
||||||
|
mapOf(
|
||||||
|
"ssid" to ssid,
|
||||||
|
"connectedToWifi" to connectedToWifi,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("openLocationSettings") {
|
||||||
|
openLocationSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current Wi-Fi SSID (or null when it can't be read) and whether
|
||||||
|
* the device is connected to a Wi-Fi network. When the device is connected but
|
||||||
|
* the SSID is null, the OS is withholding the network name — on Android this
|
||||||
|
* typically means device Location services are off (the SSID is treated as
|
||||||
|
* location-sensitive).
|
||||||
|
*/
|
||||||
|
private fun readWifi(): Pair<String?, Boolean> {
|
||||||
|
val androidContext = context.applicationContext
|
||||||
|
var connectedToWifi = false
|
||||||
|
var ssid: String? = null
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
try {
|
||||||
|
val cm = androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
val caps = cm?.let { manager ->
|
||||||
|
manager.activeNetwork?.let { net -> manager.getNetworkCapabilities(net) }
|
||||||
|
}
|
||||||
|
connectedToWifi = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||||
|
if (hasFineLocationPermission()) {
|
||||||
|
ssid = (caps?.transportInfo as? WifiInfo)?.ssid?.sanitize()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error reading Wi-Fi via ConnectivityManager", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pre-Q (API 26–28): NetworkCapabilities/transportInfo aren't available, so
|
||||||
|
// detect a Wi-Fi connection via the legacy active-network API. Without this,
|
||||||
|
// connectedToWifi stays false on Android 8/9 even when connected, so the
|
||||||
|
// "connected but SSID hidden" state never surfaces there.
|
||||||
|
try {
|
||||||
|
val cm = androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
connectedToWifi = cm?.activeNetworkInfo?.type == ConnectivityManager.TYPE_WIFI
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error reading Wi-Fi via ConnectivityManager (legacy)", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ssid == null && hasFineLocationPermission()) {
|
||||||
|
try {
|
||||||
|
val wm = androidContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
ssid = wm?.connectionInfo?.ssid?.sanitize()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error reading SSID via WifiManager", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pair(ssid, connectedToWifi)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openLocationSettings() {
|
||||||
|
try {
|
||||||
|
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.applicationContext.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Unable to open Location settings", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasFineLocationPermission(): Boolean =
|
||||||
|
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
private fun String.sanitize(): String? {
|
||||||
|
val cleaned = trim('"')
|
||||||
|
return if (cleaned.isBlank() || cleaned == UNKNOWN_SSID) null else cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "wifi-ssid",
|
"name": "wifi-ssid",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"platforms": ["ios"],
|
"platforms": ["ios", "android"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"modules": ["WifiSsidModule"]
|
"modules": ["WifiSsidModule"]
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"modules": ["expo.modules.wifissid.WifiSsidModule"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,92 @@
|
|||||||
import { Platform, requireNativeModule } from "expo-modules-core";
|
import { Platform, requireNativeModule } from "expo-modules-core";
|
||||||
|
import { Linking } from "react-native";
|
||||||
|
|
||||||
// Only load the native module on iOS
|
export interface WifiSsidResult {
|
||||||
const WifiSsidModule =
|
ssid: string | null;
|
||||||
Platform.OS === "ios" ? requireNativeModule("WifiSsid") : null;
|
/**
|
||||||
|
* Whether the device is connected to a Wi-Fi network. When true but `ssid` is
|
||||||
|
* null, the OS is withholding the network name — on Android this happens when
|
||||||
|
* device Location services are off (the SSID is location-sensitive).
|
||||||
|
*/
|
||||||
|
connectedToWifi: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the native module on iOS and Android (not TV/web). Wrapped so a
|
||||||
|
// missing/unlinked module degrades to null instead of crashing the bundle.
|
||||||
|
const WifiSsidModule = (() => {
|
||||||
|
if (Platform.OS !== "ios" && Platform.OS !== "android") return null;
|
||||||
|
try {
|
||||||
|
return requireNativeModule("WifiSsid");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current WiFi SSID on iOS.
|
* Get the current WiFi SSID and whether the device is on a Wi-Fi network.
|
||||||
* Returns null on Android or if not connected to WiFi.
|
|
||||||
*
|
*
|
||||||
* Requires:
|
* iOS requirements:
|
||||||
* - Location permission granted
|
* - Location permission granted
|
||||||
* - com.apple.developer.networking.wifi-info entitlement
|
* - com.apple.developer.networking.wifi-info entitlement
|
||||||
* - Access WiFi Information capability enabled in Apple Developer Portal
|
*
|
||||||
|
* Android requirements:
|
||||||
|
* - ACCESS_FINE_LOCATION granted at runtime, and device Location services on
|
||||||
*/
|
*/
|
||||||
export async function getSSID(): Promise<string | null> {
|
export async function getSSID(): Promise<WifiSsidResult> {
|
||||||
if (!WifiSsidModule) {
|
if (!WifiSsidModule) {
|
||||||
return null;
|
return { ssid: null, connectedToWifi: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ssid = await WifiSsidModule.getSSID();
|
return normalize(await WifiSsidModule.getSSID());
|
||||||
return ssid ?? null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[WifiSsid] Error getting SSID:", error);
|
console.error("[WifiSsid] Error getting SSID:", error);
|
||||||
return null;
|
return { ssid: null, connectedToWifi: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous version (uses the older CNCopyCurrentNetworkInfo API on iOS). */
|
||||||
|
export function getSSIDSync(): WifiSsidResult {
|
||||||
|
if (!WifiSsidModule) {
|
||||||
|
return { ssid: null, connectedToWifi: false };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalize(WifiSsidModule.getSSIDSync());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[WifiSsid] Error getting SSID (sync):", error);
|
||||||
|
return { ssid: null, connectedToWifi: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronous version - uses older CNCopyCurrentNetworkInfo API
|
* Open the system Location settings so the user can enable Location services
|
||||||
|
* (required on Android for the OS to reveal the Wi-Fi SSID). Falls back to the
|
||||||
|
* app's settings page where there is no dedicated native action (e.g. iOS).
|
||||||
*/
|
*/
|
||||||
export function getSSIDSync(): string | null {
|
export function openLocationSettings(): void {
|
||||||
if (!WifiSsidModule) {
|
try {
|
||||||
return null;
|
if (WifiSsidModule?.openLocationSettings) {
|
||||||
|
WifiSsidModule.openLocationSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[WifiSsid] Error opening location settings:", error);
|
||||||
|
}
|
||||||
|
Linking.openSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Both native modules return { ssid, connectedToWifi }. The string branch is a
|
||||||
return WifiSsidModule.getSSIDSync() ?? null;
|
// defensive fallback in case a build returns the older plain-string shape.
|
||||||
} catch (error) {
|
function normalize(result: unknown): WifiSsidResult {
|
||||||
console.error("[WifiSsid] Error getting SSID (sync):", error);
|
if (typeof result === "string") {
|
||||||
return null;
|
const ssid = result || null;
|
||||||
|
return { ssid, connectedToWifi: ssid !== null };
|
||||||
}
|
}
|
||||||
|
if (result && typeof result === "object") {
|
||||||
|
const obj = result as { ssid?: string | null; connectedToWifi?: boolean };
|
||||||
|
return {
|
||||||
|
ssid: obj.ssid ?? null,
|
||||||
|
connectedToWifi: Boolean(obj.connectedToWifi),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ssid: null, connectedToWifi: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,113 @@
|
|||||||
import ExpoModulesCore
|
import ExpoModulesCore
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
import Network
|
||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import SystemConfiguration.CaptiveNetwork
|
import SystemConfiguration.CaptiveNetwork
|
||||||
#endif
|
#endif
|
||||||
|
import UIKit
|
||||||
|
|
||||||
public class WifiSsidModule: Module {
|
public class WifiSsidModule: Module {
|
||||||
|
#if !os(tvOS)
|
||||||
|
// NWPathMonitor reports whether the active path uses Wi-Fi WITHOUT requiring
|
||||||
|
// the wifi-info entitlement, so we can tell "on Wi-Fi but can't read the SSID"
|
||||||
|
// apart from "not connected to Wi-Fi" even when fetchCurrent returns nil.
|
||||||
|
private let pathMonitor = NWPathMonitor()
|
||||||
|
private let monitorQueue = DispatchQueue(label: "expo.modules.wifissid.pathmonitor")
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var cachedOnWifi = false
|
||||||
|
private var pathMonitorStarted = false
|
||||||
|
|
||||||
|
private var isOnWifi: Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return cachedOnWifi
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPathMonitorIfNeeded() {
|
||||||
|
lock.lock()
|
||||||
|
if pathMonitorStarted {
|
||||||
|
lock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pathMonitorStarted = true
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
pathMonitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
guard let self else { return }
|
||||||
|
let onWifi = path.status == .satisfied && path.usesInterfaceType(.wifi)
|
||||||
|
self.lock.lock()
|
||||||
|
self.cachedOnWifi = onWifi
|
||||||
|
self.lock.unlock()
|
||||||
|
}
|
||||||
|
pathMonitor.start(queue: monitorQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
pathMonitor.cancel()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
public func definition() -> ModuleDefinition {
|
public func definition() -> ModuleDefinition {
|
||||||
Name("WifiSsid")
|
Name("WifiSsid")
|
||||||
|
|
||||||
// Get current WiFi SSID using NEHotspotNetwork (iOS 14+)
|
AsyncFunction("getSSID") { () -> [String: Any] in
|
||||||
// Not available on tvOS
|
|
||||||
AsyncFunction("getSSID") { () -> String? in
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
return nil
|
return ["ssid": NSNull(), "connectedToWifi": false]
|
||||||
#else
|
#else
|
||||||
return await withCheckedContinuation { continuation in
|
self.startPathMonitorIfNeeded()
|
||||||
NEHotspotNetwork.fetchCurrent { network in
|
let ssid = await self.resolveSSID()
|
||||||
if let ssid = network?.ssid {
|
var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
|
||||||
print("[WifiSsid] Got SSID via NEHotspotNetwork: \(ssid)")
|
if let ssid = ssid {
|
||||||
continuation.resume(returning: ssid)
|
result["ssid"] = ssid
|
||||||
} else {
|
} else {
|
||||||
// Fallback to CNCopyCurrentNetworkInfo for older iOS
|
result["ssid"] = NSNull()
|
||||||
print("[WifiSsid] NEHotspotNetwork returned nil, trying CNCopyCurrentNetworkInfo")
|
|
||||||
let ssid = self.getSSIDViaCNCopy()
|
|
||||||
continuation.resume(returning: ssid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronous version using only CNCopyCurrentNetworkInfo
|
Function("getSSIDSync") { () -> [String: Any] in
|
||||||
// Not available on tvOS
|
|
||||||
Function("getSSIDSync") { () -> String? in
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
return nil
|
return ["ssid": NSNull(), "connectedToWifi": false]
|
||||||
#else
|
#else
|
||||||
return self.getSSIDViaCNCopy()
|
self.startPathMonitorIfNeeded()
|
||||||
|
let ssid = self.getSSIDViaCNCopy()
|
||||||
|
var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
|
||||||
|
if let ssid = ssid {
|
||||||
|
result["ssid"] = ssid
|
||||||
|
} else {
|
||||||
|
result["ssid"] = NSNull()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("openLocationSettings") {
|
||||||
|
#if !os(tvOS)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
|
private func resolveSSID() async -> String? {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
NEHotspotNetwork.fetchCurrent { network in
|
||||||
|
if let ssid = network?.ssid {
|
||||||
|
continuation.resume(returning: ssid)
|
||||||
|
} else {
|
||||||
|
continuation.resume(returning: self.getSSIDViaCNCopy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func getSSIDViaCNCopy() -> String? {
|
private func getSSIDViaCNCopy() -> String? {
|
||||||
guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
|
guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
|
||||||
print("[WifiSsid] CNCopySupportedInterfaces returned nil")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +117,10 @@ public class WifiSsidModule: Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
|
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
|
||||||
print("[WifiSsid] Got SSID via CNCopyCurrentNetworkInfo: \(ssid)")
|
|
||||||
return ssid
|
return ssid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "1.2.0",
|
"@bottom-tabs/react-navigation": "1.4.0",
|
||||||
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
||||||
"@expo/metro-runtime": "~56.0.15",
|
"@expo/metro-runtime": "~56.0.15",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~56.0.5",
|
||||||
"expo-build-properties": "~56.0.18",
|
"expo-build-properties": "~56.0.18",
|
||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
|
"expo-clipboard": "~56.0.4",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~56.0.4",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
"expo-font": "~56.0.6",
|
"expo-font": "~56.0.6",
|
||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
"expo-image": "~56.0.11",
|
"expo-image": "~56.0.11",
|
||||||
|
"expo-key-event": "^1.9.0",
|
||||||
"expo-linear-gradient": "~56.0.4",
|
"expo-linear-gradient": "~56.0.4",
|
||||||
"expo-linking": "~56.0.14",
|
"expo-linking": "~56.0.14",
|
||||||
"expo-localization": "~56.0.6",
|
"expo-localization": "~56.0.6",
|
||||||
@@ -85,7 +87,7 @@
|
|||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-native": "npm:react-native-tvos@0.85.3-0",
|
"react-native": "npm:react-native-tvos@0.85.3-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.2.0",
|
"react-native-bottom-tabs": "1.4.0",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
|
||||||
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
|
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
|
||||||
@@ -8,7 +8,7 @@ import SwiftUI
|
|
||||||
self.delegate = delegate
|
|
||||||
}
|
|
||||||
|
|
||||||
- #if !os(macOS)
|
|
||||||
+ #if !os(macOS) && !os(tvOS)
|
|
||||||
@available(iOS 26.0, *)
|
|
||||||
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
|
|
||||||
var placementValue = "none"
|
|
||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
|
||||||
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
|
||||||
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
|
||||||
}
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
- #if os(macOS)
|
|
||||||
- // tabViewBottomAccessory is not available on macOS
|
|
||||||
+ #if os(macOS) || os(tvOS)
|
|
||||||
+ // tabViewBottomAccessory is not available on macOS or tvOS
|
|
||||||
content
|
|
||||||
#else
|
|
||||||
- if #available(iOS 26.0, tvOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil {
|
|
||||||
+ if #available(iOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil {
|
|
||||||
content
|
|
||||||
.tabViewBottomAccessory {
|
|
||||||
renderBottomAccessoryView()
|
|
||||||
@@ -95,7 +95,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func renderBottomAccessoryView() -> some View {
|
|
||||||
- #if !os(macOS)
|
|
||||||
+ #if !os(macOS) && !os(tvOS)
|
|
||||||
if let bottomAccessoryView {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
BottomAccessoryRepresentableView(view: bottomAccessoryView)
|
|
||||||
@@ -105,7 +105,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
-#if !os(macOS)
|
|
||||||
+#if !os(macOS) && !os(tvOS)
|
|
||||||
@available(iOS 26.0, *)
|
|
||||||
struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
|
|
||||||
@Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement
|
|
||||||
@@ -135,3 +135,4 @@ struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
+
|
|
||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
|
||||||
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
|
||||||
@@ -281,7 +281,7 @@ extension View {
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func tabBarMinimizeBehavior(_ behavior: MinimizeBehavior?) -> some View {
|
|
||||||
- #if compiler(>=6.2)
|
|
||||||
+ #if compiler(>=6.2) && !os(tvOS)
|
|
||||||
if #available(iOS 26.0, macOS 26.0, *) {
|
|
||||||
if let behavior {
|
|
||||||
self.tabBarMinimizeBehavior(behavior.convert())
|
|
||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
|
||||||
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
|
||||||
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
|
|
||||||
case onScrollUp
|
|
||||||
case onScrollDown
|
|
||||||
|
|
||||||
-#if compiler(>=6.2)
|
|
||||||
+#if compiler(>=6.2) && !os(tvOS)
|
|
||||||
@available(iOS 26.0, macOS 26.0, *)
|
|
||||||
func convert() -> TabBarMinimizeBehavior {
|
|
||||||
#if os(macOS)
|
|
||||||
@@ -15,12 +15,14 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AppState, Platform } from "react-native";
|
import { AppState, Platform } from "react-native";
|
||||||
import { getDeviceNameSync } from "react-native-device-info";
|
import { getDeviceNameSync } from "react-native-device-info";
|
||||||
import uuid from "react-native-uuid";
|
import uuid from "react-native-uuid";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useInterval } from "@/hooks/useInterval";
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
@@ -91,6 +93,12 @@ export const apiAtom = atom<Api | null>(initialApi);
|
|||||||
export const userAtom = atom<UserDto | null>(initialUser);
|
export const userAtom = atom<UserDto | null>(initialUser);
|
||||||
export const wsAtom = atom<WebSocket | null>(null);
|
export const wsAtom = atom<WebSocket | null>(null);
|
||||||
export const cacheVersionAtom = atom<number>(0);
|
export const cacheVersionAtom = atom<number>(0);
|
||||||
|
// Set by a login flow that wants the account saved: the protection picker
|
||||||
|
// shows AFTER the session is authorized (the login screen unmounts on
|
||||||
|
// success, so the modal lives at the root — see PendingAccountSaveModal).
|
||||||
|
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
interface LoginOptions {
|
interface LoginOptions {
|
||||||
saveAccount?: boolean;
|
saveAccount?: boolean;
|
||||||
@@ -108,6 +116,11 @@ interface JellyfinContextValue {
|
|||||||
serverName?: string,
|
serverName?: string,
|
||||||
options?: LoginOptions,
|
options?: LoginOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
saveCurrentAccount: (options?: {
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
serverName?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
initiateQuickConnect: () => Promise<string | undefined>;
|
initiateQuickConnect: () => Promise<string | undefined>;
|
||||||
stopQuickConnectPolling: () => void;
|
stopQuickConnectPolling: () => void;
|
||||||
@@ -165,6 +178,69 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// --- Session-expiry handling ----------------------------------------------
|
||||||
|
// When the server revokes the token (e.g. the device/session is deleted), a
|
||||||
|
// 401 can surface from any authenticated request. Without central handling
|
||||||
|
// the dead token stays in storage, so every reload re-fires authed calls →
|
||||||
|
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
|
||||||
|
// state. A single response interceptor on the authenticated api clears the
|
||||||
|
// session on the first 401 so the app drops cleanly to the login screen.
|
||||||
|
const sessionExpiredRef = useRef(false);
|
||||||
|
|
||||||
|
// Shared teardown for manual logout AND forced session expiry — keeping it
|
||||||
|
// in one place prevents the two paths from drifting (a 401 expiry must wipe
|
||||||
|
// plugin settings / Jellyseerr state too, or the next login on the same
|
||||||
|
// device inherits the previous user's data).
|
||||||
|
// Saved credentials are kept so the user can quick-login again.
|
||||||
|
const clearSessionState = useCallback(async () => {
|
||||||
|
// All synchronous teardown first: if the async Jellyseerr cleanup below
|
||||||
|
// fails or resolves late (user may already be re-authenticating), the
|
||||||
|
// session/cache state is already gone.
|
||||||
|
storage.remove("token");
|
||||||
|
storage.remove("user");
|
||||||
|
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
||||||
|
clearTVDiscoverySafely();
|
||||||
|
setUser(null);
|
||||||
|
setApi(null);
|
||||||
|
setPluginSettings(undefined);
|
||||||
|
queryClient.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await clearAllJellyseerData();
|
||||||
|
} catch (e) {
|
||||||
|
writeErrorLog(
|
||||||
|
`Failed to clear Jellyseerr data: ${e instanceof Error ? e.message : e}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [setUser, setApi, setPluginSettings, clearAllJellyseerData, queryClient]);
|
||||||
|
|
||||||
|
const handleSessionExpired = useCallback(() => {
|
||||||
|
if (sessionExpiredRef.current) return; // run once per session
|
||||||
|
sessionExpiredRef.current = true;
|
||||||
|
clearSessionState().catch((e) =>
|
||||||
|
writeErrorLog(`Session-expiry cleanup failed: ${e?.message ?? e}`),
|
||||||
|
);
|
||||||
|
}, [clearSessionState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only guard an authenticated session. A pre-auth api (login screen) keeps
|
||||||
|
// its own handling — a wrong-password 401 is not a session expiry.
|
||||||
|
if (!api?.accessToken) return;
|
||||||
|
sessionExpiredRef.current = false; // re-arm for this fresh session
|
||||||
|
const interceptorId = api.axiosInstance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
handleSessionExpired();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
api.axiosInstance.interceptors.response.eject(interceptorId);
|
||||||
|
};
|
||||||
|
}, [api, handleSessionExpired]);
|
||||||
|
|
||||||
const headers = useMemo(() => {
|
const headers = useMemo(() => {
|
||||||
if (!deviceId) return {};
|
if (!deviceId) return {};
|
||||||
return {
|
return {
|
||||||
@@ -307,6 +383,40 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Persist the CURRENT session to secure storage — used by the post-login
|
||||||
|
// save-account modal (the protection picker shows AFTER a successful
|
||||||
|
// login, for both the password and Quick Connect flows).
|
||||||
|
const saveCurrentAccount = useCallback(
|
||||||
|
async (options?: {
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
serverName?: string;
|
||||||
|
}) => {
|
||||||
|
const token = storage.getString("token");
|
||||||
|
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
|
||||||
|
const securityType = options?.securityType || "none";
|
||||||
|
let pinHash: string | undefined;
|
||||||
|
if (securityType === "pin") {
|
||||||
|
// Never persist a "pin" credential without its hash — it would be
|
||||||
|
// impossible to unlock.
|
||||||
|
if (!options?.pinCode) throw new Error("PIN code is required");
|
||||||
|
pinHash = await hashPIN(options.pinCode);
|
||||||
|
}
|
||||||
|
await saveAccountCredential({
|
||||||
|
serverUrl: api.basePath,
|
||||||
|
serverName: options?.serverName || "",
|
||||||
|
token,
|
||||||
|
userId: user.Id,
|
||||||
|
username: user.Name,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
securityType,
|
||||||
|
pinHash,
|
||||||
|
primaryImageTag: user.PrimaryImageTag ?? undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api?.basePath, user],
|
||||||
|
);
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
username,
|
username,
|
||||||
@@ -338,7 +448,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
if (securityType === "pin" && options.pinCode) {
|
if (securityType === "pin" && options.pinCode) {
|
||||||
pinHash = await hashPIN(options.pinCode);
|
pinHash = await hashPIN(options.pinCode);
|
||||||
}
|
}
|
||||||
|
if (securityType === "pin" && !pinHash) {
|
||||||
|
// Never persist a "pin" credential without its hash — it would be
|
||||||
|
// impossible to unlock. Skip the save rather than failing a login
|
||||||
|
// that already succeeded, and tell the user it didn't happen.
|
||||||
|
writeErrorLog("Account save skipped: PIN required but missing");
|
||||||
|
toast.error(t("save_account.not_saved"));
|
||||||
|
} else {
|
||||||
await saveAccountCredential({
|
await saveAccountCredential({
|
||||||
serverUrl: api.basePath,
|
serverUrl: api.basePath,
|
||||||
serverName: serverName || "",
|
serverName: serverName || "",
|
||||||
@@ -351,6 +467,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
|
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
||||||
@@ -409,19 +526,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
writeErrorLog("Failed to delete expo push token for device"),
|
writeErrorLog("Failed to delete expo push token for device"),
|
||||||
);
|
);
|
||||||
|
|
||||||
storage.remove("token");
|
await clearSessionState();
|
||||||
storage.remove("user");
|
|
||||||
clearTVDiscoverySafely();
|
|
||||||
setUser(null);
|
|
||||||
setApi(null);
|
|
||||||
setPluginSettings(undefined);
|
|
||||||
await clearAllJellyseerData();
|
|
||||||
|
|
||||||
// Clear React Query cache to prevent data from previous account lingering
|
|
||||||
queryClient.clear();
|
|
||||||
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
|
||||||
|
|
||||||
// Note: We keep saved credentials for quick switching back
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
@@ -509,7 +614,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Quick login failed:", error);
|
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
||||||
|
// server unreachable): the UI surfaces the message, so warn, don't error.
|
||||||
|
console.warn("Quick login failed:", error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -620,12 +727,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setUser(storedUser);
|
setUser(storedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss splash screen with cached data immediately,
|
// Validate the token and refresh user data in the background. Do NOT
|
||||||
// fetch fresh user data in the background
|
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
||||||
setInitialLoaded(true);
|
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
||||||
|
// blocks splash dismissal. The cached storedUser (set above) is enough
|
||||||
try {
|
// to render; on success we just refresh it.
|
||||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
getUserApi(apiInstance)
|
||||||
|
.getCurrentUser()
|
||||||
|
.then(async (response) => {
|
||||||
|
// The response can resolve long after startup (no axios timeout).
|
||||||
|
// If the session changed meanwhile (logout, account switch), drop
|
||||||
|
// it instead of repopulating a stale user / re-saving credentials.
|
||||||
|
if (getTokenFromStorage() !== token) return;
|
||||||
setUser(response.data);
|
setUser(response.data);
|
||||||
|
|
||||||
// Migrate current session to secure storage if not already saved
|
// Migrate current session to secure storage if not already saved
|
||||||
@@ -659,15 +772,21 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
})
|
||||||
// Background fetch failed — app already rendered with cached data
|
.catch((e) => {
|
||||||
console.warn("Background user fetch failed, using cached data:", e);
|
// Expected, handled case (offline, or a token the server rejects —
|
||||||
}
|
// the UI prompts re-login): warn, don't error. Log only
|
||||||
} else {
|
// status/message — never the raw error (axios errors carry the
|
||||||
setInitialLoaded(true);
|
// request config incl. the Authorization header / token).
|
||||||
|
console.warn(
|
||||||
|
"Background user validation failed:",
|
||||||
|
e?.response?.status ?? e?.message ?? "unknown error",
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
} finally {
|
||||||
setInitialLoaded(true);
|
setInitialLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -681,6 +800,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
removeServer: () => removeServerMutation.mutateAsync(),
|
removeServer: () => removeServerMutation.mutateAsync(),
|
||||||
login: (username, password, serverName, options) =>
|
login: (username, password, serverName, options) =>
|
||||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||||
|
saveCurrentAccount,
|
||||||
logout: () => logoutMutation.mutateAsync(),
|
logout: () => logoutMutation.mutateAsync(),
|
||||||
initiateQuickConnect,
|
initiateQuickConnect,
|
||||||
stopQuickConnectPolling,
|
stopQuickConnectPolling,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -18,6 +19,7 @@ interface ServerUrlContextValue {
|
|||||||
effectiveServerUrl: string | null;
|
effectiveServerUrl: string | null;
|
||||||
isUsingLocalUrl: boolean;
|
isUsingLocalUrl: boolean;
|
||||||
currentSSID: string | null;
|
currentSSID: string | null;
|
||||||
|
connectedToWifi: boolean;
|
||||||
refreshUrlState: () => void;
|
refreshUrlState: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ interface Props {
|
|||||||
export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { switchServerUrl } = useJellyfin();
|
const { switchServerUrl } = useJellyfin();
|
||||||
const { ssid, permissionStatus } = useWifiSSID();
|
const { ssid, connectedToWifi, permissionStatus } = useWifiSSID();
|
||||||
|
|
||||||
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
|
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
|
||||||
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
|
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
|
||||||
@@ -101,15 +103,25 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
|||||||
};
|
};
|
||||||
}, [ssid, permissionStatus, evaluateAndSwitchUrl]);
|
}, [ssid, permissionStatus, evaluateAndSwitchUrl]);
|
||||||
|
|
||||||
return (
|
const value = useMemo(
|
||||||
<ServerUrlContext.Provider
|
() => ({
|
||||||
value={{
|
|
||||||
effectiveServerUrl,
|
effectiveServerUrl,
|
||||||
isUsingLocalUrl,
|
isUsingLocalUrl,
|
||||||
currentSSID: ssid,
|
currentSSID: ssid,
|
||||||
|
connectedToWifi,
|
||||||
refreshUrlState,
|
refreshUrlState,
|
||||||
}}
|
}),
|
||||||
>
|
[
|
||||||
|
effectiveServerUrl,
|
||||||
|
isUsingLocalUrl,
|
||||||
|
ssid,
|
||||||
|
connectedToWifi,
|
||||||
|
refreshUrlState,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServerUrlContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</ServerUrlContext.Provider>
|
</ServerUrlContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const protocol = api.basePath.includes("https") ? "wss" : "ws";
|
const protocol = api.basePath.includes("https") ? "wss" : "ws";
|
||||||
const url = `${protocol}://${api.basePath
|
const url = `${protocol}://${api.basePath
|
||||||
.replace("https://", "")
|
.replace("https://", "")
|
||||||
.replace("http://", "")}/socket?api_key=${
|
.replace("http://", "")}/socket?ApiKey=${
|
||||||
api.accessToken
|
api.accessToken
|
||||||
}&deviceId=${deviceId}`;
|
}&deviceId=${deviceId}`;
|
||||||
|
|
||||||
|
|||||||
157
providers/WifiSsidProvider.tsx
Normal file
157
providers/WifiSsidProvider.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { getSSID } from "@/modules/wifi-ssid";
|
||||||
|
|
||||||
|
export type PermissionStatus =
|
||||||
|
| "granted"
|
||||||
|
| "denied"
|
||||||
|
| "undetermined"
|
||||||
|
| "unavailable";
|
||||||
|
|
||||||
|
export interface UseWifiSSIDReturn {
|
||||||
|
ssid: string | null;
|
||||||
|
connectedToWifi: boolean;
|
||||||
|
permissionStatus: PermissionStatus;
|
||||||
|
requestPermission: () => Promise<boolean>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WifiSsidContext = createContext<UseWifiSSIDReturn | null>(null);
|
||||||
|
|
||||||
|
// WiFi SSID is not available on tvOS, and expo-location isn't needed there.
|
||||||
|
const Location = Platform.isTV ? null : require("expo-location");
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
|
function mapLocationStatus(status: number | undefined): PermissionStatus {
|
||||||
|
if (!Location) return "unavailable";
|
||||||
|
switch (status) {
|
||||||
|
case Location.PermissionStatus?.GRANTED:
|
||||||
|
return "granted";
|
||||||
|
case Location.PermissionStatus?.DENIED:
|
||||||
|
return "denied";
|
||||||
|
default:
|
||||||
|
return "undetermined";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the current WiFi SSID and the location permission
|
||||||
|
* that gates it. State is shared via context so that granting permission in one
|
||||||
|
* place (e.g. the network settings screen) is reflected everywhere else — most
|
||||||
|
* importantly ServerUrlProvider, which switches between the local and remote
|
||||||
|
* server URLs based on the connected SSID.
|
||||||
|
*
|
||||||
|
* Previously each consumer created its own useWifiSSID instance with independent
|
||||||
|
* state, so the provider never learned that permission had been granted later
|
||||||
|
* in the session and reported "not connected to WiFi" forever.
|
||||||
|
*/
|
||||||
|
export function WifiSsidProvider({ children }: Props): React.ReactElement {
|
||||||
|
const [ssid, setSSID] = useState<string | null>(null);
|
||||||
|
const [connectedToWifi, setConnectedToWifi] = useState(false);
|
||||||
|
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
|
||||||
|
Platform.isTV ? "unavailable" : "undetermined",
|
||||||
|
);
|
||||||
|
const [isLoading, setIsLoading] = useState(!Platform.isTV);
|
||||||
|
|
||||||
|
const fetchSSID = useCallback(async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
const result = await getSSID();
|
||||||
|
setSSID(result.ssid);
|
||||||
|
setConnectedToWifi(result.connectedToWifi);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestPermission = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (Platform.isTV || !Location) {
|
||||||
|
setPermissionStatus("unavailable");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
const newStatus = mapLocationStatus(status);
|
||||||
|
setPermissionStatus(newStatus);
|
||||||
|
return newStatus === "granted";
|
||||||
|
} catch {
|
||||||
|
setPermissionStatus("unavailable");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check the location permission once on mount. The poll effect below takes
|
||||||
|
// over fetching the SSID whenever permission becomes "granted".
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV || !Location) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
async function initialize() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const { status } = await Location.getForegroundPermissionsAsync();
|
||||||
|
if (cancelled) return;
|
||||||
|
setPermissionStatus(mapLocationStatus(status));
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setPermissionStatus("unavailable");
|
||||||
|
}
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch the SSID immediately, then poll, whenever permission is granted.
|
||||||
|
// Because permissionStatus is shared, this also covers the case where
|
||||||
|
// permission was granted later (from another consumer) than this provider's
|
||||||
|
// mount time — the original root cause of the "not connected to WiFi" bug.
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV || permissionStatus !== "granted") return;
|
||||||
|
|
||||||
|
fetchSSID();
|
||||||
|
const interval = setInterval(fetchSSID, POLL_INTERVAL_MS);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [permissionStatus, fetchSSID]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
ssid,
|
||||||
|
connectedToWifi,
|
||||||
|
permissionStatus,
|
||||||
|
requestPermission,
|
||||||
|
isLoading,
|
||||||
|
}),
|
||||||
|
[ssid, connectedToWifi, permissionStatus, requestPermission, isLoading],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WifiSsidContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</WifiSsidContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWifiSsid(): UseWifiSSIDReturn {
|
||||||
|
const context = useContext(WifiSsidContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useWifiSsid must be used within WifiSsidProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -137,7 +137,11 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "المظهر",
|
"title": "المظهر",
|
||||||
"merge_next_up_continue_watching": "دمج تابع المشاهدة والتالي",
|
"merge_next_up_continue_watching": "دمج تابع المشاهدة والتالي",
|
||||||
|
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
|
||||||
|
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
|
||||||
|
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
|
||||||
"hide_remote_session_button": "إخفاء زر جلسة البث عن بُعد",
|
"hide_remote_session_button": "إخفاء زر جلسة البث عن بُعد",
|
||||||
|
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
|
||||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
"show_hero_carousel": "Hero Carousel",
|
"show_hero_carousel": "Hero Carousel",
|
||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
@@ -296,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
"safe_area_in_controls": "المنطقة الآمنة لعناصر التحكم",
|
"safe_area_in_controls": "المنطقة الآمنة لعناصر التحكم",
|
||||||
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
||||||
|
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
|
||||||
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
|
"show_large_home_carousel": "إظهار شريط العرض الكبير (تجريبي)",
|
||||||
"hide_libraries": "إخفاء المكتبات",
|
"hide_libraries": "إخفاء المكتبات",
|
||||||
"select_libraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
"select_libraries_you_want_to_hide": "اختر المكتبات التي تريد إخفاءها من تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||||
|
|||||||
@@ -137,7 +137,11 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
|
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
|
||||||
|
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
|
||||||
|
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button",
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
|
||||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
"show_hero_carousel": "Hero Carousel",
|
"show_hero_carousel": "Hero Carousel",
|
||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
@@ -296,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
"safe_area_in_controls": "Àrea segura als controls",
|
"safe_area_in_controls": "Àrea segura als controls",
|
||||||
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
"show_custom_menu_links": "Mostrar enllaços del menú personalitzats",
|
||||||
|
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Oculta biblioteques",
|
"hide_libraries": "Oculta biblioteques",
|
||||||
"select_libraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
|
"select_libraries_you_want_to_hide": "Seleccioneu les biblioteques que voleu ocultar de la pestanya Biblioteca i de les seccions de la pàgina d'inici.",
|
||||||
|
|||||||
@@ -137,7 +137,11 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
|
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
|
||||||
|
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
|
||||||
|
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button",
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
|
||||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
"show_hero_carousel": "Hero Carousel",
|
"show_hero_carousel": "Hero Carousel",
|
||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
@@ -296,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
"safe_area_in_controls": "Bezpečná oblast v ovládání",
|
"safe_area_in_controls": "Bezpečná oblast v ovládání",
|
||||||
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
"show_custom_menu_links": "Zobrazit vlastní Menu odkazy",
|
||||||
|
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
|
||||||
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
"show_large_home_carousel": "Zobrazit velký přehled (beta)",
|
||||||
"hide_libraries": "Skrýt knihovny",
|
"hide_libraries": "Skrýt knihovny",
|
||||||
"select_libraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
|
"select_libraries_you_want_to_hide": "Vyberte knihovny, které chcete skrýt v záložce Knihovna a v sekcích domovské stránky.",
|
||||||
|
|||||||
@@ -137,7 +137,11 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
|
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
|
||||||
|
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
|
||||||
|
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button",
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
|
||||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
"show_hero_carousel": "Hero Carousel",
|
"show_hero_carousel": "Hero Carousel",
|
||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
@@ -296,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
"safe_area_in_controls": "Sikkert område i kontroller",
|
"safe_area_in_controls": "Sikkert område i kontroller",
|
||||||
"show_custom_menu_links": "Vis tilpassede menulinks",
|
"show_custom_menu_links": "Vis tilpassede menulinks",
|
||||||
|
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Skjul biblioteker",
|
"hide_libraries": "Skjul biblioteker",
|
||||||
"select_libraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
|
"select_libraries_you_want_to_hide": "Vælg de biblioteker, du ønsker at skjule fra fanen Bibliotek og startside sektionerne.",
|
||||||
|
|||||||
@@ -137,7 +137,11 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Aussehen",
|
"title": "Aussehen",
|
||||||
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
|
"merge_next_up_continue_watching": "\"Weiterschauen\" und \"Als Nächstes\" kombinieren",
|
||||||
|
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
|
||||||
|
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
|
||||||
|
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
|
||||||
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden",
|
"hide_remote_session_button": "Button für Remote-Sitzung ausblenden",
|
||||||
|
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
|
||||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
"show_hero_carousel": "Hero Carousel",
|
"show_hero_carousel": "Hero Carousel",
|
||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
@@ -296,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
||||||
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||||
|
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
|
||||||
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
"show_large_home_carousel": "Zeige große Startseiten-Übersicht (Beta)",
|
||||||
"hide_libraries": "Bibliotheken ausblenden",
|
"hide_libraries": "Bibliotheken ausblenden",
|
||||||
"select_libraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
"select_libraries_you_want_to_hide": "Bibliotheken auswählen die aus dem Bibliothekstab und der Startseite ausgeblendet werden sollen.",
|
||||||
|
|||||||
@@ -137,7 +137,11 @@
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||||
|
"merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.",
|
||||||
|
"use_episode_images_next_up": "Use episode images for Next Up & Continue Watching",
|
||||||
|
"use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.",
|
||||||
"hide_remote_session_button": "Hide Remote Session Button",
|
"hide_remote_session_button": "Hide Remote Session Button",
|
||||||
|
"hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.",
|
||||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||||
"show_hero_carousel": "Hero Carousel",
|
"show_hero_carousel": "Hero Carousel",
|
||||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||||
@@ -296,6 +300,7 @@
|
|||||||
},
|
},
|
||||||
"safe_area_in_controls": "Ασφαλής περιοχή σε χειριστήρια",
|
"safe_area_in_controls": "Ασφαλής περιοχή σε χειριστήρια",
|
||||||
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
|
"show_custom_menu_links": "Εμφάνιση Προσαρμοσμένων Συνδέσμων Μενού",
|
||||||
|
"show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.",
|
||||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||||
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
"hide_libraries": "Απόκρυψη Βιβλιοθηκών",
|
||||||
"select_libraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
|
"select_libraries_you_want_to_hide": "Επιλέξτε τις βιβλιοθήκες που θέλετε να αποκρύψετε από την καρτέλα της Βιβλιοθήκης και τις ενότητες της αρχικής σελίδας.",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user