Compare commits

..

8 Commits

Author SHA1 Message Date
lance chant
aa2b9ae7e1 Merge branch 'develop' into fix/auto-skip-stop 2026-07-19 11:27:27 +02:00
Lance Chant
56db3591b9 fix: attempt at stopping the auto skip/play next episode
This is to attempt to stop it auto playing the next episode when it's
not ready/ the user still watching

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-19 11:22:18 +02:00
Lance Chant
5de017612d Merge remote-tracking branch 'origin/develop' into fix/skip-button-rework
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-19 11:05:29 +02:00
Lance Chant
ca9e9f1486 removing comment about the skip button location
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-19 11:02:56 +02:00
lance chant
980245f19d Merge branch 'develop' into fix/skip-button-rework 2026-07-17 14:47:48 +02:00
lance chant
228e847a4e Update components/video-player/controls/SkipSegmentOverlay.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-17 11:51:37 +02:00
Lance Chant
15c66bc714 Addressing PR comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:45:42 +02:00
Lance Chant
c4209cbf8e fix: fixing the skip button
Allowing it to show independently of the controls
Fixing the position of the button to fit over things better

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:02:23 +02:00
39 changed files with 909 additions and 1659 deletions

View File

@@ -61,14 +61,14 @@ jobs:
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -76,7 +76,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
@@ -94,7 +94,7 @@ jobs:
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -163,14 +163,14 @@ jobs:
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -178,7 +178,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
@@ -196,7 +196,7 @@ jobs:
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: android/.gradle
key: ${{ runner.os }}-${{ runner.arch }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -262,7 +262,7 @@ jobs:
eas-cache: true
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -328,7 +328,7 @@ jobs:
xcode-version: "26.6"
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -403,7 +403,7 @@ jobs:
eas-cache: true
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -467,7 +467,7 @@ jobs:
xcode-version: "26.6"
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -33,7 +33,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.bun/install/cache

View File

@@ -30,13 +30,13 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2

View File

@@ -53,7 +53,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
@@ -97,7 +97,7 @@ jobs:
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -87,7 +87,7 @@ jobs:
eas-cache: true
- name: 💾 Cache Bun dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}

View File

@@ -44,7 +44,7 @@ jobs:
output: trivy-results.sarif
- name: 📤 Upload results to code scanning
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: trivy-results.sarif
category: trivy-fs

View File

@@ -13,7 +13,7 @@ export default function FavoritesPage() {
setLoading(true);
await invalidateCache();
setLoading(false);
}, [invalidateCache]);
}, []);
const insets = useSafeAreaInsets();
if (Platform.isTV) {

View File

@@ -1 +1,210 @@
export { default } from "@/components/favorites/FavoritesSeeAll";
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
type FavoriteTypes =
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
const favoriteTypes: readonly FavoriteTypes[] = [
"Series",
"Movie",
"Episode",
"Video",
"BoxSet",
"Playlist",
] as const;
function isFavoriteType(value: unknown): value is FavoriteTypes {
return (
typeof value === "string" &&
(favoriteTypes as readonly string[]).includes(value)
);
}
export default function FavoritesSeeAllScreen() {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const searchParams = useLocalSearchParams<{
type?: string;
title?: string;
}>();
const typeParam = searchParams.type;
const titleParam = searchParams.title;
const itemType = useMemo(() => {
if (!isFavoriteType(typeParam)) return null;
return typeParam as BaseItemKind;
}, [typeParam]);
const headerTitle = useMemo(() => {
if (typeof titleParam === "string" && titleParam.trim().length > 0)
return titleParam;
return "";
}, [titleParam]);
const pageSize = 50;
const fetchItems = useCallback(
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
if (!api || !user?.Id || !itemType) return [];
const response = await getItemsApi(api as Api).getItems({
userId: user.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: true,
startIndex: pageParam,
limit: pageSize,
includeItemTypes: [itemType],
});
return response.data.Items || [];
},
[api, itemType, user?.Id],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["favorites", "see-all", itemType],
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
getNextPageParam: (lastPage, pages) => {
if (!lastPage || lastPage.length < pageSize) return undefined;
return pages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!itemType,
});
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
const nrOfCols = useMemo(() => {
if (screenWidth < 350) return 2;
if (screenWidth < 600) return 3;
if (screenWidth < 900) return 5;
return 6;
}, [screenWidth]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
item={item}
style={{
width: "100%",
}}
>
<View
style={{
alignSelf:
index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[nrOfCols],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const handleEndReached = useCallback(() => {
if (hasNextPage) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage]);
return (
<>
<Stack.Screen
options={{
headerTitle: headerTitle,
headerBlurEffect: "none",
headerTransparent: true,
headerShadowVisible: false,
}}
/>
{!itemType ? (
<View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
</View>
) : isLoading ? (
<View className='justify-center items-center h-full'>
<Loader />
</View>
) : (
<FlashList
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={handleEndReached}
onEndReachedThreshold={0.8}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items")}
</Text>
</View>
}
ListFooterComponent={
isFetching ? (
<View style={{ paddingVertical: 16 }}>
<Loader />
</View>
) : null
}
/>
)}
</>
);
}

View File

@@ -50,7 +50,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const { settings, updateSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
@@ -896,15 +896,6 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* Plugins Section */}
<TVSectionHeader title={t("home.settings.plugins.plugins_title")} />
<TVSettingsToggle
label={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
value={settings.useKefinTweaks}
onToggle={(value) => updateSettings({ useKefinTweaks: value })}
disabled={pluginSettings?.useKefinTweaks?.locked === true}
/>
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton

View File

@@ -9,7 +9,6 @@ import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { AddToFavorites } from "@/components/AddToFavorites";
import { AddToKefinWatchlist } from "@/components/AddToKefinWatchlist";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp";
@@ -19,7 +18,6 @@ import { TVSeriesPage } from "@/components/series/TVSeriesPage";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
buildOfflineSeriesFromEpisodes,
getDownloadedEpisodesForSeries,
@@ -32,7 +30,6 @@ import { storage } from "@/utils/mmkv";
const page: React.FC = () => {
const navigation = useNavigation();
const { t } = useTranslation();
const { settings } = useSettings();
const params = useLocalSearchParams();
const {
id: seriesId,
@@ -140,7 +137,6 @@ const page: React.FC = () => {
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{settings?.useKefinTweaks && <AddToKefinWatchlist item={item} />}
{!Platform.isTV && (
<DownloadItems
size='large'
@@ -161,7 +157,7 @@ const page: React.FC = () => {
</View>
) : null,
});
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
}, [allEpisodes, isLoading, item, isOffline]);
// For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null;

View File

@@ -1,15 +1,19 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import useRouter from "@/hooks/useAppRouter";
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
export default function WatchlistsLayout() {
const { t } = useTranslation();
const router = useRouter();
const streamystatsEnabled = useStreamystatsEnabled();
return (
<Stack>
{/* The create ("+") button is set from the index screen based on the
active source (Streamystats vs KefinTweaks); see index.tsx. */}
<Stack.Screen
name='index'
options={{
@@ -18,6 +22,18 @@ export default function WatchlistsLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: streamystatsEnabled
? () => (
<Pressable
onPress={() =>
router.push("/(auth)/(tabs)/(watchlists)/create")
}
className='p-1.5'
>
<Ionicons name='add' size={24} color='white' />
</Pressable>
)
: undefined,
}}
/>
<Stack.Screen

View File

@@ -1,240 +1,239 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router";
import { useHeaderHeight } from "expo-router/react-navigation";
import { useMemo, useState } from "react";
import { FlashList } from "@shopify/flash-list";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SegmentedToggle } from "@/components/common/SegmentedToggle";
import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { TVSegmentedControl } from "@/components/tv/TVSegmentedControl";
import { StreamystatsWatchlists } from "@/components/watchlists/StreamystatsWatchlists";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize";
import {
useStreamystatsEnabled,
useWatchlistsQuery,
} from "@/hooks/useWatchlists";
import { userAtom } from "@/providers/JellyfinProvider";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const TV_TOP_PADDING = scaleSize(100);
const TV_HORIZONTAL_PADDING = scaleSize(60);
type WatchlistSource = "streamystats" | "kefin";
interface WatchlistsViewProps {
streamystatsShown: boolean;
kefinShown: boolean;
interface WatchlistCardProps {
watchlist: StreamystatsWatchlist;
isOwner: boolean;
onPress: () => void;
}
function useToggleOptions() {
const WatchlistCard: React.FC<WatchlistCardProps> = ({
watchlist,
isOwner,
onPress,
}) => {
const { t } = useTranslation();
return useMemo(
() => [
{
value: "streamystats" as const,
label: t("watchlists.source_streamystats"),
},
{ value: "kefin" as const, label: t("watchlists.source_kefintweaks") },
],
[t],
);
}
/**
* Resolves which watchlist source is active given what's enabled. When both
* are shown the user toggles between them (defaulting to Streamystats);
* otherwise the single enabled source wins.
*/
function useWatchlistSource(streamystatsShown: boolean, kefinShown: boolean) {
const showToggle = streamystatsShown && kefinShown;
const [source, setSource] = useState<WatchlistSource>(
streamystatsShown ? "streamystats" : "kefin",
);
const activeSource: WatchlistSource = showToggle
? source
: streamystatsShown
? "streamystats"
: "kefin";
return { source, setSource, activeSource, showToggle };
}
/** Shared KefinTweaks (Likes-backed) view — the favorites grid with a Likes filter. */
function KefinWatchlistView() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
<TouchableOpacity
onPress={onPress}
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
activeOpacity={0.7}
>
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
<Favorites
filter='Likes'
queryKeyBase='watchlist'
seeAllNamespace='kefintweaksWatchlist'
seeAllPathname='/(auth)/(tabs)/(watchlists)/see-all'
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
emptyTextKey='kefintweaksWatchlist.noData'
/>
</View>
</ScrollView>
);
}
function MobileWatchlists({
streamystatsShown,
kefinShown,
}: WatchlistsViewProps) {
const headerHeight = useHeaderHeight();
const options = useToggleOptions();
const router = useRouter();
const { source, setSource, activeSource, showToggle } = useWatchlistSource(
streamystatsShown,
kefinShown,
);
if (!streamystatsShown && !kefinShown) return null;
const activeView =
activeSource === "streamystats" ? (
<StreamystatsWatchlists />
) : (
<KefinWatchlistView />
);
// The "+" only creates Streamystats watchlists, so hide it whenever the
// active view is KefinTweaks (Likes-backed, nothing to create).
const headerRight =
activeSource === "streamystats"
? () => (
<Pressable
onPress={() => router.push("/(auth)/(tabs)/(watchlists)/create")}
className='p-1.5'
>
<Ionicons name='add' size={24} color='white' />
</Pressable>
)
: undefined;
return (
<>
<Stack.Screen options={{ headerRight }} />
{showToggle ? (
<View style={{ flex: 1 }}>
{/* Clear the transparent iOS header; the Android header is opaque so
content already starts below it. */}
<View
style={{
paddingTop: Platform.OS === "ios" ? headerHeight : 10,
paddingBottom: 8,
paddingHorizontal: 16,
}}
>
<SegmentedToggle
options={options}
value={source}
onChange={setSource}
/>
</View>
<View style={{ flex: 1 }}>{activeView}</View>
</View>
) : (
activeView
)}
</>
);
}
function TVWatchlists({ streamystatsShown, kefinShown }: WatchlistsViewProps) {
const insets = useSafeAreaInsets();
const options = useToggleOptions();
const { source, setSource, activeSource, showToggle } = useWatchlistSource(
streamystatsShown,
kefinShown,
);
if (!streamystatsShown && !kefinShown) return null;
if (!showToggle) {
return activeSource === "streamystats" ? (
<StreamystatsWatchlists />
) : (
<TVFavorites
filter='Likes'
queryKeyBase='watchlist'
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
emptyTextKey='kefintweaksWatchlist.noData'
/>
);
}
return (
<View style={{ flex: 1 }}>
<View
style={{
paddingTop: insets.top + TV_TOP_PADDING,
paddingHorizontal: TV_HORIZONTAL_PADDING,
}}
>
<TVSegmentedControl
options={options}
value={source}
onChange={setSource}
hasTVPreferredFocus
/>
</View>
<View style={{ flex: 1 }}>
{activeSource === "streamystats" ? (
<StreamystatsWatchlists />
) : (
<TVFavorites
filter='Likes'
queryKeyBase='watchlist'
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
emptyTextKey='kefintweaksWatchlist.noData'
isFirstSection={false}
contentTopPadding={0}
<View className='flex-row items-center justify-between mb-2'>
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
{watchlist.name}
</Text>
<View className='flex-row items-center gap-2'>
{isOwner && (
<View className='bg-purple-600/20 px-2 py-1 rounded'>
<Text className='text-purple-400 text-xs'>
{t("watchlists.you")}
</Text>
</View>
)}
<Ionicons
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
size={16}
color='#9ca3af'
/>
</View>
</View>
{watchlist.description && (
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
{watchlist.description}
</Text>
)}
<View className='flex-row items-center gap-4'>
<View className='flex-row items-center gap-1'>
<Ionicons name='film-outline' size={14} color='#9ca3af' />
<Text className='text-neutral-400 text-sm'>
{watchlist.itemCount ?? 0}{" "}
{(watchlist.itemCount ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
{watchlist.allowedItemType && (
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
<Text className='text-neutral-400 text-xs'>
{watchlist.allowedItemType}
</Text>
</View>
)}
</View>
</TouchableOpacity>
);
};
const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
onCreatePress: _onCreatePress,
}) => {
const { t } = useTranslation();
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='list-outline' size={64} color='#4b5563' />
<Text className='text-xl font-semibold mt-4 text-center'>
{t("watchlists.empty_title")}
</Text>
<Text className='text-neutral-400 text-center mt-2 mb-6'>
{t("watchlists.empty_description")}
</Text>
</View>
);
}
};
const NotConfiguredState: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='settings-outline' size={64} color='#4b5563' />
<Text className='text-xl font-semibold mt-4 text-center'>
{t("watchlists.not_configured_title")}
</Text>
<Text className='text-neutral-400 text-center mt-2 mb-6'>
{t("watchlists.not_configured_description")}
</Text>
<Button
onPress={() =>
router.push(
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
)
}
className='px-6'
>
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
</Button>
</View>
);
};
/**
* Watchlists tab. Hosts the Streamystats (plugin) watchlists and/or the
* KefinTweaks (Likes-backed) watchlist depending on which is enabled:
* - streamystats only -> Streamystats list
* - kefintweaks only -> KefinTweaks grid
* - both -> Streamystats by default, with a source toggle
* - neither -> nothing (the tab is hidden upstream)
*
* `hideWatchlistsTab` suppresses only the Streamystats side (see `streamystatsShown`).
*/
export default function WatchlistsScreen() {
const { settings } = useSettings();
const streamystatsShown =
useStreamystatsEnabled() && !settings?.hideWatchlistsTab;
const kefinShown = settings?.useKefinTweaks ?? false;
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const user = useAtomValue(userAtom);
const streamystatsEnabled = useStreamystatsEnabled();
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
const [refreshing, setRefreshing] = useState(false);
if (Platform.isTV) {
return (
<TVWatchlists
streamystatsShown={streamystatsShown}
kefinShown={kefinShown}
/>
);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
}, [refetch]);
const handleCreatePress = useCallback(() => {
router.push("/(auth)/(tabs)/(watchlists)/create");
}, [router]);
const handleWatchlistPress = useCallback(
(watchlistId: number) => {
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
},
[router],
);
// Separate watchlists into "mine" and "public"
const { myWatchlists, publicWatchlists } = useMemo(() => {
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
const mine: StreamystatsWatchlist[] = [];
const pub: StreamystatsWatchlist[] = [];
for (const w of watchlists) {
if (w.userId === user?.Id) {
mine.push(w);
} else {
pub.push(w);
}
}
return { myWatchlists: mine, publicWatchlists: pub };
}, [watchlists, user?.Id]);
// Combine into sections for FlashList
const sections = useMemo(() => {
const result: Array<
| { type: "header"; title: string }
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
> = [];
if (myWatchlists.length > 0) {
result.push({ type: "header", title: t("watchlists.my_watchlists") });
for (const w of myWatchlists) {
result.push({ type: "watchlist", data: w, isOwner: true });
}
}
if (publicWatchlists.length > 0) {
result.push({ type: "header", title: t("watchlists.public_watchlists") });
for (const w of publicWatchlists) {
result.push({ type: "watchlist", data: w, isOwner: false });
}
}
return result;
}, [myWatchlists, publicWatchlists, t]);
if (!streamystatsEnabled) {
return <NotConfiguredState />;
}
if (!isLoading && (!watchlists || watchlists.length === 0)) {
return <EmptyState onCreatePress={handleCreatePress} />;
}
return (
<MobileWatchlists
streamystatsShown={streamystatsShown}
kefinShown={kefinShown}
<FlashList
data={sections}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 10 : 0,
paddingBottom: 100,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={({ item }) => {
if (item.type === "header") {
return (
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
{item.title}
</Text>
);
}
return (
<WatchlistCard
watchlist={item.data}
isOwner={item.isOwner}
onPress={() => handleWatchlistPress(item.data.id)}
/>
);
}}
getItemType={(item) => item.type}
/>
);
}

View File

@@ -1 +0,0 @@
export { default } from "@/components/favorites/FavoritesSeeAll";

View File

@@ -50,9 +50,7 @@ function TVTabLayout() {
const router = useRouter();
const currentTab = segments.find(isTabRoute);
// Widened to string: typed routes omit "index" segments, but they can still
// appear at runtime, so keep the guard without tripping TS2367.
const lastSegment: string = segments[segments.length - 1] ?? "";
const lastSegment = segments[segments.length - 1] ?? "";
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
const tabs: TVNavBarTab[] = useMemo(
@@ -61,12 +59,9 @@ function TVTabLayout() {
{ key: "(home)", label: t("tabs.home") },
{ key: "(search)", label: t("tabs.search") },
{ key: "(favorites)", label: t("tabs.favorites") },
// `hideWatchlistsTab` only suppresses the Streamystats side; KefinTweaks
// keeps the tab visible on its own.
(settings?.streamyStatsServerUrl && !settings?.hideWatchlistsTab) ||
settings?.useKefinTweaks
? { key: "(watchlists)", label: t("watchlists.title") }
: null,
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
? null
: { key: "(watchlists)", label: t("watchlists.title") },
{ key: "(libraries)", label: t("tabs.library") },
!settings?.showCustomMenuLinks
? null
@@ -76,7 +71,6 @@ function TVTabLayout() {
[
settings?.streamyStatsServerUrl,
settings?.hideWatchlistsTab,
settings?.useKefinTweaks,
settings?.showCustomMenuLinks,
t,
],
@@ -207,13 +201,8 @@ export default function TabLayout() {
name='(watchlists)'
options={{
title: t("watchlists.title"),
// Shown when Streamystats (URL set & not hidden) OR KefinTweaks is
// enabled. `hideWatchlistsTab` only suppresses the Streamystats side.
tabBarItemHidden: !(
(settings?.streamyStatsServerUrl &&
!settings?.hideWatchlistsTab) ||
settings?.useKefinTweaks
),
tabBarItemHidden:
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.star.png")

View File

@@ -472,48 +472,24 @@ export default function DirectPlayerPage() {
}
};
// PlaySessionId of the last "stopped" report, to dedupe the double teardown.
const reportedStopSessionIdRef = useRef<string | null>(null);
const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId || offline || !api) return;
// Leaving the player reports "stopped" twice: once from the beforeRemove
// listener (via stop()) and once from the unmount cleanup backstop. Dedupe
// per PlaySessionId — not a plain boolean, since the component survives
// in-place item switches and the next session must report again.
const sessionId = stream.sessionId;
if (reportedStopSessionIdRef.current === sessionId) return;
reportedStopSessionIdRef.current = sessionId;
const currentTimeInTicks = msToTicks(progress.get());
try {
await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
ItemId: item.Id,
MediaSourceId: mediaSourceId,
PositionTicks: currentTimeInTicks,
PlaySessionId: sessionId,
// Release the server-side live stream (and its tuner slot) on stop.
// Jellyfin only closes a live stream opened via autoOpenLiveStream when
// the stop report carries its LiveStreamId; without it the stream leaks
// and Live TV eventually fails for everyone with "M3U simultaneous
// stream limit has been reached". Undefined for non-live items (no-op).
LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined,
},
});
} catch (error) {
// Un-mark the session so a later teardown path can retry: e.g. a failed
// report from a WebSocket remote-stop (player still mounted) must not
// suppress the report when the user then navigates away. Callers are
// fire-and-forget, so swallow instead of rethrowing unhandled.
if (reportedStopSessionIdRef.current === sessionId) {
reportedStopSessionIdRef.current = null;
}
writeToLog(
"ERROR",
"reportPlaybackStopped failed",
error instanceof Error ? error.message : String(error),
);
}
await getPlaystateApi(api).reportPlaybackStopped({
playbackStopInfo: {
ItemId: item.Id,
MediaSourceId: mediaSourceId,
PositionTicks: currentTimeInTicks,
PlaySessionId: stream.sessionId,
// Release the server-side live stream (and its tuner slot) on stop.
// Jellyfin only closes a live stream opened via autoOpenLiveStream when
// the stop report carries its LiveStreamId; without it the stream leaks
// and Live TV eventually fails for everyone with "M3U simultaneous
// stream limit has been reached". Undefined for non-live items (no-op).
LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined,
},
});
}, [api, item, mediaSourceId, stream, progress, offline]);
const stop = useCallback(() => {
@@ -542,36 +518,13 @@ export default function DirectPlayerPage() {
deactivateKeepAwake();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
// Keep refs to the latest stop / stopped-report so the effects below don't
// need these churning callbacks in their dependency arrays. Reporting
// "stopped" from an effect cleanup that re-runs during playback (e.g. when
// the 30s router.setParams position write mutates navigation state) caused a
// spurious PlaybackStopped every URL_UPDATE_INTERVAL.
const stopRef = useRef(stop);
const reportPlaybackStoppedRef = useRef(reportPlaybackStopped);
useEffect(() => {
stopRef.current = stop;
reportPlaybackStoppedRef.current = reportPlaybackStopped;
}, [stop, reportPlaybackStopped]);
// Report playback stopped only on a real source switch or unmount.
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
reportPlaybackStoppedRef.current();
};
}, [item?.Id, stream?.sessionId, mediaSourceId]);
// Stop on navigation-away. Re-subscribing when the navigation object identity
// changes (e.g. after a setParams) no longer reports anything on its own.
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", () =>
stopRef.current(),
);
return () => {
reportPlaybackStopped();
beforeRemoveListener();
};
}, [navigation]);
}, [navigation, stop, reportPlaybackStopped]);
const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo
@@ -1040,11 +993,7 @@ export default function DirectPlayerPage() {
// e.g. to burn an image sub in or out). Same-item mirror of VideoContext's
// replacePlayer, resuming at the live position.
const replaceWithTrackSelection = useCallback(
(params: {
subtitleIndex?: string;
audioIndex?: string;
bitrateValue?: string;
}) => {
(params: { subtitleIndex?: string; audioIndex?: string }) => {
const queryParams = new URLSearchParams({
itemId: item?.Id ?? "",
audioIndex: params.audioIndex ?? String(currentAudioIndex ?? ""),
@@ -1052,7 +1001,7 @@ export default function DirectPlayerPage() {
params.subtitleIndex ??
String(toServerSubtitleIndex(currentSubtitleIndex)),
mediaSourceId: stream?.mediaSource?.Id ?? "",
bitrateValue: params.bitrateValue ?? bitrateValue?.toString() ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition: msToTicks(progress.get()).toString(),
}).toString();
// Destroy the current mpv instance before re-navigating, same rationale as
@@ -1072,16 +1021,6 @@ export default function DirectPlayerPage() {
],
);
// TV quality (max bitrate) change handler. A bitrate change always requires
// re-negotiating the stream with the server, so it goes through the same
// player-replace path as track changes while transcoding.
const handleBitrateChange = useCallback(
(bitrate: number | undefined) => {
replaceWithTrackSelection({ bitrateValue: bitrate?.toString() ?? "" });
},
[replaceWithTrackSelection],
);
// TV/mobile subtitle track change handler
const handleSubtitleIndexChange = useCallback(
async (index: number) => {
@@ -1553,7 +1492,6 @@ export default function DirectPlayerPage() {
subtitleIndex={currentSubtitleIndex}
onAudioIndexChange={handleAudioIndexChange}
onSubtitleIndexChange={handleSubtitleIndexChange}
onBitrateChange={handleBitrateChange}
previousItem={previousItem}
nextItem={nextItem}
goToPreviousItem={goToPreviousItem}

View File

@@ -1,29 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useWatchlist } from "@/hooks/useWatchlist";
interface Props extends ViewProps {
item: BaseItemDto;
}
/**
* KefinTweaks watchlist toggle, backed by Jellyfin's "Likes" rating.
* Render only when settings.useKefinTweaks is enabled.
*/
export const AddToKefinWatchlist: FC<Props> = ({ item, ...props }) => {
const { isWatchlisted, toggleWatchlist, isPending } = useWatchlist(item);
return (
<View {...props}>
<RoundButton
size='large'
icon={isWatchlisted ? "bookmark" : "bookmark-outline"}
color={isWatchlisted ? "purple" : "white"}
onPress={toggleWatchlist}
disabled={isPending}
/>
</View>
);
};

View File

@@ -29,7 +29,6 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites";
import { AddToKefinWatchlist } from "./AddToKefinWatchlist";
import { AddToWatchlist } from "./AddToWatchlist";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
@@ -139,9 +138,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.useKefinTweaks && (
<AddToKefinWatchlist item={item} />
)}
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
@@ -164,9 +160,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
{settings.useKefinTweaks && (
<AddToKefinWatchlist item={item} />
)}
{settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && (
<AddToWatchlist item={item} />
@@ -185,7 +178,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
settings.hideRemoteSessionButton,
settings.streamyStatsServerUrl,
settings.hideWatchlistsTab,
settings.useKefinTweaks,
]);
useEffect(() => {

View File

@@ -39,7 +39,6 @@ import {
TVRefreshButton,
TVSeriesNavigation,
TVTechnicalDetails,
TVWatchlistButton,
} from "@/components/tv";
import {
LOCAL_SUBTITLE_INDEX_START,
@@ -757,7 +756,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text>
</TVButton>
<TVFavoriteButton item={item} />
{settings.useKefinTweaks && <TVWatchlistButton item={item} />}
<TVPlayedButton item={item} />
<TVRefreshButton itemId={item.Id} />
</View>

View File

@@ -13,7 +13,6 @@ interface Props extends ViewProps {
fillColor?: "primary";
color?: "white" | "purple";
hapticFeedback?: boolean;
disabled?: boolean;
}
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
@@ -25,7 +24,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
fillColor,
color = "white",
hapticFeedback = true,
disabled = false,
...viewProps
}) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
@@ -33,7 +31,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
const lightHapticFeedback = useHaptic("light");
const handlePress = () => {
if (disabled) return;
if (hapticFeedback) {
lightHapticFeedback();
}
@@ -44,8 +41,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return (
<Pressable
onPress={handlePress}
disabled={disabled}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
>
{icon ? (
@@ -64,8 +60,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return (
<Pressable
onPress={handlePress}
disabled={disabled}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
>
{icon ? (
@@ -83,8 +78,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return (
<Pressable
onPress={handlePress}
disabled={disabled}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
>
{icon ? (
@@ -102,10 +96,9 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return (
<Pressable
onPress={handlePress}
disabled={disabled}
className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-transparent"
} ${disabled ? "opacity-50" : ""}`}
}`}
{...(viewProps as any)}
>
{icon ? (
@@ -120,14 +113,10 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
);
return (
<Pressable
onPress={handlePress}
disabled={disabled}
{...(viewProps as any)}
>
<Pressable onPress={handlePress} {...(viewProps as any)}>
<BlurView
intensity={90}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
>
{icon ? (

View File

@@ -1,50 +0,0 @@
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
export interface SegmentedToggleOption<T extends string> {
value: T;
label: string;
}
interface SegmentedToggleProps<T extends string> {
options: SegmentedToggleOption<T>[];
value: T;
onChange: (value: T) => void;
}
/**
* Full-width segmented view switcher: the options split the available width
* evenly as chunky segments inside a rounded track. Used to swap between two
* (or more) views, e.g. Streamystats vs KefinTweaks watchlists.
*/
export function SegmentedToggle<T extends string>({
options,
value,
onChange,
}: SegmentedToggleProps<T>) {
return (
<View className='flex-row w-full bg-neutral-800 rounded-xl p-1'>
{options.map((option) => {
const selected = value === option.value;
return (
<TouchableOpacity
key={option.value}
onPress={() => onChange(option.value)}
activeOpacity={0.8}
className={`flex-1 items-center justify-center rounded-lg py-4 ${
selected ? "bg-purple-600" : ""
}`}
>
<Text
className={`text-base font-semibold ${
selected ? "text-white" : "text-neutral-400"
}`}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
);
}

View File

@@ -11,10 +11,8 @@ import {
import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useWatchlist } from "@/hooks/useWatchlist";
import { useDownload } from "@/providers/DownloadProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
@@ -157,8 +155,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
const { settings } = useSettings();
const router = useRouter();
const isOffline = useOfflineMode();
const { deleteFile } = useDownload();
@@ -187,66 +183,36 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
)
return;
// Build options as { label, action } so dynamic entries (watchlist,
// offline delete) don't break index-based handling.
const actions: {
label: string;
action: () => void;
destructive?: boolean;
}[] = [
{
label: t("common.mark_as_played"),
action: () => {
markAsPlayedStatus(true);
},
},
{
label: t("common.mark_as_not_played"),
action: () => {
markAsPlayedStatus(false);
},
},
{
label: isFavorite
? t("music.track_options.remove_from_favorites")
: t("music.track_options.add_to_favorites"),
action: toggleFavorite,
},
const options: string[] = [
t("common.mark_as_played"),
t("common.mark_as_not_played"),
isFavorite
? t("music.track_options.remove_from_favorites")
: t("music.track_options.add_to_favorites"),
...(isOffline ? [t("home.downloads.delete_download")] : []),
t("common.cancel"),
];
if (settings?.useKefinTweaks) {
actions.push({
label: isWatchlisted
? t("watchlists.remove_from_watchlist")
: t("watchlists.add_to_watchlist"),
action: toggleWatchlist,
});
}
if (isOffline && item.Id) {
const id = item.Id;
actions.push({
label: t("home.downloads.delete_download"),
action: () => deleteFile(id),
destructive: true,
});
}
const options = [...actions.map((a) => a.label), t("common.cancel")];
const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = actions.findIndex((a) => a.destructive);
const destructiveButtonIndex = isOffline
? cancelButtonIndex - 1
: undefined;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
destructiveButtonIndex:
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
destructiveButtonIndex,
},
(selectedIndex) => {
if (selectedIndex === undefined || selectedIndex >= actions.length)
return;
actions[selectedIndex].action();
async (selectedIndex) => {
if (selectedIndex === 0) {
await markAsPlayedStatus(true);
} else if (selectedIndex === 1) {
await markAsPlayedStatus(false);
} else if (selectedIndex === 2) {
toggleFavorite();
} else if (isOffline && selectedIndex === 3 && item.Id) {
deleteFile(item.Id);
}
},
);
}, [
@@ -254,9 +220,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isFavorite,
markAsPlayedStatus,
toggleFavorite,
isWatchlisted,
toggleWatchlist,
settings?.useKefinTweaks,
isOffline,
deleteFile,
item.Id,

View File

@@ -1,220 +0,0 @@
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { Platform, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
type FavoriteTypes =
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
const favoriteTypes: readonly FavoriteTypes[] = [
"Series",
"Movie",
"Episode",
"Video",
"BoxSet",
"Playlist",
] as const;
function isFavoriteType(value: unknown): value is FavoriteTypes {
return (
typeof value === "string" &&
(favoriteTypes as readonly string[]).includes(value)
);
}
/**
* Shared "See all" grid for the favorites / KefinTweaks-watchlist rows. Reads
* `type` / `title` / `filter` route params, so it renders identically whether
* mounted under the (favorites) or (watchlists) tab.
*/
export default function FavoritesSeeAll() {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const searchParams = useLocalSearchParams<{
type?: string;
title?: string;
filter?: string;
}>();
const typeParam = searchParams.type;
const titleParam = searchParams.title;
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
const filter: ItemFilter =
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
const itemType = useMemo(() => {
if (!isFavoriteType(typeParam)) return null;
return typeParam as BaseItemKind;
}, [typeParam]);
const headerTitle = useMemo(() => {
if (typeof titleParam === "string" && titleParam.trim().length > 0)
return titleParam;
return "";
}, [titleParam]);
const pageSize = 50;
const fetchItems = useCallback(
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
if (!api || !user?.Id || !itemType) return [];
const response = await getItemsApi(api as Api).getItems({
userId: user.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: [filter],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: true,
startIndex: pageParam,
limit: pageSize,
includeItemTypes: [itemType],
});
return response.data.Items || [];
},
[api, itemType, user?.Id, filter],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["favorites", "see-all", itemType, filter],
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
getNextPageParam: (lastPage, pages) => {
if (!lastPage || lastPage.length < pageSize) return undefined;
return pages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!itemType,
});
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
const nrOfCols = useMemo(() => {
if (screenWidth < 350) return 2;
if (screenWidth < 600) return 3;
if (screenWidth < 900) return 5;
return 6;
}, [screenWidth]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
item={item}
style={{
width: "100%",
}}
>
<View
style={{
alignSelf:
index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[nrOfCols],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const handleEndReached = useCallback(() => {
if (hasNextPage) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage]);
return (
<>
<Stack.Screen
options={{
headerTitle: headerTitle,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
{!itemType ? (
<View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
</View>
) : isLoading ? (
<View className='justify-center items-center h-full'>
<Loader />
</View>
) : (
<FlashList
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={handleEndReached}
onEndReachedThreshold={0.8}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items")}
</Text>
</View>
}
ListFooterComponent={
isFetching ? (
<View style={{ paddingVertical: 16 }}>
<Loader />
</View>
) : null
}
/>
)}
</>
);
}

View File

@@ -1,13 +1,10 @@
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Text, View } from "react-native";
// PNG ASSET
import heart from "@/assets/icons/heart.fill.png";
@@ -23,42 +20,20 @@ type FavoriteTypes =
| "Video"
| "BoxSet"
| "Playlist";
// `null` = not settled yet (loading/unknown); avoids flashing the empty
// message during a favorites/watchlist switch before the new queries resolve.
type EmptyState = Record<FavoriteTypes, boolean | null>;
type EmptyState = Record<FavoriteTypes, boolean>;
interface FavoritesProps {
/** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */
filter?: ItemFilter;
/** Query key segment used to keep favorites/watchlist caches separate. */
queryKeyBase?: string;
emptyTitleKey?: string;
emptyTextKey?: string;
/** Namespace for the see-all page headers ("favorites" or "kefintweaksWatchlist"). */
seeAllNamespace?: "kefintweaksWatchlist" | "favorites";
/** Route the "See all" screen lives at; defaults to the favorites tab. */
seeAllPathname?: string;
}
export const Favorites = ({
filter = "IsFavorite",
queryKeyBase = "favorites",
emptyTitleKey = "favorites.noDataTitle",
emptyTextKey = "favorites.noData",
seeAllNamespace = "favorites",
seeAllPathname = "/(auth)/(tabs)/(favorites)/see-all",
}: FavoritesProps = {}) => {
export const Favorites = () => {
const router = useRouter();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const pageSize = 20;
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: null,
Movie: null,
Episode: null,
Video: null,
BoxSet: null,
Playlist: null,
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
const fetchFavoritesByType = useCallback(
@@ -71,7 +46,7 @@ export const Favorites = ({
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: [filter],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -81,28 +56,39 @@ export const Favorites = ({
limit: limit,
includeItemTypes: [itemType],
});
return response.data.Items || [];
const items = response.data.Items || [];
// Update empty state for this specific type only for the first page
if (startIndex === 0) {
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
}
return items;
},
[api, user, filter],
[api, user],
);
// Emptiness is reported by each list once its query settles (incl. cache
// hits), so it stays correct where a queryFn side effect would go stale.
const setTypeEmpty = useCallback(
(type: FavoriteTypes, isEmpty: boolean | null) =>
setEmptyState((prev) =>
prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty },
),
[],
);
// Reset empty state when component mounts or dependencies change
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user]);
// Show the empty message only once every category has settled AND is empty.
// A `null` (still loading) keeps it hidden, so switching favorites/watchlist
// (props swap in place, no remount) never flashes a stale empty state.
// Check if all categories that have been loaded are empty
const areAllEmpty = () => {
const categories = Object.values(emptyState);
const loadedCategories = Object.values(emptyState);
return (
categories.length > 0 && categories.every((isEmpty) => isEmpty === true)
loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
);
};
@@ -137,26 +123,47 @@ export const Favorites = ({
[fetchFavoritesByType, pageSize],
);
// Navigate to the shared see-all screen. `name` is the capitalized type
// suffix of the see-all header key (e.g. "Series" -> "seeAllSeries").
// The namespace is branched explicitly so each t() call has a static prefix
// (favorites.seeAll* / kefintweaksWatchlist.seeAll*) that the i18n usage
// checker can detect — see scripts/check-i18n-keys.mjs. The `as any` is
// needed because the route's custom params aren't part of expo-router's
// typed Href.
const seeAll = useCallback(
(type: FavoriteTypes, name: string) => {
const title =
seeAllNamespace === "kefintweaksWatchlist"
? t(`kefintweaksWatchlist.seeAll${name}`)
: t(`favorites.seeAll${name}`);
router.push({
pathname: seeAllPathname,
params: { type, title, filter },
} as any);
},
[router, filter, seeAllNamespace, seeAllPathname],
);
const handleSeeAllSeries = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Series", title: t("favorites.series") },
} as any);
}, [router]);
const handleSeeAllMovies = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Movie", title: t("favorites.movies") },
} as any);
}, [router]);
const handleSeeAllEpisodes = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Episode", title: t("favorites.episodes") },
} as any);
}, [router]);
const handleSeeAllVideos = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Video", title: t("favorites.videos") },
} as any);
}, [router]);
const handleSeeAllBoxsets = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "BoxSet", title: t("favorites.boxsets") },
} as any);
}, [router]);
const handleSeeAllPlaylists = useCallback(() => {
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
params: { type: "Playlist", title: t("favorites.playlists") },
} as any);
}, [router]);
return (
<View className='flex flex-co gap-y-4'>
@@ -169,67 +176,61 @@ export const Favorites = ({
source={heart}
/>
<Text className='text-xl font-semibold text-white mb-2'>
{t(emptyTitleKey)}
{t("favorites.noDataTitle")}
</Text>
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
{t(emptyTextKey)}
{t("favorites.noData")}
</Text>
</View>
)}
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", queryKeyBase, "series"]}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
onPressSeeAll={() => seeAll("Series", "Series")}
onPressSeeAll={handleSeeAllSeries}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", queryKeyBase, "movies"]}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
onPressSeeAll={() => seeAll("Movie", "Movies")}
onPressSeeAll={handleSeeAllMovies}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", queryKeyBase, "episodes"]}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
onPressSeeAll={() => seeAll("Episode", "Episodes")}
onPressSeeAll={handleSeeAllEpisodes}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", queryKeyBase, "videos"]}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
onPressSeeAll={() => seeAll("Video", "Videos")}
onPressSeeAll={handleSeeAllVideos}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", queryKeyBase, "boxsets"]}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
onPressSeeAll={() => seeAll("BoxSet", "Boxsets")}
onPressSeeAll={handleSeeAllBoxsets}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", queryKeyBase, "playlists"]}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
onPressSeeAll={() => seeAll("Playlist", "Playlists")}
onPressSeeAll={handleSeeAllPlaylists}
/>
</View>
);

View File

@@ -1,12 +1,9 @@
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -16,7 +13,6 @@ import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrol
import { Colors } from "@/constants/Colors";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { scaleSize } from "@/utils/scaleSize";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
@@ -29,47 +25,22 @@ type FavoriteTypes =
| "Video"
| "BoxSet"
| "Playlist";
// `null` = not settled yet (loading/unknown); avoids flashing the empty
// message during a favorites/watchlist switch before the new queries resolve.
type EmptyState = Record<FavoriteTypes, boolean | null>;
type EmptyState = Record<FavoriteTypes, boolean>;
interface FavoritesProps {
/** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */
filter?: ItemFilter;
/** Query key segment used to keep favorites/watchlist caches separate. */
queryKeyBase?: string;
emptyTitleKey?: string;
emptyTextKey?: string;
/** false when a toggle sits above these lists (so the toggle takes first focus). */
isFirstSection?: boolean;
/** Overrides the default nav-bar clearance; used when a toggle already clears it. */
contentTopPadding?: number;
}
export const Favorites = ({
filter = "IsFavorite",
queryKeyBase = "favorites",
emptyTitleKey = "favorites.noDataTitle",
emptyTextKey = "favorites.noData",
isFirstSection = true,
contentTopPadding,
}: FavoritesProps = {}) => {
export const Favorites = () => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const pageSize = 20;
const topPadding = contentTopPadding ?? insets.top + TOP_PADDING;
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: null,
Movie: null,
Episode: null,
Video: null,
BoxSet: null,
Playlist: null,
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
const fetchFavoritesByType = useCallback(
@@ -82,7 +53,7 @@ export const Favorites = ({
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: [filter],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
@@ -92,28 +63,36 @@ export const Favorites = ({
limit: limit,
includeItemTypes: [itemType],
});
return response.data.Items || [];
const items = response.data.Items || [];
if (startIndex === 0) {
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
}
return items;
},
[api, user, filter],
[api, user],
);
// Emptiness is reported by each list once its query settles (incl. cache
// hits), so it stays correct where a queryFn side effect would go stale.
const setTypeEmpty = useCallback(
(type: FavoriteTypes, isEmpty: boolean | null) =>
setEmptyState((prev) =>
prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty },
),
[],
);
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user]);
// Show the empty message only once every category has settled AND is empty.
// A `null` (still loading) keeps it hidden, so switching favorites/watchlist
// never flashes a stale empty state.
const areAllEmpty = () => {
const categories = Object.values(emptyState);
const loadedCategories = Object.values(emptyState);
return (
categories.length > 0 && categories.every((isEmpty) => isEmpty === true)
loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
);
};
@@ -148,110 +127,103 @@ export const Favorites = ({
[fetchFavoritesByType, pageSize],
);
if (areAllEmpty()) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Image
style={{
width: 64,
height: 64,
marginBottom: 16,
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t("favorites.noDataTitle")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
{t("favorites.noData")}
</Text>
</View>
);
}
return (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: topPadding,
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
flexGrow: 1,
}}
>
<View style={{ gap: SECTION_GAP, flex: 1 }}>
{/* Rendered alongside the lists (never instead of them) so they stay
mounted and re-report emptiness on a favorites/watchlist switch;
an early return here would freeze the all-empty state. */}
{areAllEmpty() && (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Image
style={{
width: scaleSize(64),
height: scaleSize(64),
marginBottom: scaleSize(16),
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t(emptyTitleKey)}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
{t(emptyTextKey)}
</Text>
</View>
)}
<View style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", queryKeyBase, "series"]}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
isFirstSection={isFirstSection}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
isFirstSection
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", queryKeyBase, "movies"]}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", queryKeyBase, "episodes"]}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", queryKeyBase, "videos"]}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", queryKeyBase, "boxsets"]}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", queryKeyBase, "playlists"]}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
/>
</View>
</ScrollView>

View File

@@ -33,13 +33,6 @@ interface Props extends ViewProps {
onPressSeeAll?: () => void;
enabled?: boolean;
onLoaded?: () => void;
/**
* Reports emptiness whenever the query settles (incl. cache hits):
* `null` while loading (unknown), otherwise whether the list is empty.
* Lets a parent derive an aggregate empty-state reactively instead of via a
* queryFn side effect, which React Query skips when it serves cache.
*/
onEmptyStateChange?: (isEmpty: boolean | null) => void;
}
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
@@ -53,7 +46,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onPressSeeAll,
enabled = true,
onLoaded,
onEmptyStateChange,
...props
}) => {
const effectivePageSize = Math.max(1, pageSize);
@@ -61,7 +53,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const {
data,
isLoading,
isError,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
@@ -114,17 +105,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped;
}, [data]);
// Report emptiness on every settle (incl. cache hits). Errors report null
// (unknown) so a failed fetch never reads as "no content". Callback held in
// a ref so an inline parent callback doesn't retrigger the effect each render.
const onEmptyStateChangeRef = useRef(onEmptyStateChange);
onEmptyStateChangeRef.current = onEmptyStateChange;
useEffect(() => {
onEmptyStateChangeRef.current?.(
isLoading || isError ? null : allItems.length === 0,
);
}, [isLoading, isError, allItems.length]);
const snapOffsets = useMemo(() => {
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
return allItems.map((_, index) => index * itemWidth);

View File

@@ -6,7 +6,7 @@ import {
useInfiniteQuery,
} from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -43,13 +43,6 @@ interface Props extends ViewProps {
isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
parentId?: string;
/**
* Reports emptiness whenever the query settles (incl. cache hits):
* `null` while loading (unknown), otherwise whether the list is empty.
* Lets a parent derive an aggregate empty-state reactively instead of via a
* queryFn side effect, which React Query skips when it serves cache.
*/
onEmptyStateChange?: (isEmpty: boolean | null) => void;
}
type Typography = ReturnType<typeof useScaledTVTypography>;
@@ -131,7 +124,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
isFirstSection = false,
onItemFocus,
parentId,
onEmptyStateChange,
...props
}) => {
const typography = useScaledTVTypography();
@@ -154,30 +146,24 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
[onItemFocus],
);
const {
data,
isLoading,
isError,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
const { t } = useTranslation();
const { settings } = useSettings();
@@ -198,17 +184,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped;
}, [data]);
// Report emptiness on every settle (incl. cache hits). Errors report null
// (unknown) so a failed fetch never reads as "no content". Callback held in
// a ref so an inline parent callback doesn't retrigger the effect each render.
const onEmptyStateChangeRef = useRef(onEmptyStateChange);
onEmptyStateChangeRef.current = onEmptyStateChange;
useEffect(() => {
onEmptyStateChangeRef.current?.(
isLoading || isError ? null : allItems.length === 0,
);
}, [isLoading, isError, allItems.length]);
const itemWidth =
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;

View File

@@ -1,109 +0,0 @@
import type React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVSegmentBadgeProps {
label: string;
isSelected: boolean;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}
const TVSegmentBadge: React.FC<TVSegmentBadgeProps> = ({
label,
isSelected,
onPress,
hasTVPreferredFocus = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ duration: 150 });
// Design language: white for focused/selected, transparent white for unfocused
const getBackgroundColor = () => {
if (focused) return "#fff";
if (isSelected) return "rgba(255,255,255,0.25)";
return "rgba(255,255,255,0.1)";
};
const getTextColor = () => {
if (focused) return "#000";
return "#fff";
};
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 24,
backgroundColor: getBackgroundColor(),
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<Text
style={{
fontSize: typography.callout,
color: getTextColor(),
fontWeight: isSelected || focused ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export interface TVSegmentedControlOption<T extends string> {
value: T;
label: string;
}
interface TVSegmentedControlProps<T extends string> {
options: TVSegmentedControlOption<T>[];
value: T;
onChange: (value: T) => void;
/** When true, the currently-selected badge receives initial TV focus. */
hasTVPreferredFocus?: boolean;
}
/**
* Focusable TV segmented control (design: white focus, blurred pill unfocused).
* Generalized from the former TVFavoritesTabBadges so it can drive any
* segmented swap on TV. Only the selected badge takes preferred focus.
*/
export function TVSegmentedControl<T extends string>({
options,
value,
onChange,
hasTVPreferredFocus = false,
}: TVSegmentedControlProps<T>) {
return (
<View style={{ flexDirection: "row", gap: 16, marginBottom: 24 }}>
{options.map((option) => (
<TVSegmentBadge
key={option.value}
label={option.label}
isSelected={value === option.value}
onPress={() => onChange(option.value)}
hasTVPreferredFocus={hasTVPreferredFocus && value === option.value}
/>
))}
</View>
);
}

View File

@@ -1,36 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { useWatchlist } from "@/hooks/useWatchlist";
import { TVButton } from "./TVButton";
export interface TVWatchlistButtonProps {
item: BaseItemDto;
disabled?: boolean;
}
/**
* KefinTweaks watchlist toggle (Likes-backed) for TV detail pages.
* Render only when settings.useKefinTweaks is enabled.
*/
export const TVWatchlistButton: React.FC<TVWatchlistButtonProps> = ({
item,
disabled,
}) => {
const { isWatchlisted, toggleWatchlist, isPending } = useWatchlist(item);
return (
<TVButton
onPress={toggleWatchlist}
variant='glass'
square
disabled={disabled || isPending}
>
<Ionicons
name={isWatchlisted ? "bookmark" : "bookmark-outline"}
size={28}
color='#FFFFFF'
/>
</TVButton>
);
};

View File

@@ -70,5 +70,3 @@ export { TVTrackCard } from "./TVTrackCard";
// User switching
export type { TVUserCardProps } from "./TVUserCard";
export { TVUserCard } from "./TVUserCard";
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
export { TVWatchlistButton } from "./TVWatchlistButton";

View File

@@ -15,7 +15,6 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble";
@@ -34,11 +33,8 @@ interface BottomControlsProps {
showRemoteBubble: boolean;
currentTime: number;
remainingTime: number;
showSkipButton: boolean;
showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
nextItem?: BaseItemDto | null;
handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void;
@@ -86,11 +82,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble,
currentTime,
remainingTime,
showSkipButton,
showSkipCreditButton,
hasContentAfterCredits,
skipIntro,
skipCredit,
nextItem,
handleNextEpisodeAutoPlay,
handleNextEpisodeManual,
@@ -180,21 +173,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null}
</View>
<View className='flex flex-row items-center space-x-2 shrink-0'>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
buttonText={t("player.skip_intro")}
/>
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
- Show "Next Episode" if credits extend to video end AND next episode exists */}
<SkipButton
showButton={
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText={t("player.skip_credits")}
/>
{settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount <

View File

@@ -39,6 +39,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime";
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -111,6 +112,12 @@ export const Controls: FC<Props> = ({
const [episodeView, setEpisodeView] = useState(false);
const [showAudioSlider, setShowAudioSlider] = useState(false);
// Set when the user manually presses Skip Credits. Once set, the next-episode
// countdown is allowed to appear but will NOT auto-fire — the user must tap
// it. This prevents a (possibly wrong) credit end timestamp from landing in
// the end zone and auto-advancing to the next episode. Resets on unmount
// (navigation to the next episode or away).
const [suppressAutoNextEpisode, setSuppressAutoNextEpisode] = useState(false);
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
const { previousItem, nextItem } = usePlaybackManager({
@@ -355,6 +362,23 @@ export const Controls: FC<Props> = ({
maxMs,
);
// Whether the "Next Episode" countdown will actually be rendered. The Skip
// Credits button yields to it only when this is true; if autoplay is
// disabled or its episode limit is reached, Skip Credits must stay available
// (mirrors the NextEpisodeCountDownButton mount gate in BottomControls).
const willShowNextEpisode =
!!nextItem &&
settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
// Wrap skipCredit so a manual press also arms the auto-advance suppression
// (see suppressAutoNextEpisode). The underlying seek is unchanged.
const handleSkipCredit = useCallback(() => {
setSuppressAutoNextEpisode(true);
skipCredit();
}, [skipCredit]);
const goToItemCommon = useCallback(
(item: BaseItemDto) => {
if (!item || !settings) {
@@ -464,8 +488,12 @@ export const Controls: FC<Props> = ({
// Add a memoized handler for autoplay next episode
const handleNextEpisodeAutoPlay = useCallback(() => {
// If the user manually skipped credits, don't auto-advance — let the
// countdown sit so they can tap it. Guards against wrong credit end
// timestamps landing in the end zone and skipping the episode's tail.
if (suppressAutoNextEpisode) return;
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
}, [goToNextItem, suppressAutoNextEpisode]);
// Add a memoized handler for manual next episode
const handleNextEpisodeManual = useCallback(() => {
@@ -587,11 +615,8 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble}
currentTime={currentTime}
remainingTime={remainingTime}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual}
@@ -611,6 +636,17 @@ export const Controls: FC<Props> = ({
time={isSliding || showRemoteBubble ? time : remoteTime}
/>
</Animated.View>
{/* Skip Intro / Skip Credits float independently of the controls so
they're visible (and tappable) without summoning the controls. */}
<SkipSegmentOverlay
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
willShowNextEpisode={willShowNextEpisode}
skipIntro={skipIntro}
skipCredit={handleSkipCredit}
controlsVisible={showControls}
/>
</>
)}
{settings.maxAutoPlayEpisodeCount.value !== -1 && (

View File

@@ -29,7 +29,6 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import {
TVControlButton,
@@ -82,7 +81,6 @@ interface Props {
subtitleIndex?: number;
onAudioIndexChange?: (index: number) => void;
onSubtitleIndexChange?: (index: number) => void;
onBitrateChange?: (bitrate: number | undefined) => void;
previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null;
goToPreviousItem?: () => void;
@@ -209,7 +207,6 @@ export const Controls: FC<Props> = ({
subtitleIndex,
onAudioIndexChange,
onSubtitleIndexChange,
onBitrateChange,
previousItem,
nextItem: nextItemProp,
goToPreviousItem,
@@ -258,12 +255,18 @@ export const Controls: FC<Props> = ({
const { subtitleTracks: videoContextSubtitleTracks } = useVideoContext();
// Track which button should have preferred focus when controls show
type LastModalType = "audio" | "subtitle" | "quality" | "techInfo" | null;
type LastModalType = "audio" | "subtitle" | "techInfo" | null;
const [lastOpenedModal, setLastOpenedModal] = useState<LastModalType>(null);
// Track if play button should have focus (when showing controls via up/down D-pad)
const [focusPlayButton, setFocusPlayButton] = useState(false);
// Set when the user manually presses Skip Credits. Once set, the next-episode
// countdown is allowed to appear but will NOT auto-fire — the user must select
// it. Prevents a (possibly wrong) credit end timestamp from landing in the end
// zone and auto-advancing to the next episode. Resets on unmount.
const [suppressAutoNextEpisode, setSuppressAutoNextEpisode] = useState(false);
// State for progress bar focus and focus guide refs
const [isProgressBarFocused, setIsProgressBarFocused] = useState(false);
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
@@ -307,24 +310,6 @@ export const Controls: FC<Props> = ({
[onAudioIndexChange],
);
// Quality options mirror the mobile menu: value is the max bitrate as a
// string, "" meaning Max (no limit) — same encoding as the bitrateValue
// route param, so selection matching is a plain string compare.
const bitrateOptions: TVOptionItem<string>[] = useMemo(() => {
return BITRATES.map((bitrate) => ({
label: bitrate.key,
value: bitrate.value?.toString() ?? "",
selected: (bitrateValue ?? "") === (bitrate.value?.toString() ?? ""),
}));
}, [bitrateValue]);
const handleBitrateChange = useCallback(
(value: string) => {
onBitrateChange?.(value ? Number.parseInt(value, 10) : undefined);
},
[onBitrateChange],
);
const _handleSubtitleChange = useCallback(
(index: number) => {
onSubtitleIndexChange?.(index);
@@ -595,19 +580,6 @@ export const Controls: FC<Props> = ({
controlsInteractionRef.current();
}, [showOptions, t, audioOptions, handleAudioChange]);
const handleOpenQualitySheet = useCallback(() => {
setLastOpenedModal("quality");
showOptions({
title: t("item_card.quality"),
options: bitrateOptions,
onSelect: handleBitrateChange,
// Changing quality replaces the player route (stream re-negotiation);
// apply it after the modal is dismissed so it isn't swallowed.
deferApplyUntilDismissed: true,
});
controlsInteractionRef.current();
}, [showOptions, t, bitrateOptions, handleBitrateChange]);
const handleLocalSubtitleDownloaded = useCallback(
(path: string) => {
addSubtitleFile?.(path);
@@ -1124,10 +1096,21 @@ export const Controls: FC<Props> = ({
goToNextItemRef.current = goToNextItem;
// Wrap skipCredit so a manual press also arms the auto-advance suppression
// (see suppressAutoNextEpisode). The underlying seek is unchanged.
const handleSkipCredit = useCallback(() => {
setSuppressAutoNextEpisode(true);
skipCredit();
}, [skipCredit]);
const handleAutoPlayFinish = useCallback(() => {
if (exitingRef.current) return;
// If the user manually skipped credits, don't auto-advance — let the
// countdown sit so they can select it. Guards against wrong credit end
// timestamps landing in the end zone and skipping the episode's tail.
if (suppressAutoNextEpisode) return;
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
}, [goToNextItem, suppressAutoNextEpisode]);
const topOverlayFocusTarget = skipSegmentRef ?? nextEpisodeRef;
@@ -1187,7 +1170,7 @@ export const Controls: FC<Props> = ({
(hasContentAfterCredits || !nextItem) &&
!isCountdownActive
}
onPress={skipCredit}
onPress={handleSkipCredit}
type='credits'
controlsVisible={showControls}
refSetter={setSkipSegmentRef}
@@ -1405,17 +1388,6 @@ export const Controls: FC<Props> = ({
<View style={styles.controlButtonsSpacer} />
{onBitrateChange && !offline && !isLiveTV && (
<TVControlButton
icon='speedometer'
onPress={handleOpenQualitySheet}
hasTVPreferredFocus={
!isCountdownActive && lastOpenedModal === "quality"
}
size={24}
/>
)}
{audioOptions.length > 0 && (
<TVControlButton
icon='volume-high'

View File

@@ -0,0 +1,125 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import { StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import SkipButton from "./SkipButton";
interface Props {
showSkipButton: boolean;
showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
willShowNextEpisode: boolean;
skipIntro: () => void;
skipCredit: () => void;
controlsVisible: boolean;
}
// Offsets are relative to the safe-area insets so they hold in both portrait
// and landscape (the insets move with the notch / home indicator).
//
// Hidden: low, far-right — nothing else is drawn there, this is the familiar
// spot and was working fine.
// Visible: shifted up (clear of the horizontal progress bar) and left (clear
// of the chapters icon), while staying below the vertical volume
// slider which sits at the vertical middle of the screen.
const HIDDEN_BOTTOM = 24;
const HIDDEN_RIGHT = 12;
const VISIBLE_BOTTOM = 65;
const VISIBLE_RIGHT = 42;
const ANIM_DURATION = 250;
// Keeps `value` true for `duration` ms after it turns false. SkipButton hides
// itself instantly via a `hidden` (display:none) class, which would preempt
// the parent's opacity fade-out — lagging the flag keeps the button rendered
// (and visible) while the fade plays out.
const useDelayedHide = (value: boolean, duration: number): boolean => {
const [display, setDisplay] = useState(value);
useEffect(() => {
if (value) {
setDisplay(true);
return;
}
const t = setTimeout(() => setDisplay(false), duration);
return () => clearTimeout(t);
}, [value, duration]);
return value || display;
};
/**
* Floating Skip Intro / Skip Credits buttons shown independently of the
* player controls. They appear on their own during an intro or credits segment
* without the user having to summon the controls.
*/
export const SkipSegmentOverlay: FC<Props> = ({
showSkipButton,
showSkipCreditButton,
hasContentAfterCredits,
willShowNextEpisode,
skipIntro,
skipCredit,
controlsVisible,
}) => {
const insets = useControlsSafeAreaInsets();
const showCredit =
showSkipCreditButton && (hasContentAfterCredits || !willShowNextEpisode);
const visible = showSkipButton || showCredit;
// Drive each SkipButton with a lagged flag so it stays visible while the
// opacity fade-out plays, instead of disappearing the instant its segment
// ends. `visible` above still drives opacity/pointerEvents immediately.
const renderSkip = useDelayedHide(showSkipButton, ANIM_DURATION);
const renderCredit = useDelayedHide(showCredit, ANIM_DURATION);
const opacity = useSharedValue(visible ? 1 : 0);
useEffect(() => {
opacity.value = withTiming(visible ? 1 : 0, {
duration: ANIM_DURATION,
easing: Easing.out(Easing.quad),
});
}, [visible, opacity]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
// Position is recomputed on render (no slide animation) so the button never
// sweeps through an overlap zone while the controls toggle.
const bottom =
insets.bottom + (controlsVisible ? VISIBLE_BOTTOM : HIDDEN_BOTTOM);
const right = insets.right + (controlsVisible ? VISIBLE_RIGHT : HIDDEN_RIGHT);
return (
<Animated.View
style={[styles.container, { right, bottom }, animatedStyle]}
pointerEvents={visible ? "box-none" : "none"}
>
<SkipButton
showButton={renderSkip}
onPress={skipIntro}
buttonText='Skip Intro'
/>
<SkipButton
showButton={renderCredit}
onPress={skipCredit}
buttonText='Skip Credits'
/>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: "absolute",
flexDirection: "row",
gap: 8,
zIndex: 15,
},
});

View File

@@ -1,238 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { FlashList } from "@shopify/flash-list";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import {
useStreamystatsEnabled,
useWatchlistsQuery,
} from "@/hooks/useWatchlists";
import { userAtom } from "@/providers/JellyfinProvider";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
interface WatchlistCardProps {
watchlist: StreamystatsWatchlist;
isOwner: boolean;
onPress: () => void;
}
const WatchlistCard: React.FC<WatchlistCardProps> = ({
watchlist,
isOwner,
onPress,
}) => {
const { t } = useTranslation();
return (
<TouchableOpacity
onPress={onPress}
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
activeOpacity={0.7}
>
<View className='flex-row items-center justify-between mb-2'>
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
{watchlist.name}
</Text>
<View className='flex-row items-center gap-2'>
{isOwner && (
<View className='bg-purple-600/20 px-2 py-1 rounded'>
<Text className='text-purple-400 text-xs'>
{t("watchlists.you")}
</Text>
</View>
)}
<Ionicons
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
size={16}
color='#9ca3af'
/>
</View>
</View>
{watchlist.description && (
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
{watchlist.description}
</Text>
)}
<View className='flex-row items-center gap-4'>
<View className='flex-row items-center gap-1'>
<Ionicons name='film-outline' size={14} color='#9ca3af' />
<Text className='text-neutral-400 text-sm'>
{watchlist.itemCount ?? 0}{" "}
{(watchlist.itemCount ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
{watchlist.allowedItemType && (
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
<Text className='text-neutral-400 text-xs'>
{watchlist.allowedItemType}
</Text>
</View>
)}
</View>
</TouchableOpacity>
);
};
const EmptyState: React.FC = () => {
const { t } = useTranslation();
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='list-outline' size={64} color='#4b5563' />
<Text className='text-xl font-semibold mt-4 text-center'>
{t("watchlists.empty_title")}
</Text>
<Text className='text-neutral-400 text-center mt-2 mb-6'>
{t("watchlists.empty_description")}
</Text>
</View>
);
};
const NotConfiguredState: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='settings-outline' size={64} color='#4b5563' />
<Text className='text-xl font-semibold mt-4 text-center'>
{t("watchlists.not_configured_title")}
</Text>
<Text className='text-neutral-400 text-center mt-2 mb-6'>
{t("watchlists.not_configured_description")}
</Text>
<Button
onPress={() =>
router.push(
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
)
}
className='px-6'
>
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
</Button>
</View>
);
};
/**
* Streamystats (plugin-backed) custom/shared watchlists list. Extracted from
* the watchlists tab index so the tab can switch between this and the
* KefinTweaks (Likes) view.
*/
export const StreamystatsWatchlists: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const user = useAtomValue(userAtom);
const streamystatsEnabled = useStreamystatsEnabled();
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
}, [refetch]);
const handleWatchlistPress = useCallback(
(watchlistId: number) => {
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
},
[router],
);
// Separate watchlists into "mine" and "public"
const { myWatchlists, publicWatchlists } = useMemo(() => {
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
const mine: StreamystatsWatchlist[] = [];
const pub: StreamystatsWatchlist[] = [];
for (const w of watchlists) {
if (w.userId === user?.Id) {
mine.push(w);
} else {
pub.push(w);
}
}
return { myWatchlists: mine, publicWatchlists: pub };
}, [watchlists, user?.Id]);
// Combine into sections for FlashList
const sections = useMemo(() => {
const result: Array<
| { type: "header"; title: string }
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
> = [];
if (myWatchlists.length > 0) {
result.push({ type: "header", title: t("watchlists.my_watchlists") });
for (const w of myWatchlists) {
result.push({ type: "watchlist", data: w, isOwner: true });
}
}
if (publicWatchlists.length > 0) {
result.push({ type: "header", title: t("watchlists.public_watchlists") });
for (const w of publicWatchlists) {
result.push({ type: "watchlist", data: w, isOwner: false });
}
}
return result;
}, [myWatchlists, publicWatchlists, t]);
if (!streamystatsEnabled) {
return <NotConfiguredState />;
}
if (!isLoading && (!watchlists || watchlists.length === 0)) {
return <EmptyState />;
}
return (
<FlashList
data={sections}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 10 : 0,
paddingBottom: 100,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={({ item }) => {
if (item.type === "header") {
return (
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
{item.title}
</Text>
);
}
return (
<WatchlistCard
watchlist={item.data}
isOwner={item.isOwner}
onPress={() => handleWatchlistPress(item.data.id)}
/>
);
}}
getItemType={(item) => item.type}
/>
);
};

View File

@@ -1,156 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { toast } from "sonner-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Shared atom to store watchlist (Likes) status across all components
// Maps itemId -> isWatchlisted
const watchlistAtom = atom<Record<string, boolean>>({});
/**
* KefinTweaks watchlist is backed by Jellyfin's native "Likes" rating.
* Toggling watchlist membership toggles UserData.Likes on the item.
*/
export const useWatchlist = (item: BaseItemDto) => {
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [watchlist, setWatchlist] = useAtom(watchlistAtom);
const watchlistKey = user?.Id && item.Id ? `${user.Id}:${item.Id}` : "";
// Get current watchlist status from shared state, falling back to item data
const isWatchlisted = watchlistKey
? (watchlist[watchlistKey] ?? item.UserData?.Likes)
: item.UserData?.Likes;
// Update shared state when item data changes
useEffect(() => {
if (watchlistKey && item.UserData?.Likes !== undefined) {
setWatchlist((prev) => ({
...prev,
[watchlistKey]: item.UserData!.Likes!,
}));
}
}, [watchlistKey, item.UserData?.Likes, setWatchlist]);
// Helper to update watchlist status in shared state
const setIsWatchlisted = useCallback(
(value: boolean | null | undefined) => {
if (watchlistKey && typeof value === "boolean") {
setWatchlist((prev) => ({ ...prev, [watchlistKey]: value }));
}
},
[watchlistKey, setWatchlist],
);
// Use refs to avoid stale closure issues in mutationFn
const itemRef = useRef(item);
const apiRef = useRef(api);
const userRef = useRef(user);
// Keep refs updated
useEffect(() => {
itemRef.current = item;
}, [item]);
useEffect(() => {
apiRef.current = api;
}, [api]);
useEffect(() => {
userRef.current = user;
}, [user]);
const itemQueryKeyPrefix = useMemo(
() => ["item", item.Id] as const,
[item.Id],
);
const updateItemInQueries = useCallback(
(newData: Partial<BaseItemDto>) => {
queryClient.setQueriesData<BaseItemDto | null | undefined>(
{ queryKey: itemQueryKeyPrefix },
(old) => {
if (!old) return old;
return {
...old,
...newData,
UserData: { ...old.UserData, ...newData.UserData },
};
},
);
},
[itemQueryKeyPrefix, queryClient],
);
const watchlistMutation = useMutation({
mutationFn: async (nextIsWatchlisted: boolean) => {
const currentApi = apiRef.current;
const currentUser = userRef.current;
const currentItem = itemRef.current;
if (!currentApi || !currentUser?.Id || !currentItem?.Id) {
throw new Error("Cannot update watchlist: not signed in");
}
// Watchlist == Jellyfin "Likes" rating:
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=true - add to watchlist
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=false - remove from watchlist
const path = `/UserItems/${currentItem.Id}/Rating`;
const response = await currentApi.post(
path,
{},
{ params: { userId: currentUser.Id, likes: nextIsWatchlisted } },
);
return response.data;
},
onMutate: async (nextIsWatchlisted: boolean) => {
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
const previousIsWatchlisted = isWatchlisted;
const previousQueries = queryClient.getQueriesData<BaseItemDto | null>({
queryKey: itemQueryKeyPrefix,
});
setIsWatchlisted(nextIsWatchlisted);
updateItemInQueries({ UserData: { Likes: nextIsWatchlisted } });
return { previousIsWatchlisted, previousQueries };
},
onError: (error: Error, _nextIsWatchlisted, context) => {
// Roll back the optimistic Likes flip applied in onMutate.
if (context?.previousQueries) {
for (const [queryKey, data] of context.previousQueries) {
queryClient.setQueryData(queryKey, data);
}
}
setIsWatchlisted(context?.previousIsWatchlisted);
toast.error(error.message || "Failed to update watchlist");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
queryClient.invalidateQueries({ queryKey: ["home", "watchlist"] });
// The favorites/watchlist "see all" grid keeps its own infinite query
// (["favorites", "see-all", ...]); invalidate it so removing an item
// from within the see-all screen updates the list in place.
queryClient.invalidateQueries({ queryKey: ["favorites", "see-all"] });
},
});
const toggleWatchlist = useCallback(() => {
// Ignore taps while a flip is in flight so overlapping requests can't
// race and leave Jellyfin's Likes value out of sync with the UI.
if (watchlistMutation.isPending) return;
watchlistMutation.mutate(!isWatchlisted);
}, [watchlistMutation, isWatchlisted]);
return {
isWatchlisted,
toggleWatchlist,
isPending: watchlistMutation.isPending,
watchlistMutation,
};
};

View File

@@ -85,7 +85,7 @@ const optIdx = lines.findIndex(
if (optIdx === -1)
throw new Error(`options: not found after id: ${DROPDOWN_ID}`);
const itemIndent = `${lines[optIdx].match(/^\s*/)[0]} `; // options items are nested one level deeper
const itemIndent = lines[optIdx].match(/^\s*/)[0] + " "; // options items are nested one level deeper
let end = optIdx + 1;
const sentinels = [];
while (end < lines.length && /^\s*-\s+/.test(lines[end])) {

View File

@@ -616,25 +616,9 @@
"videos": "Videos",
"boxsets": "Box sets",
"playlists": "Playlists",
"seeAllSeries": "Favorited Series",
"seeAllMovies": "Favorited Movies",
"seeAllEpisodes": "Favorited Episodes",
"seeAllVideos": "Favorited Videos",
"seeAllBoxsets": "Favorited Box sets",
"seeAllPlaylists": "Favorited Playlists",
"noDataTitle": "No favorites yet",
"noData": "Mark items as favorites to see them appear here for quick access."
},
"kefintweaksWatchlist": {
"seeAllSeries": "Watchlisted Series",
"seeAllMovies": "Watchlisted Movies",
"seeAllEpisodes": "Watchlisted Episodes",
"seeAllVideos": "Watchlisted Videos",
"seeAllBoxsets": "Watchlisted Box sets",
"seeAllPlaylists": "Watchlisted Playlists",
"noDataTitle": "No watchlisted items yet",
"noData": "Add items to your watchlist to see them appear here."
},
"custom_links": {
"no_links": "No links"
},
@@ -981,9 +965,7 @@
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
"loading": "Loading watchlists...",
"no_compatible_watchlists": "No compatible watchlists",
"create_one_first": "Create a watchlist that accepts this content type",
"source_streamystats": "Streamystats",
"source_kefintweaks": "Kefintweaks"
"create_one_first": "Create a watchlist that accepts this content type"
},
"playback_speed": {
"title": "Playback speed",

View File

@@ -859,16 +859,6 @@
"date_created": "Datum skapad"
}
},
"kefintweaksWatchlist": {
"seeAllSeries": "Bevakade serier",
"seeAllMovies": "Bevakade filmer",
"seeAllEpisodes": "Bevakade avsnitt",
"seeAllVideos": "Bevakade videor",
"seeAllBoxsets": "Bevakade boxset",
"seeAllPlaylists": "Bevakade spellistor",
"noDataTitle": "Inga bevakade objekt än",
"noData": "Lägg till objekt i din bevakningslista för att se dem här."
},
"watchlists": {
"title": "Bevakningslistor",
"my_watchlists": "Mina bevakningslistor",
@@ -912,9 +902,7 @@
"remove_item_message": "Ta bort \"{{name}}\" från denna bevakningslista?",
"loading": "Laddar bevakningslistor...",
"no_compatible_watchlists": "Inga kompatibla bevakningslistor",
"create_one_first": "Skapa en bevakningslista som accepterar denna innehållstyp",
"source_streamystats": "Streamystats",
"source_kefintweaks": "Kefintweaks"
"create_one_first": "Skapa en bevakningslista som accepterar denna innehållstyp"
},
"playback_speed": {
"title": "Uppspelningshastighet",

View File

@@ -82,6 +82,8 @@ export const useFilterOptions = () => {
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
];
console.log("filterOptions");
console.log(filterOptions);
return filterOptions;
};