Compare commits

..

10 Commits

Author SHA1 Message Date
Fredrik Burmester
e1ac98b597 fix(tv): scale option-modal cards with text size + honor cardWidth
The TV option modal hardcoded the card size to scaleSize(160)/(75),
ignoring the caller's cardWidth/cardHeight and never growing with the
user's text-scale setting. With "Large" text, long values (e.g. a
root-folder path) overflowed the fixed card and were truncated. Honor
the provided cardWidth/cardHeight and multiply by the text scale
(new useTVRelativeScale) so the card grows in step with the font.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 15:27:59 +02:00
Fredrik Burmester
304cb06e0d fix(tv): use navigation option modal for advanced request selectors
The advanced request modal opened the quality-profile / root-folder /
request-as pickers as inline TVOptionSelector overlays (visible-prop),
which stacked on top of the modal and broke TV focus. Switch them to the
navigation-based useTVOptionModal().showOptions pattern (the same one
settings uses) so each picker is its own route with proper focus, and
returns to the request modal on select. Removes the activeSelector state
and the inline selectors.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 12:05:36 +02:00
Fredrik Burmester
11d71af468 fix(tv): replace season sheet with advanced request modal, not stack it
When a user has advanced-request permission, the season-select sheet
opened the advanced request modal via router.back() + push(). back() is
batched, so the push landed first and the advanced sheet stacked on top
of the season sheet, breaking focus. Add a `replace` option to
showRequestModal and use it here so the advanced modal takes the season
sheet's place in the stack.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 11:57:26 +02:00
Fredrik Burmester
01fd552a0c fix(tv): match season-select card font sizes to other TV sheets
The season title used typography.body (40) — larger than the sheet's own
heading — and the episode count used callout. Other TV cards/sheets use
callout for the primary label and callout-4 for secondary text. Align the
season card to that convention.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 11:49:37 +02:00
Fredrik Burmester
427e70e7ef fix(tv): stop season-select card content from collapsing (flex:1)
The season info container used flex:1 inside the card, but the card is
an auto-height column — flex:1 (flexBasis:0) collapsed the box to ~0
height, so the season number and episode count never had room to render
(only the explicitly-sized checkmark/status icons showed). Size the
container to its content instead.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 10:46:18 +02:00
Fredrik Burmester
d8fcb801e1 fix(tv): scale season-select card text so season info is visible
The TV "Select seasons" request sheet rendered each card's season
number and episode count via the common Text component, which has no
default font size — so they fell back to ~14px and were effectively
invisible on a TV (only the checkmark and status badge showed). Use the
scaled TV typography (body/callout) for the card text and widen the card
(scaleSize) so the larger labels fit.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 10:03:23 +02:00
Fredrik Burmester
913bd9b1da fix(tv): add top padding so search filter badges aren't clipped
The filter badges are the first row in the results ScrollView; their
focus-scale animation and shadow extended above the ScrollView's top
edge and got clipped. Add paddingTop to the content container.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 09:20:01 +02:00
Fredrik Burmester
f33c777e0c fix(tv): align search page edges to the app-wide horizontal padding
The TV search/discover page had three competing left-edge paddings:
the Library/Discover badges used HORIZONTAL_PADDING (60), the Jellyseerr
discover sections used SCALE_PADDING (20), while the rest of the app
(home rows, library sections, loading skeleton) uses
sizes.padding.horizontal. This left the filter badges visibly
misaligned with the content grid below them.

Unify the badges and the Jellyseerr discover/search sections onto
sizes.padding.horizontal so the filter row, section headers, and posters
share one edge — consistent with the home and library screens.

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 09:12:19 +02:00
Fredrik Burmester
eba08b412f docs(learned-facts): useNetworkAwareQueryClient now Proxy-based
The hook was rewritten from Object.create to a Proxy that binds
non-invalidateQueries methods to the real client, so removeQueries /
setQueryData / getQueriesData work through it now. Update the fact to
reflect this (discovered while adding removeQueries to clearAllJellyseerData).

Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 08:48:19 +02:00
Fredrik Burmester
bbef84132b feat(tv): add Jellyseerr connect support on TV (incl. Apple TV)
Adds the missing piece for Jellyseerr on TV: a way to configure and
connect to a Jellyseerr server from the TV settings screen. The discover
and search UI, native tvOS search field, and post-login auto-connect
already existed on develop, but there was no TV-side connect/disconnect
flow — so seerr could never be enabled on Apple TV.

- settings.tv.tsx: new "seerr" section with server URL + password inputs
  and Connect/Disconnect (respects plugin-locked server URLs)
- useJellyseerr: add validateJellyseerrSession(); clear cached search
  results on disconnect
- search: prompt to connect when a server is configured but no session
  exists, and warn when the session has expired on Discover
- translations: add connect/session keys to en + sv

All additions are platform-agnostic React Native, so they work on both
Apple TV and Android TV. Ported from #1676 (which was 40 commits behind
develop and conflicting); the unrelated Android tv-recommendations
changes from that PR were intentionally left out.

Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
2026-06-29 08:47:23 +02:00
16 changed files with 434 additions and 198 deletions

View File

@@ -6,4 +6,6 @@
## Detail
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.
**Updated 2026-06-29**: This limitation no longer applies. The hook was rewritten to use a `Proxy` (not `Object.create`). It overrides only `invalidateQueries` (network-aware) / `forceInvalidateQueries`, and binds every other method to the real `queryClient` target (`value.bind(target)`). So private-field methods like `getQueriesData`, `setQueryData`, and `removeQueries` work correctly through it now — no need to fall back to a separate `useQueryClient`. (Confirmed when adding `queryClient.removeQueries` to `clearAllJellyseerData` in `hooks/useJellyseerr.ts`.)
Historical (pre-2026-06): the hook used `Object.create(queryClient)`, which broke methods relying on JavaScript private fields; back then only `invalidateQueries` was safe.

View File

@@ -1,12 +1,13 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
@@ -21,6 +22,7 @@ import {
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { settings, updateSettings, pluginSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
@@ -59,6 +61,51 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
// Jellyseerr connection state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const jellyseerrLoginMutation = useMutation({
mutationFn: async () => {
const url = jellyseerrServerUrl.trim();
if (!url) throw new Error("Missing server url");
if (!user?.Name) throw new Error("Missing user info");
const tempApi = new JellyseerrApi(url);
const testResult = await tempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return tempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (loggedInUser) => {
setJellyseerrUser(loggedInUser);
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword("");
},
});
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -877,6 +924,72 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
<TVSettingsTextInput
label={t("home.settings.plugins.jellyseerr.server_url")}
value={jellyseerrServerUrl}
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={t("home.settings.plugins.jellyseerr.password")}
value={jellyseerrPassword}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name },
)}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrLoginMutation.isPending}
/>
<TVSettingsOptionButton
label={
jellyseerrLoginMutation.isPending
? t("common.connecting")
: t("common.connect")
}
value=''
onPress={() => jellyseerrLoginMutation.mutate()}
disabled={jellyseerrLoginMutation.isPending}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected")
: t("common.not_connected")
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
)}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton

View File

@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import {
useIsFocused,
useLocalSearchParams,
useNavigation,
useSegments,
} from "expo-router";
import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import {
@@ -20,7 +25,13 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Text } from "@/components/common/Text";
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
useJellyseerr,
validateJellyseerrSession,
} from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -106,8 +120,40 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom);
const isFocused = useIsFocused();
const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr();
// Prompt the user to connect when a Jellyseerr server is configured but no
// session exists yet (only once per focus, and only while the tab is focused).
const jellyseerrAlertedRef = useRef(false);
useEffect(() => {
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
if (jellyseerrAlertedRef.current) return;
jellyseerrAlertedRef.current = true;
Alert.alert(
t("jellyseerr.connect_to_jellyseerr"),
t("jellyseerr.connect_in_settings"),
);
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
// Validate the Jellyseerr session when switching to Discover; warn if expired.
useEffect(() => {
if (
searchType !== "Discover" ||
!jellyseerrApi ||
!settings?.jellyseerrServerUrl
)
return;
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
if (status.valid) return;
Alert.alert(
t("jellyseerr.session_expired"),
t("jellyseerr.session_expired_connect_again"),
);
});
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[

View File

@@ -11,7 +11,10 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVOptionCard } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import {
useScaledTVTypography,
useTVRelativeScale,
} from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
@@ -22,6 +25,7 @@ export default function TVOptionModal() {
const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom);
const typography = useScaledTVTypography();
const relativeScale = useTVRelativeScale();
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
@@ -97,8 +101,15 @@ export default function TVOptionModal() {
}
const { title, options } = modalState;
const scaledCardWidth = scaleSize(160);
const scaledCardHeight = scaleSize(75);
// Honor the caller-provided card size (e.g. wider cards for long root-folder
// paths) and grow it in step with the user's text-scale setting so larger
// fonts don't get clipped.
const scaledCardWidth = scaleSize(
(modalState.cardWidth ?? 160) * relativeScale,
);
const scaledCardHeight = scaleSize(
(modalState.cardHeight ?? 75) * relativeScale,
);
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>

View File

@@ -15,11 +15,12 @@ import {
import { Text } from "@/components/common/Text";
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton, TVOptionSelector } from "@/components/tv";
import { TVButton } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
import type {
QualityProfile,
@@ -35,6 +36,7 @@ export default function TVRequestModalPage() {
const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { showOptions } = useTVOptionModal();
const [isReady, setIsReady] = useState(false);
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
@@ -43,10 +45,6 @@ export default function TVRequestModalPage() {
userId: jellyseerrUser?.id,
});
const [activeSelector, setActiveSelector] = useState<
"profile" | "folder" | "user" | null
>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
@@ -242,17 +240,14 @@ export default function TVRequestModalPage() {
// Handlers
const handleProfileChange = useCallback((profileId: number) => {
setRequestOverrides((prev) => ({ ...prev, profileId }));
setActiveSelector(null);
}, []);
const handleFolderChange = useCallback((rootFolder: string) => {
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
setActiveSelector(null);
}, []);
const handleUserChange = useCallback((userId: number) => {
setRequestOverrides((prev) => ({ ...prev, userId }));
setActiveSelector(null);
}, []);
const handleTagToggle = useCallback(
@@ -353,18 +348,37 @@ export default function TVRequestModalPage() {
<TVRequestOptionRow
label={t("jellyseerr.quality_profile")}
value={selectedProfileName}
onPress={() => setActiveSelector("profile")}
onPress={() =>
showOptions({
title: t("jellyseerr.quality_profile"),
options: qualityProfileOptions,
onSelect: handleProfileChange,
})
}
hasTVPreferredFocus
/>
<TVRequestOptionRow
label={t("jellyseerr.root_folder")}
value={selectedFolderName}
onPress={() => setActiveSelector("folder")}
onPress={() =>
showOptions({
title: t("jellyseerr.root_folder"),
options: rootFolderOptions,
onSelect: handleFolderChange,
cardWidth: 280,
})
}
/>
<TVRequestOptionRow
label={t("jellyseerr.request_as")}
value={selectedUserName}
onPress={() => setActiveSelector("user")}
onPress={() =>
showOptions({
title: t("jellyseerr.request_as"),
options: userOptions,
onSelect: handleUserChange,
})
}
/>
{tagItems.length > 0 && (
@@ -409,33 +423,6 @@ export default function TVRequestModalPage() {
</TVFocusGuideView>
</BlurView>
</Animated.View>
{/* Sub-selectors */}
<TVOptionSelector
visible={activeSelector === "profile"}
title={t("jellyseerr.quality_profile")}
options={qualityProfileOptions}
onSelect={handleProfileChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
<TVOptionSelector
visible={activeSelector === "folder"}
title={t("jellyseerr.root_folder")}
options={rootFolderOptions}
onSelect={handleFolderChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
cardWidth={280}
/>
<TVOptionSelector
visible={activeSelector === "user"}
title={t("jellyseerr.request_as")}
options={userOptions}
onSelect={handleUserChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
</Animated.View>
);
}

View File

@@ -26,6 +26,7 @@ import {
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { scaleSize } from "@/utils/scaleSize";
import { store } from "@/utils/store";
interface TVSeasonToggleCardProps {
@@ -49,6 +50,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
hasTVPreferredFocus,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
@@ -119,7 +121,10 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
<Text
style={[
styles.seasonTitle,
{ color: focused ? "#000000" : "#FFFFFF" },
{
fontSize: typography.callout,
color: focused ? "#000000" : "#FFFFFF",
},
]}
numberOfLines={1}
>
@@ -132,6 +137,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
style={[
styles.episodeCount,
{
fontSize: typography.callout - 4,
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
},
]}
@@ -251,14 +257,15 @@ export default function TVSeasonSelectModalPage() {
};
if (modalState.hasAdvancedRequestPermission) {
// Close this modal and open the advanced request modal
router.back();
// Replace this sheet with the advanced request modal so it takes our
// place in the stack instead of stacking on top (which breaks focus).
showRequestModal({
requestBody: body,
title: modalState.title,
id: modalState.mediaId,
mediaType: MediaType.TV,
onRequested: modalState.onRequested,
replace: true,
});
return;
}
@@ -401,7 +408,7 @@ const styles = StyleSheet.create({
gap: 16,
},
seasonCard: {
width: 160,
width: scaleSize(220),
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
@@ -415,7 +422,10 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
seasonInfo: {
flex: 1,
// Note: no `flex: 1` here — the card is an auto-height column, so flex:1
// (flexBasis: 0) would collapse this box and hide the text. Let it size to
// its content instead.
alignSelf: "stretch",
},
seasonTitle: {
fontWeight: "600",
@@ -426,9 +436,7 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "space-between",
},
episodeCount: {
fontSize: 14,
},
episodeCount: {},
statusBadge: {
width: 22,
height: 22,

View File

@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
@@ -166,6 +167,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -238,7 +240,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
}}
>
{slideTitle}
@@ -249,7 +251,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
gap: 20,
}}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -233,6 +234,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
@@ -243,7 +245,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
}}
>
{title}
@@ -254,7 +256,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
@@ -285,6 +287,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
@@ -295,7 +298,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
}}
>
{title}
@@ -306,7 +309,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
@@ -337,6 +340,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
onItemPress,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null;
return (
@@ -347,7 +351,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
marginLeft: sizes.padding.horizontal,
}}
>
{title}
@@ -358,7 +362,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingHorizontal: sizes.padding.horizontal,
paddingVertical: SCALE_PADDING,
gap: 20,
}}

View File

@@ -22,7 +22,6 @@ import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
import { TVSearchSection } from "./TVSearchSection";
import { TVSearchTabBadges } from "./TVSearchTabBadges";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Height of the native search bar itself. The tvOS grid keyboard presents as
// its own overlay when the field is focused, so we only reserve the bar height
@@ -163,6 +162,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
discoverSliders,
}) => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
@@ -251,7 +251,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
) : (
<View
style={{
marginHorizontal: HORIZONTAL_PADDING,
marginHorizontal: sizes.padding.horizontal,
marginBottom: 24,
}}
>
@@ -280,12 +280,15 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag'
contentContainerStyle={{
// Top padding so the focus-scale/shadow on the first row (filter
// badges) isn't clipped against the ScrollView's top edge.
paddingTop: 16,
paddingBottom: insets.bottom + 60,
}}
>
{/* Search Type Tab Badges */}
{showDiscover && (
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
<View style={{ marginHorizontal: sizes.padding.horizontal }}>
<TVSearchTabBadges
searchType={searchType}
setSearchType={setSearchType}

View File

@@ -55,6 +55,20 @@ export type ScaledTVTypography = {
callout: number;
};
/**
* Returns the user's text-scale factor relative to the Default scale (1.0 at
* Default, >1 for Large/ExtraLarge, <1 for Small). Use it to scale containers
* (e.g. option-card width/height) in step with the scaled font so larger text
* settings don't overflow fixed boxes.
*/
export const useTVRelativeScale = (): number => {
const { settings } = useSettings();
const scale =
scaleMultipliers[settings.tvTypographyScale] ??
scaleMultipliers[TVTypographyScale.Default];
return scale / scaleMultipliers[TVTypographyScale.Default];
};
/**
* Hook that returns scaled TV typography values based on user settings.
* Use this instead of the static TVTypography constant for dynamic scaling.

View File

@@ -70,6 +70,35 @@ export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_COOKIES);
};
export type JellyseerrSessionStatus =
| { valid: true }
| { valid: false; reason: "no_session" | "expired" };
/**
* Checks whether the persisted Jellyseerr session (user + cookies) is still
* valid by hitting the server status endpoint. Clears local session data if the
* request fails (expired/revoked cookie).
*/
export async function validateJellyseerrSession(
serverUrl: string,
): Promise<JellyseerrSessionStatus> {
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (!user || !cookies) {
return { valid: false, reason: "no_session" };
}
try {
const api = new JellyseerrApi(serverUrl);
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
return { valid: true };
} catch {
clearJellyseerrStorageData();
return { valid: false, reason: "expired" };
}
}
export enum Endpoints {
STATUS = "/status",
API_V1 = "/api/v1",
@@ -450,7 +479,8 @@ export const useJellyseerr = () => {
clearJellyseerrStorageData();
setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined });
}, []);
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
}, [queryClient]);
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {

View File

@@ -11,6 +11,12 @@ interface ShowRequestModalParams {
id: number;
mediaType: MediaType;
onRequested: () => void;
/**
* Replace the current route instead of pushing. Use when opening the request
* modal from another modal (e.g. the season selector) so the new sheet takes
* its place rather than stacking on top of it (which breaks TV focus).
*/
replace?: boolean;
}
export const useTVRequestModal = () => {
@@ -25,7 +31,11 @@ export const useTVRequestModal = () => {
mediaType: params.mediaType,
onRequested: params.onRequested,
});
router.push("/(auth)/tv-request-modal");
if (params.replace) {
router.replace("/(auth)/tv-request-modal");
} else {
router.push("/(auth)/tv-request-modal");
}
},
[router],
);

View File

@@ -2,12 +2,14 @@ package expo.modules.mpvplayer
import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
@@ -28,26 +30,15 @@ data class VideoLoadConfig(
val cacheEnabled: String? = null,
val cacheSeconds: Int? = null,
val demuxerMaxBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null,
val demuxerMaxBackBytes: Int? = null
)
/**
* MpvPlayerView - ExpoView that hosts the MPV player.
*
* Uses SurfaceView (not TextureView) so the surface routes directly to
* SurfaceFlinger (the OS compositor) rather than compositing into the
* app's window surface. This matches mpv-android's architecture and
* gives mpv a standalone surface.
*
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
* recreated on PiP entry/exit, and the new surface's initial dimensions
* can be stale until the next layout pass. We push dimension updates to
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
* OnLayoutChangeListener, so the PiP transition (which fires layout
* passes on the view itself) reaches mpv promptly.
* Uses TextureView for reliable Picture-in-Picture support.
*/
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
companion object {
private const val TAG = "MpvPlayerView"
@@ -61,7 +52,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val onTracksReady by EventDispatcher()
val onPictureInPictureChange by EventDispatcher()
private var surfaceView: SurfaceView
private var textureView: TextureView
private var renderer: MPVLayerRenderer? = null
private var pipController: PiPController? = null
@@ -72,45 +63,31 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private var surfaceReady: Boolean = false
private var pendingConfig: VideoLoadConfig? = null
private var rendererStarted: Boolean = false
private var pendingSurface: Surface? = null
private var activeSurface: Surface? = null
private var surfaceTexture: SurfaceTexture? = null
// PiP state tracking
private var isWaitingForPiPTransition: Boolean = false
private var isPiPSurfaceForced: Boolean = false
private val pipHandler = Handler(Looper.getMainLooper())
init {
setBackgroundColor(Color.BLACK)
// SurfaceView for video rendering. Routes the surface directly to
// SurfaceFlinger (the OS compositor), giving mpv a standalone
// surface. TextureView composites into the app's window surface
// which is less efficient and breaks PiP transitions.
surfaceView = SurfaceView(context).apply {
// Create TextureView for video rendering (composites into app window for PiP support)
textureView = TextureView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
surfaceTextureListener = this@MpvPlayerView
}
surfaceView.holder.addCallback(this@MpvPlayerView)
addView(surfaceView)
// Push dimension updates to mpv on every view bounds change. This
// is the primary PiP black-screen fix: entering PiP fires a layout
// pass on the SurfaceView itself, and we proactively tell mpv the
// new size so it resizes its EGL swapchain before rendering.
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
oldLeft, oldTop, oldRight, oldBottom ->
val w = right - left
val h = bottom - top
val oldW = oldRight - oldLeft
val oldH = oldBottom - oldTop
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
renderer?.updateSurfaceSize(w, h)
}
}
addView(textureView)
// Initialize PiP controller with Expo's AppContext for proper activity access
pipController = PiPController(context, appContext)
pipController?.setPlayerView(surfaceView)
pipController?.setPlayerView(textureView)
pipController?.delegate = object : PiPController.Delegate {
override fun onPlay() {
play()
@@ -126,17 +103,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
if (isInPiP) {
// Post size syncs after the PiP layout settles. Two passes
// catch both the immediate surface re-attach and the
// post-animation layout pass. Replaces the old TextureView
// measure/layout polling hack (forcePiPBufferSize).
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
if (!isWaitingForPiPTransition) {
isWaitingForPiPTransition = true
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
} else {
// Restore from PiP: surface resized back to fullscreen.
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
restoreFromPiP()
}
onPictureInPictureChange(mapOf("isActive" to isInPiP))
}
@@ -149,7 +126,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
/**
* Start the renderer with the given VO driver.
* Called lazily on first loadVideo so user settings are available.
* Called lazily on first loadVideo so the voDriver setting is available.
*/
private fun ensureRendererStarted(voDriver: String?) {
if (rendererStarted) return
@@ -158,14 +135,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
renderer?.start(voDriver ?: "gpu-next")
rendererStarted = true
// If the surface is already alive (surfaceCreated fired before
// loadVideo), attach it now. With SurfaceView the surface is
// owned by the holder, so we read it from there directly rather
// than stashing it on the side.
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
pendingSurface?.let { surface ->
activeSurface = surface
renderer?.attachSurface(surface)
syncSurfaceSizeToView()
pendingSurface = null
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start renderer: ${e.message}")
@@ -173,20 +146,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
// MARK: - SurfaceHolder.Callback
// MARK: - TextureView.SurfaceTextureListener
override fun surfaceCreated(holder: SurfaceHolder) {
val surface = holder.surface
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
this.surfaceTexture = surfaceTexture
val surface = Surface(surfaceTexture)
surfaceTexture.setDefaultBufferSize(width, height)
surfaceReady = true
if (rendererStarted) {
// The previous Surface reference is holder-owned; do NOT release
// it (SurfaceView manages its lifecycle). Just track the new one.
// Release the previous wrapper Surface before losing the only
// reference to it. cleanup() only runs on detach, so without this
// repeated PiP/background/resize cycles leak native surface objects.
activeSurface?.release()
activeSurface = surface
renderer?.attachSurface(surface)
// Push the actual view dimensions immediately so mpv doesn't
// render against stale full-screen geometry during PiP transitions.
syncSurfaceSizeToView()
} else {
pendingSurface = surface
}
// If we have a pending load, execute it now
@@ -197,36 +173,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
if (width > 0 && height > 0) {
renderer?.updateSurfaceSize(width, height)
}
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
surfaceTexture.setDefaultBufferSize(width, height)
renderer?.updateSurfaceSize(width, height)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
this.surfaceTexture = null
surfaceReady = false
renderer?.detachSurface()
// Do NOT issue mpv "stop" here. Playback continues against the
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
// background/foreground), we re-attach and frames resume. This
// matches the keep-open=always setting in MPVLayerRenderer.
//
// Do NOT release activeSurface — SurfaceView owns it via the holder.
activeSurface = null
return false // mpv manages the SurfaceTexture
}
/**
* Read the actual SurfaceView width/height and push them to mpv.
* The PiP transition can fire surfaceCreated before the view's layout
* has settled to PiP dimensions, so we re-sync after layout passes.
*/
private fun syncSurfaceSizeToView() {
if (!surfaceReady) return
val w = surfaceView.width
val h = surfaceView.height
if (w > 0 && h > 0) {
renderer?.updateSurfaceSize(w, h)
}
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// Called every frame — no action needed, mpv drives rendering directly
}
// MARK: - Video Loading
@@ -315,7 +275,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// Reset view-level state so a subsequent loadVideo() on the SAME view
// instance re-creates the mpv handle and re-attaches the still-live
// SurfaceView surface. Without this, rendererStarted stays true and
// TextureView surface. Without this, rendererStarted stays true and
// ensureRendererStarted() early-returns, so renderer.start() is never
// called again — but stop() already nulled the renderer's mpv handle.
// The next loadVideo() then runs loadVideoInternal() -> renderer.load()
@@ -326,12 +286,13 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// which call destroy() immediately before router.replace() to the
// same route — Expo Router reuses the same MpvPlayerView instance,
// so the next source load happens on this view without a remount.
//
// SurfaceView note: the surface is owned by the holder and survives
// across destroy()/loadVideo() on the same view instance. The next
// ensureRendererStarted() reads it from surfaceView.holder.surface.
rendererStarted = false
currentUrl = null
// Move the active surface back to pending so ensureRendererStarted()
// re-attaches it to the freshly created mpv instance on next load.
// The Surface itself is still valid — onSurfaceTextureDestroyed has
// not fired because the TextureView is not being unmounted.
activeSurface?.let { pendingSurface = it }
activeSurface = null
}
@@ -366,10 +327,59 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
// MARK: - Picture in Picture
fun startPictureInPicture() {
isWaitingForPiPTransition = true
pipController?.startPictureInPicture()
// Resize buffer to match PiP window after animation settles
pipHandler.removeCallbacksAndMessages(null)
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
}
}
/**
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
* visible rect so mpv renders at the PiP window's actual dimensions.
*/
private fun forcePiPBufferSize() {
if (!isWaitingForPiPTransition || !surfaceReady) return
val rect = Rect()
textureView.getGlobalVisibleRect(rect)
val visW = rect.width()
val visH = rect.height()
val vw = textureView.width
val vh = textureView.height
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
surfaceTexture?.setDefaultBufferSize(visW, visH)
renderer?.updateSurfaceSize(visW, visH)
// Force TextureView layout to match PiP visible area.
// layoutParams alone doesn't work during PiP because the parent
// never re-lays out its children.
textureView.measure(
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
)
textureView.layout(0, 0, visW, visH)
isPiPSurfaceForced = true
}
private fun restoreFromPiP() {
if (!isPiPSurfaceForced) return
isPiPSurfaceForced = false
val lp = textureView.layoutParams
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
textureView.layoutParams = lp
textureView.requestLayout()
}
fun stopPictureInPicture() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
}
@@ -537,12 +547,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
* off the JS path.
*/
fun cleanup() {
isWaitingForPiPTransition = false
pipHandler.removeCallbacksAndMessages(null)
pipController?.stopPictureInPicture()
renderer?.stop()
renderer?.delegate = null
// SurfaceView owns the Surface via its holder — do NOT release it.
// Release the Surface that wraps the SurfaceTexture. These Surface
// objects are created in onSurfaceTextureAvailable and were never
// released; each playback session previously leaked one. The
// SurfaceTexture itself is owned by TextureView and released by it
// via onSurfaceTextureDestroyed, so we leave it alone.
pendingSurface?.release()
pendingSurface = null
activeSurface?.release()
activeSurface = null
surfaceReady = false
currentUrl = null

View File

@@ -44,11 +44,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
private var currentPosition: Double = 0.0
private var currentDuration: Double = 0.0
private var playbackRate: Double = 1.0
// Independently tracks whether the system should auto-enter PiP on home
// press. Decoupled from playbackRate so that disabling auto-enter
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
// state that buildPiPActions() derives from playbackRate.
private var autoEnterEnabled: Boolean = false
private var videoWidth: Int = 0
private var videoHeight: Int = 0
@@ -111,37 +106,15 @@ class PiPController(private val context: Context, private val appContext: AppCon
}
fun stopPictureInPicture() {
// Disable auto-enter eligibility without touching playbackRate.
// playbackRate drives the play/pause icon in buildPiPActions();
// mutating it here would cause a stale icon if PiP is re-entered
// before the next playback state callback corrects it.
autoEnterEnabled = false
isInPiPMode = false
pipEntryNotified = false
unregisterLifecycleCallbacks()
val activity = getActivity() ?: return
// Push minimal params with just auto-enter disabled. Do NOT call
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
// setActions(), which would re-register the broadcast receiver
// (just unregistered above) and attach play/pause/skip actions to
// params being torn down. That leaves a live receiver + stale
// actions after the player has unmounted.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
activity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = getActivity()
if (activity?.isInPictureInPictureMode == true) {
activity.moveTaskToBack(false)
}
}
if (activity.isInPictureInPictureMode) {
activity.moveTaskToBack(false)
}
}
fun isCurrentlyInPiP(): Boolean = isInPiPMode
@@ -153,7 +126,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
fun setPlaybackRate(rate: Double) {
playbackRate = rate
autoEnterEnabled = rate > 0
if (rate > 0) {
registerLifecycleCallbacks()
@@ -236,7 +208,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
builder.setActions(buildPiPActions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
}
return builder.build()

View File

@@ -505,7 +505,11 @@
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
"seeAll": "See all"
"seeAll": "See all",
"connect": "Connect",
"connecting": "Connecting…",
"connected": "Connected",
"not_connected": "Not connected"
},
"search": {
"search": "Search...",
@@ -732,6 +736,10 @@
"request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to log in",
"connect_to_jellyseerr": "Connect to Jellyseerr",
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
"session_expired": "Session expired",
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
"cast": "Cast",
"details": "Details",
"status": "Status",

View File

@@ -505,7 +505,11 @@
"episodes": "Episodes",
"movies": "Movies",
"loading": "Loading…",
"seeAll": "See all"
"seeAll": "See all",
"connect": "Anslut",
"connecting": "Ansluter…",
"connected": "Ansluten",
"not_connected": "Inte ansluten"
},
"search": {
"search": "Sök...",
@@ -732,6 +736,10 @@
"request_button": "Önska",
"are_you_sure_you_want_to_request_all_seasons": "Är du säker på att du vill begära alla säsonger?",
"failed_to_login": "Inloggningen Misslyckades",
"connect_to_jellyseerr": "Anslut till Jellyseerr",
"connect_in_settings": "Jellyseerr är tillgängligt. Anslut i Inställningar för att aktivera förfrågningsfunktioner.",
"session_expired": "Sessionen har gått ut",
"session_expired_connect_again": "Din Jellyseerr-session har gått ut. Anslut igen i Inställningar.",
"cast": "Roller",
"details": "Detaljer",
"status": "Status",