From 95f4d15e98bdafdcd172430a7b591bb70f78c887 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 14 Jul 2026 18:23:54 +0200 Subject: [PATCH 1/3] chore: remove unused TrackSheet and TVSubtitleSheet component (#1780) --- components/TrackSheet.tsx | 99 ---- .../video-player/controls/TVSubtitleSheet.tsx | 560 ------------------ translations/en.json | 2 - 3 files changed, 661 deletions(-) delete mode 100644 components/TrackSheet.tsx delete mode 100644 components/video-player/controls/TVSubtitleSheet.tsx diff --git a/components/TrackSheet.tsx b/components/TrackSheet.tsx deleted file mode 100644 index 52735f6fe..000000000 --- a/components/TrackSheet.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; -import { useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Platform, TouchableOpacity, View } from "react-native"; -import { Text } from "./common/Text"; -import { FilterSheet } from "./filters/FilterSheet"; - -interface Props extends React.ComponentProps { - source?: MediaSourceInfo; - onChange: (value: number) => void; - selected?: number | undefined; - streamType?: string; - title: string; -} - -export const TrackSheet: React.FC = ({ - source, - onChange, - selected, - streamType, - title, - ...props -}) => { - const isTv = Platform.isTV; - const { t } = useTranslation(); - - const streams = useMemo( - () => source?.MediaStreams?.filter((x) => x.Type === streamType), - [source, streamType], - ); - - const selectedSteam = useMemo( - () => streams?.find((x) => x.Index === selected), - [streams, selected], - ); - - const noneOption = useMemo( - () => ({ Index: -1, DisplayTitle: t("common.none") }), - [t], - ); - - // Creates a modified data array that includes a "None" option for subtitles - // We might want to possibly do this for other places, like audio? - const addNoneToSubtitles = useMemo(() => { - if (streamType === "Subtitle") { - const result = streams ? [noneOption, ...streams] : [noneOption]; - return result; - } - return streams; - }, [streams, streamType, noneOption]); - const [open, setOpen] = useState(false); - - if (isTv || (streams && streams.length === 0)) return null; - - return ( - - - {title} - setOpen(true)} - > - - {selected === -1 && streamType === "Subtitle" - ? t("common.none") - : selectedSteam?.DisplayTitle || t("common.select")} - - - - { - const label = (item as any).DisplayTitle || ""; - return label.toLowerCase().includes(query.toLowerCase()); - }} - renderItemLabel={(item) => ( - {(item as any).DisplayTitle || ""} - )} - set={(vals) => { - const chosen = vals[0] as any; - if (chosen && chosen.Index !== null && chosen.Index !== undefined) { - onChange(chosen.Index); - } - }} - /> - - ); -}; diff --git a/components/video-player/controls/TVSubtitleSheet.tsx b/components/video-player/controls/TVSubtitleSheet.tsx deleted file mode 100644 index 6284b5660..000000000 --- a/components/video-player/controls/TVSubtitleSheet.tsx +++ /dev/null @@ -1,560 +0,0 @@ -import { Ionicons } from "@expo/vector-icons"; -import type { - BaseItemDto, - MediaStream, -} from "@jellyfin/sdk/lib/generated-client"; -import { BlurView } from "expo-blur"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useTranslation } from "react-i18next"; -import { - ActivityIndicator, - Animated, - Easing, - ScrollView, - StyleSheet, - TVFocusGuideView, - View, -} from "react-native"; -import { Text } from "@/components/common/Text"; -import { - TVCancelButton, - TVLanguageCard, - TVSubtitleResultCard, - TVTabButton, - TVTrackCard, -} from "@/components/tv"; -import { - type SubtitleSearchResult, - useRemoteSubtitles, -} from "@/hooks/useRemoteSubtitles"; -import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api"; - -interface TVSubtitleSheetProps { - visible: boolean; - item: BaseItemDto; - mediaSourceId?: string | null; - subtitleTracks: MediaStream[]; - currentSubtitleIndex: number; - onSubtitleIndexChange: (index: number) => void; - onClose: () => void; - onServerSubtitleDownloaded?: () => void; - onLocalSubtitleDownloaded?: (path: string) => void; -} - -type TabType = "tracks" | "download"; - -export const TVSubtitleSheet: React.FC = ({ - visible, - item, - mediaSourceId, - subtitleTracks, - currentSubtitleIndex, - onSubtitleIndexChange, - onClose, - onServerSubtitleDownloaded, - onLocalSubtitleDownloaded, -}) => { - const { t } = useTranslation(); - - console.log( - "[TVSubtitleSheet] visible:", - visible, - "tracks:", - subtitleTracks.length, - ); - const [activeTab, setActiveTab] = useState("tracks"); - const [selectedLanguage, setSelectedLanguage] = useState("eng"); - const [downloadingId, setDownloadingId] = useState(null); - const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false); - const [isReady, setIsReady] = useState(false); - const [isTabContentReady, setIsTabContentReady] = useState(false); - const firstTrackRef = useRef(null); - - const { - hasOpenSubtitlesApiKey, - isSearching, - searchError, - searchResults, - search, - downloadAsync, - reset, - } = useRemoteSubtitles({ - itemId: item.Id ?? "", - item, - mediaSourceId, - }); - - const resetRef = useRef(reset); - resetRef.current = reset; - - const overlayOpacity = useRef(new Animated.Value(0)).current; - const sheetTranslateY = useRef(new Animated.Value(300)).current; - - const initialSelectedTrackIndex = useMemo(() => { - if (currentSubtitleIndex === -1) return 0; - const trackIdx = subtitleTracks.findIndex( - (t) => t.Index === currentSubtitleIndex, - ); - return trackIdx >= 0 ? trackIdx + 1 : 0; - }, [subtitleTracks, currentSubtitleIndex]); - - useEffect(() => { - if (visible) { - overlayOpacity.setValue(0); - sheetTranslateY.setValue(300); - - Animated.parallel([ - Animated.timing(overlayOpacity, { - toValue: 1, - duration: 250, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(sheetTranslateY, { - toValue: 0, - duration: 300, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - ]).start(); - } - }, [visible, overlayOpacity, sheetTranslateY]); - - useEffect(() => { - if (!visible) { - setHasSearchedThisSession(false); - setActiveTab("tracks"); - resetRef.current(); - setIsReady(false); - } - }, [visible]); - - useEffect(() => { - if (visible) { - const timer = setTimeout(() => setIsReady(true), 100); - return () => clearTimeout(timer); - } - setIsReady(false); - }, [visible]); - - useEffect(() => { - if (visible && activeTab === "download" && !hasSearchedThisSession) { - search({ language: selectedLanguage }); - setHasSearchedThisSession(true); - } - }, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]); - - useEffect(() => { - if (isReady) { - setIsTabContentReady(false); - const timer = setTimeout(() => setIsTabContentReady(true), 50); - return () => clearTimeout(timer); - } - setIsTabContentReady(false); - }, [activeTab, isReady]); - - const handleLanguageSelect = useCallback( - (code: string) => { - setSelectedLanguage(code); - search({ language: code }); - }, - [search], - ); - - const handleTrackSelect = useCallback( - (index: number) => { - onSubtitleIndexChange(index); - onClose(); - }, - [onSubtitleIndexChange, onClose], - ); - - const handleDownload = useCallback( - async (result: SubtitleSearchResult) => { - setDownloadingId(result.id); - - try { - const downloadResult = await downloadAsync(result); - - if (downloadResult.type === "server") { - onServerSubtitleDownloaded?.(); - } else if (downloadResult.type === "local" && downloadResult.path) { - onLocalSubtitleDownloaded?.(downloadResult.path); - } - - onClose(); - } catch (error) { - console.error("Failed to download subtitle:", error); - } finally { - setDownloadingId(null); - } - }, - [ - downloadAsync, - onServerSubtitleDownloaded, - onLocalSubtitleDownloaded, - onClose, - ], - ); - - const displayLanguages = useMemo( - () => COMMON_SUBTITLE_LANGUAGES.slice(0, 16), - [], - ); - - const trackOptions = useMemo(() => { - const noneOption = { - label: t("item_card.subtitles.none"), - sublabel: undefined as string | undefined, - value: -1, - selected: currentSubtitleIndex === -1, - }; - const options = subtitleTracks.map((track) => ({ - label: - track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, - sublabel: track.Codec?.toUpperCase(), - value: track.Index!, - selected: track.Index === currentSubtitleIndex, - })); - return [noneOption, ...options]; - }, [subtitleTracks, currentSubtitleIndex, t]); - - if (!visible) return null; - - return ( - - - - - {/* Header with tabs */} - - - {t("item_card.subtitles.label") || "Subtitles"} - - - {/* Tab bar */} - - setActiveTab("tracks")} - /> - setActiveTab("download")} - /> - - - - {/* Tracks Tab Content */} - {activeTab === "tracks" && isTabContentReady && ( - - - {trackOptions.map((option, index) => ( - handleTrackSelect(option.value)} - /> - ))} - - - )} - - {/* Download Tab Content */} - {activeTab === "download" && isTabContentReady && ( - <> - {/* Language Selector */} - - - {t("player.language") || "Language"} - - - {displayLanguages.map((lang, index) => ( - handleLanguageSelect(lang.code)} - /> - ))} - - - - {/* Results Section */} - - - {t("player.results") || "Results"} - {searchResults && ` (${searchResults.length})`} - - - {/* Loading state */} - {isSearching && ( - - - - {t("player.searching") || "Searching..."} - - - )} - - {/* Error state */} - {searchError && !isSearching && ( - - - - {t("player.search_failed") || "Search failed"} - - - {!hasOpenSubtitlesApiKey - ? t("player.no_subtitle_provider") || - "No subtitle provider configured on server" - : String(searchError)} - - - )} - - {/* No results */} - {searchResults && - searchResults.length === 0 && - !isSearching && - !searchError && ( - - - - {t("player.no_subtitles_found") || - "No subtitles found"} - - - )} - - {/* Results list */} - {searchResults && - searchResults.length > 0 && - !isSearching && ( - - {searchResults.map((result, index) => ( - handleDownload(result)} - /> - ))} - - )} - - - {/* API Key hint if no fallback available */} - {!hasOpenSubtitlesApiKey && ( - - - - {t("player.add_opensubtitles_key_hint") || - "Add OpenSubtitles API key in settings for client-side fallback"} - - - )} - - )} - - {/* Cancel button */} - {isReady && ( - - - - )} - - - - - ); -}; - -const styles = StyleSheet.create({ - overlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.6)", - justifyContent: "flex-end", - zIndex: 1000, - }, - sheetContainer: { - maxHeight: "70%", - }, - blurContainer: { - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - overflow: "hidden", - }, - content: { - paddingTop: 24, - paddingBottom: 48, - }, - header: { - paddingHorizontal: 48, - marginBottom: 20, - }, - title: { - fontSize: 24, - fontWeight: "600", - color: "#fff", - marginBottom: 16, - }, - tabRow: { - flexDirection: "row", - gap: 24, - }, - section: { - marginBottom: 20, - }, - sectionTitle: { - fontSize: 14, - fontWeight: "500", - color: "rgba(255,255,255,0.5)", - textTransform: "uppercase", - letterSpacing: 1, - marginBottom: 12, - paddingHorizontal: 48, - }, - tracksScroll: { - overflow: "visible", - }, - tracksScrollContent: { - paddingHorizontal: 48, - paddingVertical: 8, - gap: 12, - }, - languageScroll: { - overflow: "visible", - }, - languageScrollContent: { - paddingHorizontal: 48, - paddingVertical: 8, - gap: 10, - }, - resultsScroll: { - overflow: "visible", - }, - resultsScrollContent: { - paddingHorizontal: 48, - paddingVertical: 8, - gap: 12, - }, - loadingContainer: { - paddingVertical: 40, - alignItems: "center", - }, - loadingText: { - color: "rgba(255,255,255,0.6)", - marginTop: 12, - fontSize: 14, - }, - errorContainer: { - paddingVertical: 40, - paddingHorizontal: 48, - alignItems: "center", - }, - errorText: { - color: "rgba(255,100,100,0.9)", - marginTop: 8, - fontSize: 16, - fontWeight: "500", - }, - errorHint: { - color: "rgba(255,255,255,0.5)", - marginTop: 4, - fontSize: 13, - textAlign: "center", - }, - emptyContainer: { - paddingVertical: 40, - alignItems: "center", - }, - emptyText: { - color: "rgba(255,255,255,0.5)", - marginTop: 8, - fontSize: 14, - }, - apiKeyHint: { - flexDirection: "row", - alignItems: "center", - gap: 8, - paddingHorizontal: 48, - paddingTop: 8, - }, - apiKeyHintText: { - color: "rgba(255,255,255,0.4)", - fontSize: 12, - }, - cancelButtonContainer: { - paddingHorizontal: 48, - paddingTop: 20, - alignItems: "flex-start", - }, -}); diff --git a/translations/en.json b/translations/en.json index 3c4271b1a..ba3ffcebf 100644 --- a/translations/en.json +++ b/translations/en.json @@ -484,7 +484,6 @@ }, "common": { "no_results": "No results", - "select": "Select", "no_trailer_available": "No trailer available", "video": "Video", "audio": "Audio", @@ -621,7 +620,6 @@ "using_jellyfin_server": "Using Jellyfin server", "language": "Language", "results": "Results", - "searching": "Searching...", "search_failed": "Search failed", "no_subtitle_provider": "No subtitle provider configured on server", "no_subtitles_found": "No subtitles found", From 8d54d65b63ec98bff4aed7217a9d1117e7e97101 Mon Sep 17 00:00:00 2001 From: lance chant <13349722+lancechant@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:04:15 +0200 Subject: [PATCH 2/3] feat: adding episode count indicator (#1787) Co-authored-by: Gauvain Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- components/WatchedIndicator.tsx | 130 ++++++++++++++++++++++------ components/posters/ItemPoster.tsx | 2 +- components/posters/SeriesPoster.tsx | 2 + components/tv/TVPosterCard.tsx | 13 ++- 4 files changed, 117 insertions(+), 30 deletions(-) diff --git a/components/WatchedIndicator.tsx b/components/WatchedIndicator.tsx index 0d2999f53..145ee7e0e 100644 --- a/components/WatchedIndicator.tsx +++ b/components/WatchedIndicator.tsx @@ -1,43 +1,119 @@ import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import type React from "react"; -import { Platform, View } from "react-native"; +import React from "react"; +import { Platform, View, type ViewStyle } from "react-native"; +import { Text } from "@/components/common/Text"; +import { scaleSize } from "@/utils/scaleSize"; -export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => { - if (Platform.isTV) { - // TV: Show white checkmark when watched - if ( - item.UserData?.Played && - (item.Type === "Movie" || item.Type === "Episode") - ) { +const isAggregateType = (item: BaseItemDto) => + item.Type === "Series" || item.Type === "BoxSet"; + +// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize). +const tvBadgeBase: ViewStyle = { + position: "absolute", + top: scaleSize(8), + right: scaleSize(8), + height: scaleSize(28), + borderRadius: scaleSize(14), + backgroundColor: "rgba(255,255,255,0.92)", + alignItems: "center", + justifyContent: "center", +}; + +// Mobile uses raw dp — no scaling. +const mobileBadgeBase: ViewStyle = { + position: "absolute", + top: 4, + right: 4, + height: 20, + borderRadius: 10, + backgroundColor: "#9333ea", + alignItems: "center", + justifyContent: "center", +}; + +/** + * Renders the unplayed-episode count badge for Series/BoxSet items that still + * have episodes left to watch. Returns null for non-aggregate types, fully + * watched items, or items with no unplayed count, so it is safe to mount + * unconditionally as an overlay (e.g. on top of the tvOS glass poster, where + * the watched checkmark is drawn natively and only the count needs RN). + */ +export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo( + ({ item }) => { + if (!isAggregateType(item)) return null; + if (item.UserData?.Played) return null; + const unplayed = item.UserData?.UnplayedItemCount ?? 0; + if (unplayed <= 0) return null; + // Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+). + const label = unplayed >= 1000 ? "1k+" : String(unplayed); + + if (Platform.isTV) { return ( - + + {label} + ); } - return null; + + return ( + + + {label} + + + ); + }, +); + +export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => { + const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode"; + const isAggregate = isAggregateType(item); + const isPlayed = item.UserData?.Played === true; + + if (Platform.isTV) { + // Fully watched → white checkmark badge (top-right) + if (isPlayed && (isMovieOrEpisode || isAggregate)) { + return ( + + + + ); + } + // Series/BoxSet with remaining episodes → count badge + return ; } - // Mobile: Show purple triangle for unwatched + // Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior) return ( <> - {item.UserData?.Played === false && - (item.Type === "Movie" || item.Type === "Episode") && ( - - )} + {/* Strict === false: items without UserData (unknown state) get no ribbon */} + {isMovieOrEpisode && item.UserData?.Played === false && ( + + )} + + {/* Fully watched Series/BoxSet → small purple checkmark */} + {isAggregate && isPlayed && ( + + + + )} + + {/* Series/BoxSet with remaining episodes → count badge */} + ); }; diff --git a/components/posters/ItemPoster.tsx b/components/posters/ItemPoster.tsx index bcd857e79..9990727be 100644 --- a/components/posters/ItemPoster.tsx +++ b/components/posters/ItemPoster.tsx @@ -1,8 +1,8 @@ import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useState } from "react"; import { View, type ViewProps } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; import { ItemImage } from "../common/ItemImage"; -import { WatchedIndicator } from "../WatchedIndicator"; interface Props extends ViewProps { item: BaseItemDto; diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx index 07f212d7f..82cf146c7 100644 --- a/components/posters/SeriesPoster.tsx +++ b/components/posters/SeriesPoster.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import { useAtom } from "jotai"; import { useMemo } from "react"; import { View } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; @@ -52,6 +53,7 @@ const SeriesPoster: React.FC = ({ item }) => { width: "100%", }} /> + ); }; diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index 6c20075fd..9a24a5034 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -12,7 +12,10 @@ import { } from "react-native"; import { ProgressBar } from "@/components/common/ProgressBar"; import { Text } from "@/components/common/Text"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; +import { + UnplayedCountBadge, + WatchedIndicator, +} from "@/components/WatchedIndicator"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { @@ -427,6 +430,12 @@ export const TVPosterCard: React.FC = ({ /> {PlayButtonOverlay} {NowPlayingBadge} + {/* + The glass view draws the watched checkmark natively but cannot show + an unplayed-episode count, so render it as an RN overlay on top. + Returns null when not applicable (non-series / fully watched). + */} + {showWatchedIndicator && } ); } @@ -459,7 +468,7 @@ export const TVPosterCard: React.FC = ({ /> {PlayButtonOverlay} {NowPlayingBadge} - + {showWatchedIndicator && } ); From 2df160adb7eb0c8b02db2d0727a9f37bd63279c8 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Tue, 14 Jul 2026 19:45:42 +0200 Subject: [PATCH 3/3] ci: skip PR title comment when the lint run was cancelled (#1803) --- .github/workflows/pr-title-comment.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-title-comment.yml b/.github/workflows/pr-title-comment.yml index a3117f269..648eb1c44 100644 --- a/.github/workflows/pr-title-comment.yml +++ b/.github/workflows/pr-title-comment.yml @@ -20,7 +20,12 @@ concurrency: jobs: comment: - if: github.event.workflow_run.event == 'pull_request' + # Only run when the lint run actually produced a result artifact: a run + # cancelled by concurrency (rapid pushes to the same PR) completes with + # conclusion "cancelled" before uploading it, and would fail the download. + if: > + github.event.workflow_run.event == 'pull_request' && + contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion) runs-on: ubuntu-26.04 steps: - name: ⬇️ Download lint result