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>>( + new Map(), + ); + + const subscribe = useCallback( + (messageType: string, handler: WebSocketMessageHandler) => { + const listeners = listenersRef.current; + let handlers = listeners.get(messageType); + if (!handlers) { + handlers = new Set(); + listeners.set(messageType, handlers); + } + handlers.add(handler); + return () => { + handlers?.delete(handler); + // Only drop the map entry if it still points at THIS set. After an + // unsubscribe + re-subscribe for the same type, a stale second call to + // this cleanup would otherwise delete the new subscribers' set and + // silently stop delivering their messages. + if ( + handlers && + handlers.size === 0 && + listeners.get(messageType) === handlers + ) { + listeners.delete(messageType); + } + }; + }, + [], + ); + + const dispatchMessage = useCallback((message: WebSocketMessage) => { + const handlers = listenersRef.current.get(message.MessageType); + if (!handlers || handlers.size === 0) return; + // Copy to tolerate handlers that unsubscribe during dispatch. + for (const handler of [...handlers]) { + // Isolate each handler so one throwing subscriber can't abort the rest + // (and isn't misreported as a parse failure by the outer onmessage catch). + try { + handler(message.Data, message); + } catch (error) { + console.error( + `Error handling WebSocket message type "${message.MessageType}":`, + error, + ); + } + } + }, []); const connectWebSocket = useCallback(() => { + // Cancel any reconnect queued by a previous onerror before opening a new + // socket, so we never end up with two live sockets โ€” each would double the + // message fan-out and double-invalidate queries. + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (!deviceId || !api?.accessToken || !isNetworkConnected) { return; } @@ -85,6 +186,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { newWebSocket.onopen = () => { setIsConnected(true); reconnectAttemptsRef.current = 0; + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } keepAliveInterval = setInterval(() => { if (newWebSocket.readyState === WebSocket.OPEN) { newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" })); @@ -96,9 +201,15 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { // Don't log errors - this is expected when offline or server unreachable setIsConnected(false); + // Replace any still-pending reconnect so only one is ever queued; the + // previously untracked handle could leak and open a second socket. + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } if (reconnectAttemptsRef.current < maxReconnectAttempts) { reconnectAttemptsRef.current++; - setTimeout(() => { + reconnectTimeoutRef.current = setTimeout(() => { + reconnectTimeoutRef.current = null; connectWebSocket(); }, reconnectDelay); } @@ -113,7 +224,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { newWebSocket.onmessage = (e) => { try { const message = JSON.parse(e.data); - setLastMessage(message); // Store the last message in context + // Legacy single-slot state, still consumed by useWebsockets. + setLastMessage(message); + // Pub/sub: deliver to every subscriber without coalescing. + dispatchMessage(message); } catch (error) { console.error("Error parsing WebSocket message:", error); } @@ -124,9 +238,13 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { if (keepAliveInterval) { clearInterval(keepAliveInterval); } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } newWebSocket.close(); }; - }, [api, deviceId, isNetworkConnected]); + }, [api, deviceId, isNetworkConnected, dispatchMessage]); const handleLibraryChanged = useCallback( (data: any) => { @@ -157,47 +275,80 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { [queryClient], ); - useEffect(() => { - if (!lastMessage) { - return; - } - if (lastMessage.MessageType === "Play") { - handlePlayCommand(lastMessage.Data); - } else if (lastMessage.MessageType === "LibraryChanged") { - handleLibraryChanged(lastMessage.Data); - } - }, [lastMessage, router, handleLibraryChanged]); + const handleUserDataChanged = useCallback( + (data: any) => { + // Jellyfin sends UserDataChanged when playback position, played status + // or favorites change (e.g. finishing an episode). Only the + // progression-based home sections care about it. + if (!((data?.UserDataList?.length ?? 0) > 0)) { + return; + } + + // Finishing an item can emit several UserDataChanged messages, so + // debounce to invalidate the affected sections only once. + if (userDataChangeDebounceRef.current) { + clearTimeout(userDataChangeDebounceRef.current); + } + userDataChangeDebounceRef.current = setTimeout(() => { + for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) { + queryClient.invalidateQueries({ queryKey: [...queryKey] }); + } + }, 800); + }, + [queryClient], + ); + + // Refresh library-dependent queries when the server reports a change. + useEffect( + () => subscribe("LibraryChanged", handleLibraryChanged), + [subscribe, handleLibraryChanged], + ); + + // Refresh "Continue Watching" / "Next Up" when playback state changes. + useEffect( + () => subscribe("UserDataChanged", handleUserDataChanged), + [subscribe, handleUserDataChanged], + ); useEffect(() => { return () => { if (libraryChangeDebounceRef.current) { clearTimeout(libraryChangeDebounceRef.current); } + if (userDataChangeDebounceRef.current) { + clearTimeout(userDataChangeDebounceRef.current); + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } }; }, []); - const handlePlayCommand = useCallback( - (data: any) => { - if (!data?.ItemIds?.length) { - return; - } + const handlePlayCommand = useCallback((data: any) => { + if (!data?.ItemIds?.length) { + return; + } - const itemId = data.ItemIds[0]; + const itemId = data.ItemIds[0]; - router.push({ - pathname: "/(auth)/player/direct-player", - params: { - itemId: itemId, - playCommand: data.PlayCommand || "PlayNow", - audioIndex: data.AudioStreamIndex?.toString(), - subtitleIndex: data.SubtitleStreamIndex?.toString(), - mediaSourceId: data.MediaSourceId || "", - bitrateValue: "", - offline: "false", - }, - }); - }, - [router], + router.push({ + pathname: "/(auth)/player/direct-player", + params: { + itemId: itemId, + playCommand: data.PlayCommand || "PlayNow", + audioIndex: data.AudioStreamIndex?.toString(), + subtitleIndex: data.SubtitleStreamIndex?.toString(), + mediaSourceId: data.MediaSourceId || "", + bitrateValue: "", + offline: "false", + }, + }); + }, []); + + // Server-initiated "Play me this item" remote command. + useEffect( + () => subscribe("Play", handlePlayCommand), + [subscribe, handlePlayCommand], ); useEffect(() => { @@ -267,7 +418,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { }, []); return ( {children} diff --git a/translations/en.json b/translations/en.json index 25b97838..3c4271b1 100644 --- a/translations/en.json +++ b/translations/en.json @@ -108,7 +108,7 @@ "features_description": "Streamyfin has a bunch of features and integrates with a wide array of software, which you can find in the settings menu. These include:", "jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.", "downloads_feature_title": "Downloads", - "downloads_feature_description": "Download movies and series to watch offline. Use either the default method or install the optimize server to download files in the background.", + "downloads_feature_description": "Download movies and series to watch offline.", "chromecast_feature_description": "Cast movies and series to your Chromecast devices.", "centralised_settings_plugin_title": "Centralised Settings plugin", "centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.", @@ -320,7 +320,6 @@ "plugins": { "plugins_title": "Plugins", "jellyseerr": { - "jellyseerr_warning": "This integration is in its early stages. Expect things to change.", "server_url": "Server URL", "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)", "server_url_placeholder": "Seerr URL", @@ -432,10 +431,6 @@ "4_hours": "4 hours", "24_hours": "24 hours" } - }, - "dashboard": { - "title": "Dashboard", - "sessions_title": "Sessions" } }, "sessions": { diff --git a/utils/bToMb.ts b/utils/bToMb.ts deleted file mode 100644 index 79b7caf4..00000000 --- a/utils/bToMb.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Convert bits to megabits or gigabits - * - * Return nice looking string - * If under 1000Mb, return XXXMB, else return X.XGB - */ - -export function convertBitsToMegabitsOrGigabits(bits?: number | null): string { - if (!bits) return "0MB"; - - const megabits = bits / 1000000; - - if (megabits < 1000) { - return `${Math.round(megabits)}MB`; - } - const gigabits = megabits / 1000; - return `${gigabits.toFixed(1)}GB`; -} diff --git a/utils/collectionTypeToItemType.ts b/utils/collectionTypeToItemType.ts deleted file mode 100644 index 0889b8d6..00000000 --- a/utils/collectionTypeToItemType.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - BaseItemKind, - CollectionType, -} from "@jellyfin/sdk/lib/generated-client"; - -/** - * Converts a ColletionType to a BaseItemKind (also called ItemType) - * - * CollectionTypes - * readonly Unknown: "unknown"; - readonly Movies: "movies"; - readonly Tvshows: "tvshows"; - readonly Trailers: "trailers"; - readonly Homevideos: "homevideos"; - readonly Boxsets: "boxsets"; - readonly Books: "books"; - readonly Photos: "photos"; - readonly Livetv: "livetv"; - readonly Playlists: "playlists"; - readonly Folders: "folders"; - */ -export const colletionTypeToItemType = ( - collectionType?: CollectionType | null, -): BaseItemKind | undefined => { - if (!collectionType) return undefined; - - switch (collectionType) { - case CollectionType.Movies: - return BaseItemKind.Movie; - case CollectionType.Tvshows: - return BaseItemKind.Series; - case CollectionType.Homevideos: - return BaseItemKind.Video; - case CollectionType.Books: - return BaseItemKind.Book; - case CollectionType.Playlists: - return BaseItemKind.Playlist; - case CollectionType.Folders: - return BaseItemKind.Folder; - case CollectionType.Photos: - return BaseItemKind.Photo; - case CollectionType.Trailers: - return BaseItemKind.Trailer; - } - - return undefined; -}; diff --git a/utils/hls/parseM3U8ForSubtitles.ts b/utils/hls/parseM3U8ForSubtitles.ts deleted file mode 100644 index 5e0ad382..00000000 --- a/utils/hls/parseM3U8ForSubtitles.ts +++ /dev/null @@ -1,56 +0,0 @@ -import axios from "axios"; - -export interface SubtitleTrack { - index: number; - name: string; - uri: string; - language: string; - default: boolean; - forced: boolean; - autoSelect: boolean; -} - -export async function parseM3U8ForSubtitles( - url: string, -): Promise { - try { - const response = await axios.get(url, { responseType: "text" }); - const lines = response.data.split(/\r?\n/); - const subtitleTracks: SubtitleTrack[] = []; - let index = 0; - - lines.forEach((line: string) => { - if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) { - const attributes = parseAttributes(line); - const track: SubtitleTrack = { - index: index++, - name: attributes.NAME || "", - uri: attributes.URI || "", - language: attributes.LANGUAGE || "", - default: attributes.DEFAULT === "YES", - forced: attributes.FORCED === "YES", - autoSelect: attributes.AUTOSELECT === "YES", - }; - subtitleTracks.push(track); - } - }); - - return subtitleTracks; - } catch (error) { - console.error("Failed to fetch or parse the M3U8 file:", error); - throw error; - } -} - -function parseAttributes(line: string): { [key: string]: string } { - const attributes: { [key: string]: string } = {}; - const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g; - - for (const match of line.matchAll(regex)) { - const key = match[1]; - const value = match[2] ?? match[3]; // quoted or unquoted - attributes[key] = value; - } - - return attributes; -} diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts deleted file mode 100644 index 4e4f2074..00000000 --- a/utils/jellyfin/session/capabilities.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { AxiosResponse } from "axios"; -import type { Settings } from "../../atoms/settings"; -import { generateDeviceProfile } from "../../profiles/native"; -import { getAuthHeaders } from "../jellyfin"; - -interface PostCapabilitiesParams { - api: Api | null | undefined; - itemId: string | null | undefined; - sessionId: string | null | undefined; - deviceProfile: Settings["deviceProfile"]; -} - -/** - * Marks a media item as not played for a specific user. - * - * @param params - The parameters for marking an item as not played - * @returns A promise that resolves to true if the operation was successful, false otherwise - */ -export const postCapabilities = async ({ - api, - itemId, - sessionId, -}: PostCapabilitiesParams): Promise => { - if (!api || !itemId || !sessionId) { - throw new Error("Missing parameters for marking item as not played"); - } - - try { - const d = api.axiosInstance.post( - `${api.basePath}/Sessions/Capabilities/Full`, - { - playableMediaTypes: ["Audio", "Video"], - supportedCommands: [ - "PlayState", - "Play", - "ToggleFullscreen", - "DisplayMessage", - "Mute", - "Unmute", - "SetVolume", - "ToggleMute", - ], - supportsMediaControl: true, - id: sessionId, - DeviceProfile: generateDeviceProfile(), - }, - { - headers: getAuthHeaders(api), - }, - ); - return d; - } catch (_error) { - throw new Error("Failed to mark as not played"); - } -}; diff --git a/utils/jellyfin/tvshows/nextUp.ts b/utils/jellyfin/tvshows/nextUp.ts deleted file mode 100644 index 414a47a7..00000000 --- a/utils/jellyfin/tvshows/nextUp.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getAuthHeaders } from "../jellyfin"; - -interface NextUpParams { - itemId?: string | null; - userId?: string | null; - api?: Api | null; -} - -/** - * Fetches the next up episodes for a series or all series for a user. - * - * @param params - The parameters for fetching next up episodes - * @returns A promise that resolves to an array of BaseItemDto representing the next up episodes - */ -export const nextUp = async ({ - itemId, - userId, - api, -}: NextUpParams): Promise => { - if (!userId || !api) { - console.error("Invalid parameters for nextUp: missing userId or api"); - return []; - } - - try { - const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>( - `${api.basePath}/Shows/NextUp`, - { - params: { - SeriesId: itemId || undefined, - UserId: userId, - Fields: "MediaSourceCount", - }, - headers: getAuthHeaders(api), - }, - ); - - return response.data.Items; - } catch (_error) { - return []; - } -}; diff --git a/utils/jellyfin/user-library/getItemById.ts b/utils/jellyfin/user-library/getItemById.ts deleted file mode 100644 index 261733b6..00000000 --- a/utils/jellyfin/user-library/getItemById.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Api } from "@jellyfin/sdk"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; - -/** - * Retrieves an item by its ID from the API. - * - * @param api - The Jellyfin API instance. - * @param itemId - The ID of the item to retrieve. - * @returns The item object or undefined if no item matches the ID. - */ -export const getItemById = async ( - api?: Api | null | undefined, - itemId?: string | null | undefined, -): Promise => { - if (!api || !itemId) { - return undefined; - } - - try { - const itemData = await getUserLibraryApi(api).getItem({ itemId }); - - const item = itemData.data; - if (!item) { - console.error("No items found with the specified ID:", itemId); - return undefined; - } - - return item; - } catch (error) { - console.error("Failed to retrieve the item:", error); - throw new Error(`Failed to retrieve the item due to an error: ${error}`); - } -}; diff --git a/utils/log.tsx b/utils/log.tsx index c633ec69..7dc1d53a 100644 --- a/utils/log.tsx +++ b/utils/log.tsx @@ -72,21 +72,6 @@ export const readFromLog = (): LogEntry[] => { return logs ? JSON.parse(logs) : []; }; -export const clearLogs = () => { - storage.remove("logs"); -}; - -export const dumpDownloadDiagnostics = (extra: any = {}) => { - const diagnostics = { - timestamp: new Date().toISOString(), - processes: extra?.processes || [], - nativeTasks: extra?.nativeTasks || [], - focusedProcess: extra?.focusedProcess || null, - }; - writeDebugLog("Download diagnostics", diagnostics); - return diagnostics; -}; - export function useLog() { const context = useContext(LogContext); if (context === null) { diff --git a/utils/secondsToTicks.ts b/utils/secondsToTicks.ts deleted file mode 100644 index df13813e..00000000 --- a/utils/secondsToTicks.ts +++ /dev/null @@ -1,5 +0,0 @@ -// seconds to ticks util - -export function secondsToTicks(seconds: number): number { - return seconds * 10000000; -} diff --git a/utils/secureCredentials.ts b/utils/secureCredentials.ts index bb5f7713..3aad1535 100644 --- a/utils/secureCredentials.ts +++ b/utils/secureCredentials.ts @@ -203,27 +203,6 @@ export async function hasAccountCredential( return stored !== null; } -/** - * Delete all credentials for all accounts on all servers. - */ -export async function clearAllCredentials(): Promise { - const previousServers = getPreviousServers(); - - for (const server of previousServers) { - for (const account of server.accounts) { - const key = credentialKey(server.address, account.userId); - await SecureStore.deleteItemAsync(key); - } - } - - // Clear all accounts from servers - const clearedServers = previousServers.map((server) => ({ - ...server, - accounts: [], - })); - storage.set("previousServers", JSON.stringify(clearedServers)); -} - /** * Add or update an account in a server's accounts list. */ diff --git a/utils/version.ts b/utils/version.ts index 79ba2c1b..812665c4 100644 --- a/utils/version.ts +++ b/utils/version.ts @@ -10,6 +10,7 @@ export interface BuildMeta { commit?: string | null; branch?: string | null; profile?: string | null; + runNumber?: string | null; builtAt?: string | null; } @@ -22,8 +23,10 @@ export interface VersionInfo { commit: string | null; /** Git branch the build was made from, e.g. "develop". */ branch: string | null; - /** EAS build profile, e.g. "production", "preview", or null for local. */ + /** EAS build profile, e.g. "production", "ci", "preview", or null for local. */ profile: string | null; + /** GitHub Actions run number the build came from, e.g. "2098". Null outside CI. */ + runNumber: string | null; isDev: boolean; isProduction: boolean; /** Graduated label for the Settings "App version" row (see tiering below). */ @@ -34,13 +37,13 @@ export interface VersionInfo { * Resolve a graduated version string for Settings. * * Tiering (most โ†’ least detailed): - * - dev / local build โ†’ `version ยท branch ยท commit` (full context for debugging) - * - develop / CI / preview โ†’ `version ยท commit` (pin the exact source) - * - production (store / TestFlight) โ†’ `version (build)` (store-correlatable; the - * build number lets TestFlight reports pin a build whose version isn't a - * published release. Note: TestFlight and the public App Store ship the same - * binary โ€” telling them apart needs a runtime iOS receipt check, intentionally - * not done here.) + * - dev / local build โ†’ `version ยท branch ยท commit` (full context for debugging) + * - develop / CI / preview โ†’ `version ยท commit ยท #run` (pin the exact source; the + * Actions run number maps the build to its run โ€” artifacts + logs โ€” without + * Expo access) + * - production (store / TestFlight) โ†’ `version` (build number intentionally + * not shown: TestFlight already displays it to testers, and the commit pins the + * binary better) */ export function getVersionInfo(): VersionInfo { // Read native/config values defensively โ€” a version string must never crash Settings @@ -60,6 +63,7 @@ export function getVersionInfo(): VersionInfo { const commit = meta.commit ?? null; const branch = meta.branch ?? null; const profile = meta.profile ?? null; + const runNumber = meta.runNumber ?? null; const isDev = __DEV__ === true; const isProduction = typeof profile === "string" && profile.startsWith("production"); @@ -68,10 +72,12 @@ export function getVersionInfo(): VersionInfo { if (isDev) { display = [version ?? "dev", branch, commit].filter(Boolean).join(" ยท "); } else if (isProduction) { - display = - version && build ? `${version} (${build})` : (version ?? build ?? "N/A"); + display = version ?? build ?? "N/A"; } else { - display = [version, commit].filter(Boolean).join(" ยท ") || version || "N/A"; + display = + [version, commit, runNumber && `#${runNumber}`] + .filter(Boolean) + .join(" ยท ") || "N/A"; } return { @@ -80,6 +86,7 @@ export function getVersionInfo(): VersionInfo { commit, branch, profile, + runNumber, isDev, isProduction, display,