diff --git a/.github/workflows/build-apps.yml b/.github/workflows/build-apps.yml
index 69a11507..c3fee61b 100644
--- a/.github/workflows/build-apps.yml
+++ b/.github/workflows/build-apps.yml
@@ -12,10 +12,13 @@ on:
branches: [develop, master]
# Exposed to `expo prebuild` (app.config.js โ extra.build) so Settings can show the
-# branch + commit a CI build was made from. EAS cloud builds use EAS_BUILD_* instead.
+# branch + commit + Actions run a CI build was made from. EAS cloud builds use
+# EAS_BUILD_* instead. The run number maps a sideloaded build back to its Actions
+# run (artifacts + logs) without needing Expo access.
env:
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
+ EXPO_PUBLIC_GIT_RUN_NUMBER: ${{ github.run_number }}
jobs:
build-android-phone:
@@ -237,7 +240,9 @@ jobs:
- name: ๐ Build iOS app
env:
EXPO_TV: 0
- run: eas build -p ios --local --non-interactive
+ # `ci` profile (extends production, autoIncrement off): keeps CI builds out of
+ # the production version tier and stops them inflating the store build counter.
+ run: eas build -p ios --local --non-interactive --profile ci
- name: ๐
Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -362,7 +367,7 @@ jobs:
- name: ๐ Build iOS app
env:
EXPO_TV: 1
- run: eas build -p ios --local --non-interactive
+ run: eas build -p ios --local --non-interactive --profile ci_tv
- name: ๐
Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
diff --git a/.github/workflows/conflict.yml b/.github/workflows/conflict.yml
index 7793851c..de854ab6 100644
--- a/.github/workflows/conflict.yml
+++ b/.github/workflows/conflict.yml
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: ๐ฉ Apply merge conflict label
- uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
+ uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: 'โ๏ธ merge-conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
diff --git a/GLOBAL_MODAL_GUIDE.md b/GLOBAL_MODAL_GUIDE.md
index 5426ca72..11a05f10 100644
--- a/GLOBAL_MODAL_GUIDE.md
+++ b/GLOBAL_MODAL_GUIDE.md
@@ -143,14 +143,6 @@ interface ModalOptions {
}
```
-## Examples
-
-See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
-- Simple content modal
-- Modal with custom snap points
-- Complex component in modal
-- Success/error modals triggered from functions
-
## Default Styling
The modal uses these default styles (can be overridden via options):
diff --git a/app.config.js b/app.config.js
index 97056736..d29ddc32 100644
--- a/app.config.js
+++ b/app.config.js
@@ -33,6 +33,12 @@ const buildMeta = {
process.env.EAS_BUILD_PROFILE ||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
null,
+ // GitHub Actions run number (#2098) โ lets anyone map a sideloaded CI build back
+ // to its Actions run (artifacts + logs) without Expo access. Null outside CI.
+ runNumber:
+ process.env.GITHUB_RUN_NUMBER ||
+ process.env.EXPO_PUBLIC_GIT_RUN_NUMBER ||
+ null,
builtAt: new Date().toISOString(),
};
diff --git a/augmentations/index.ts b/augmentations/index.ts
index abec02c9..0c193e83 100644
--- a/augmentations/index.ts
+++ b/augmentations/index.ts
@@ -1,4 +1,3 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
-export * from "./string";
diff --git a/augmentations/number.ts b/augmentations/number.ts
index bef44ac5..c8146d65 100644
--- a/augmentations/number.ts
+++ b/augmentations/number.ts
@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
- hoursToMilliseconds(): number;
}
}
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
-Number.prototype.hoursToMilliseconds = function () {
- return this.valueOf() * (60).minutesToMilliseconds();
-};
-
export {};
diff --git a/augmentations/string.ts b/augmentations/string.ts
deleted file mode 100644
index f4a50b55..00000000
--- a/augmentations/string.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-declare global {
- interface String {
- toTitle(): string;
- }
-}
-
-String.prototype.toTitle = function () {
- return this.replaceAll("_", " ").replace(
- /\w\S*/g,
- (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
- );
-};
-
-export {};
diff --git a/components/ContextMenu.tv.ts b/components/ContextMenu.tv.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/components/ExampleGlobalModalUsage.tsx b/components/ExampleGlobalModalUsage.tsx
deleted file mode 100644
index ccebb823..00000000
--- a/components/ExampleGlobalModalUsage.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-/**
- * Example Usage of Global Modal
- *
- * This file demonstrates how to use the global modal system from anywhere in your app.
- * You can delete this file after understanding how it works.
- */
-
-import { Ionicons } from "@expo/vector-icons";
-import { TouchableOpacity, View } from "react-native";
-import { Text } from "@/components/common/Text";
-import { useGlobalModal } from "@/providers/GlobalModalProvider";
-
-/**
- * Example 1: Simple Content Modal
- */
-export const SimpleModalExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal(
-
- Simple Modal
-
- This is a simple modal with just some text content.
-
-
- Swipe down or tap outside to close.
-
- ,
- );
- };
-
- return (
-
- Open Simple Modal
-
- );
-};
-
-/**
- * Example 2: Modal with Custom Snap Points
- */
-export const CustomSnapPointsExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal(
-
-
- Custom Snap Points
-
-
- This modal has custom snap points (25%, 50%, 90%).
-
-
-
- Try dragging the modal to different heights!
-
-
- ,
- {
- snapPoints: ["25%", "50%", "90%"],
- enableDynamicSizing: false,
- },
- );
- };
-
- return (
-
- Custom Snap Points
-
- );
-};
-
-/**
- * Example 3: Complex Component in Modal
- */
-const SettingsModalContent = () => {
- const { hideModal } = useGlobalModal();
-
- const settings = [
- {
- id: 1,
- title: "Notifications",
- icon: "notifications-outline" as const,
- enabled: true,
- },
- { id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
- {
- id: 3,
- title: "Auto-play",
- icon: "play-outline" as const,
- enabled: false,
- },
- ];
-
- return (
-
- Settings
-
- {settings.map((setting, index) => (
-
-
-
- {setting.title}
-
-
-
-
-
- ))}
-
-
- Close
-
-
- );
-};
-
-export const ComplexModalExample = () => {
- const { showModal } = useGlobalModal();
-
- const handleOpenModal = () => {
- showModal();
- };
-
- return (
-
- Complex Component
-
- );
-};
-
-/**
- * Example 4: Modal Triggered from Function (e.g., API response)
- */
-export const useShowSuccessModal = () => {
- const { showModal } = useGlobalModal();
-
- return (message: string) => {
- showModal(
-
-
-
-
- Success!
- {message}
- ,
- );
- };
-};
-
-/**
- * Main Demo Component
- */
-export const GlobalModalDemo = () => {
- const showSuccess = useShowSuccessModal();
-
- return (
-
-
- Global Modal Examples
-
-
-
-
-
-
- showSuccess("Operation completed successfully!")}
- className='bg-orange-600 px-4 py-2 rounded-lg'
- >
- Show Success Modal
-
-
- );
-};
diff --git a/components/common/LargePoster.tsx b/components/common/LargePoster.tsx
deleted file mode 100644
index ab3b16fb..00000000
--- a/components/common/LargePoster.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Image } from "expo-image";
-import { View } from "react-native";
-
-export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
- if (!url)
- return (
-
-
-
- );
-
- return (
-
-
-
- );
-};
diff --git a/components/common/VerticalSkeleton.tsx b/components/common/VerticalSkeleton.tsx
deleted file mode 100644
index 02a8a256..00000000
--- a/components/common/VerticalSkeleton.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { View, type ViewProps } from "react-native";
-
-interface Props extends ViewProps {
- index: number;
-}
-
-export const VerticalSkeleton: React.FC = ({ index, ...props }) => {
- return (
-
-
-
-
-
-
- );
-};
diff --git a/components/navigation/TabBarIcon.tsx b/components/navigation/TabBarIcon.tsx
deleted file mode 100644
index a28bba84..00000000
--- a/components/navigation/TabBarIcon.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
-
-import type { IconProps } from "@expo/vector-icons/build/createIconSet";
-import Ionicons from "@expo/vector-icons/Ionicons";
-import type { ComponentProps } from "react";
-
-export function TabBarIcon({
- style,
- ...rest
-}: IconProps["name"]>) {
- return ;
-}
diff --git a/components/posters/EpisodePoster.tsx b/components/posters/EpisodePoster.tsx
deleted file mode 100644
index af42989b..00000000
--- a/components/posters/EpisodePoster.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
-import { View } from "react-native";
-import { WatchedIndicator } from "@/components/WatchedIndicator";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-type MoviePosterProps = {
- item: BaseItemDto;
- showProgress?: boolean;
-};
-
-export const EpisodePoster: React.FC = ({
- item,
- showProgress = false,
-}) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(() => {
- if (item.Type === "Episode") {
- return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
- }
- }, [item]);
-
- const [progress, _setProgress] = useState(
- item.UserData?.PlayedPercentage || 0,
- );
-
- const blurhash = useMemo(() => {
- const key = item.ImageTags?.Primary as string;
- return item.ImageBlurHashes?.Primary?.[key];
- }, [item]);
-
- return (
-
-
-
- {showProgress && progress > 0 && (
-
- )}
-
- );
-};
diff --git a/components/posters/ParentPoster.tsx b/components/posters/ParentPoster.tsx
deleted file mode 100644
index 47b62e4c..00000000
--- a/components/posters/ParentPoster.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { View } from "react-native";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-type PosterProps = {
- id?: string;
- showProgress?: boolean;
-};
-
-const ParentPoster: React.FC = ({ id }) => {
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(
- () => `${api?.basePath}/Items/${id}/Images/Primary`,
- [id],
- );
-
- if (!url || !id)
- return (
-
- );
-
- return (
-
-
-
- );
-};
-
-export default ParentPoster;
diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx
index 580e8a00..24e6120d 100644
--- a/components/search/TVSearchPage.tsx
+++ b/components/search/TVSearchPage.tsx
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai";
import { useMemo } 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";
@@ -231,26 +231,48 @@ export const TVSearchPage: React.FC = ({
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 " 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. */}
-
- setSearch(e.nativeEvent.text)}
- />
-
+ {/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
+ TextInput fallback on Android TV (the native module is Apple-only). */}
+ {Platform.OS === "ios" ? (
+
+ {/* No horizontal margin here: the native tvOS search bar centers
+ itself and renders a trailing "Hold to Dictate" hint. */}
+ setSearch(e.nativeEvent.text)}
+ />
+
+ ) : (
+
+
+
+ )}
{
- const { settings } = useSettings();
- const { sessions = [] } = useSessions({} as useSessionsProps);
- const router = useRouter();
-
- const { t } = useTranslation();
-
- if (!settings) return null;
- return (
-
-
- router.push("/settings/dashboard/sessions")}
- title={t("home.settings.dashboard.sessions_title")}
- showArrow
- />
-
-
- );
-};
diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx
deleted file mode 100644
index 3a0017ac..00000000
--- a/components/settings/DownloadSettings.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function DownloadSettings() {
- return null;
-}
diff --git a/components/settings/DownloadSettings.tv.tsx b/components/settings/DownloadSettings.tv.tsx
deleted file mode 100644
index 3a0017ac..00000000
--- a/components/settings/DownloadSettings.tv.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-export default function DownloadSettings() {
- return null;
-}
diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx
index 470d40a2..436b46c4 100644
--- a/components/settings/Jellyseerr.tsx
+++ b/components/settings/Jellyseerr.tsx
@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
>
) : (
-
- {t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
-
{t("home.settings.plugins.jellyseerr.server_url")}
diff --git a/components/settings/UserInfo.tsx b/components/settings/UserInfo.tsx
index 6711d655..9c55293d 100644
--- a/components/settings/UserInfo.tsx
+++ b/components/settings/UserInfo.tsx
@@ -14,7 +14,7 @@ export const UserInfo: React.FC = ({ ...props }) => {
const { t } = useTranslation();
// Graduated build identifier โ see utils/version.ts:
- // dev โ "0.54.1 ยท branch ยท commit", develop/CI โ "0.54.1 ยท commit", production โ "0.54.1 (42)".
+ // dev โ "0.54.1 ยท branch ยท commit", develop/CI โ "0.54.1 ยท commit ยท #run", production โ "0.54.1".
const { display: version } = getVersionInfo();
return (
diff --git a/constants/Languages.ts b/constants/Languages.ts
deleted file mode 100644
index 8014e380..00000000
--- a/constants/Languages.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { DefaultLanguageOption } from "@/utils/atoms/settings";
-
-export const LANGUAGES: DefaultLanguageOption[] = [
- { label: "English", value: "eng" },
- { label: "Spanish", value: "spa" },
- { label: "Chinese (Mandarin)", value: "cmn" },
- { label: "Hindi", value: "hin" },
- { label: "Arabic", value: "ara" },
- { label: "French", value: "fra" },
- { label: "Russian", value: "rus" },
- { label: "Portuguese", value: "por" },
- { label: "Japanese", value: "jpn" },
- { label: "German", value: "deu" },
- { label: "Italian", value: "ita" },
- { label: "Korean", value: "kor" },
- { label: "Turkish", value: "tur" },
- { label: "Dutch", value: "nld" },
- { label: "Polish", value: "pol" },
- { label: "Vietnamese", value: "vie" },
- { label: "Thai", value: "tha" },
- { label: "Indonesian", value: "ind" },
- { label: "Greek", value: "ell" },
- { label: "Swedish", value: "swe" },
- { label: "Danish", value: "dan" },
- { label: "Norwegian", value: "nor" },
- { label: "Finnish", value: "fin" },
- { label: "Czech", value: "ces" },
- { label: "Hungarian", value: "hun" },
- { label: "Romanian", value: "ron" },
- { label: "Ukrainian", value: "ukr" },
- { label: "Hebrew", value: "heb" },
- { label: "Bengali", value: "ben" },
- { label: "Punjabi", value: "pan" },
- { label: "Tagalog", value: "tgl" },
- { label: "Swahili", value: "swa" },
- { label: "Malay", value: "msa" },
- { label: "Persian", value: "fas" },
- { label: "Urdu", value: "urd" },
-];
diff --git a/eas.json b/eas.json
index 966881ce..f4099eda 100644
--- a/eas.json
+++ b/eas.json
@@ -97,6 +97,14 @@
"credentialsSource": "local",
"config": "ios-production.yml"
}
+ },
+ "ci": {
+ "extends": "production",
+ "autoIncrement": false
+ },
+ "ci_tv": {
+ "extends": "production_tv",
+ "autoIncrement": false
}
},
"submit": {
diff --git a/hooks/useControlsVisibility.ts b/hooks/useControlsVisibility.ts
deleted file mode 100644
index caca0d84..00000000
--- a/hooks/useControlsVisibility.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import { useSharedValue } from "react-native-reanimated";
-
-export const useControlsVisibility = (timeout = 3000) => {
- const opacity = useSharedValue(1);
-
- const hideControlsTimerRef = useRef | null>(
- null,
- );
-
- const showControls = useCallback(() => {
- opacity.value = 1;
- if (hideControlsTimerRef.current) {
- clearTimeout(hideControlsTimerRef.current);
- }
- hideControlsTimerRef.current = setTimeout(() => {
- opacity.value = 0;
- }, timeout);
- }, [timeout]);
-
- const hideControls = useCallback(() => {
- opacity.value = 0;
- if (hideControlsTimerRef.current) {
- clearTimeout(hideControlsTimerRef.current);
- }
- }, []);
-
- useEffect(() => {
- return () => {
- if (hideControlsTimerRef.current) {
- clearTimeout(hideControlsTimerRef.current);
- }
- };
- }, []);
-
- return { opacity, showControls, hideControls };
-};
diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts
deleted file mode 100644
index 845161a1..00000000
--- a/hooks/useDownloadedFileOpener.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import { useCallback } from "react";
-import useRouter from "@/hooks/useAppRouter";
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
-import { writeToLog } from "@/utils/log";
-
-export const useDownloadedFileOpener = () => {
- const router = useRouter();
- const { setPlayUrl, setOfflineSettings } = usePlaySettings();
-
- const openFile = useCallback(
- async (item: BaseItemDto) => {
- if (!item.Id) {
- writeToLog("ERROR", "Attempted to open a file without an ID.");
- console.error("Attempted to open a file without an ID.");
- return;
- }
- const queryParams = new URLSearchParams({
- itemId: item.Id,
- offline: "true",
- playbackPosition:
- item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
- });
- try {
- router.push(`/player/direct-player?${queryParams.toString()}`);
- } catch (error) {
- writeToLog("ERROR", "Error opening file", error);
- console.error("Error opening file:", error);
- }
- },
- [setOfflineSettings, setPlayUrl, router],
- );
-
- return { openFile };
-};
diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts
deleted file mode 100644
index 4d8a0136..00000000
--- a/hooks/useImageColors.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import { useAtom, useAtomValue } from "jotai";
-import { useEffect, useMemo } from "react";
-import { Platform } from "react-native";
-import type * as ImageColorsType from "react-native-image-colors";
-import { apiAtom } from "@/providers/JellyfinProvider";
-
-// Conditionally import react-native-image-colors only on non-TV platforms
-const ImageColors = Platform.isTV
- ? null
- : (require("react-native-image-colors") as typeof ImageColorsType);
-
-import {
- adjustToNearBlack,
- calculateTextColor,
- isCloseToBlack,
- itemThemeColorAtom,
-} from "@/utils/atoms/primaryColor";
-import { getItemImage } from "@/utils/getItemImage";
-import { storage } from "@/utils/mmkv";
-
-/**
- * Custom hook to extract and manage image colors for a given item.
- *
- * @param item - The BaseItemDto object representing the item.
- * @param disabled - A boolean flag to disable color extraction.
- *
- */
-export const useImageColors = ({
- item,
- url,
- disabled,
-}: {
- item?: BaseItemDto | null;
- url?: string | null;
- disabled?: boolean;
-}) => {
- const api = useAtomValue(apiAtom);
- const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
-
- const isTv = Platform.isTV;
-
- const source = useMemo(() => {
- if (!api) return;
- if (url) return { uri: url };
- if (item)
- return getItemImage({
- item,
- api,
- variant: "Primary",
- quality: 80,
- width: 300,
- });
- return null;
- }, [api, item, url]);
-
- useEffect(() => {
- if (isTv) return;
- if (disabled) return;
- if (source?.uri) {
- const _primary = storage.getString(`${source.uri}-primary`);
- const _text = storage.getString(`${source.uri}-text`);
-
- if (_primary && _text) {
- setPrimaryColor({
- primary: _primary,
- text: _text,
- });
- return;
- }
-
- // Extract colors from the image
- if (!ImageColors?.getColors) return;
-
- ImageColors.getColors(source.uri, {
- fallback: "#fff",
- cache: false,
- })
- .then((colors: ImageColorsType.ImageColorsResult) => {
- let primary = "#fff";
- let text = "#000";
- let backup = "#fff";
-
- // Select the appropriate color based on the platform
- if (colors.platform === "android") {
- primary = colors.dominant;
- backup = colors.vibrant;
- } else if (colors.platform === "ios") {
- primary = colors.detail;
- backup = colors.primary;
- }
-
- // Adjust the primary color if it's too close to black
- if (primary && isCloseToBlack(primary)) {
- if (backup && !isCloseToBlack(backup)) primary = backup;
- primary = adjustToNearBlack(primary);
- }
-
- // Calculate the text color based on the primary color
- if (primary) text = calculateTextColor(primary);
-
- setPrimaryColor({
- primary,
- text,
- });
-
- // Cache the colors in storage
- if (source.uri && primary) {
- storage.set(`${source.uri}-primary`, primary);
- storage.set(`${source.uri}-text`, text);
- }
- })
- .catch((error: any) => {
- console.error("Error getting colors", error);
- });
- }
- }, [isTv, source?.uri, setPrimaryColor, disabled]);
-
- if (isTv) return;
-};
diff --git a/hooks/useWifiSSID.ts b/hooks/useWifiSSID.ts
index 2b442a58..de0e2828 100644
--- a/hooks/useWifiSSID.ts
+++ b/hooks/useWifiSSID.ts
@@ -53,7 +53,6 @@ export function useWifiSSID(): UseWifiSSIDReturn {
const fetchSSID = useCallback(async () => {
if (Platform.isTV) return;
const result = await getSSID();
- console.log("[WiFi Debug] Native module SSID:", result);
setSSID(result);
}, []);
diff --git a/modules/tv-recommendations/android/src/main/AndroidManifest.xml b/modules/tv-recommendations/android/src/main/AndroidManifest.xml
index 87a7944c..c2261bb8 100644
--- a/modules/tv-recommendations/android/src/main/AndroidManifest.xml
+++ b/modules/tv-recommendations/android/src/main/AndroidManifest.xml
@@ -1,10 +1,12 @@
-
+
+
+
+
+
diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt
index 7946648e..77703cf5 100644
--- a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt
+++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsPublisher.kt
@@ -16,12 +16,13 @@ import androidx.tvprovider.media.tv.PreviewProgram
import androidx.tvprovider.media.tv.TvContractCompat
import org.json.JSONArray
import org.json.JSONObject
+import java.security.MessageDigest
internal object TvRecommendationsPublisher {
private const val TAG = "TvRecommendations"
private const val PREFS_NAME = "StreamyfinTvRecommendations"
private const val KEY_PAYLOAD = "payload"
- private const val KEY_CHANNEL_ID = "channelId"
+ private const val KEY_CHANNEL_ID_PREFIX = "channelId_"
private const val KEY_PROGRAM_IDS = "programIds"
private const val DEFAULT_CHANNEL_NAME = "Continue and Next Up"
@@ -61,31 +62,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,128 +127,274 @@ 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 channelMap = JSONObject(programIdsJson)
+ val channelKeys = channelMap.keys()
+ while (channelKeys.hasNext()) {
+ val channelId = channelKeys.next()
+ val inner = channelMap.optJSONObject(channelId) ?: continue
+ val providerKeys = inner.keys()
+ while (providerKeys.hasNext()) {
+ val providerId = providerKeys.next()
+ if (inner.optLong(providerId, -1L) == programId) {
+ inner.remove(providerId)
+ if (inner.length() == 0) {
+ channelMap.remove(channelId)
+ }
+ break
+ }
+ }
+ }
+ prefs.edit().putString(KEY_PROGRAM_IDS, channelMap.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()
+ 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()
+
+ 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 channelKey = getChannelKey(displayName)
+ val existingChannelId = prefs.getLong(channelKey, -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")
- prefs.edit().remove(KEY_CHANNEL_ID).apply()
+ // Channel truly doesn't exist in provider โ recreate
+ Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
+ prefs.edit().remove(channelKey).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)
+ prefs.edit().putLong(channelKey, channelId).apply()
TvContractCompat.requestChannelBrowsable(context, channelId)
storeChannelLogo(context, channelId)
Log.d(TAG, "getOrCreateChannel(): created new channelId=$channelId displayName=\"$displayName\"")
@@ -225,6 +402,10 @@ internal object TvRecommendationsPublisher {
return channelId
}
+ private fun getChannelKey(displayName: String): String {
+ return KEY_CHANNEL_ID_PREFIX + displayName.hashCode()
+ }
+
private fun upsertPreviewProgram(
context: Context,
channelId: Long,
@@ -249,42 +430,67 @@ 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 stable cache key derived from the image URL.
+ * The Jellyfin image URLs already include a `tag=` query param (etag)
+ * that changes whenever the image content changes, so a deterministic
+ * hash of the URL is sufficient โ the param only changes when the URL
+ * (and therefore the image) actually changes, avoiding unnecessary
+ * re-downloads on every sync.
+ */
+ private fun appendCacheBuster(imageUrl: String): String {
+ val digest = MessageDigest.getInstance("MD5").digest(imageUrl.toByteArray())
+ val hash = digest.joinToString("") { "%02x".format(it) }.substring(0, 16)
+ val separator = if (imageUrl.contains("?")) "&" else "?"
+ return "$imageUrl${separator}_v=$hash"
+ }
+
private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink)
@@ -306,13 +512,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 +551,14 @@ internal object TvRecommendationsPublisher {
return bitmap
}
+ fun getChannelId(context: Context, displayName: String = DEFAULT_CHANNEL_NAME): Long {
+ return preferences(context).getLong(getChannelKey(displayName), -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 +587,10 @@ 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)
+} catch (error: Exception) {
+ Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
+}
}
}
diff --git a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt
index 1fde77c7..a98af39b 100644
--- a/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt
+++ b/modules/tv-recommendations/android/src/main/java/expo/modules/tvrecommendations/TvRecommendationsReceiver.kt
@@ -3,16 +3,24 @@ package expo.modules.tvrecommendations
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.content.ContentUris
import android.util.Log
import androidx.tvprovider.media.tv.TvContractCompat
class TvRecommendationsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
- if (intent.action != TvContractCompat.ACTION_INITIALIZE_PROGRAMS) {
- return
+ when (intent.action) {
+ TvContractCompat.ACTION_INITIALIZE_PROGRAMS -> {
+ Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
+ TvRecommendationsPublisher.refreshFromCache(context)
+ }
+ "android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" -> {
+ val programId = intent.data?.let { ContentUris.parseId(it) } ?: -1L
+ if (programId > 0L) {
+ Log.d("TvRecommendations", "Handling PREVIEW_PROGRAM_BROWSABLE_DISABLED for programId=$programId")
+ TvRecommendationsPublisher.deletePreviewProgram(context, programId)
+ }
+ }
}
-
- Log.d("TvRecommendations", "Handling INITIALIZE_PROGRAMS broadcast")
- TvRecommendationsPublisher.refreshFromCache(context)
}
}
diff --git a/modules/tv-search/src/TvSearchView.tsx b/modules/tv-search/src/TvSearchView.tsx
index aa1a81d2..bc64b162 100644
--- a/modules/tv-search/src/TvSearchView.tsx
+++ b/modules/tv-search/src/TvSearchView.tsx
@@ -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
-> = 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
diff --git a/modules/wifi-ssid/index.ts b/modules/wifi-ssid/index.ts
index 71bfbcfb..00d2bf83 100644
--- a/modules/wifi-ssid/index.ts
+++ b/modules/wifi-ssid/index.ts
@@ -15,7 +15,6 @@ const WifiSsidModule =
*/
export async function getSSID(): Promise {
if (!WifiSsidModule) {
- console.log("[WifiSsid] Module not available on this platform");
return null;
}
diff --git a/providers/Downloads/hooks/useDownloadEventHandlers.ts b/providers/Downloads/hooks/useDownloadEventHandlers.ts
index ebf8b116..0bc8c352 100644
--- a/providers/Downloads/hooks/useDownloadEventHandlers.ts
+++ b/providers/Downloads/hooks/useDownloadEventHandlers.ts
@@ -142,31 +142,12 @@ export function useDownloadEventHandlers({
} else {
// Transcoding - estimate from bitrate
const process = processes.find((p) => p.id === processId);
- console.log(
- `[DPL] Transcoding detected, looking for process ${processId}, found:`,
- process ? "yes" : "no",
- );
- if (process) {
- console.log(`[DPL] Process bitrate:`, {
- key: process.maxBitrate.key,
- value: process.maxBitrate.value,
- runTimeTicks: process.item.RunTimeTicks,
- });
- if (process.maxBitrate.value && process.item.RunTimeTicks) {
- const { estimateDownloadSize } = require("@/utils/download");
- estimatedTotalBytes = estimateDownloadSize(
- process.maxBitrate.value,
- process.item.RunTimeTicks,
- );
- console.log(
- `[DPL] Calculated estimatedTotalBytes:`,
- estimatedTotalBytes,
- );
- } else {
- console.log(
- `[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
- );
- }
+ if (process?.maxBitrate.value && process.item.RunTimeTicks) {
+ const { estimateDownloadSize } = require("@/utils/download");
+ estimatedTotalBytes = estimateDownloadSize(
+ process.maxBitrate.value,
+ process.item.RunTimeTicks,
+ );
}
}
diff --git a/providers/WebSocketProvider.tsx b/providers/WebSocketProvider.tsx
index ed9db754..c704d373 100644
--- a/providers/WebSocketProvider.tsx
+++ b/providers/WebSocketProvider.tsx
@@ -1,4 +1,5 @@
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
+import { router } from "expo-router";
import { useAtomValue } from "jotai";
import {
createContext,
@@ -11,7 +12,6 @@ import {
useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
-import useRouter from "@/hooks/useAppRouter";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
@@ -28,6 +28,20 @@ const LIBRARY_CHANGE_QUERY_KEYS = [
["episodes"],
] as const;
+// Query keys that depend on per-user playback state (resume position, played
+// status, favorites) and should be refreshed when the server reports a
+// `UserDataChanged`. Scoped to the progression-based sections so finishing an
+// episode does not pointlessly refetch "recently added" or suggestions.
+const USER_DATA_CHANGE_QUERY_KEYS = [
+ ["home", "continueAndNextUp"],
+ ["home", "resumeItems"],
+ ["home", "nextUp-all"],
+ ["home", "heroItems"],
+ ["resumeItems"],
+ ["nextUp-all"],
+ ["nextUp"],
+] as const;
+
interface WebSocketMessage {
MessageType: string;
Data: any;
@@ -38,10 +52,30 @@ interface WebSocketProviderProps {
children: ReactNode;
}
+/**
+ * Handler invoked for every message of a given `MessageType`. Receives the
+ * message `Data` payload and the full message.
+ */
+type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
+
interface WebSocketContextType {
ws: WebSocket | null;
isConnected: boolean;
+ /**
+ * @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
+ * message, so bursts arriving in the same tick are coalesced and lost. Kept
+ * for `useWebsockets` (GeneralCommand handling) until it is migrated.
+ */
lastMessage: WebSocketMessage | null;
+ /**
+ * Subscribe to a given message type. The handler is called synchronously for
+ * every matching message (no coalescing, unlike `lastMessage`). Returns an
+ * unsubscribe function to call on cleanup.
+ */
+ subscribe: (
+ messageType: string,
+ handler: WebSocketMessageHandler,
+ ) => () => void;
sendMessage: (message: any) => void;
clearLastMessage: () => void;
}
@@ -54,7 +88,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const [ws, setWs] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState(null);
- const router = useRouter();
const queryClient = useNetworkAwareQueryClient();
const deviceId = useMemo(() => {
return getOrSetDeviceId();
@@ -63,8 +96,76 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const libraryChangeDebounceRef = useRef | null>(
null,
);
+ const userDataChangeDebounceRef = useRef | null>(null);
+ // Handle for the onerror backoff timer. Tracked so a reconnect triggered by
+ // another path (foreground, network reconnect, effect re-run) can cancel a
+ // pending one โ an untracked timer would later open a second socket.
+ const reconnectTimeoutRef = useRef | null>(
+ null,
+ );
+
+ // Pub/sub registry: messageType -> set of handlers. Stored in a ref so
+ // subscribing/dispatching never triggers a re-render.
+ const listenersRef = useRef