rewrite where watchlists ends up and cleanup

This commit is contained in:
Simon Eklundh
2026-07-19 11:29:21 +02:00
parent 9f428336d1
commit 70aece80fc
16 changed files with 822 additions and 644 deletions

View File

@@ -1,37 +1,25 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useSettings } from "@/utils/atoms/settings";
export default function FavoritesPage() {
const invalidateCache = useInvalidatePlaybackProgressCache();
const { t } = useTranslation();
const { settings } = useSettings();
const [loading, setLoading] = useState(false);
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
const watchlistEnabled = settings?.useKefinTweaks ?? false;
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
"Favorites",
);
const refetch = useCallback(async () => {
setLoading(true);
await invalidateCache();
setLoading(false);
}, []);
}, [invalidateCache]);
const insets = useSafeAreaInsets();
if (Platform.isTV) {
return <TVFavorites />;
}
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
return (
<ScrollView
nestedScrollEnabled
@@ -46,26 +34,7 @@ export default function FavoritesPage() {
}}
>
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
{watchlistEnabled && (
<View className='pl-4 pr-4 flex flex-row mb-2'>
<FavoritesTabButtons
viewType={viewType}
setViewType={setViewType}
t={t}
/>
</View>
)}
{isWatchlist ? (
<Favorites
filter='Likes'
queryKeyBase='watchlist'
seeAllNamespace='kefintweaksWatchlist'
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
emptyTextKey='kefintweaksWatchlist.noData'
/>
) : (
<Favorites />
)}
<Favorites />
</View>
</ScrollView>
);

View File

@@ -1,215 +1 @@
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)
);
}
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;
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
}
/>
)}
</>
);
}
export { default } from "@/components/favorites/FavoritesSeeAll";

View File

@@ -1,19 +1,15 @@
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={{
@@ -22,18 +18,6 @@ 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,239 +1,239 @@
import { Ionicons } from "@expo/vector-icons";
import { FlashList } from "@shopify/flash-list";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { Stack } from "expo-router";
import { useHeaderHeight } from "expo-router/react-navigation";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
import { Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
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 useRouter from "@/hooks/useAppRouter";
import {
useStreamystatsEnabled,
useWatchlistsQuery,
} from "@/hooks/useWatchlists";
import { userAtom } from "@/providers/JellyfinProvider";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
import { useSettings } from "@/utils/atoms/settings";
interface WatchlistCardProps {
watchlist: StreamystatsWatchlist;
isOwner: boolean;
onPress: () => void;
const TV_TOP_PADDING = 100;
const TV_HORIZONTAL_PADDING = 60;
type WatchlistSource = "streamystats" | "kefin";
interface WatchlistsViewProps {
streamystatsShown: boolean;
kefinShown: boolean;
}
const WatchlistCard: React.FC<WatchlistCardProps> = ({
watchlist,
isOwner,
onPress,
}) => {
function useToggleOptions() {
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>
return useMemo(
() => [
{
value: "streamystats" as const,
label: t("watchlists.source_streamystats"),
},
{ value: "kefin" as const, label: t("watchlists.source_kefintweaks") },
],
[t],
);
};
}
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>
/**
* 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 NotConfiguredState: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
const activeSource: WatchlistSource = showToggle
? source
: streamystatsShown
? "streamystats"
: "kefin";
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>
);
};
return { source, setSource, activeSource, showToggle };
}
export default function WatchlistsScreen() {
const { t } = useTranslation();
const router = useRouter();
/** Shared KefinTweaks (Likes-backed) view — the favorites grid with a Likes filter. */
function KefinWatchlistView() {
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 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 (
<FlashList
data={sections}
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 10 : 0,
paddingBottom: 100,
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
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>
);
}
>
<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>
);
}
return (
<WatchlistCard
watchlist={item.data}
isOwner={item.isOwner}
onPress={() => handleWatchlistPress(item.data.id)}
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}
/>
);
}}
getItemType={(item) => item.type}
)}
</View>
</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;
if (Platform.isTV) {
return (
<TVWatchlists
streamystatsShown={streamystatsShown}
kefinShown={kefinShown}
/>
);
}
return (
<MobileWatchlists
streamystatsShown={streamystatsShown}
kefinShown={kefinShown}
/>
);
}

View File

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

View File

@@ -61,9 +61,12 @@ function TVTabLayout() {
{ key: "(home)", label: t("tabs.home") },
{ key: "(search)", label: t("tabs.search") },
{ key: "(favorites)", label: t("tabs.favorites") },
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab
? null
: { key: "(watchlists)", label: t("watchlists.title") },
// `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,
{ key: "(libraries)", label: t("tabs.library") },
!settings?.showCustomMenuLinks
? null
@@ -73,6 +76,7 @@ function TVTabLayout() {
[
settings?.streamyStatsServerUrl,
settings?.hideWatchlistsTab,
settings?.useKefinTweaks,
settings?.showCustomMenuLinks,
t,
],
@@ -203,8 +207,13 @@ export default function TabLayout() {
name='(watchlists)'
options={{
title: t("watchlists.title"),
tabBarItemHidden:
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
// Shown when Streamystats (URL set & not hidden) OR KefinTweaks is
// enabled. `hideWatchlistsTab` only suppresses the Streamystats side.
tabBarItemHidden: !(
(settings?.streamyStatsServerUrl &&
!settings?.hideWatchlistsTab) ||
settings?.useKefinTweaks
),
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.star.png")

View File

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,220 @@
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,74 +0,0 @@
import { Platform, TouchableOpacity, View } from "react-native";
import { Tag } from "@/components/GenreTags";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import crashes the route tree on tvOS at module load.
// Load it lazily and only off-TV; TV never renders this component.
const { Button, Host, HStack, Spacer } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { buttonStyle } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
type ViewType = "Favorites" | "Watchlist";
interface FavoritesTabButtonsProps {
viewType: ViewType;
setViewType: (type: ViewType) => void;
t: (key: string) => string;
}
export const FavoritesTabButtons: React.FC<FavoritesTabButtonsProps> = ({
viewType,
setViewType,
t,
}) => {
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<Host style={{ height: 40, flex: 1 }}>
<HStack spacing={8}>
<Button
modifiers={[
buttonStyle(
viewType === "Favorites" ? "glassProminent" : "glass",
),
]}
onPress={() => setViewType("Favorites")}
label={t("tabs.favorites")}
/>
<Button
modifiers={[
buttonStyle(
viewType === "Watchlist" ? "glassProminent" : "glass",
),
]}
onPress={() => setViewType("Watchlist")}
label={t("favorites.watchlist")}
/>
<Spacer />
</HStack>
</Host>
);
}
// Android UI
return (
<View className='flex flex-row gap-1 mr-1'>
<TouchableOpacity onPress={() => setViewType("Favorites")}>
<Tag
text={t("tabs.favorites")}
textClass='p-1'
className={viewType === "Favorites" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setViewType("Watchlist")}>
<Tag
text={t("favorites.watchlist")}
textClass='p-1'
className={viewType === "Watchlist" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
</View>
);
};

View File

@@ -36,6 +36,8 @@ interface FavoritesProps {
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 = ({
@@ -44,6 +46,7 @@ export const Favorites = ({
emptyTitleKey = "favorites.noDataTitle",
emptyTextKey = "favorites.noData",
seeAllNamespace = "favorites",
seeAllPathname = "/(auth)/(tabs)/(favorites)/see-all",
}: FavoritesProps = {}) => {
const router = useRouter();
const [api] = useAtom(apiAtom);
@@ -148,11 +151,11 @@ export const Favorites = ({
? t(`kefintweaksWatchlist.seeAll${name}`)
: t(`favorites.seeAll${name}`);
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all",
pathname: seeAllPathname,
params: { type, title, filter },
} as any);
},
[router, filter, seeAllNamespace],
[router, filter, seeAllNamespace, seeAllPathname],
);
return (

View File

@@ -12,12 +12,10 @@ import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import heart from "@/assets/icons/heart.fill.png";
import { Text } from "@/components/common/Text";
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { Colors } from "@/constants/Colors";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
@@ -34,32 +32,35 @@ type FavoriteTypes =
// message during a favorites/watchlist switch before the new queries resolve.
type EmptyState = Record<FavoriteTypes, boolean | null>;
export const Favorites = () => {
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 = {}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { settings } = useSettings();
const pageSize = 20;
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
const watchlistEnabled = settings?.useKefinTweaks ?? false;
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
"Favorites",
);
const filter: ItemFilter =
watchlistEnabled && viewType === "Watchlist" ? "Likes" : "IsFavorite";
const queryKeyBase =
watchlistEnabled && viewType === "Watchlist" ? "watchlist" : "favorites";
// Translation namespace for the empty state, swapped for the KefinTweaks
// watchlist (Likes-backed) view. Section titles stay generic ("Series").
const emptyNamespace =
watchlistEnabled && viewType === "Watchlist"
? "kefintweaksWatchlist"
: "favorites";
const emptyTitleKey = `${emptyNamespace}.noDataTitle`;
const emptyTextKey = `${emptyNamespace}.noData`;
const topPadding = contentTopPadding ?? insets.top + TOP_PADDING;
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: null,
@@ -146,31 +147,17 @@ export const Favorites = () => {
[fetchFavoritesByType, pageSize],
);
const tabBadges = (
<TVFavoritesTabBadges
viewType={viewType}
setViewType={setViewType}
enabled={watchlistEnabled}
hasTVPreferredFocus={watchlistEnabled}
/>
);
return (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingTop: topPadding,
paddingBottom: insets.bottom + 60,
flexGrow: 1,
}}
>
<View style={{ gap: SECTION_GAP, flex: 1 }}>
{watchlistEnabled && (
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
{tabBadges}
</View>
)}
{/* 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. */}
@@ -221,7 +208,7 @@ export const Favorites = () => {
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
isFirstSection={!watchlistEnabled}
isFirstSection={isFirstSection}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
/>
<InfiniteScrollingCollectionList

View File

@@ -1,20 +1,17 @@
import React from "react";
import { useTranslation } from "react-i18next";
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";
type ViewType = "Favorites" | "Watchlist";
interface TVFavoritesTabBadgeProps {
interface TVSegmentBadgeProps {
label: string;
isSelected: boolean;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}
const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
const TVSegmentBadge: React.FC<TVSegmentBadgeProps> = ({
label,
isSelected,
onPress,
@@ -72,46 +69,41 @@ const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
);
};
export interface TVFavoritesTabBadgesProps {
viewType: ViewType;
setViewType: (type: ViewType) => void;
/** Only render the toggle when the KefinTweaks watchlist is enabled. */
enabled: boolean;
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;
}
export const TVFavoritesTabBadges: React.FC<TVFavoritesTabBadgesProps> = ({
viewType,
setViewType,
enabled,
/**
* 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,
}) => {
const { t } = useTranslation();
if (!enabled) {
return null;
}
}: TVSegmentedControlProps<T>) {
return (
<View
style={{
flexDirection: "row",
gap: 16,
marginBottom: 24,
}}
>
<TVFavoritesTabBadge
label={t("tabs.favorites")}
isSelected={viewType === "Favorites"}
onPress={() => setViewType("Favorites")}
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Favorites"}
/>
<TVFavoritesTabBadge
label={t("favorites.watchlist")}
isSelected={viewType === "Watchlist"}
onPress={() => setViewType("Watchlist")}
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Watchlist"}
/>
<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

@@ -0,0 +1,238 @@
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

@@ -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

@@ -601,8 +601,7 @@
"seeAllBoxsets": "Favorited Box sets",
"seeAllPlaylists": "Favorited Playlists",
"noDataTitle": "No favorites yet",
"noData": "Mark items as favorites to see them appear here for quick access.",
"watchlist": "Watchlist"
"noData": "Mark items as favorites to see them appear here for quick access."
},
"kefintweaksWatchlist": {
"seeAllSeries": "Watchlisted Series",
@@ -919,7 +918,9 @@
"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"
"create_one_first": "Create a watchlist that accepts this content type",
"source_streamystats": "Streamystats",
"source_kefintweaks": "Kefintweaks"
},
"playback_speed": {
"title": "Playback speed",

View File

@@ -854,6 +854,16 @@
"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",
@@ -897,7 +907,9 @@
"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"
"create_one_first": "Skapa en bevakningslista som accepterar denna innehållstyp",
"source_streamystats": "Streamystats",
"source_kefintweaks": "Kefintweaks"
},
"playback_speed": {
"title": "Uppspelningshastighet",