diff --git a/.github/workflows/detect-duplicate.yml b/.github/workflows/detect-duplicate.yml new file mode 100644 index 000000000..09aa23565 --- /dev/null +++ b/.github/workflows/detect-duplicate.yml @@ -0,0 +1,38 @@ +name: πŸ” Detect Duplicate Issues + +on: + issues: + types: [opened] + +permissions: + contents: read + +concurrency: + group: detect-duplicate-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + detect: + name: πŸ” Find similar issues + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + permissions: + issues: write + contents: read + steps: + - name: πŸ“₯ Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: 🍞 Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: πŸ” Detect duplicate issues + run: bun scripts/detect-duplicate-issue.mjs + env: + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index acc7f8173..ed31b438f 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -166,7 +166,7 @@ export default function IndexLayout() { open={dropdownOpen} onOpenChange={setDropdownOpen} trigger={ - + { keyboardDismissMode='none' screenOptions={{ tabBarBounces: true, + tabBarActiveTintColor: "#FFFFFF", + tabBarInactiveTintColor: "#9CA3AF", tabBarLabelStyle: { fontSize: TAB_LABEL_FONT_SIZE, fontWeight: "600", diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 29d3748f1..53fbeb910 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -102,8 +102,8 @@ export default function TabLayout() { !settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab, tabBarIcon: Platform.OS === "android" - ? (_e) => require("@/assets/icons/list.png") - : (_e) => ({ sfSymbol: "list.bullet.rectangle" }), + ? (_e) => require("@/assets/icons/list.star.png") + : (_e) => ({ sfSymbol: "list.star" }), }} /> require("@/assets/icons/server.rack.png") + ? (_e) => require("@/assets/icons/rectangle.stack.fill.png") : (_e) => ({ sfSymbol: "rectangle.stack.fill" }), }} /> @@ -123,8 +123,8 @@ export default function TabLayout() { tabBarItemHidden: !settings?.showCustomMenuLinks, tabBarIcon: Platform.OS === "android" - ? (_e) => require("@/assets/icons/list.png") - : (_e) => ({ sfSymbol: "list.dash.fill" }), + ? (_e) => require("@/assets/icons/link.png") + : (_e) => ({ sfSymbol: "link" }), }} /> require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform + ? (_e) => require("@/assets/icons/gearshape.fill.png") : (_e) => ({ sfSymbol: "gearshape.fill" }), }} /> diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 937c32092..2b269991b 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -274,6 +274,11 @@ export default function DirectPlayerPage() { }; if (itemId) { + setItem(null); + setDownloadedItem(null); + // Clear the previous episode's stream so the loader gate stays closed + // until the new item's stream resolves (avoids a stale MPV source frame). + setStream(null); fetchItemData(); } }, [itemId, offline, api, user?.Id]); @@ -316,6 +321,12 @@ export default function DirectPlayerPage() { return null; } + // Ensure item matches the current itemId to avoid race conditions + if (item.Id !== itemId) { + setStreamStatus({ isLoading: false, isError: false }); + return null; + } + let result: Stream | null = null; if (offline && downloadedItem?.mediaSource) { const url = downloadedItem.videoFilePath; @@ -388,6 +399,7 @@ export default function DirectPlayerPage() { item, user?.Id, downloadedItem, + offline, ]); useEffect(() => { @@ -427,21 +439,15 @@ export default function DirectPlayerPage() { if (!item?.Id || !stream?.sessionId || offline || !api) return; const currentTimeInTicks = msToTicks(progress.get()); - await getPlaystateApi(api).onPlaybackStopped({ - itemId: item.Id, - mediaSourceId: mediaSourceId, - positionTicks: currentTimeInTicks, - playSessionId: stream.sessionId, + await getPlaystateApi(api).reportPlaybackStopped({ + playbackStopInfo: { + ItemId: item.Id, + MediaSourceId: mediaSourceId, + PositionTicks: currentTimeInTicks, + PlaySessionId: stream.sessionId, + }, }); - }, [ - api, - item, - mediaSourceId, - stream, - progress, - offline, - revalidateProgressCache, - ]); + }, [api, item, mediaSourceId, stream, progress, offline]); const stop = useCallback(() => { // Update URL with final playback position before stopping @@ -459,9 +465,10 @@ export default function DirectPlayerPage() { useEffect(() => { const beforeRemoveListener = navigation.addListener("beforeRemove", stop); return () => { + reportPlaybackStopped(); beforeRemoveListener(); }; - }, [navigation, stop]); + }, [navigation, stop, reportPlaybackStopped]); const currentPlayStateInfo = useCallback((): | PlaybackProgressInfo diff --git a/assets/icons/gear.png b/assets/icons/gear.png deleted file mode 100644 index f5b98cf07..000000000 Binary files a/assets/icons/gear.png and /dev/null differ diff --git a/assets/icons/gearshape.fill.png b/assets/icons/gearshape.fill.png new file mode 100644 index 000000000..a3ee5bfe1 Binary files /dev/null and b/assets/icons/gearshape.fill.png differ diff --git a/assets/icons/heart.fill.png b/assets/icons/heart.fill.png index 25bb2527a..fd868d990 100644 Binary files a/assets/icons/heart.fill.png and b/assets/icons/heart.fill.png differ diff --git a/assets/icons/heart.png b/assets/icons/heart.png deleted file mode 100644 index 96a448a79..000000000 Binary files a/assets/icons/heart.png and /dev/null differ diff --git a/assets/icons/house.fill.png b/assets/icons/house.fill.png index 9e32f71e7..aa6f116c9 100644 Binary files a/assets/icons/house.fill.png and b/assets/icons/house.fill.png differ diff --git a/assets/icons/jellyseerr-logo.svg b/assets/icons/jellyseerr-logo.svg deleted file mode 100644 index 1f8b997df..000000000 --- a/assets/icons/jellyseerr-logo.svg +++ /dev/null @@ -1,118 +0,0 @@ - -AAAsdGp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAACxOanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46dXVpZDpjOGFmZTAwYS1iN2JiLTRkNTUtYmUwZi1iN2Y2Mzc4NzRlYTUAAAABtGp1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAANdqdW1iAAAAJmp1bWRjYm9yABEAEIAAAKoAOJtxA2MycGEuYWN0aW9ucwAAAACpY2JvcqFnYWN0aW9uc4GjZmFjdGlvbmtjMnBhLmVkaXRlZG1zb2Z0d2FyZUFnZW50bUFkb2JlIEZpcmVmbHlxZGlnaXRhbFNvdXJjZVR5cGV4U2h0dHA6Ly9jdi5pcHRjLm9yZy9uZXdzY29kZXMvZGlnaXRhbHNvdXJjZXR5cGUvY29tcG9zaXRlV2l0aFRyYWluZWRBbGdvcml0aG1pY01lZGlhAAAArGp1bWIAAAAoanVtZGNib3IAEQAQgAAAqgA4m3EDYzJwYS5oYXNoLmRhdGEAAAAAfGNib3KlamV4Y2x1c2lvbnOBomVzdGFydBjuZmxlbmd0aBk7SGRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFggrnb/Z0LL/KWPpqmjemYRvQg3RH4cxUsaxZtMKj493SpjcGFkSQAAAAAAAAAAAAAAAgtqdW1iAAAAJGp1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0AAAAB32Nib3KoaGRjOnRpdGxlb0dlbmVyYXRlZCBJbWFnZWlkYzpmb3JtYXRtaW1hZ2Uvc3ZnK3htbGppbnN0YW5jZUlEeCx4bXA6aWlkOjJmMzZiOTBiLTUyNTctNGIzMi05NjIyLTExOGUyYjY1NTJmZW9jbGFpbV9nZW5lcmF0b3J4NkFkb2JlX0lsbHVzdHJhdG9yLzI4LjQgYWRvYmVfYzJwYS8wLjcuNiBjMnBhLXJzLzAuMjUuMnRjbGFpbV9nZW5lcmF0b3JfaW5mb4G/ZG5hbWVxQWRvYmUgSWxsdXN0cmF0b3JndmVyc2lvbmQyOC40/2lzaWduYXR1cmV4GXNlbGYjanVtYmY9YzJwYS5zaWduYXR1cmVqYXNzZXJ0aW9uc4KiY3VybHgn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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2WQEAcNiFxc4R79ozvFI3cymplwVvAWDIKFyiBFAYVnZ4u3HEcPLDTfIt9X7Nd1vyzbJIZpVE6NOicYEaRwt+uauSMcSPsX9PHUJgyWALEQ6RHudtr57nbNIgmioCefdyEtzGbCylEalKZNWNlzjT2rgZFB1shhJ3hhVHDBPaKX2KxL3C8utMK2iBREKaVCatCmw4JVECUjwN7Qn3V347tiBf5wbCt/a+q382311bbBSW57XWiNjoek/xXArl25l6pWZSkTcShpTPT7ynjoFFRwCewR5+xU+2LKETQ4wrV3n5nK6RayHlThKGkqv3GuPOMk8ogRGaHezj/nphLuUsoIjpNA== - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/icons/link.png b/assets/icons/link.png new file mode 100644 index 000000000..95d08787f Binary files /dev/null and b/assets/icons/link.png differ diff --git a/assets/icons/list.png b/assets/icons/list.png deleted file mode 100644 index 3c548bb4c..000000000 Binary files a/assets/icons/list.png and /dev/null differ diff --git a/assets/icons/list.star.png b/assets/icons/list.star.png new file mode 100644 index 000000000..cfa85c3ac Binary files /dev/null and b/assets/icons/list.star.png differ diff --git a/assets/icons/magnifyingglass.png b/assets/icons/magnifyingglass.png index 5fc44c41b..d62b64825 100644 Binary files a/assets/icons/magnifyingglass.png and b/assets/icons/magnifyingglass.png differ diff --git a/assets/icons/rectangle.stack.fill.png b/assets/icons/rectangle.stack.fill.png new file mode 100644 index 000000000..86460d708 Binary files /dev/null and b/assets/icons/rectangle.stack.fill.png differ diff --git a/assets/icons/seerr-logo.svg b/assets/icons/seerr-logo.svg new file mode 100644 index 000000000..a0e32e793 --- /dev/null +++ b/assets/icons/seerr-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/server.rack.png b/assets/icons/server.rack.png deleted file mode 100644 index 245e5ad2d..000000000 Binary files a/assets/icons/server.rack.png and /dev/null differ diff --git a/assets/images/not-rotten-tomatoes.svg b/assets/images/not-rotten-tomatoes.svg deleted file mode 100644 index 18fa58b89..000000000 --- a/assets/images/not-rotten-tomatoes.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/images/rotten-tomatoes.png b/assets/images/rotten-tomatoes.png deleted file mode 100644 index 341b62b00..000000000 Binary files a/assets/images/rotten-tomatoes.png and /dev/null differ diff --git a/assets/images/rt_aud_fresh.svg b/assets/images/rt_aud_fresh.svg new file mode 100644 index 000000000..f9fa29044 --- /dev/null +++ b/assets/images/rt_aud_fresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/rt_aud_rotten.svg b/assets/images/rt_aud_rotten.svg new file mode 100644 index 000000000..cd84ac5b0 --- /dev/null +++ b/assets/images/rt_aud_rotten.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/rt_fresh.svg b/assets/images/rt_fresh.svg new file mode 100644 index 000000000..ed6f44d73 --- /dev/null +++ b/assets/images/rt_fresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/rt_rotten.svg b/assets/images/rt_rotten.svg new file mode 100644 index 000000000..60ba169e0 --- /dev/null +++ b/assets/images/rt_rotten.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/tmdb_logo.svg b/assets/images/tmdb_logo.svg new file mode 100644 index 000000000..bdf988ba7 --- /dev/null +++ b/assets/images/tmdb_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/IntroSheet.tsx b/components/IntroSheet.tsx index 0a744e329..2b04b114f 100644 --- a/components/IntroSheet.tsx +++ b/components/IntroSheet.tsx @@ -89,14 +89,14 @@ export const IntroSheet = forwardRef((_, ref) => { - Jellyseerr + Seerr {t("home.intro.jellyseerr_feature_description")} diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index aaea71b3f..5487393dd 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,13 +1,7 @@ import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; -import React, { useEffect, useState } from "react"; -import { - type LayoutChangeEvent, - Platform, - StyleSheet, - TouchableOpacity, - View, -} from "react-native"; +import React, { useEffect } from "react"; +import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; @@ -217,24 +211,6 @@ const PlatformDropdownComponent = ({ }: PlatformDropdownProps) => { const { showModal, hideModal, isVisible } = useGlobalModal(); - // @expo/ui's (SDK 55) fills its available space by default, and - // `matchContents` doesn't help here: it reports the native Menu's size via - // setStyleSize and overrides any explicit size. Instead we measure the - // trigger's intrinsic size in plain RN (off-layout) and pin it on the Host. - const [triggerSize, setTriggerSize] = useState<{ - width: number; - height: number; - } | null>(null); - - const handleMeasureTrigger = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout; - setTriggerSize((prev) => - prev && prev.width === width && prev.height === height - ? prev - : { width, height }, - ); - }; - // Handle controlled open state for Android useEffect(() => { if (Platform.OS === "android" && controlledOpen === true) { @@ -265,25 +241,11 @@ const PlatformDropdownComponent = ({ }, [isVisible, controlledOpen, controlledOnOpenChange]); if (Platform.OS === "ios" && !Platform.isTV) { - // Pin the wrapper to the measured trigger size. @expo/ui's (SDK 55) - // fills its parent and reports its own size via setStyleSize, so it can't - // size itself to content. If the wrapper has no size, the Host's `flex: 1` - // height depends on the parent while the parent depends on the Host β€” a - // circular dependency that collapses to 0 for any selector nested more than - // one level deep (so only the first, shallowest dropdown stays visible). - // Giving the wrapper the measured size breaks the cycle; the Host then - // fills a concrete box. + // @expo/ui's can't size to content, so an in-flow invisible copy of + // the trigger sizes the wrapper while the Host overlays the real Menu. return ( - - {/* Hidden measurer: lays the trigger out off-flow to capture its - intrinsic size. Absolutely positioned WITHOUT right/bottom so it - sizes to the trigger's content rather than to its parent. */} - + + {trigger} diff --git a/components/Ratings.tsx b/components/Ratings.tsx index 5741233fb..2e06403fb 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -40,8 +40,8 @@ export const Ratings: React.FC = ({ item, ...props }) => { >(null); const entries = useMemo(() => sortedChapters(chapters), [chapters]); @@ -79,7 +81,17 @@ function ChapterListComponent({ supportedOrientations={["portrait", "landscape"]} > - e.stopPropagation()} style={styles.sheet}> + e.stopPropagation()} + style={[ + styles.sheet, + { + marginLeft: safeArea.left, + marginRight: safeArea.right, + paddingBottom: safeArea.bottom, + }, + ]} + > {t("chapters.title")} { onPress={() => { router.push("/(auth)/downloads"); }} - className='ml-1.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > { const { t } = useTranslation(); - const hasMovies = movieResults && movieResults.length > 0; - const hasTv = tvResults && tvResults.length > 0; - const hasPersons = personResults && personResults.length > 0; - if (loading) { return null; } @@ -431,22 +427,26 @@ export const TVJellyseerrSearchResults: React.FC< return ( + {/* No section requests `hasTVPreferredFocus`: the native search field + keeps focus while typing, otherwise the first result would re-grab + focus on every keystroke as results re-render. The user navigates + down to the grid manually. */} diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index ab928d845..580e8a002 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -235,10 +235,13 @@ export const TVSearchPage: React.FC = ({ 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. */} @@ -280,13 +283,17 @@ export const TVSearchPage: React.FC = ({ {/* Library Search Results */} {isLibraryMode && !loading && ( - {sections.map((section, index) => ( + {sections.map((section) => ( = ({ removeClippedSubviews={false} getItemLayout={getItemLayout} style={{ overflow: "visible" }} - contentInset={{ - left: edgePadding, - right: edgePadding, - }} - contentOffset={{ x: -edgePadding, y: 0 }} + // Edge padding via contentContainerStyle, NOT contentInset+contentOffset. + // contentOffset only applies on initial mount; since this FlatList is + // reused across searches (stable key), a second search left the inset + // without the offset and the grid snapped flush to the left edge. contentContainerStyle={{ + paddingHorizontal: edgePadding, paddingVertical: SCALE_PADDING, }} /> diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index bb87ef12e..e3bba0a98 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -31,8 +31,12 @@ export const SeasonEpisodesCarousel: React.FC = ({ }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const isOffline = useOfflineMode(); const router = useRouter(); + const isOffline = useOfflineMode(); + // Read the live (cached) downloads DB inside the query rather than the + // provider's downloadedItems snapshot, so refetches after + // updateDownloadedItem() reflect the latest state instead of a stale + // refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap. const { getDownloadedItems } = useDownload(); const scrollRef = useRef(null); @@ -100,7 +104,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ onPress={() => { router.setParams({ id: _item.Id }); }} - className={`flex flex-col w-44 + className={`flex flex-col w-44 ${item?.Id === _item.Id ? "" : "opacity-50"} `} > diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index b0e32dad4..81c77ab8c 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next"; import { Pressable, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { type SharedValue } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ChapterList } from "@/components/chapters/ChapterList"; import { ChapterTicks } from "@/components/chapters/ChapterTicks"; import { Text } from "@/components/common/Text"; +import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useSettings } from "@/utils/atoms/settings"; import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; @@ -75,9 +75,6 @@ interface BottomControlsProps { minutes: number; seconds: number; }; - - // Chapter props - chapterPositions?: number[]; } export const BottomControls: FC = ({ @@ -111,11 +108,10 @@ export const BottomControls: FC = ({ trickPlayUrl, trickplayInfo, time, - chapterPositions = [], }) => { const { settings } = useSettings(); const { t } = useTranslation(); - const insets = useSafeAreaInsets(); + const insets = useControlsSafeAreaInsets(); const [chapterListVisible, setChapterListVisible] = useState(false); // Only expose chapter UI when there are at least two real markers. @@ -146,13 +142,9 @@ export const BottomControls: FC = ({ style={[ { position: "absolute", - right: - (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, - left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, - bottom: - (settings?.safeAreaInControlsEnabled ?? true) - ? Math.max(insets.bottom - 17, 0) - : 0, + right: insets.right, + left: insets.left, + bottom: Math.max(insets.bottom - 17, 0), }, ]} className={"flex flex-col px-2"} @@ -188,17 +180,6 @@ export const BottomControls: FC = ({ ) : null} - {hasChapters && ( - setChapterListVisible(true)} - hitSlop={10} - className='justify-center mr-4' - accessibilityRole='button' - accessibilityLabel={t("chapters.open")} - > - - - )} = ({ onPress={handleNextEpisodeManual} /> )} + {hasChapters && ( + setChapterListVisible(true)} + hitSlop={10} + className='justify-center ml-4' + accessibilityRole='button' + accessibilityLabel={t("chapters.open")} + > + + + )} = ({ goToNextChapter, }) => { const { settings } = useSettings(); - const insets = useSafeAreaInsets(); + const insets = useControlsSafeAreaInsets(); return ( = ({ hasNextChapter, goToPreviousChapter, goToNextChapter, - chapterPositions, } = useChapterNavigation({ chapters: item.Chapters, progress, @@ -366,7 +365,9 @@ export const Controls: FC = ({ { applyLanguagePreferences: true }, ); - const queryParams = new URLSearchParams({ + // Use setParams instead of replace to avoid unmounting/remounting the player, + // which would create a new MPV native view and crash with "mp_initialize already initialized". + router.setParams({ ...(offline && { offline: "true" }), itemId: item.Id ?? "", audioIndex: defaultAudioIndex?.toString() ?? "", @@ -375,11 +376,17 @@ export const Controls: FC = ({ bitrateValue: bitrateValue?.toString(), playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "", - }).toString(); - - router.replace(`player/direct-player?${queryParams}` as any); + }); }, - [settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router], + [ + settings, + subtitleIndex, + audioIndex, + mediaSource, + bitrateValue, + router, + offline, + ], ); const goToPreviousItem = useCallback(() => { @@ -585,7 +592,6 @@ export const Controls: FC = ({ trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={isSliding || showRemoteBubble ? time : remoteTime} - chapterPositions={chapterPositions} /> diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index 35a235353..13c02f008 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -5,7 +5,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useRef } from "react"; import { TouchableOpacity, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { HorizontalScroll, @@ -17,10 +16,10 @@ import { SeasonDropdown, type SeasonIndexState, } from "@/components/series/SeasonDropdown"; +import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; -import { useSettings } from "@/utils/atoms/settings"; import { getDownloadedEpisodesForSeason, getDownloadedSeasonNumbers, @@ -46,8 +45,7 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { scrollViewRef.current?.scrollToIndex(index, 100); }; const isOffline = useOfflineMode(); - const { settings } = useSettings(); - const insets = useSafeAreaInsets(); + const insets = useControlsSafeAreaInsets(); // Set the initial season index useEffect(() => { @@ -59,6 +57,11 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { } }, []); + // Read the live (cached) downloads DB inside the query rather than the + // provider's downloadedItems snapshot. The snapshot only refreshes on the + // provider refreshKey, so after updateDownloadedItem() invalidates + // ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would + // return stale data. getAllDownloadedItems() is cached, so this stays cheap. const { getDownloadedItems } = useDownload(); const seasonIndex = seasonIndexState[item.ParentId ?? ""]; @@ -182,12 +185,9 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { backgroundColor: "black", height: "100%", width: "100%", - paddingTop: - (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0, - paddingLeft: - (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, - paddingRight: - (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, + paddingTop: insets.top, + paddingLeft: insets.left, + paddingRight: insets.right, }} > = ({ showTechnicalInfo = false, onToggleTechnicalInfo, }) => { - const { settings } = useSettings(); const router = useRouter(); - const insets = useSafeAreaInsets(); + const insets = useControlsSafeAreaInsets(); const lightHapticFeedback = useHaptic("light"); const { orientation, lockOrientation } = useOrientation(); const [isTogglingOrientation, setIsTogglingOrientation] = useState(false); @@ -99,10 +97,9 @@ export const HeaderControls: FC = ({ style={[ { position: "absolute", - top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0, - left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0, - right: - (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0, + top: insets.top, + left: insets.left, + right: insets.right, padding: HEADER_LAYOUT.CONTAINER_PADDING, }, ]} diff --git a/components/video-player/controls/TechnicalInfoOverlay.tsx b/components/video-player/controls/TechnicalInfoOverlay.tsx index 20ec1fc47..fe98a16a9 100644 --- a/components/video-player/controls/TechnicalInfoOverlay.tsx +++ b/components/video-player/controls/TechnicalInfoOverlay.tsx @@ -16,8 +16,8 @@ import Animated, { } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import type { TechnicalInfo } from "@/modules/mpv-player"; -import { useSettings } from "@/utils/atoms/settings"; import { HEADER_LAYOUT } from "./constants"; type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode"; @@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC = memo( currentAudioIndex, }) => { const typography = useScaledTVTypography(); - const { settings } = useSettings(); const insets = useSafeAreaInsets(); + const safeInsets = useControlsSafeAreaInsets(); const [info, setInfo] = useState(null); const opacity = useSharedValue(0); @@ -268,14 +268,8 @@ export const TechnicalInfoOverlay: FC = memo( left: Math.max(insets.left, 48) + 20, } : { - top: - (settings?.safeAreaInControlsEnabled ?? true) - ? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4 - : HEADER_LAYOUT.CONTAINER_PADDING + 4, - left: - (settings?.safeAreaInControlsEnabled ?? true) - ? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20 - : HEADER_LAYOUT.CONTAINER_PADDING + 20, + top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4, + left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20, }; const textStyle = Platform.isTV diff --git a/hooks/useControlsSafeAreaInsets.ts b/hooks/useControlsSafeAreaInsets.ts new file mode 100644 index 000000000..4fa4968e6 --- /dev/null +++ b/hooks/useControlsSafeAreaInsets.ts @@ -0,0 +1,18 @@ +import { + type EdgeInsets, + useSafeAreaInsets, +} from "react-native-safe-area-context"; +import { useSettings } from "@/utils/atoms/settings"; + +const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 }; + +/** + * Returns safe-area insets to apply to in-player controls, honoring the + * `safeAreaInControlsEnabled` user setting. When the setting is disabled, + * returns zero insets so controls can sit flush against the screen edges. + */ +export const useControlsSafeAreaInsets = (): EdgeInsets => { + const { settings } = useSettings(); + const insets = useSafeAreaInsets(); + return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS; +}; diff --git a/hooks/useImageStorage.ts b/hooks/useImageStorage.ts index ec66c5053..b5b6896ca 100644 --- a/hooks/useImageStorage.ts +++ b/hooks/useImageStorage.ts @@ -1,3 +1,4 @@ +import { File, Paths } from "expo-file-system"; import { useCallback } from "react"; import { storage } from "@/utils/mmkv"; @@ -12,36 +13,28 @@ const useImageStorage = () => { } }, []); + /** + * expo-file-system instead of fetch+Blob+FileReader: the latter silently + * resolves to an empty payload under RN's New Architecture. + */ const image2Base64 = useCallback(async (url?: string | null) => { if (!url) return null; - let blob: Blob; + const tmpFile = new File( + Paths.cache, + `img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`, + ); try { - // Fetch the data from the URL - const response = await fetch(url); - blob = await response.blob(); + const downloaded = await File.downloadFileAsync(url, tmpFile, { + idempotent: true, + }); + return await downloaded.base64(); } catch (error) { console.warn("Error fetching image:", error); return null; + } finally { + if (tmpFile.exists) tmpFile.delete(); } - - // Create a FileReader instance - const reader = new FileReader(); - - // Convert blob to base64 - return new Promise((resolve, reject) => { - reader.onloadend = () => { - if (typeof reader.result === "string") { - // Extract the base64 string (remove the data URL prefix) - const base64 = reader.result.split(",")[1]; - resolve(base64); - } else { - reject(new Error("Failed to convert image to base64")); - } - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); }, []); const saveImage = useCallback( diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index b4316241a..94abb98b1 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -109,30 +109,35 @@ export const usePlaybackManager = ({ staleTime: 0, }); + /** + * Derive prev/next from the current item's real position in the adjacent + * list rather than from the array length. `getEpisodes({ adjacentTo })` does + * not guarantee a fixed [prev, current, next] shape β€” at the first/last + * episode it can still return the current item as the first/last entry β€” so + * length-based indexing wrongly surfaces the current episode as "previous". + */ + const currentIndex = useMemo( + () => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1, + [adjacentItems, item], + ); + + /** A neighbour is only navigable if it has an actual media file (not a + * "Virtual"/missing episode placeholder, e.g. an absent Special). */ + const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto => + !!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual"; + const previousItem = useMemo(() => { - if (!adjacentItems || adjacentItems.length <= 1) { - return null; - } - - if (adjacentItems.length === 2) { - return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0]; - } - - return adjacentItems[0]; - }, [adjacentItems, item]); + if (!adjacentItems || currentIndex <= 0) return null; + const candidate = adjacentItems[currentIndex - 1]; + return isNavigable(candidate) ? candidate : null; + }, [adjacentItems, currentIndex, item]); /** The next item in the series */ const nextItem = useMemo(() => { - if (!adjacentItems || adjacentItems.length <= 1) { - return null; - } - - if (adjacentItems.length === 2) { - return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1]; - } - - return adjacentItems[2]; - }, [adjacentItems, item]); + if (!adjacentItems || currentIndex < 0) return null; + const candidate = adjacentItems[currentIndex + 1]; + return isNavigable(candidate) ? candidate : null; + }, [adjacentItems, currentIndex, item]); /** * Reports playback progress. diff --git a/modules/mpv-player/ios/MpvPlayerModule.swift b/modules/mpv-player/ios/MpvPlayerModule.swift index fe43d8968..768916869 100644 --- a/modules/mpv-player/ios/MpvPlayerModule.swift +++ b/modules/mpv-player/ios/MpvPlayerModule.swift @@ -213,7 +213,7 @@ public class MpvPlayerModule: Module { } // Defines events that the view can send to JavaScript - Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady") + Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") } } } diff --git a/modules/mpv-player/ios/MpvPlayerView.swift b/modules/mpv-player/ios/MpvPlayerView.swift index 0b3158e76..41f19eb07 100644 --- a/modules/mpv-player/ios/MpvPlayerView.swift +++ b/modules/mpv-player/ios/MpvPlayerView.swift @@ -61,6 +61,7 @@ class MpvPlayerView: ExpoView { let onProgress = EventDispatcher() let onError = EventDispatcher() let onTracksReady = EventDispatcher() + let onPictureInPictureChange = EventDispatcher() private var currentURL: URL? private var cachedPosition: Double = 0 @@ -81,7 +82,6 @@ class MpvPlayerView: ExpoView { private func setupView() { clipsToBounds = true backgroundColor = .black - configureAudioSession() videoContainer = UIView() videoContainer.translatesAutoresizingMaskIntoConstraints = false @@ -141,21 +141,26 @@ class MpvPlayerView: ExpoView { CATransaction.commit() } + // MARK: - Audio Session & Notifications + private func configureAudioSession() { - let audioSession = AVAudioSession.sharedInstance() + let session = AVAudioSession.sharedInstance() do { - try audioSession.setCategory( - .playback, - mode: .moviePlayback, - policy: .longFormAudio, - options: [] - ) - try audioSession.setActive(true) + try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: []) + try session.setActive(true) } catch { print("Failed to configure audio session: \(error)") } } - // MARK: - Audio Session & Notifications + + /// Deactivate the session AND reset the category β€” `setActive(false)` alone + /// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later + /// reactivation (foreground, route change, other modules) re-steals audio. + private func tearDownAudioSession() { + let session = AVAudioSession.sharedInstance() + try? session.setActive(false, options: .notifyOthersOnDeactivation) + try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers]) + } private func setupNotifications() { // Handle audio session interruptions (e.g., incoming calls, other apps playing audio) @@ -270,6 +275,7 @@ class MpvPlayerView: ExpoView { func play() { intendedPlayState = true + configureAudioSession() setupRemoteCommands() renderer?.play() pipController?.setPlaybackRate(1.0) @@ -440,6 +446,7 @@ class MpvPlayerView: ExpoView { renderer?.stop() displayLayer.removeFromSuperlayer() clearNowPlayingInfo() + tearDownAudioSession() NotificationCenter.default.removeObserver(self) } } @@ -519,9 +526,7 @@ extension MpvPlayerView: MPVLayerRendererDelegate { } func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) { - // Audio output is now active - this is the right time to activate audio session and set Now Playing - print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing") - nowPlayingManager.activateAudioSession() + print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing") syncNowPlaying(isPlaying: !isPaused()) } } @@ -633,6 +638,9 @@ extension MpvPlayerView: PiPControllerDelegate { print("PiP did start: \(didStartPictureInPicture)") // Ensure current time is synced when PiP starts pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) + // Notify JS of the actual PiP active state. `didStartPictureInPicture` + // is `false` when AVKit reports a failure to start, so reflect that. + onPictureInPictureChange(["isActive": didStartPictureInPicture]) } func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) { @@ -651,6 +659,9 @@ extension MpvPlayerView: PiPControllerDelegate { if _isZoomedToFill { displayLayer.videoGravity = .resizeAspectFill } + // Notify JS that PiP has fully stopped so the controls overlay can + // be re-mounted when the user returns to full screen. + onPictureInPictureChange(["isActive": false]) } func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) { diff --git a/providers/Downloads/database.ts b/providers/Downloads/database.ts index 667a5b4dc..9d77ad2a0 100644 --- a/providers/Downloads/database.ts +++ b/providers/Downloads/database.ts @@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types"; const DOWNLOADS_DATABASE_KEY = "downloads.v2.json"; +// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls +let cachedDb: DownloadsDatabase | null = null; +let cacheVersion = 0; + +// Performance optimization: Cache the flattened items array +let cachedItems: DownloadedItem[] | null = null; +let itemsCacheVersion = -1; + +// Performance optimization: Index for O(1) item lookups by ID +let itemIndex: Map | null = null; +let indexCacheVersion = -1; + /** * Get the downloads database from storage + * PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls. + * NOTE: Returns the shared cached instance β€” do NOT mutate it directly. Go + * through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so + * saveDownloadsDatabase() runs and the derived caches stay consistent. */ export function getDownloadsDatabase(): DownloadsDatabase { + // Return cached database if available + if (cachedDb !== null) { + return cachedDb; + } + + // Parse from storage and cache the result const file = storage.getString(DOWNLOADS_DATABASE_KEY); if (file) { - return JSON.parse(file) as DownloadsDatabase; + cachedDb = JSON.parse(file) as DownloadsDatabase; + return cachedDb; } - return { movies: {}, series: {}, other: {} }; + + const emptyDb = { movies: {}, series: {}, other: {} }; + cachedDb = emptyDb; + return emptyDb; } /** * Save the downloads database to storage + * PERFORMANCE: Updates cache and invalidates derived caches */ export function saveDownloadsDatabase(db: DownloadsDatabase): void { storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); + // Update the cache with the new database + cachedDb = db; + // Invalidate derived caches (items array and index) + cachedItems = null; + itemIndex = null; + cacheVersion++; } /** * Get all downloaded items as a flat array + * PERFORMANCE: Caches the flattened array to avoid rebuilding on every call */ export function getAllDownloadedItems(): DownloadedItem[] { + // Return cached items if available and up-to-date + if (cachedItems !== null && itemsCacheVersion === cacheVersion) { + return cachedItems; + } + + // Build the items array from the database const db = getDownloadsDatabase(); const items: DownloadedItem[] = []; @@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] { } } + // Cache the result + cachedItems = items; + itemsCacheVersion = cacheVersion; + return items; } /** - * Get a downloaded item by its ID + * Build or refresh the item index for O(1) lookups */ -export function getDownloadedItemById(id: string): DownloadedItem | undefined { - const db = getDownloadsDatabase(); - - if (db.movies[id]) { - return db.movies[id]; +function ensureItemIndex(): void { + if (itemIndex !== null && indexCacheVersion === cacheVersion) { + return; // Index is up-to-date } - for (const series of Object.values(db.series)) { - for (const season of Object.values(series.seasons)) { - for (const episode of Object.values(season.episodes)) { - if (episode.item.Id === id) { - return episode; - } - } + // Build new index from all items + itemIndex = new Map(); + const items = getAllDownloadedItems(); + + for (const item of items) { + if (item.item.Id) { + itemIndex.set(item.item.Id, item); } } - if (db.other?.[id]) { - return db.other[id]; - } + indexCacheVersion = cacheVersion; +} - return undefined; +/** + * Get a downloaded item by its ID + * PERFORMANCE: Uses O(1) index lookup instead of O(nΒ²) iteration + */ +export function getDownloadedItemById(id: string): DownloadedItem | undefined { + ensureItemIndex(); + return itemIndex!.get(id); } /** @@ -221,4 +268,5 @@ export function updateDownloadedItem( */ export function clearAllDownloadedItems(): void { saveDownloadsDatabase({ movies: {}, series: {}, other: {} }); + // saveDownloadsDatabase already invalidates caches } diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 8608222b8..185c306c7 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -619,44 +619,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setUser(storedUser); } - const response = await getUserApi(apiInstance).getCurrentUser(); - setUser(response.data); + // Dismiss splash screen with cached data immediately, + // fetch fresh user data in the background + setInitialLoaded(true); - // Migrate current session to secure storage if not already saved - if (storedUser?.Id && storedUser?.Name) { - const existingCredential = await getAccountCredential( - serverUrl, - storedUser.Id, - ); - if (!existingCredential) { - await saveAccountCredential({ + try { + const response = await getUserApi(apiInstance).getCurrentUser(); + setUser(response.data); + + // Migrate current session to secure storage if not already saved + if (storedUser?.Id && storedUser?.Name) { + const existingCredential = await getAccountCredential( serverUrl, - serverName: "", - token, - userId: storedUser.Id, - username: storedUser.Name, - savedAt: Date.now(), - securityType: "none", - primaryImageTag: response.data.PrimaryImageTag ?? undefined, - }); - } else if ( - response.data.PrimaryImageTag !== - existingCredential.primaryImageTag - ) { - // Update image tag if it has changed - addAccountToServer(serverUrl, existingCredential.serverName, { - userId: existingCredential.userId, - username: existingCredential.username, - securityType: existingCredential.securityType, - savedAt: existingCredential.savedAt, - primaryImageTag: response.data.PrimaryImageTag ?? undefined, - }); + storedUser.Id, + ); + if (!existingCredential) { + await saveAccountCredential({ + serverUrl, + serverName: "", + token, + userId: storedUser.Id, + username: storedUser.Name, + savedAt: Date.now(), + securityType: "none", + primaryImageTag: response.data.PrimaryImageTag ?? undefined, + }); + } else if ( + response.data.PrimaryImageTag !== + existingCredential.primaryImageTag + ) { + // Update image tag if it has changed + addAccountToServer(serverUrl, existingCredential.serverName, { + userId: existingCredential.userId, + username: existingCredential.username, + securityType: existingCredential.securityType, + savedAt: existingCredential.savedAt, + primaryImageTag: response.data.PrimaryImageTag ?? undefined, + }); + } } + } catch (e) { + // Background fetch failed β€” app already rendered with cached data + console.warn("Background user fetch failed, using cached data:", e); } + } else { + setInitialLoaded(true); } } catch (e) { console.error(e); - } finally { setInitialLoaded(true); } }; diff --git a/scripts/detect-duplicate-issue.mjs b/scripts/detect-duplicate-issue.mjs new file mode 100644 index 000000000..26886b265 --- /dev/null +++ b/scripts/detect-duplicate-issue.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env bun +/** + * Flags likely-duplicate issues when a new issue is opened, using lexical similarity + * (Jaccard over word sets of the title and body) β€” no API key, no embeddings. + * + * On a match it posts ONE comment listing the closest open issues and adds the + * "possible duplicate" label. If nothing is similar enough, it does nothing. + * + * Env: + * GITHUB_REPOSITORY owner/repo + * ISSUE_NUMBER the new issue number + * ISSUE_TITLE the new issue title + * ISSUE_BODY the new issue body + * GH_TOKEN/GITHUB_TOKEN for gh (provided in CI) + * DUP_THRESHOLD similarity threshold 0..1 (default 0.3) + * DUP_MAX max matches to report (default 5) + * DUP_FIXTURE optional path to a JSON array of {number,title,body} (local testing) + * DRY_RUN if set, print results instead of commenting/labelling + */ + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured. +const numEnv = (name, def) => { + const raw = process.env[name]; + if (raw === undefined || raw === "") return def; + const n = Number(raw); + return Number.isNaN(n) ? def : n; +}; + +const REPO = process.env.GITHUB_REPOSITORY || "streamyfin/streamyfin"; +const NUMBER = numEnv("ISSUE_NUMBER", Number.NaN); +const TITLE = process.env.ISSUE_TITLE || ""; +const BODY = process.env.ISSUE_BODY || ""; +const THRESHOLD = numEnv("DUP_THRESHOLD", 0.3); +const MAX = numEnv("DUP_MAX", 5); +const DRY = !!process.env.DRY_RUN; +const LABEL = "possible duplicate"; +const MARKER = ""; + +// Generic stop words only β€” keep domain/feature/platform words (android, downloads, +// subtitles…) since those are exactly what makes two reports the same or different. +const STOP = new Set( + ( + "a an the and or but if then of to in on at by for with from as is are was were be been being do does did " + + "it its this that these those i you we they me my your our their he she him her " + + "when while where what which who how why so just then than too very can could would should will " + + "not no nor only own same s t don dont im ive please thanks hi hello also still get got use used using " + + "app application streamyfin issue bug" + ).split(/\s+/), +); + +const stem = (w) => w.replace(/(ing|ed|es|s)$/, ""); + +const tokens = (s) => + (s || "") + .toLowerCase() + .replace(/```[\s\S]*?```/g, " ") // drop code blocks + .replace(//g, " ") // drop html comments + .replace(/https?:\/\/\S+/g, " ") // drop urls + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 2 && !STOP.has(w)) + .map(stem) + .filter((w) => w.length > 2); + +const jaccard = (a, b) => { + const A = new Set(a); + const B = new Set(b); + if (!A.size || !B.size) return 0; + let inter = 0; + for (const x of A) if (B.has(x)) inter++; + return inter / (A.size + B.size - inter); +}; + +const newTitle = tokens(TITLE); +const newBody = tokens(BODY); +const score = (o) => + 0.6 * jaccard(newTitle, tokens(o.title)) + + 0.4 * jaccard(newBody, tokens(o.body)); + +// fetch open issues (excluding PRs and the new issue itself) +let issues; +if (process.env.DUP_FIXTURE) { + issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")); +} else { + const raw = execFileSync( + "gh", + [ + "api", + `repos/${REPO}/issues`, + "--paginate", + "-X", + "GET", + "-f", + "state=open", + "-f", + "per_page=100", + "--jq", + ".[] | select(.pull_request | not) | {number, title, body}", + ], + { encoding: "utf8", maxBuffer: 1e8 }, + ); + issues = raw + .split("\n") + .filter(Boolean) + .map((l) => JSON.parse(l)); +} + +const matches = issues + .filter((o) => o.number !== NUMBER) + .map((o) => ({ ...o, s: score(o) })) + .filter((o) => o.s >= THRESHOLD) + .sort((a, b) => b.s - a.s) + .slice(0, MAX); + +if (!matches.length) { + console.log("No likely duplicates found."); + process.exit(0); +} + +// Neutralise other issues' titles before echoing them back: break @mentions and +// strip markdown/HTML control chars so a maliciously-named issue can't ping people +// or inject formatting into our comment. GitHub linkifies "#123" on its own. +const safeTitle = (t) => + (t || "") + .replace(/@/g, "@​") + .replace(/[`<>|*_~[\]]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 140); +const list = matches + .map( + (m) => + `- #${m.number} β€” ${safeTitle(m.title)} (β‰ˆ ${Math.round(m.s * 100)}% similar)`, + ) + .join("\n"); +const comment = [ + MARKER, + "πŸ” **This looks like it might be a duplicate.** Possibly related open issues:", + "", + list, + "", + "If yours is different, ignore this β€” a maintainer will confirm. Otherwise, please πŸ‘ the existing issue and add any extra details there.", +].join("\n"); + +console.log(`Found ${matches.length} possible duplicate(s):\n${list}`); + +if (DRY) { + console.log("\nDRY_RUN: not commenting/labelling."); + process.exit(0); +} + +// Live mode needs a real issue number; refuse rather than POST to /issues/NaN/... +if (!Number.isInteger(NUMBER) || NUMBER <= 0) { + console.error( + `Invalid ISSUE_NUMBER ${JSON.stringify(process.env.ISSUE_NUMBER)} β€” refusing to comment.`, + ); + process.exit(1); +} + +// Idempotency: skip if we've already flagged this issue (guards re-runs / future triggers). +const priorComments = execFileSync( + "gh", + [ + "api", + `repos/${REPO}/issues/${NUMBER}/comments`, + "--paginate", + "--jq", + ".[].body", + ], + { encoding: "utf8", maxBuffer: 1e8 }, +); +if (priorComments.includes(MARKER)) { + console.log("Already flagged (marker present); skipping."); + process.exit(0); +} + +execFileSync( + "gh", + [ + "api", + "-X", + "POST", + `repos/${REPO}/issues/${NUMBER}/comments`, + "-f", + `body=${comment}`, + ], + { stdio: "ignore" }, +); +try { + execFileSync( + "gh", + [ + "api", + "-X", + "POST", + `repos/${REPO}/issues/${NUMBER}/labels`, + "-f", + `labels[]=${LABEL}`, + ], + { stdio: "ignore" }, + ); +} catch { + // label may not exist yet β€” create then add + execFileSync( + "gh", + [ + "api", + "-X", + "POST", + `repos/${REPO}/labels`, + "-f", + `name=${LABEL}`, + "-f", + "color=fbca04", + "-f", + "description=Automatically flagged as a possible duplicate", + ], + { stdio: "ignore" }, + ); + execFileSync( + "gh", + [ + "api", + "-X", + "POST", + `repos/${REPO}/issues/${NUMBER}/labels`, + "-f", + `labels[]=${LABEL}`, + ], + { stdio: "ignore" }, + ); +} +console.log("Commented and labelled.");