mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-11 16:30:24 +01:00
Compare commits
5 Commits
refactor/j
...
feat/kefin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c024d1ed05 | ||
|
|
c648134954 | ||
|
|
97eec2438b | ||
|
|
1d0c2f0a31 | ||
|
|
eba72e9d73 |
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -42,7 +42,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for ALL files (no .js files). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
|
||||
2
.github/workflows/detect-duplicate.yml
vendored
2
.github/workflows/detect-duplicate.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
bun-version: latest
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.ts
|
||||
run: bun scripts/detect-duplicate-issue.mjs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,6 +12,10 @@ web-build/
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
/iosmobile
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
@@ -152,7 +152,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for all files (no .js). Tooling-required exceptions: `babel.config.js`, `metro.config.js`, `react-native.config.js`, `tailwind.config.js` (their loaders cannot parse TypeScript)
|
||||
- Use TypeScript for all files (no .js)
|
||||
- Use functional React components with hooks
|
||||
- Use Jotai atoms for global state, React Query for server state
|
||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
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();
|
||||
@@ -20,6 +30,8 @@ export default function FavoritesPage() {
|
||||
return <TVFavorites />;
|
||||
}
|
||||
|
||||
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
@@ -34,7 +46,26 @@ export default function FavoritesPage() {
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||
<Favorites />
|
||||
{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 />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -10,7 +11,7 @@ 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 { 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";
|
||||
@@ -52,9 +53,13 @@ export default function FavoritesSeeAllScreen() {
|
||||
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;
|
||||
@@ -77,7 +82,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
userId: user.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -90,12 +95,12 @@ export default function FavoritesSeeAllScreen() {
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, itemType, user?.Id],
|
||||
[api, itemType, user?.Id, filter],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["favorites", "see-all", itemType],
|
||||
queryKey: ["favorites", "see-all", itemType, filter],
|
||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||
@@ -155,7 +160,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
options={{
|
||||
headerTitle: headerTitle,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -18,6 +19,7 @@ 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,
|
||||
@@ -30,6 +32,7 @@ import { storage } from "@/utils/mmkv";
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
id: seriesId,
|
||||
@@ -137,6 +140,7 @@ 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'
|
||||
@@ -157,7 +161,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
) : null,
|
||||
});
|
||||
}, [allEpisodes, isLoading, item, isOffline]);
|
||||
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
|
||||
|
||||
// For offline mode, we can show the page even without backdropUrl
|
||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||
|
||||
28
components/AddToKefinWatchlist.tsx
Normal file
28
components/AddToKefinWatchlist.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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 } = useWatchlist(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||
color={isWatchlisted ? "purple" : "white"}
|
||||
onPress={toggleWatchlist}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -29,6 +29,7 @@ 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";
|
||||
@@ -138,6 +139,9 @@ 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} />
|
||||
@@ -160,6 +164,9 @@ 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} />
|
||||
@@ -178,6 +185,7 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
settings.hideRemoteSessionButton,
|
||||
settings.streamyStatsServerUrl,
|
||||
settings.hideWatchlistsTab,
|
||||
settings.useKefinTweaks,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
TVRefreshButton,
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
TVWatchlistButton,
|
||||
} from "@/components/tv";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -752,6 +753,7 @@ 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>
|
||||
|
||||
@@ -11,8 +11,10 @@ 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;
|
||||
@@ -155,6 +157,8 @@ 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();
|
||||
@@ -183,36 +187,66 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
)
|
||||
return;
|
||||
|
||||
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"),
|
||||
// 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,
|
||||
},
|
||||
];
|
||||
|
||||
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 = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
const destructiveButtonIndex = actions.findIndex((a) => a.destructive);
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
destructiveButtonIndex:
|
||||
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
|
||||
},
|
||||
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);
|
||||
}
|
||||
(selectedIndex) => {
|
||||
if (selectedIndex === undefined || selectedIndex >= actions.length)
|
||||
return;
|
||||
actions[selectedIndex].action();
|
||||
},
|
||||
);
|
||||
}, [
|
||||
@@ -220,6 +254,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isWatchlisted,
|
||||
toggleWatchlist,
|
||||
settings?.useKefinTweaks,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
|
||||
74
components/favorites/FavoritesTabButtons.tsx
Normal file
74
components/favorites/FavoritesTabButtons.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
|
||||
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 TVFavoritesTabBadgesProps {
|
||||
viewType: ViewType;
|
||||
setViewType: (type: ViewType) => void;
|
||||
/** Only render the toggle when the KefinTweaks watchlist is enabled. */
|
||||
enabled: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
export const TVFavoritesTabBadges: React.FC<TVFavoritesTabBadgesProps> = ({
|
||||
viewType,
|
||||
setViewType,
|
||||
enabled,
|
||||
hasTVPreferredFocus = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
@@ -22,7 +25,24 @@ type FavoriteTypes =
|
||||
| "Playlist";
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
|
||||
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;
|
||||
/** Namespace for the see-all page headers ("favorites" or "kefintweaksWatchlist"). */
|
||||
seeAllNamespace?: string;
|
||||
}
|
||||
|
||||
export const Favorites = ({
|
||||
filter = "IsFavorite",
|
||||
queryKeyBase = "favorites",
|
||||
emptyTitleKey = "favorites.noDataTitle",
|
||||
emptyTextKey = "favorites.noData",
|
||||
seeAllNamespace = "favorites",
|
||||
}: FavoritesProps = {}) => {
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -46,7 +66,7 @@ export const Favorites = () => {
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -68,7 +88,7 @@ export const Favorites = () => {
|
||||
|
||||
return items;
|
||||
},
|
||||
[api, user],
|
||||
[api, user, filter],
|
||||
);
|
||||
|
||||
// Reset empty state when component mounts or dependencies change
|
||||
@@ -126,44 +146,68 @@ export const Favorites = () => {
|
||||
const handleSeeAllSeries = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Series", title: t("favorites.series") },
|
||||
params: {
|
||||
type: "Series",
|
||||
title: t(`${seeAllNamespace}.seeAllSeries`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllMovies = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Movie", title: t("favorites.movies") },
|
||||
params: {
|
||||
type: "Movie",
|
||||
title: t(`${seeAllNamespace}.seeAllMovies`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllEpisodes = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Episode", title: t("favorites.episodes") },
|
||||
params: {
|
||||
type: "Episode",
|
||||
title: t(`${seeAllNamespace}.seeAllEpisodes`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllVideos = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Video", title: t("favorites.videos") },
|
||||
params: {
|
||||
type: "Video",
|
||||
title: t(`${seeAllNamespace}.seeAllVideos`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllBoxsets = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
||||
params: {
|
||||
type: "BoxSet",
|
||||
title: t(`${seeAllNamespace}.seeAllBoxsets`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
const handleSeeAllPlaylists = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Playlist", title: t("favorites.playlists") },
|
||||
params: {
|
||||
type: "Playlist",
|
||||
title: t(`${seeAllNamespace}.seeAllPlaylists`),
|
||||
filter,
|
||||
},
|
||||
} as any);
|
||||
}, [router]);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
|
||||
return (
|
||||
<View className='flex flex-co gap-y-4'>
|
||||
@@ -176,16 +220,16 @@ export const Favorites = () => {
|
||||
source={heart}
|
||||
/>
|
||||
<Text className='text-xl font-semibold text-white mb-2'>
|
||||
{t("favorites.noDataTitle")}
|
||||
{t(emptyTitleKey)}
|
||||
</Text>
|
||||
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
|
||||
{t("favorites.noData")}
|
||||
{t(emptyTextKey)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -193,7 +237,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
@@ -202,7 +246,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -210,7 +254,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -218,7 +262,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -226,7 +270,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -9,10 +12,12 @@ 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;
|
||||
@@ -33,7 +38,27 @@ export const Favorites = () => {
|
||||
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 [emptyState, setEmptyState] = useState<EmptyState>({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
@@ -53,7 +78,7 @@ export const Favorites = () => {
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -74,7 +99,7 @@ export const Favorites = () => {
|
||||
|
||||
return items;
|
||||
},
|
||||
[api, user],
|
||||
[api, user, filter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +111,7 @@ export const Favorites = () => {
|
||||
BoxSet: false,
|
||||
Playlist: false,
|
||||
});
|
||||
}, [api, user]);
|
||||
}, [api, user, viewType]);
|
||||
|
||||
const areAllEmpty = () => {
|
||||
const loadedCategories = Object.values(emptyState);
|
||||
@@ -127,46 +152,63 @@ export const Favorites = () => {
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
const tabBadges = (
|
||||
<TVFavoritesTabBadges
|
||||
viewType={viewType}
|
||||
setViewType={setViewType}
|
||||
enabled={watchlistEnabled}
|
||||
hasTVPreferredFocus={watchlistEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
if (areAllEmpty()) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
{tabBadges}
|
||||
<View
|
||||
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",
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noDataTitle")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noData")}
|
||||
</Text>
|
||||
<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(emptyTitleKey)}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t(emptyTextKey)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -181,17 +223,22 @@ export const Favorites = () => {
|
||||
}}
|
||||
>
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{watchlistEnabled && (
|
||||
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
|
||||
{tabBadges}
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
isFirstSection
|
||||
isFirstSection={!watchlistEnabled}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
@@ -199,28 +246,28 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
|
||||
36
components/tv/TVWatchlistButton.tsx
Normal file
36
components/tv/TVWatchlistButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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 } = useWatchlist(item);
|
||||
|
||||
return (
|
||||
<TVButton
|
||||
onPress={toggleWatchlist}
|
||||
variant='glass'
|
||||
square
|
||||
disabled={disabled}
|
||||
>
|
||||
<Ionicons
|
||||
name={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||
size={28}
|
||||
color='#FFFFFF'
|
||||
/>
|
||||
</TVButton>
|
||||
);
|
||||
};
|
||||
@@ -68,3 +68,5 @@ export { TVTrackCard } from "./TVTrackCard";
|
||||
// User switching
|
||||
export type { TVUserCardProps } from "./TVUserCard";
|
||||
export { TVUserCard } from "./TVUserCard";
|
||||
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
|
||||
export { TVWatchlistButton } from "./TVWatchlistButton";
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
const MediaTypes = {
|
||||
export default {
|
||||
Audio: "Audio",
|
||||
Video: "Video",
|
||||
Photo: "Photo",
|
||||
Book: "Book",
|
||||
} as const;
|
||||
|
||||
export type MediaType = (typeof MediaTypes)[keyof typeof MediaTypes];
|
||||
|
||||
export default MediaTypes;
|
||||
};
|
||||
146
hooks/useWatchlist.ts
Normal file
146
hooks/useWatchlist.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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 { 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 itemId = item.Id ?? "";
|
||||
|
||||
// Get current watchlist status from shared state, falling back to item data
|
||||
const isWatchlisted = itemId
|
||||
? (watchlist[itemId] ?? item.UserData?.Likes)
|
||||
: item.UserData?.Likes;
|
||||
|
||||
// Update shared state when item data changes
|
||||
useEffect(() => {
|
||||
if (itemId && item.UserData?.Likes !== undefined) {
|
||||
setWatchlist((prev) => ({
|
||||
...prev,
|
||||
[itemId]: item.UserData!.Likes!,
|
||||
}));
|
||||
}
|
||||
}, [itemId, item.UserData?.Likes, setWatchlist]);
|
||||
|
||||
// Helper to update watchlist status in shared state
|
||||
const setIsWatchlisted = useCallback(
|
||||
(value: boolean | null | undefined) => {
|
||||
if (itemId && typeof value === "boolean") {
|
||||
setWatchlist((prev) => ({ ...prev, [itemId]: value }));
|
||||
}
|
||||
},
|
||||
[itemId, 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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: (_err, _nextIsWatchlisted, context) => {
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
setIsWatchlisted(context?.previousIsWatchlisted);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "watchlist"] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleWatchlist = useCallback(() => {
|
||||
watchlistMutation.mutate(!isWatchlisted);
|
||||
}, [watchlistMutation, isWatchlisted]);
|
||||
|
||||
return {
|
||||
isWatchlisted,
|
||||
toggleWatchlist,
|
||||
watchlistMutation,
|
||||
};
|
||||
};
|
||||
@@ -17,13 +17,13 @@
|
||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||
"ios:unsigned-build:tv": "cross-env EXPO_TV=1 bun scripts/ios/build-ios.ts --production",
|
||||
"prepare": "husky",
|
||||
"typecheck": "bun scripts/typecheck.ts",
|
||||
"typecheck": "node scripts/typecheck.js",
|
||||
"check": "biome check . --max-diagnostics 1000",
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.ts",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.ts --fix-unused",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
* - Edge cases the static scan cannot see can be allow-listed in the config file.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/check-i18n-keys.ts # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.ts --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.ts --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.ts --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.ts --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
* bun scripts/check-i18n-keys.mjs # report + exit 1 on missing OR unused
|
||||
* bun scripts/check-i18n-keys.mjs --unused=warn # exit 1 only on missing; unused = warning
|
||||
* bun scripts/check-i18n-keys.mjs --unused=off # ignore unused entirely
|
||||
* bun scripts/check-i18n-keys.mjs --json # machine-readable output
|
||||
* bun scripts/check-i18n-keys.mjs --fix-unused # remove dead keys from en.json (Crowdin syncs the rest)
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -34,20 +34,9 @@ import {
|
||||
} from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
|
||||
type LocaleTree = { [key: string]: LocaleTree | string };
|
||||
|
||||
interface I18nConfig {
|
||||
localesDir: string;
|
||||
sourceLocale: string;
|
||||
srcDirs: string[];
|
||||
srcExtensions: string[];
|
||||
excludeDirs: string[];
|
||||
ignoreUnused: string[];
|
||||
}
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name: string, def: string | boolean): string | boolean => {
|
||||
const flag = (name, def) => {
|
||||
const a = args.find((x) => x === `--${name}` || x.startsWith(`--${name}=`));
|
||||
if (!a) return def;
|
||||
const [, v] = a.split("=");
|
||||
@@ -59,7 +48,7 @@ const FIX_UNUSED = !!flag("fix-unused", false);
|
||||
|
||||
// ---- config ----
|
||||
const CONFIG_PATH = join(ROOT, "scripts", "i18n-keys.config.json");
|
||||
const DEFAULT_CONFIG: I18nConfig = {
|
||||
const DEFAULT_CONFIG = {
|
||||
localesDir: "translations",
|
||||
sourceLocale: "en",
|
||||
// Scan the whole repo by default so keys referenced outside the obvious dirs
|
||||
@@ -80,36 +69,29 @@ const DEFAULT_CONFIG: I18nConfig = {
|
||||
// Keys (or glob-ish prefixes ending with .* or *) known to be used dynamically / externally.
|
||||
ignoreUnused: [],
|
||||
};
|
||||
const config: I18nConfig = existsSync(CONFIG_PATH)
|
||||
? {
|
||||
...DEFAULT_CONFIG,
|
||||
...(JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<I18nConfig>),
|
||||
}
|
||||
const config = existsSync(CONFIG_PATH)
|
||||
? { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf8")) }
|
||||
: DEFAULT_CONFIG;
|
||||
|
||||
// ---- helpers ----
|
||||
const flatten = (
|
||||
obj: LocaleTree,
|
||||
prefix = "",
|
||||
out: Record<string, string> = {},
|
||||
): Record<string, string> => {
|
||||
const flatten = (obj, prefix = "", out = {}) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
|
||||
else out[key] = v as string;
|
||||
else out[key] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const globMatch = (key: string, pattern: string): boolean => {
|
||||
const globMatch = (key, pattern) => {
|
||||
if (pattern.endsWith(".*"))
|
||||
return key === pattern.slice(0, -2) || key.startsWith(pattern.slice(0, -1));
|
||||
if (pattern.endsWith("*")) return key.startsWith(pattern.slice(0, -1));
|
||||
return key === pattern;
|
||||
};
|
||||
|
||||
const walk = (dir: string, files: string[] = []): string[] => {
|
||||
let entries: string[];
|
||||
const walk = (dir, files = []) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
@@ -117,7 +99,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
|
||||
}
|
||||
for (const name of entries) {
|
||||
const full = join(dir, name);
|
||||
let st: ReturnType<typeof statSync>;
|
||||
let st;
|
||||
try {
|
||||
st = statSync(full);
|
||||
} catch {
|
||||
@@ -136,7 +118,7 @@ const walk = (dir: string, files: string[] = []): string[] => {
|
||||
// ---- load source keys ----
|
||||
const sourcePath = join(ROOT, config.localesDir, `${config.sourceLocale}.json`);
|
||||
const sourceKeys = Object.keys(
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree),
|
||||
flatten(JSON.parse(readFileSync(sourcePath, "utf8"))),
|
||||
);
|
||||
const sourceKeySet = new Set(sourceKeys);
|
||||
|
||||
@@ -147,16 +129,16 @@ const TPL_DYN_RE = /\bt\(\s*`([^`$]*)\$\{/g; // t(`a.b.${x}`) -> prefix "a.b."
|
||||
const I18NKEY_RE = /\bi18nKey\s*=\s*(?:\{\s*)?(['"])((?:\\.|(?!\1).)+?)\1/g; // <Trans i18nKey="a.b">
|
||||
const KEY_SHAPE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+$/; // dotted key, e.g. home.x.y
|
||||
|
||||
const usedStatic = new Set<string>(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set<string>();
|
||||
const fullyDynamic: Array<{ file: string; line: number }> = [];
|
||||
const usedStatic = new Set(); // keys passed to t(...) / i18nKey — used for MISSING detection
|
||||
const dynamicPrefixes = new Set();
|
||||
const fullyDynamic = []; // { file, line }
|
||||
let codeBlob = ""; // all (comment-stripped) source text — searched for delimited key literals
|
||||
|
||||
// Strip comments so keys mentioned in comments (e.g. `// t("old.key")`) are not counted as
|
||||
// usage. Block comments and JSX {/* */} are blanked (preserving newlines for line numbers);
|
||||
// line comments are only stripped when `//` follows start/whitespace/punctuation, which keeps
|
||||
// `://` inside string URLs intact.
|
||||
const stripComments = (src: string): string =>
|
||||
const stripComments = (src) =>
|
||||
src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||
.replace(/(^|[\s;{}()[\],=>])\/\/[^\n]*/g, (_m, p) => p);
|
||||
@@ -186,11 +168,11 @@ const prefixList = [...dynamicPrefixes];
|
||||
// the code (covers t("k"), <Trans i18nKey>, and keys stored as bare string constants in
|
||||
// arrays/config then resolved via t(variable)), or it is reached via a dynamic prefix, or
|
||||
// explicitly allow-listed. Delimited search avoids substring false-matches (e.g. a.b vs a.b_c).
|
||||
const literalUsed = (key: string): boolean =>
|
||||
const literalUsed = (key) =>
|
||||
codeBlob.includes(`"${key}"`) ||
|
||||
codeBlob.includes(`'${key}'`) ||
|
||||
codeBlob.includes(`\`${key}\``);
|
||||
const isUsed = (key: string): boolean =>
|
||||
const isUsed = (key) =>
|
||||
literalUsed(key) ||
|
||||
prefixList.some((p) => key.startsWith(p)) ||
|
||||
config.ignoreUnused.some((g) => globMatch(key, g));
|
||||
@@ -209,22 +191,25 @@ const missing = [...usedStatic]
|
||||
// keys are static literals in practice; revisit if dynamic key constants become common.
|
||||
|
||||
// ---- optional fix: strip dead keys from the source locale (en.json) ----
|
||||
const removeKey = (obj: LocaleTree, parts: string[]): void => {
|
||||
const removeKey = (obj, parts) => {
|
||||
const [head, ...rest] = parts;
|
||||
if (!(head in obj)) return;
|
||||
if (rest.length === 0) {
|
||||
delete obj[head];
|
||||
return;
|
||||
}
|
||||
const child = obj[head];
|
||||
if (!child || typeof child !== "object") return;
|
||||
removeKey(child, rest);
|
||||
if (Object.keys(child).length === 0) delete obj[head];
|
||||
removeKey(obj[head], rest);
|
||||
if (
|
||||
obj[head] &&
|
||||
typeof obj[head] === "object" &&
|
||||
Object.keys(obj[head]).length === 0
|
||||
)
|
||||
delete obj[head];
|
||||
};
|
||||
if (FIX_UNUSED && unused.length) {
|
||||
// Only edit the SOURCE locale (en.json). Crowdin owns the target locales and removes
|
||||
// the keys from them automatically on the next sync once they disappear from the source.
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8")) as LocaleTree;
|
||||
const data = JSON.parse(readFileSync(sourcePath, "utf8"));
|
||||
for (const key of unused) removeKey(data, key.split("."));
|
||||
writeFileSync(sourcePath, `${JSON.stringify(data, null, 2)}\n`);
|
||||
console.log(
|
||||
@@ -274,7 +259,7 @@ if (JSON_OUT) {
|
||||
);
|
||||
for (const k of unused) console.log(` - ${k}`);
|
||||
console.log(
|
||||
`\n → remove with: bun scripts/check-i18n-keys.ts --fix-unused`,
|
||||
`\n → remove with: bun scripts/check-i18n-keys.mjs --fix-unused`,
|
||||
);
|
||||
console.log(
|
||||
` → or allow-list a dynamic key in scripts/i18n-keys.config.json ("ignoreUnused").`,
|
||||
@@ -21,14 +21,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
interface Issue {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
}
|
||||
|
||||
// Parse a numeric env var, falling back to `def` only when unset/empty/NaN so an explicit 0 is honoured.
|
||||
const numEnv = (name: string, def: number): number => {
|
||||
const numEnv = (name, def) => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return def;
|
||||
const n = Number(raw);
|
||||
@@ -57,9 +51,9 @@ const STOP = new Set(
|
||||
).split(/\s+/),
|
||||
);
|
||||
|
||||
const stem = (w: string): string => w.replace(/(ing|ed|es|s)$/, "");
|
||||
const stem = (w) => w.replace(/(ing|ed|es|s)$/, "");
|
||||
|
||||
const tokens = (s: string | null): string[] =>
|
||||
const tokens = (s) =>
|
||||
(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/```[\s\S]*?```/g, " ") // drop code blocks
|
||||
@@ -71,7 +65,7 @@ const tokens = (s: string | null): string[] =>
|
||||
.map(stem)
|
||||
.filter((w) => w.length > 2);
|
||||
|
||||
const jaccard = (a: string[], b: string[]): number => {
|
||||
const jaccard = (a, b) => {
|
||||
const A = new Set(a);
|
||||
const B = new Set(b);
|
||||
if (!A.size || !B.size) return 0;
|
||||
@@ -82,14 +76,14 @@ const jaccard = (a: string[], b: string[]): number => {
|
||||
|
||||
const newTitle = tokens(TITLE);
|
||||
const newBody = tokens(BODY);
|
||||
const score = (o: Issue): number =>
|
||||
const score = (o) =>
|
||||
0.6 * jaccard(newTitle, tokens(o.title)) +
|
||||
0.4 * jaccard(newBody, tokens(o.body));
|
||||
|
||||
// fetch open issues (excluding PRs and the new issue itself)
|
||||
let issues: Issue[];
|
||||
let issues;
|
||||
if (process.env.DUP_FIXTURE) {
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8")) as Issue[];
|
||||
issues = JSON.parse(readFileSync(process.env.DUP_FIXTURE, "utf8"));
|
||||
} else {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
@@ -111,7 +105,7 @@ if (process.env.DUP_FIXTURE) {
|
||||
issues = raw
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => JSON.parse(l) as Issue);
|
||||
.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
const matches = issues
|
||||
@@ -129,7 +123,7 @@ if (!matches.length) {
|
||||
// Neutralise other issues' titles before echoing them back: break @mentions and
|
||||
// strip markdown/HTML control chars so a maliciously-named issue can't ping people
|
||||
// or inject formatting into our comment. GitHub linkifies "#123" on its own.
|
||||
const safeTitle = (t: string): string =>
|
||||
const safeTitle = (t) =>
|
||||
(t || "")
|
||||
.replace(/@/g, "@")
|
||||
.replace(/[`<>|*_~[\]]/g, " ")
|
||||
62
scripts/symlink-native-dirs.js
Normal file
62
scripts/symlink-native-dirs.js
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const _fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const process = require("node:process");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const root = process.cwd();
|
||||
// const tvosPath = path.join(root, 'iostv');
|
||||
// const iosPath = path.join(root, 'iosmobile');
|
||||
// const androidPath = path.join(root, 'androidmobile');
|
||||
// const androidTVPath = path.join(root, 'androidtv');
|
||||
// const device = process.argv[2];
|
||||
// const platform = process.argv[2];
|
||||
const isTV = process.env.EXPO_TV || false;
|
||||
|
||||
const paths = new Map([
|
||||
["tvos", path.join(root, "iostv")],
|
||||
["ios", path.join(root, "iosmobile")],
|
||||
["android", path.join(root, "androidmobile")],
|
||||
["androidtv", path.join(root, "androidtv")],
|
||||
]);
|
||||
|
||||
// const platformPath = paths.get(platform);
|
||||
|
||||
if (isTV) {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("tvos")}; ln -nsf ${paths.get("tvos")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("androidtv")}; ln -nsf ${paths.get(
|
||||
"androidtv",
|
||||
)} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
} else {
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("ios")}; ln -nsf ${paths.get("ios")} ios`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
stdout = execSync(
|
||||
`mkdir -p ${paths.get("android")}; ln -nsf ${paths.get("android")} android`,
|
||||
);
|
||||
console.log(stdout.toString());
|
||||
}
|
||||
|
||||
// target = "";
|
||||
// switch (platform) {
|
||||
// case "tvos":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "ios":
|
||||
// target = "ios";
|
||||
// break;
|
||||
// case "android":
|
||||
// target = "android";
|
||||
// break;
|
||||
// case "androidtv":
|
||||
// target = "android";
|
||||
// break;
|
||||
// }
|
||||
@@ -1,8 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import process from "node:process";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const process = require("node:process");
|
||||
|
||||
// Enhanced ANSI color codes and styles
|
||||
const colors = {
|
||||
@@ -35,7 +32,7 @@ const centeredTitle = " ".repeat(titlePadding) + title;
|
||||
|
||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
|
||||
function log(message: string, color = "") {
|
||||
function log(message, color = "") {
|
||||
if (useColor && color) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
} else {
|
||||
@@ -43,7 +40,7 @@ function log(message: string, color = "") {
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(errorLine: string): string {
|
||||
function formatError(errorLine) {
|
||||
if (!useColor) return errorLine;
|
||||
|
||||
// Color file paths in cyan
|
||||
@@ -73,15 +70,12 @@ function formatError(errorLine: string): string {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
formattedErrors: string[];
|
||||
errorsByFile: Map<string, number>;
|
||||
} {
|
||||
function parseErrorsAndCreateSummary(errorOutput) {
|
||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
||||
const errorsByFile = new Map<string, number>();
|
||||
const formattedErrors: string[] = [];
|
||||
const errorsByFile = new Map();
|
||||
const formattedErrors = [];
|
||||
|
||||
let currentError: string[] = [];
|
||||
let currentError = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
@@ -102,7 +96,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
if (!errorsByFile.has(filePath)) {
|
||||
errorsByFile.set(filePath, 0);
|
||||
}
|
||||
errorsByFile.set(filePath, (errorsByFile.get(filePath) ?? 0) + 1);
|
||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
||||
|
||||
// Start new error
|
||||
currentError.push(formatError(line));
|
||||
@@ -125,7 +119,7 @@ function parseErrorsAndCreateSummary(errorOutput: string): {
|
||||
return { formattedErrors, errorsByFile };
|
||||
}
|
||||
|
||||
function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||
function createErrorSummaryTable(errorsByFile) {
|
||||
if (errorsByFile.size === 0) return "";
|
||||
|
||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
||||
@@ -142,12 +136,12 @@ function createErrorSummaryTable(errorsByFile: Map<string, number>): string {
|
||||
return table;
|
||||
}
|
||||
|
||||
function runTypeCheck(): { ok: boolean } {
|
||||
function runTypeCheck() {
|
||||
const extraArgs = process.argv.slice(2);
|
||||
|
||||
// Prefer local TypeScript binary when available
|
||||
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
|
||||
let execArgs: { cmd: string; args: string[] };
|
||||
let execArgs = null;
|
||||
try {
|
||||
const tscBin = require.resolve("typescript/bin/tsc");
|
||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
||||
@@ -180,8 +174,7 @@ function runTypeCheck(): { ok: boolean } {
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const execError = error as { stderr?: string; stdout?: string };
|
||||
const errorOutput = execError.stderr || execError.stdout || "";
|
||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
||||
|
||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||
// that generates a large volume of known type errors
|
||||
@@ -593,8 +593,25 @@
|
||||
"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."
|
||||
"noData": "Mark items as favorites to see them appear here for quick access.",
|
||||
"watchlist": "Watchlist"
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -675,8 +675,25 @@
|
||||
"videos": "Videor",
|
||||
"boxsets": "Box Set",
|
||||
"playlists": "Spellistor",
|
||||
"seeAllSeries": "Favoritmarkerade serier",
|
||||
"seeAllMovies": "Favoritmarkerade filmer",
|
||||
"seeAllEpisodes": "Favoritmarkerade avsnitt",
|
||||
"seeAllVideos": "Favoritmarkerade videor",
|
||||
"seeAllBoxsets": "Favoritmarkerade box set",
|
||||
"seeAllPlaylists": "Favoritmarkerade spellistor",
|
||||
"noDataTitle": "Inga favoriter än",
|
||||
"noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst."
|
||||
"noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst.",
|
||||
"watchlist": "Bevakningslista"
|
||||
},
|
||||
"kefintweaksWatchlist": {
|
||||
"seeAllSeries": "Bevakade serier",
|
||||
"seeAllMovies": "Bevakade filmer",
|
||||
"seeAllEpisodes": "Bevakade avsnitt",
|
||||
"seeAllVideos": "Bevakade videor",
|
||||
"seeAllBoxsets": "Bevakade box set",
|
||||
"seeAllPlaylists": "Bevakade spellistor",
|
||||
"noDataTitle": "Inga bevakade objekt än",
|
||||
"noData": "Lägg till objekt i din bevakningslista för att se dem visas här."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Inga Länkar"
|
||||
|
||||
@@ -82,8 +82,6 @@ export const useFilterOptions = () => {
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
];
|
||||
console.log("filterOptions");
|
||||
console.log(filterOptions);
|
||||
return filterOptions;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type {
|
||||
DeviceProfile,
|
||||
SubtitleProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type AudioTranscodeModeType, generateDeviceProfile } from "./native";
|
||||
import { generateDeviceProfile } from "./native";
|
||||
|
||||
/**
|
||||
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
|
||||
*/
|
||||
|
||||
/**
|
||||
* Download-specific subtitle profiles.
|
||||
* These are more permissive than streaming profiles since we can embed subtitles.
|
||||
*/
|
||||
const downloadSubtitleProfiles: SubtitleProfile[] = [
|
||||
const downloadSubtitleProfiles = [
|
||||
// Official formats
|
||||
{ Format: "vtt", Method: "Encode" },
|
||||
{ Format: "webvtt", Method: "Encode" },
|
||||
@@ -46,10 +46,11 @@ const downloadSubtitleProfiles: SubtitleProfile[] = [
|
||||
/**
|
||||
* Generates a device profile optimized for downloads.
|
||||
* Uses the same audio codec logic as streaming but with download-specific bitrate limits.
|
||||
*
|
||||
* @param {AudioTranscodeModeType} [audioMode="auto"] - Audio transcoding mode
|
||||
* @returns {Object} Jellyfin device profile for downloads
|
||||
*/
|
||||
export const generateDownloadProfile = (
|
||||
audioMode: AudioTranscodeModeType = "auto",
|
||||
): DeviceProfile => {
|
||||
export const generateDownloadProfile = (audioMode = "auto") => {
|
||||
// Get the base profile with proper audio codec configuration
|
||||
const baseProfile = generateDeviceProfile({ audioMode });
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
@@ -194,7 +193,7 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: getSubtitleProfiles(),
|
||||
} satisfies DeviceProfile;
|
||||
};
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type { SubtitleProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
// Image-based formats - these need to be burned in by Jellyfin (Encode method)
|
||||
// because MPV cannot load them externally over HTTP
|
||||
@@ -14,7 +13,7 @@ const IMAGE_BASED_FORMATS = [
|
||||
"pgssub",
|
||||
"teletext",
|
||||
"vobsub",
|
||||
] as const;
|
||||
];
|
||||
|
||||
// Text-based formats - these can be loaded externally by MPV
|
||||
const TEXT_BASED_FORMATS = [
|
||||
@@ -38,10 +37,10 @@ const TEXT_BASED_FORMATS = [
|
||||
"text",
|
||||
"vplayer",
|
||||
"xsub",
|
||||
] as const;
|
||||
];
|
||||
|
||||
export const getSubtitleProfiles = (): SubtitleProfile[] => {
|
||||
const profiles: SubtitleProfile[] = [];
|
||||
export const getSubtitleProfiles = () => {
|
||||
const profiles = [];
|
||||
|
||||
// Image-based formats: Embed or Encode (burn-in), NOT External
|
||||
for (const format of IMAGE_BASED_FORMATS) {
|
||||
@@ -59,4 +58,4 @@ export const getSubtitleProfiles = (): SubtitleProfile[] => {
|
||||
};
|
||||
|
||||
// Export for use in player filtering
|
||||
export const IMAGE_SUBTITLE_CODECS: readonly string[] = IMAGE_BASED_FORMATS;
|
||||
export const IMAGE_SUBTITLE_CODECS = IMAGE_BASED_FORMATS;
|
||||
19
utils/profiles/trackplayer.d.ts
vendored
Normal file
19
utils/profiles/trackplayer.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export type PlatformType = "ios" | "android";
|
||||
|
||||
export interface TrackPlayerProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
}
|
||||
|
||||
export function generateTrackPlayerProfile(
|
||||
options?: TrackPlayerProfileOptions,
|
||||
): any;
|
||||
|
||||
declare const _default: any;
|
||||
export default _default;
|
||||
@@ -3,26 +3,23 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import type {
|
||||
CodecProfile,
|
||||
DeviceProfile,
|
||||
DirectPlayProfile,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
|
||||
export type PlatformType = "ios" | "android";
|
||||
|
||||
export interface TrackPlayerProfileOptions {
|
||||
/** Target platform */
|
||||
platform?: PlatformType;
|
||||
}
|
||||
/**
|
||||
* @typedef {"ios" | "android"} PlatformType
|
||||
*
|
||||
* @typedef {Object} TrackPlayerProfileOptions
|
||||
* @property {PlatformType} [platform] - Target platform
|
||||
*/
|
||||
|
||||
/**
|
||||
* Audio direct play profiles for react-native-track-player.
|
||||
* iOS uses AVPlayer, Android uses ExoPlayer - each has different codec support.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
*/
|
||||
const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
|
||||
const getDirectPlayProfile = (platform) => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer supported formats
|
||||
return {
|
||||
@@ -42,8 +39,10 @@ const getDirectPlayProfile = (platform: PlatformType): DirectPlayProfile => {
|
||||
|
||||
/**
|
||||
* Audio codec profiles for react-native-track-player.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
*/
|
||||
const getCodecProfile = (platform: PlatformType): CodecProfile => {
|
||||
const getCodecProfile = (platform) => {
|
||||
if (platform === "ios") {
|
||||
// iOS AVPlayer codec constraints
|
||||
return {
|
||||
@@ -65,11 +64,12 @@ const getCodecProfile = (platform: PlatformType): CodecProfile => {
|
||||
* This profile is specifically for standalone audio playback using:
|
||||
* - AVPlayer on iOS
|
||||
* - ExoPlayer on Android
|
||||
*
|
||||
* @param {TrackPlayerProfileOptions} [options] - Profile configuration options
|
||||
* @returns {Object} Jellyfin device profile for track player
|
||||
*/
|
||||
export const generateTrackPlayerProfile = (
|
||||
options: TrackPlayerProfileOptions = {},
|
||||
): DeviceProfile => {
|
||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||
export const generateTrackPlayerProfile = (options = {}) => {
|
||||
const platform = options.platform || Platform.OS;
|
||||
|
||||
return {
|
||||
Name: "Track Player",
|
||||
Reference in New Issue
Block a user