mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 21:48:31 +01:00
Compare commits
4 Commits
renovate/l
...
fix/tv-see
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d18e2bec | ||
|
|
326956dfda | ||
|
|
7528274249 | ||
|
|
0d796d01b8 |
@@ -3,7 +3,7 @@ import { 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";
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
TVSettingsToggle,
|
||||
} from "@/components/tv";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrConnect } from "@/hooks/useJellyseerrConnect";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||
import { APP_LANGUAGES } from "@/i18n";
|
||||
@@ -59,6 +61,37 @@ export default function SettingsTV() {
|
||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||
const typography = useScaledTVTypography();
|
||||
const queryClient = useQueryClient();
|
||||
const { jellyseerrApi, clearAllJellyseerData } = useJellyseerr();
|
||||
const { connecting: jellyseerrConnecting, connect: jellyseerrConnect } =
|
||||
useJellyseerrConnect();
|
||||
|
||||
// Jellyseerr state
|
||||
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
|
||||
settings.jellyseerrServerUrl || "",
|
||||
);
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
|
||||
const { pluginSettings } = useSettings();
|
||||
|
||||
const isJellyseerrLocked =
|
||||
pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||
const isJellyseerrConnected = !!jellyseerrApi;
|
||||
|
||||
const handleJellyseerrUrlBlur = useCallback(() => {
|
||||
const url = jellyseerrServerUrl.trim();
|
||||
updateSettings({ jellyseerrServerUrl: url || undefined });
|
||||
}, [jellyseerrServerUrl, updateSettings]);
|
||||
|
||||
const handleJellyseerrConnect = useCallback(async () => {
|
||||
const url = jellyseerrServerUrl.trim();
|
||||
if (!url) return;
|
||||
await jellyseerrConnect(url, jellyseerrPassword);
|
||||
}, [jellyseerrServerUrl, jellyseerrPassword, jellyseerrConnect]);
|
||||
|
||||
const handleDisconnectJellyseerr = useCallback(() => {
|
||||
clearAllJellyseerData();
|
||||
setJellyseerrServerUrl("");
|
||||
setJellyseerrPassword("");
|
||||
}, [clearAllJellyseerData]);
|
||||
|
||||
// Local state for OpenSubtitles API key (only commit on blur)
|
||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||
@@ -883,6 +916,81 @@ 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") ||
|
||||
"Enter your Jellyseerr server URL to enable discover and request features."}
|
||||
</Text>
|
||||
<TVSettingsTextInput
|
||||
label={
|
||||
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
|
||||
}
|
||||
value={jellyseerrServerUrl}
|
||||
placeholder={
|
||||
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
|
||||
"https://jellyseerr.example.com"
|
||||
}
|
||||
onChangeText={setJellyseerrServerUrl}
|
||||
onBlur={handleJellyseerrUrlBlur}
|
||||
disabled={isJellyseerrLocked || jellyseerrConnecting}
|
||||
/>
|
||||
{!isJellyseerrConnected && !isJellyseerrLocked && (
|
||||
<>
|
||||
<TVSettingsTextInput
|
||||
label={
|
||||
t("home.settings.plugins.jellyseerr.password") || "Password"
|
||||
}
|
||||
value={jellyseerrPassword}
|
||||
placeholder={
|
||||
t("home.settings.plugins.jellyseerr.password_placeholder", {
|
||||
username: user?.Name,
|
||||
}) || `Jellyfin password`
|
||||
}
|
||||
onChangeText={setJellyseerrPassword}
|
||||
secureTextEntry
|
||||
disabled={jellyseerrConnecting}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={
|
||||
jellyseerrConnecting
|
||||
? t("common.connecting", "Connecting...") || "Connecting..."
|
||||
: t("common.connect", "Connect") || "Connect"
|
||||
}
|
||||
value=''
|
||||
onPress={handleJellyseerrConnect}
|
||||
disabled={jellyseerrConnecting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TVSettingsRow
|
||||
label={
|
||||
isJellyseerrConnected
|
||||
? t("common.connected", "Connected") || "Connected"
|
||||
: t("common.not_connected", "Not connected") || "Not connected"
|
||||
}
|
||||
value=''
|
||||
showChevron={false}
|
||||
/>
|
||||
{isJellyseerrConnected && !isJellyseerrLocked && (
|
||||
<TVSettingsOptionButton
|
||||
label={
|
||||
t(
|
||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
||||
) || "Disconnect"
|
||||
}
|
||||
value=''
|
||||
onPress={handleDisconnectJellyseerr}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Storage Section */}
|
||||
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||
<TVSettingsOptionButton
|
||||
|
||||
@@ -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 {
|
||||
@@ -22,8 +23,6 @@ import type {
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
interface TVDiscoverPosterProps {
|
||||
item: MovieResult | TvResult;
|
||||
isFirstItem?: boolean;
|
||||
@@ -34,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
isFirstItem = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const router = useRouter();
|
||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
@@ -50,6 +50,8 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||
|
||||
const posterWidth = sizes.posters.poster;
|
||||
|
||||
const handlePress = () => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||
@@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
@@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
borderRadius: sizes.gaps.small,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
}}
|
||||
@@ -140,12 +142,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{year && (
|
||||
{year != null && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
marginTop: sizes.gaps.small,
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
@@ -166,6 +168,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
isFirstSlide = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const { t } = useTranslation();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
@@ -231,14 +234,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
if (!flatData || flatData.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginBottom: sizes.gaps.small,
|
||||
marginLeft: sizes.padding.scale,
|
||||
}}
|
||||
>
|
||||
{slideTitle}
|
||||
@@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
paddingHorizontal: sizes.padding.scale,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: sizes.gaps.item,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
onEndReached={() => {
|
||||
|
||||
@@ -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";
|
||||
@@ -14,8 +15,6 @@ import type {
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
interface TVJellyseerrPosterProps {
|
||||
item: MovieResult | TvResult;
|
||||
onPress: () => void;
|
||||
@@ -28,6 +27,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
isFirstItem = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
@@ -43,6 +43,8 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||
|
||||
const posterWidth = sizes.posters.poster;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -54,7 +56,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
@@ -64,9 +66,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
borderRadius: sizes.gaps.small,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
}}
|
||||
@@ -117,13 +119,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
fontSize: typography.callout,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
marginTop: sizes.gaps.small,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{year && (
|
||||
{year != null && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
@@ -149,6 +151,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
onPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation();
|
||||
@@ -157,13 +160,15 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
||||
: null;
|
||||
|
||||
const avatarSize = Math.round(sizes.posters.poster * 0.67);
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 160,
|
||||
width: avatarSize,
|
||||
alignItems: "center",
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
@@ -174,9 +179,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
borderRadius: avatarSize / 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
@@ -198,7 +203,11 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
|
||||
<Ionicons
|
||||
name='person'
|
||||
size={Math.round(avatarSize * 0.35)}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -207,7 +216,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
marginTop: sizes.gaps.small,
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
@@ -233,17 +242,18 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginBottom: sizes.gaps.small,
|
||||
marginLeft: sizes.padding.scale,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -254,9 +264,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
paddingHorizontal: sizes.padding.scale,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: sizes.gaps.item,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
@@ -285,17 +295,18 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginBottom: sizes.gaps.small,
|
||||
marginLeft: sizes.padding.scale,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -306,9 +317,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
paddingHorizontal: sizes.padding.scale,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: sizes.gaps.item,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
@@ -337,17 +348,18 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginBottom: sizes.gaps.small,
|
||||
marginLeft: sizes.padding.scale,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -358,9 +370,9 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
paddingHorizontal: sizes.padding.scale,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: sizes.gaps.item,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item }) => (
|
||||
@@ -400,6 +412,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
onPersonPress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
@@ -410,7 +423,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
@@ -418,7 +431,9 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||
<Text
|
||||
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
|
||||
>
|
||||
"{searchQuery}"
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Platform, ScrollView, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||
@@ -166,6 +166,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
|
||||
// Image URL getter for music items
|
||||
const getImageUrl = useMemo(() => {
|
||||
@@ -231,26 +232,51 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
}}
|
||||
>
|
||||
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
||||
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,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<TvSearchView
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||
/>
|
||||
</View>
|
||||
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
|
||||
TextInput fallback on Android TV (the native module is Apple-only). */}
|
||||
{Platform.OS === "ios" ? (
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{/* No horizontal margin here: the native tvOS search bar centers
|
||||
itself and renders a trailing "Hold to Dictate" hint. */}
|
||||
<TvSearchView
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
marginHorizontal: HORIZONTAL_PADDING,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
style={{
|
||||
height: 56,
|
||||
width: "100%",
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 20,
|
||||
fontSize: 28,
|
||||
color: "#fff",
|
||||
}}
|
||||
placeholder={t("search.search")}
|
||||
placeholderTextColor='rgba(255,255,255,0.4)'
|
||||
onChangeText={setSearch}
|
||||
defaultValue=''
|
||||
autoFocus={false}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setIsSearchFocused(false)}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@@ -268,6 +294,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
showDiscover={showDiscover}
|
||||
disabled={isSearchFocused}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -294,6 +321,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
// every keystroke as results re-render. User navigates down to the
|
||||
// grid manually.
|
||||
isFirstSection={false}
|
||||
disabled={isSearchFocused}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
|
||||
@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ class MpvPlayerView: ExpoView {
|
||||
let onProgress = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
let onTracksReady = EventDispatcher()
|
||||
let onPictureInPictureChange = EventDispatcher()
|
||||
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
@@ -637,6 +638,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
// Ensure current time is synced when PiP starts
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
||||
// is `false` when AVKit reports a failure to start, so reflect that.
|
||||
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
@@ -655,6 +659,9 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
// Notify JS that PiP has fully stopped so the controls overlay can
|
||||
// be re-mounted when the user returns to full screen.
|
||||
onPictureInPictureChange(["isActive": false])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
<application>
|
||||
<receiver
|
||||
android:name=".TvRecommendationsReceiver"
|
||||
android:exported="true">
|
||||
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
@@ -61,31 +61,61 @@ internal object TvRecommendationsPublisher {
|
||||
|
||||
fun clear(context: Context): Boolean {
|
||||
val prefs = preferences(context)
|
||||
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (programIds != null) {
|
||||
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" }
|
||||
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||
if (allProgramIds != null) {
|
||||
var deletedPrograms = 0
|
||||
val keys = programIds.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val programId = programIds.optLong(key, -1L)
|
||||
if (programId > 0L) {
|
||||
contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
)
|
||||
deletedPrograms += 1
|
||||
val channelKeys = allProgramIds.keys()
|
||||
while (channelKeys.hasNext()) {
|
||||
val channelIdStr = channelKeys.next()
|
||||
val programIdsJson = allProgramIds.optString(channelIdStr)
|
||||
if (programIdsJson.isBlank()) continue
|
||||
|
||||
try {
|
||||
val programIds = JSONObject(programIdsJson)
|
||||
val keys = programIds.keys()
|
||||
while (keys.hasNext()) {
|
||||
val providerId = keys.next()
|
||||
val programId = programIds.optLong(providerId, -1L)
|
||||
if (programId > 0L) {
|
||||
deletePreviewProgram(contentResolver, programId)
|
||||
deletedPrograms += 1
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
|
||||
}
|
||||
|
||||
// Notify the channel
|
||||
val channelId = channelIdStr.toLongOrNull() ?: -1L
|
||||
if (channelId > 0L) {
|
||||
try {
|
||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove per-channel pref
|
||||
prefs.edit().remove("programIds_$channelIdStr").apply()
|
||||
}
|
||||
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
|
||||
}
|
||||
|
||||
if (channelId > 0L) {
|
||||
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
|
||||
Log.d(TAG, "clear(): notified channel $channelId")
|
||||
// Also handle legacy format (flat { providerId: programId }) for migration
|
||||
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject)
|
||||
if (legacyProgramIds != null) {
|
||||
val keys = legacyProgramIds.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val programId = legacyProgramIds.optLong(key, -1L)
|
||||
if (programId > 0L) {
|
||||
deletePreviewProgram(contentResolver, programId)
|
||||
}
|
||||
}
|
||||
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
|
||||
}
|
||||
|
||||
prefs.edit()
|
||||
@@ -96,126 +126,262 @@ internal object TvRecommendationsPublisher {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single preview program from the TvProvider.
|
||||
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
|
||||
*/
|
||||
fun deletePreviewProgram(context: Context, programId: Long) {
|
||||
try {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
)
|
||||
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
|
||||
|
||||
// Also remove from stored programIds prefs
|
||||
removeProgramFromPrefs(context, programId)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
|
||||
try {
|
||||
contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeProgramFromPrefs(context: Context, programId: Long) {
|
||||
val prefs = preferences(context)
|
||||
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
|
||||
try {
|
||||
val programIds = JSONObject(programIdsJson)
|
||||
val keys = programIds.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
if (programIds.optLong(key, -1L) == programId) {
|
||||
programIds.remove(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun synchronize(context: Context, payload: JSONObject): Boolean {
|
||||
val sections = payload.optJSONArray("sections") ?: JSONArray()
|
||||
val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
|
||||
val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||
val items = firstSection?.optJSONArray("items") ?: JSONArray()
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
|
||||
)
|
||||
|
||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||
if (channelId <= 0L) {
|
||||
Log.w(TAG, "synchronize(): failed to get or create preview channel")
|
||||
if (sections.length() == 0) {
|
||||
Log.w(TAG, "synchronize(): no sections in payload")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
|
||||
val prefs = preferences(context)
|
||||
val allNextProgramIds = JSONObject()
|
||||
var totalActive = 0
|
||||
var totalDeleted = 0
|
||||
|
||||
val previousProgramIds = preferences(context)
|
||||
.getString(KEY_PROGRAM_IDS, null)
|
||||
?.let(::JSONObject)
|
||||
?: JSONObject()
|
||||
val nextProgramIds = JSONObject()
|
||||
val activeProviderIds = mutableSetOf<String>()
|
||||
for (sectionIndex in 0 until sections.length()) {
|
||||
val section = sections.optJSONObject(sectionIndex) ?: continue
|
||||
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
|
||||
val items = section.optJSONArray("items") ?: JSONArray()
|
||||
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.optJSONObject(index) ?: continue
|
||||
val providerId = item.optString("id")
|
||||
if (providerId.isBlank()) continue
|
||||
|
||||
val programId = upsertPreviewProgram(
|
||||
context = context,
|
||||
channelId = channelId,
|
||||
item = item,
|
||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||
weight = index
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
|
||||
)
|
||||
|
||||
if (programId > 0L) {
|
||||
activeProviderIds += providerId
|
||||
nextProgramIds.put(providerId, programId)
|
||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||
val channelId = getOrCreateChannel(context, sectionTitle)
|
||||
if (channelId <= 0L) {
|
||||
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
var deletedPrograms = 0
|
||||
val previousKeys = previousProgramIds.keys()
|
||||
while (previousKeys.hasNext()) {
|
||||
val providerId = previousKeys.next()
|
||||
if (activeProviderIds.contains(providerId)) continue
|
||||
// Per Android docs: check channel.isBrowsable() and request if needed.
|
||||
if (!isChannelBrowsable(context, channelId)) {
|
||||
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
|
||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||
}
|
||||
|
||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||
if (programId > 0L) {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.buildPreviewProgramUri(programId),
|
||||
null,
|
||||
null
|
||||
val prefKey = "programIds_$channelId"
|
||||
val previousProgramIds = prefs.getString(prefKey, null)
|
||||
?.let(::JSONObject)
|
||||
?: JSONObject()
|
||||
val nextProgramIds = JSONObject()
|
||||
val activeProviderIds = mutableSetOf<String>()
|
||||
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.optJSONObject(index) ?: continue
|
||||
val providerId = item.optString("id")
|
||||
if (providerId.isBlank()) continue
|
||||
|
||||
val programId = upsertPreviewProgram(
|
||||
context = context,
|
||||
channelId = channelId,
|
||||
item = item,
|
||||
previousProgramId = previousProgramIds.optLong(providerId, -1L),
|
||||
weight = index
|
||||
)
|
||||
deletedPrograms += 1
|
||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||
|
||||
if (programId > 0L) {
|
||||
activeProviderIds += providerId
|
||||
nextProgramIds.put(providerId, programId)
|
||||
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
|
||||
}
|
||||
}
|
||||
|
||||
var deletedPrograms = 0
|
||||
val previousKeys = previousProgramIds.keys()
|
||||
while (previousKeys.hasNext()) {
|
||||
val providerId = previousKeys.next()
|
||||
if (activeProviderIds.contains(providerId)) continue
|
||||
|
||||
val programId = previousProgramIds.optLong(providerId, -1L)
|
||||
if (programId > 0L) {
|
||||
deletePreviewProgram(context, programId)
|
||||
deletedPrograms += 1
|
||||
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
|
||||
}
|
||||
}
|
||||
|
||||
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
|
||||
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
|
||||
totalActive += activeProviderIds.size
|
||||
totalDeleted += deletedPrograms
|
||||
|
||||
logProviderState(context, channelId)
|
||||
}
|
||||
|
||||
preferences(context)
|
||||
.edit()
|
||||
.putLong(KEY_CHANNEL_ID, channelId)
|
||||
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
|
||||
.apply()
|
||||
|
||||
logProviderState(context, channelId)
|
||||
// Store all channel program IDs for clear() to use
|
||||
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
|
||||
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)"
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Query provider to check if a channel is browsable.
|
||||
* Per Android docs: "check channel.isBrowsable() before updating programs."
|
||||
*/
|
||||
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
|
||||
return try {
|
||||
context.contentResolver.query(
|
||||
TvContractCompat.buildChannelUri(channelId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
|
||||
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
|
||||
true // Assume browsable if we can't check, to avoid blocking updates
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query provider to verify a channel actually exists.
|
||||
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
|
||||
* we must first check whether the channel was deleted by the system
|
||||
* or if the update simply failed for another reason.
|
||||
*/
|
||||
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
|
||||
return try {
|
||||
context.contentResolver.query(
|
||||
TvContractCompat.buildChannelUri(channelId),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
} ?: false
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateChannel(context: Context, displayName: String): Long {
|
||||
val prefs = preferences(context)
|
||||
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (existingChannelId > 0L) {
|
||||
val updated = Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(displayName)
|
||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||
.build()
|
||||
// Query provider first to verify channel actually exists (prevents recreate bug)
|
||||
val exists = channelExistsInProvider(context, existingChannelId)
|
||||
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildChannelUri(existingChannelId),
|
||||
updated.toContentValues(),
|
||||
null,
|
||||
null
|
||||
)
|
||||
if (exists) {
|
||||
// Channel exists — update it in place, never recreate
|
||||
val updated = Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(displayName)
|
||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||
.build()
|
||||
|
||||
if (updatedRows > 0) {
|
||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||
storeChannelLogo(context, existingChannelId)
|
||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||
return existingChannelId
|
||||
try {
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildChannelUri(existingChannelId),
|
||||
updated.toContentValues(),
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
if (updatedRows > 0) {
|
||||
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
|
||||
storeChannelLogo(context, existingChannelId)
|
||||
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
|
||||
return existingChannelId
|
||||
}
|
||||
|
||||
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
|
||||
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
|
||||
return existingChannelId
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
|
||||
return existingChannelId
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
|
||||
// Channel truly doesn't exist in provider — recreate
|
||||
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
|
||||
prefs.edit().remove(KEY_CHANNEL_ID).apply()
|
||||
}
|
||||
|
||||
// Create a new channel
|
||||
val channel = Channel.Builder()
|
||||
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||
.setDisplayName(displayName)
|
||||
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
|
||||
.build()
|
||||
|
||||
val channelUri = contentResolver.insert(
|
||||
TvContractCompat.Channels.CONTENT_URI,
|
||||
channel.toContentValues()
|
||||
) ?: return -1L
|
||||
val channelUri = try {
|
||||
contentResolver.insert(
|
||||
TvContractCompat.Channels.CONTENT_URI,
|
||||
channel.toContentValues()
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
|
||||
null
|
||||
} ?: return -1L
|
||||
|
||||
val channelId = ContentUris.parseId(channelUri)
|
||||
TvContractCompat.requestChannelBrowsable(context, channelId)
|
||||
@@ -249,42 +415,62 @@ internal object TvRecommendationsPublisher {
|
||||
builder.setDescription(it)
|
||||
}
|
||||
|
||||
// Per Android docs: use unique URIs for all images to avoid stale cache
|
||||
imageUrl.takeIf { it.isNotBlank() }?.let {
|
||||
val imageUri = Uri.parse(it)
|
||||
val uniqueImageUrl = appendCacheBuster(it)
|
||||
val imageUri = Uri.parse(uniqueImageUrl)
|
||||
builder.setPosterArtUri(imageUri)
|
||||
builder.setThumbnailUri(imageUri)
|
||||
}
|
||||
|
||||
|
||||
val contentValues = builder.build().toContentValues()
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
if (previousProgramId > 0L) {
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||
contentValues,
|
||||
null,
|
||||
null
|
||||
)
|
||||
try {
|
||||
val updatedRows = contentResolver.update(
|
||||
TvContractCompat.buildPreviewProgramUri(previousProgramId),
|
||||
contentValues,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
if (updatedRows > 0) {
|
||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||
return previousProgramId
|
||||
if (updatedRows > 0) {
|
||||
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
|
||||
return previousProgramId
|
||||
}
|
||||
|
||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
|
||||
}
|
||||
|
||||
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
|
||||
}
|
||||
|
||||
val insertedUri = contentResolver.insert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
contentValues
|
||||
) ?: return -1L
|
||||
val insertedUri = try {
|
||||
contentResolver.insert(
|
||||
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||
contentValues
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
|
||||
null
|
||||
} ?: return -1L
|
||||
|
||||
val programId = ContentUris.parseId(insertedUri)
|
||||
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
|
||||
return programId
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a cache-busting parameter to ensure unique URIs when images change.
|
||||
* Per Android docs: "Use unique Uris for all images... the old image will
|
||||
* continue to appear if you don't change the Uri."
|
||||
*/
|
||||
private fun appendCacheBuster(imageUrl: String): String {
|
||||
val separator = if (imageUrl.contains("?")) "&" else "?"
|
||||
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
|
||||
}
|
||||
|
||||
private fun buildIntentUri(context: Context, deepLink: String): Uri {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(deepLink)
|
||||
@@ -306,13 +492,17 @@ internal object TvRecommendationsPublisher {
|
||||
|
||||
private fun storeChannelLogo(context: Context, channelId: Long) {
|
||||
val bitmap = applicationIconBitmap(context) ?: return
|
||||
val outputStream = context.contentResolver.openOutputStream(
|
||||
TvContractCompat.buildChannelLogoUri(channelId)
|
||||
) ?: return
|
||||
try {
|
||||
val outputStream = context.contentResolver.openOutputStream(
|
||||
TvContractCompat.buildChannelLogoUri(channelId)
|
||||
) ?: return
|
||||
|
||||
outputStream.use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
stream.flush()
|
||||
outputStream.use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
|
||||
stream.flush()
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,9 +531,14 @@ internal object TvRecommendationsPublisher {
|
||||
return bitmap
|
||||
}
|
||||
|
||||
fun getChannelId(context: Context): Long {
|
||||
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
|
||||
}
|
||||
|
||||
private fun preferences(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
private fun logProviderState(context: Context, channelId: Long) {
|
||||
val contentResolver = context.contentResolver
|
||||
|
||||
@@ -372,8 +567,8 @@ internal object TvRecommendationsPublisher {
|
||||
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
|
||||
}
|
||||
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
|
||||
} catch (error: SecurityException) {
|
||||
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import type { View } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import type { TvSearchViewProps } from "./TvSearchView.types";
|
||||
|
||||
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
|
||||
// On Android the component is never rendered, but we must avoid calling
|
||||
// `requireNativeView` at module-scope because it would crash on import.
|
||||
const NativeView: React.ComponentType<
|
||||
TvSearchViewProps & React.RefAttributes<View>
|
||||
> = requireNativeView("TvSearchModule");
|
||||
> =
|
||||
Platform.OS === "ios"
|
||||
? requireNativeView("TvSearchModule")
|
||||
: ((() => null) as any);
|
||||
|
||||
/**
|
||||
* Forwards its ref to the underlying native view so it can be used as a
|
||||
|
||||
@@ -592,7 +592,8 @@
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
|
||||
Reference in New Issue
Block a user