mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 21:48:31 +01:00
Merge branch 'develop' into feature/sync-play
This commit is contained in:
@@ -1,13 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
@@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
||||
// `matchContents` doesn't help here: it reports the native Menu's size via
|
||||
// setStyleSize and overrides any explicit size. Instead we measure the
|
||||
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
||||
const [triggerSize, setTriggerSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setTriggerSize((prev) =>
|
||||
prev && prev.width === width && prev.height === height
|
||||
? prev
|
||||
: { width, height },
|
||||
);
|
||||
};
|
||||
|
||||
// Handle controlled open state for Android
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true) {
|
||||
@@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||
// height depends on the parent while the parent depends on the Host — a
|
||||
// circular dependency that collapses to 0 for any selector nested more than
|
||||
// one level deep (so only the first, shallowest dropdown stays visible).
|
||||
// Giving the wrapper the measured size breaks the cycle; the Host then
|
||||
// fills a concrete box.
|
||||
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
||||
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
||||
return (
|
||||
<View style={triggerSize ?? { opacity: 0 }}>
|
||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||
sizes to the trigger's content rather than to its parent. */}
|
||||
<View
|
||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||
pointerEvents='none'
|
||||
aria-hidden
|
||||
onLayout={handleMeasureTrigger}
|
||||
>
|
||||
<View>
|
||||
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
||||
{trigger}
|
||||
</View>
|
||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import {
|
||||
type ChapterEntry,
|
||||
chapterStartsMs,
|
||||
@@ -38,6 +39,7 @@ function ChapterListComponent({
|
||||
onClose,
|
||||
}: ChapterListProps) {
|
||||
const { t } = useTranslation();
|
||||
const safeArea = useControlsSafeAreaInsets();
|
||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||
|
||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||
@@ -74,9 +76,22 @@ function ChapterListComponent({
|
||||
transparent
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
// iOS defaults <Modal> to portrait-only; without this it rotates the app
|
||||
// back to portrait when opened from the landscape player. Android ignores it.
|
||||
supportedOrientations={["portrait", "landscape"]}
|
||||
>
|
||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||
<Pressable
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
marginLeft: safeArea.left,
|
||||
marginRight: safeArea.right,
|
||||
paddingBottom: safeArea.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||
<Pressable
|
||||
@@ -157,14 +172,12 @@ const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: Colors.background,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "70%",
|
||||
paddingBottom: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -37,11 +37,12 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
|
||||
}}
|
||||
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
|
||||
style={
|
||||
Platform.isTV
|
||||
? { width: `${progress}%`, backgroundColor: "#ffffff" }
|
||||
: { width: `${progress}%` }
|
||||
}
|
||||
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useSegments } from "expo-router";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
@@ -149,6 +150,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const segments = useSegments();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
@@ -182,11 +184,13 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
return;
|
||||
|
||||
const options: string[] = [
|
||||
"Mark as Played",
|
||||
"Mark as Not Played",
|
||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||
...(isOffline ? ["Delete Download"] : []),
|
||||
"Cancel",
|
||||
t("common.mark_as_played"),
|
||||
t("common.mark_as_not_played"),
|
||||
isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites"),
|
||||
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
||||
t("common.cancel"),
|
||||
];
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const destructiveButtonIndex = isOffline
|
||||
@@ -219,6 +223,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (
|
||||
|
||||
@@ -133,7 +133,6 @@ const HomeMobile = () => {
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className='ml-1.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather
|
||||
|
||||
@@ -37,7 +37,20 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
return { ...item, People: people } as BaseItemDto;
|
||||
}, [item, people]);
|
||||
|
||||
const topPeople = useMemo(() => people.slice(0, 3), [people]);
|
||||
// Jellyfin can list the same person several times (e.g. an actor also
|
||||
// credited as writer). Dedupe by Id so the same actor section isn't rendered
|
||||
// twice and we still surface 3 distinct people.
|
||||
const topPeople = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const unique: BaseItemPerson[] = [];
|
||||
for (const person of people) {
|
||||
if (!person.Id || seen.has(person.Id)) continue;
|
||||
seen.add(person.Id);
|
||||
unique.push(person);
|
||||
if (unique.length >= 3) break;
|
||||
}
|
||||
return unique;
|
||||
}, [people]);
|
||||
|
||||
const renderActorSection = useCallback(
|
||||
(person: BaseItemPerson, idx: number, total: number) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "i18next";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
</View>
|
||||
|
||||
{/* Pair with Phone */}
|
||||
{onStartPairing && (
|
||||
{Platform.OS !== "ios" && onStartPairing && (
|
||||
<View>
|
||||
<Button
|
||||
onPress={onStartPairing}
|
||||
|
||||
@@ -401,10 +401,6 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasMovies = movieResults && movieResults.length > 0;
|
||||
const hasTv = tvResults && tvResults.length > 0;
|
||||
const hasPersons = personResults && personResults.length > 0;
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
@@ -431,22 +427,26 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* No section requests `hasTVPreferredFocus`: the native search field
|
||||
keeps focus while typing, otherwise the first result would re-grab
|
||||
focus on every keystroke as results re-render. The user navigates
|
||||
down to the grid manually. */}
|
||||
<TVJellyseerrMovieSection
|
||||
title={t("search.request_movies")}
|
||||
items={movieResults}
|
||||
isFirstSection={hasMovies}
|
||||
isFirstSection={false}
|
||||
onItemPress={onMoviePress}
|
||||
/>
|
||||
<TVJellyseerrTvSection
|
||||
title={t("search.request_series")}
|
||||
items={tvResults}
|
||||
isFirstSection={!hasMovies && hasTv}
|
||||
isFirstSection={false}
|
||||
onItemPress={onTvPress}
|
||||
/>
|
||||
<TVJellyseerrPersonSection
|
||||
title={t("search.actors")}
|
||||
items={personResults}
|
||||
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||
isFirstSection={false}
|
||||
onItemPress={onPersonPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -235,10 +235,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
module). It renders the native search bar + grid keyboard and
|
||||
forwards typed text into the existing query pipeline via setSearch;
|
||||
our own results grid renders below. */}
|
||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||
margins squeeze the bar's width and clip that trailing hint, so let
|
||||
the native view span the full width and own its own insets. */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
marginHorizontal: HORIZONTAL_PADDING,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
@@ -280,13 +283,17 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => (
|
||||
{sections.map((section) => (
|
||||
<TVSearchSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
items={section.items!}
|
||||
orientation={section.orientation || "vertical"}
|
||||
isFirstSection={index === 0}
|
||||
// Never auto-focus a result. The native search field owns focus
|
||||
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
||||
// every keystroke as results re-render. User navigates down to the
|
||||
// grid manually.
|
||||
isFirstSection={false}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
|
||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
removeClippedSubviews={false}
|
||||
getItemLayout={getItemLayout}
|
||||
style={{ overflow: "visible" }}
|
||||
contentInset={{
|
||||
left: edgePadding,
|
||||
right: edgePadding,
|
||||
}}
|
||||
contentOffset={{ x: -edgePadding, y: 0 }}
|
||||
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
||||
// contentOffset only applies on initial mount; since this FlatList is
|
||||
// reused across searches (stable key), a second search left the inset
|
||||
// without the offset and the grid snapped flush to the left edge.
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: edgePadding,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot, so refetches after
|
||||
// updateDownloadedItem() reflect the latest state instead of a stale
|
||||
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
@@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
onPress={() => {
|
||||
router.setParams({ id: _item.Id });
|
||||
}}
|
||||
className={`flex flex-col w-44
|
||||
className={`flex flex-col w-44
|
||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
|
||||
/>
|
||||
</OptionGroup>
|
||||
|
||||
<OptionGroup title='Options'>
|
||||
<OptionGroup title={t("library.options.options_title")}>
|
||||
<ToggleItem
|
||||
label={t("library.options.show_titles")}
|
||||
value={settings.showTitles}
|
||||
|
||||
@@ -196,7 +196,10 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
|
||||
@@ -229,7 +229,10 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
disabled={
|
||||
!settings.autoPlayNextEpisode ||
|
||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||
}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
|
||||
@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { type SharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
@@ -75,9 +75,6 @@ interface BottomControlsProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
// Chapter props
|
||||
chapterPositions?: number[];
|
||||
}
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
@@ -111,11 +108,10 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
chapterPositions = [],
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Only expose chapter UI when there are at least two real markers.
|
||||
@@ -146,13 +142,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
bottom:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? Math.max(insets.bottom - 17, 0)
|
||||
: 0,
|
||||
right: insets.right,
|
||||
left: insets.left,
|
||||
bottom: Math.max(insets.bottom - 17, 0),
|
||||
},
|
||||
]}
|
||||
className={"flex flex-col px-2"}
|
||||
@@ -188,17 +180,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center mr-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
@@ -230,6 +211,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center ml-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { FC } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
@@ -47,7 +47,7 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
goToNextChapter,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
// SyncPlay state from global provider
|
||||
const {
|
||||
@@ -66,8 +66,8 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -219,7 +219,6 @@ export const Controls: FC<Props> = ({
|
||||
hasNextChapter,
|
||||
goToPreviousChapter,
|
||||
goToNextChapter,
|
||||
chapterPositions,
|
||||
} = useChapterNavigation({
|
||||
chapters: item.Chapters,
|
||||
progress,
|
||||
@@ -366,7 +365,9 @@ export const Controls: FC<Props> = ({
|
||||
{ applyLanguagePreferences: true },
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
||||
router.setParams({
|
||||
...(offline && { offline: "true" }),
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
@@ -375,11 +376,17 @@ export const Controls: FC<Props> = ({
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
});
|
||||
},
|
||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||
[
|
||||
settings,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
offline,
|
||||
],
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
@@ -585,7 +592,6 @@ export const Controls: FC<Props> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
chapterPositions={chapterPositions}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
|
||||
@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
{t("player.ends_at", { time: getFinishTime() })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
{t("player.ends_at", { time: getFinishTime() })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
@@ -17,10 +16,10 @@ import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getDownloadedEpisodesForSeason,
|
||||
getDownloadedSeasonNumbers,
|
||||
@@ -46,8 +45,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
@@ -59,6 +57,11 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
||||
// provider refreshKey, so after updateDownloadedItem() invalidates
|
||||
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
||||
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
@@ -182,12 +185,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
paddingTop:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
paddingLeft:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
paddingRight:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
paddingTop: insets.top,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -5,12 +5,11 @@ import type {
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
@@ -58,9 +57,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const { orientation, lockOrientation } = useOrientation();
|
||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||
@@ -99,10 +97,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
top: insets.top,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -16,8 +16,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT } from "./constants";
|
||||
|
||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeInsets = useControlsSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
@@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
left: Math.max(insets.left, 48) + 20,
|
||||
}
|
||||
: {
|
||||
top:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
};
|
||||
|
||||
const textStyle = Platform.isTV
|
||||
|
||||
Reference in New Issue
Block a user