mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-30 01:22:56 +01:00
Compare commits
12 Commits
fix/pip-su
...
feat/kefin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c2631b35 | ||
|
|
a8698a5c11 | ||
|
|
f2e54cd230 | ||
|
|
14c84f5ec2 | ||
|
|
803ee368ad | ||
|
|
000e873922 | ||
|
|
bc13317f00 | ||
|
|
c024d1ed05 | ||
|
|
c648134954 | ||
|
|
97eec2438b | ||
|
|
1d0c2f0a31 | ||
|
|
eba72e9d73 |
@@ -1,14 +1,24 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
|
||||||
import { Favorites } from "@/components/home/Favorites";
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function FavoritesPage() {
|
export default function FavoritesPage() {
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
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 () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await invalidateCache();
|
await invalidateCache();
|
||||||
@@ -20,6 +30,8 @@ export default function FavoritesPage() {
|
|||||||
return <TVFavorites />;
|
return <TVFavorites />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -34,7 +46,26 @@ export default function FavoritesPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
<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>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Api } from "@jellyfin/sdk";
|
|||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
|
ItemFilter,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
@@ -10,7 +11,7 @@ import { Stack, useLocalSearchParams } from "expo-router";
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -52,9 +53,13 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
const searchParams = useLocalSearchParams<{
|
const searchParams = useLocalSearchParams<{
|
||||||
type?: string;
|
type?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
filter?: string;
|
||||||
}>();
|
}>();
|
||||||
const typeParam = searchParams.type;
|
const typeParam = searchParams.type;
|
||||||
const titleParam = searchParams.title;
|
const titleParam = searchParams.title;
|
||||||
|
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
|
||||||
|
const filter: ItemFilter =
|
||||||
|
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
const itemType = useMemo(() => {
|
||||||
if (!isFavoriteType(typeParam)) return null;
|
if (!isFavoriteType(typeParam)) return null;
|
||||||
@@ -77,7 +82,7 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: ["Ascending"],
|
||||||
filters: ["IsFavorite"],
|
filters: [filter],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
collapseBoxSetItems: false,
|
collapseBoxSetItems: false,
|
||||||
@@ -90,12 +95,12 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
|
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
},
|
},
|
||||||
[api, itemType, user?.Id],
|
[api, itemType, user?.Id, filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: ["favorites", "see-all", itemType],
|
queryKey: ["favorites", "see-all", itemType, filter],
|
||||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||||
@@ -155,7 +160,7 @@ export default function FavoritesSeeAllScreen() {
|
|||||||
options={{
|
options={{
|
||||||
headerTitle: headerTitle,
|
headerTitle: headerTitle,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: true,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useEffect, useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
|
import { AddToKefinWatchlist } from "@/components/AddToKefinWatchlist";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
@@ -18,6 +19,7 @@ import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
buildOfflineSeriesFromEpisodes,
|
buildOfflineSeriesFromEpisodes,
|
||||||
getDownloadedEpisodesForSeries,
|
getDownloadedEpisodesForSeries,
|
||||||
@@ -30,6 +32,7 @@ import { storage } from "@/utils/mmkv";
|
|||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const {
|
const {
|
||||||
id: seriesId,
|
id: seriesId,
|
||||||
@@ -137,6 +140,7 @@ const page: React.FC = () => {
|
|||||||
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings?.useKefinTweaks && <AddToKefinWatchlist item={item} />}
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
size='large'
|
size='large'
|
||||||
@@ -157,7 +161,7 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
}, [allEpisodes, isLoading, item, isOffline]);
|
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
|
||||||
|
|
||||||
// For offline mode, we can show the page even without backdropUrl
|
// For offline mode, we can show the page even without backdropUrl
|
||||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||||
|
|||||||
@@ -456,23 +456,10 @@ export default function DirectPlayerPage() {
|
|||||||
});
|
});
|
||||||
reportPlaybackStopped();
|
reportPlaybackStopped();
|
||||||
setIsPlaybackStopped(true);
|
setIsPlaybackStopped(true);
|
||||||
// Synchronously destroy the mpv instance + decoder + surface buffers
|
videoRef.current?.pause();
|
||||||
// BEFORE the screen unmounts. Otherwise the next screen (or the next
|
|
||||||
// episode's player) mounts while the old 4K decoder is still alive,
|
|
||||||
// causing OOM on low-RAM devices. Native stop() is idempotent so the
|
|
||||||
// later React unmount cleanup is still safe.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
// Pre-libmpv-1.0 used `stop()`:
|
|
||||||
// videoRef.current?.stop();
|
|
||||||
revalidateProgressCache();
|
revalidateProgressCache();
|
||||||
// Resume inactivity timer when leaving player (TV only)
|
// Resume inactivity timer when leaving player (TV only)
|
||||||
resumeInactivityTimer();
|
resumeInactivityTimer();
|
||||||
// Release the keep-awake wakelock acquired during playback so it
|
|
||||||
// doesn't follow us back to the home screen and block the TV
|
|
||||||
// screensaver. activateKeepAwakeAsync() is tag-scoped to this module
|
|
||||||
// and only released on the "paused" event; without this, navigating
|
|
||||||
// away mid-play leaves FLAG_KEEP_SCREEN_ON set on the window.
|
|
||||||
deactivateKeepAwake();
|
|
||||||
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1118,15 +1105,6 @@ export default function DirectPlayerPage() {
|
|||||||
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
// Destroy the current mpv instance BEFORE navigating so the old 4K
|
|
||||||
// decoder + surface buffers are freed before the new player screen
|
|
||||||
// mounts. Without this, Expo Router briefly holds two simultaneous
|
|
||||||
// mpv instances during the transition (~768 MB of surface buffers
|
|
||||||
// for two 4K HDR10+ decoders) and OOM-kills the app on low-RAM
|
|
||||||
// devices. Native stop() is idempotent so the subsequent React
|
|
||||||
// unmount cleanup is still safe.
|
|
||||||
videoRef.current?.destroy().catch(() => {});
|
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
}, [
|
}, [
|
||||||
nextItem,
|
nextItem,
|
||||||
@@ -1137,7 +1115,6 @@ export default function DirectPlayerPage() {
|
|||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
isPlaybackStopped,
|
isPlaybackStopped,
|
||||||
videoRef,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply subtitle settings when video loads
|
// Apply subtitle settings when video loads
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { onlineManager, QueryClient } from "@tanstack/react-query";
|
|||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
@@ -101,22 +100,6 @@ SplashScreen.setOptions({
|
|||||||
fade: true,
|
fade: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cap expo-image's in-memory cache. Default is unbounded (maxMemoryCost=0),
|
|
||||||
// which on a 2GB Android TV box leads to ~200MB of decoded backdrops/posters
|
|
||||||
// pinned in RAM after browsing. Caps are intentionally tighter on TV (which
|
|
||||||
// has less RAM and runs alongside libmpv/MediaCodec) than on mobile.
|
|
||||||
// Cost is measured in bytes of decoded bitmap (ARGB8888 = 4 bytes/pixel).
|
|
||||||
try {
|
|
||||||
Image.configureCache({
|
|
||||||
maxMemoryCost: Platform.isTV
|
|
||||||
? 8 * 1024 * 1024 // ~8 MB on TV
|
|
||||||
: 128 * 1024 * 1024, // ~128 MB on mobile
|
|
||||||
maxDiskSize: 200 * 1024 * 1024, // 200 MB disk cache on all platforms
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// configureCache is a no-op on some platforms/versions; safe to ignore.
|
|
||||||
}
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
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 { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
|
import { AddToKefinWatchlist } from "./AddToKefinWatchlist";
|
||||||
import { AddToWatchlist } from "./AddToWatchlist";
|
import { AddToWatchlist } from "./AddToWatchlist";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
@@ -138,6 +139,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings.useKefinTweaks && (
|
||||||
|
<AddToKefinWatchlist item={item} />
|
||||||
|
)}
|
||||||
{settings.streamyStatsServerUrl &&
|
{settings.streamyStatsServerUrl &&
|
||||||
!settings.hideWatchlistsTab && (
|
!settings.hideWatchlistsTab && (
|
||||||
<AddToWatchlist item={item} />
|
<AddToWatchlist item={item} />
|
||||||
@@ -160,6 +164,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings.useKefinTweaks && (
|
||||||
|
<AddToKefinWatchlist item={item} />
|
||||||
|
)}
|
||||||
{settings.streamyStatsServerUrl &&
|
{settings.streamyStatsServerUrl &&
|
||||||
!settings.hideWatchlistsTab && (
|
!settings.hideWatchlistsTab && (
|
||||||
<AddToWatchlist item={item} />
|
<AddToWatchlist item={item} />
|
||||||
@@ -178,6 +185,7 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
|||||||
settings.hideRemoteSessionButton,
|
settings.hideRemoteSessionButton,
|
||||||
settings.streamyStatsServerUrl,
|
settings.streamyStatsServerUrl,
|
||||||
settings.hideWatchlistsTab,
|
settings.hideWatchlistsTab,
|
||||||
|
settings.useKefinTweaks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
TVRefreshButton,
|
TVRefreshButton,
|
||||||
TVSeriesNavigation,
|
TVSeriesNavigation,
|
||||||
TVTechnicalDetails,
|
TVTechnicalDetails,
|
||||||
|
TVWatchlistButton,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import type { Track } from "@/components/video-player/controls/types";
|
import type { Track } from "@/components/video-player/controls/types";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
@@ -752,6 +753,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
</Text>
|
</Text>
|
||||||
</TVButton>
|
</TVButton>
|
||||||
<TVFavoriteButton item={item} />
|
<TVFavoriteButton item={item} />
|
||||||
|
{settings.useKefinTweaks && <TVWatchlistButton item={item} />}
|
||||||
<TVPlayedButton item={item} />
|
<TVPlayedButton item={item} />
|
||||||
<TVRefreshButton itemId={item.Id} />
|
<TVRefreshButton itemId={item.Id} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
|
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -155,6 +157,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||||
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
@@ -183,36 +187,66 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const options: string[] = [
|
// Build options as { label, action } so dynamic entries (watchlist,
|
||||||
t("common.mark_as_played"),
|
// offline delete) don't break index-based handling.
|
||||||
t("common.mark_as_not_played"),
|
const actions: {
|
||||||
isFavorite
|
label: string;
|
||||||
? t("music.track_options.remove_from_favorites")
|
action: () => void;
|
||||||
: t("music.track_options.add_to_favorites"),
|
destructive?: boolean;
|
||||||
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
}[] = [
|
||||||
t("common.cancel"),
|
{
|
||||||
|
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 cancelButtonIndex = options.length - 1;
|
||||||
const destructiveButtonIndex = isOffline
|
const destructiveButtonIndex = actions.findIndex((a) => a.destructive);
|
||||||
? cancelButtonIndex - 1
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
options,
|
options,
|
||||||
cancelButtonIndex,
|
cancelButtonIndex,
|
||||||
destructiveButtonIndex,
|
destructiveButtonIndex:
|
||||||
|
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
|
||||||
},
|
},
|
||||||
async (selectedIndex) => {
|
(selectedIndex) => {
|
||||||
if (selectedIndex === 0) {
|
if (selectedIndex === undefined || selectedIndex >= actions.length)
|
||||||
await markAsPlayedStatus(true);
|
return;
|
||||||
} else if (selectedIndex === 1) {
|
actions[selectedIndex].action();
|
||||||
await markAsPlayedStatus(false);
|
|
||||||
} else if (selectedIndex === 2) {
|
|
||||||
toggleFavorite();
|
|
||||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
@@ -220,6 +254,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
isFavorite,
|
isFavorite,
|
||||||
markAsPlayedStatus,
|
markAsPlayedStatus,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
|
isWatchlisted,
|
||||||
|
toggleWatchlist,
|
||||||
|
settings?.useKefinTweaks,
|
||||||
isOffline,
|
isOffline,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
item.Id,
|
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 { 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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
@@ -22,7 +25,24 @@ type FavoriteTypes =
|
|||||||
| "Playlist";
|
| "Playlist";
|
||||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
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?: "kefintweaksWatchlist" | "favorites";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Favorites = ({
|
||||||
|
filter = "IsFavorite",
|
||||||
|
queryKeyBase = "favorites",
|
||||||
|
emptyTitleKey = "favorites.noDataTitle",
|
||||||
|
emptyTextKey = "favorites.noData",
|
||||||
|
seeAllNamespace = "favorites",
|
||||||
|
}: FavoritesProps = {}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -46,7 +66,7 @@ export const Favorites = () => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: ["Ascending"],
|
||||||
filters: ["IsFavorite"],
|
filters: [filter],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
collapseBoxSetItems: false,
|
collapseBoxSetItems: false,
|
||||||
@@ -68,10 +88,13 @@ export const Favorites = () => {
|
|||||||
|
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
[api, user],
|
[api, user, filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset empty state when component mounts or dependencies change
|
// Reset empty state when the account or active view changes. `filter`
|
||||||
|
// matters because switching the favorites/watchlist toggle swaps this
|
||||||
|
// component's props in place (no remount), so stale per-type emptiness
|
||||||
|
// from the previous view must be cleared until the new queries resolve.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEmptyState({
|
setEmptyState({
|
||||||
Series: false,
|
Series: false,
|
||||||
@@ -81,7 +104,7 @@ export const Favorites = () => {
|
|||||||
BoxSet: false,
|
BoxSet: false,
|
||||||
Playlist: false,
|
Playlist: false,
|
||||||
});
|
});
|
||||||
}, [api, user]);
|
}, [api, user, filter]);
|
||||||
|
|
||||||
// Check if all categories that have been loaded are empty
|
// Check if all categories that have been loaded are empty
|
||||||
const areAllEmpty = () => {
|
const areAllEmpty = () => {
|
||||||
@@ -123,47 +146,26 @@ export const Favorites = () => {
|
|||||||
[fetchFavoritesByType, pageSize],
|
[fetchFavoritesByType, pageSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSeeAllSeries = useCallback(() => {
|
// Navigate to the shared see-all screen. `name` is the capitalized type
|
||||||
router.push({
|
// suffix of the see-all header key (e.g. "Series" -> "seeAllSeries").
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
// The namespace is branched explicitly so each t() call has a static prefix
|
||||||
params: { type: "Series", title: t("favorites.series") },
|
// (favorites.seeAll* / kefintweaksWatchlist.seeAll*) that the i18n usage
|
||||||
} as any);
|
// checker can detect — see scripts/check-i18n-keys.mjs. The `as any` is
|
||||||
}, [router]);
|
// needed because the route's custom params aren't part of expo-router's
|
||||||
|
// typed Href.
|
||||||
const handleSeeAllMovies = useCallback(() => {
|
const seeAll = useCallback(
|
||||||
router.push({
|
(type: FavoriteTypes, name: string) => {
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
const title =
|
||||||
params: { type: "Movie", title: t("favorites.movies") },
|
seeAllNamespace === "kefintweaksWatchlist"
|
||||||
} as any);
|
? t(`kefintweaksWatchlist.seeAll${name}`)
|
||||||
}, [router]);
|
: t(`favorites.seeAll${name}`);
|
||||||
|
router.push({
|
||||||
const handleSeeAllEpisodes = useCallback(() => {
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
router.push({
|
params: { type, title, filter },
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
} as any);
|
||||||
params: { type: "Episode", title: t("favorites.episodes") },
|
},
|
||||||
} as any);
|
[router, filter, seeAllNamespace],
|
||||||
}, [router]);
|
);
|
||||||
|
|
||||||
const handleSeeAllVideos = useCallback(() => {
|
|
||||||
router.push({
|
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
|
||||||
params: { type: "Video", title: t("favorites.videos") },
|
|
||||||
} as any);
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const handleSeeAllBoxsets = useCallback(() => {
|
|
||||||
router.push({
|
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
|
||||||
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
|
||||||
} as any);
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const handleSeeAllPlaylists = useCallback(() => {
|
|
||||||
router.push({
|
|
||||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
|
||||||
params: { type: "Playlist", title: t("favorites.playlists") },
|
|
||||||
} as any);
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-co gap-y-4'>
|
<View className='flex flex-co gap-y-4'>
|
||||||
@@ -176,61 +178,61 @@ export const Favorites = () => {
|
|||||||
source={heart}
|
source={heart}
|
||||||
/>
|
/>
|
||||||
<Text className='text-xl font-semibold text-white mb-2'>
|
<Text className='text-xl font-semibold text-white mb-2'>
|
||||||
{t("favorites.noDataTitle")}
|
{t(emptyTitleKey)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
|
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
|
||||||
{t("favorites.noData")}
|
{t(emptyTextKey)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteSeries}
|
queryFn={fetchFavoriteSeries}
|
||||||
queryKey={["home", "favorites", "series"]}
|
queryKey={["home", queryKeyBase, "series"]}
|
||||||
title={t("favorites.series")}
|
title={t("favorites.series")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPressSeeAll={handleSeeAllSeries}
|
onPressSeeAll={() => seeAll("Series", "Series")}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteMovies}
|
queryFn={fetchFavoriteMovies}
|
||||||
queryKey={["home", "favorites", "movies"]}
|
queryKey={["home", queryKeyBase, "movies"]}
|
||||||
title={t("favorites.movies")}
|
title={t("favorites.movies")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
orientation='vertical'
|
orientation='vertical'
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPressSeeAll={handleSeeAllMovies}
|
onPressSeeAll={() => seeAll("Movie", "Movies")}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteEpisodes}
|
queryFn={fetchFavoriteEpisodes}
|
||||||
queryKey={["home", "favorites", "episodes"]}
|
queryKey={["home", queryKeyBase, "episodes"]}
|
||||||
title={t("favorites.episodes")}
|
title={t("favorites.episodes")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPressSeeAll={handleSeeAllEpisodes}
|
onPressSeeAll={() => seeAll("Episode", "Episodes")}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteVideos}
|
queryFn={fetchFavoriteVideos}
|
||||||
queryKey={["home", "favorites", "videos"]}
|
queryKey={["home", queryKeyBase, "videos"]}
|
||||||
title={t("favorites.videos")}
|
title={t("favorites.videos")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPressSeeAll={handleSeeAllVideos}
|
onPressSeeAll={() => seeAll("Video", "Videos")}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteBoxsets}
|
queryFn={fetchFavoriteBoxsets}
|
||||||
queryKey={["home", "favorites", "boxsets"]}
|
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||||
title={t("favorites.boxsets")}
|
title={t("favorites.boxsets")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPressSeeAll={handleSeeAllBoxsets}
|
onPressSeeAll={() => seeAll("BoxSet", "Boxsets")}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoritePlaylists}
|
queryFn={fetchFavoritePlaylists}
|
||||||
queryKey={["home", "favorites", "playlists"]}
|
queryKey={["home", queryKeyBase, "playlists"]}
|
||||||
title={t("favorites.playlists")}
|
title={t("favorites.playlists")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPressSeeAll={handleSeeAllPlaylists}
|
onPressSeeAll={() => seeAll("Playlist", "Playlists")}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
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 { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -9,10 +12,12 @@ import { ScrollView, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import heart from "@/assets/icons/heart.fill.png";
|
import heart from "@/assets/icons/heart.fill.png";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
const TOP_PADDING = 100;
|
const TOP_PADDING = 100;
|
||||||
@@ -33,7 +38,27 @@ export const Favorites = () => {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
const pageSize = 20;
|
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>({
|
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||||
Series: false,
|
Series: false,
|
||||||
Movie: false,
|
Movie: false,
|
||||||
@@ -53,7 +78,7 @@ export const Favorites = () => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
sortOrder: ["Ascending"],
|
sortOrder: ["Ascending"],
|
||||||
filters: ["IsFavorite"],
|
filters: [filter],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
collapseBoxSetItems: false,
|
collapseBoxSetItems: false,
|
||||||
@@ -74,7 +99,7 @@ export const Favorites = () => {
|
|||||||
|
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
[api, user],
|
[api, user, filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,7 +111,7 @@ export const Favorites = () => {
|
|||||||
BoxSet: false,
|
BoxSet: false,
|
||||||
Playlist: false,
|
Playlist: false,
|
||||||
});
|
});
|
||||||
}, [api, user]);
|
}, [api, user, viewType]);
|
||||||
|
|
||||||
const areAllEmpty = () => {
|
const areAllEmpty = () => {
|
||||||
const loadedCategories = Object.values(emptyState);
|
const loadedCategories = Object.values(emptyState);
|
||||||
@@ -127,46 +152,63 @@ export const Favorites = () => {
|
|||||||
[fetchFavoritesByType, pageSize],
|
[fetchFavoritesByType, pageSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tabBadges = (
|
||||||
|
<TVFavoritesTabBadges
|
||||||
|
viewType={viewType}
|
||||||
|
setViewType={setViewType}
|
||||||
|
enabled={watchlistEnabled}
|
||||||
|
hasTVPreferredFocus={watchlistEnabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (areAllEmpty()) {
|
if (areAllEmpty()) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
justifyContent: "center",
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{tabBadges}
|
||||||
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 64,
|
flex: 1,
|
||||||
height: 64,
|
alignItems: "center",
|
||||||
marginBottom: 16,
|
justifyContent: "center",
|
||||||
tintColor: Colors.primary,
|
|
||||||
}}
|
|
||||||
contentFit='contain'
|
|
||||||
source={heart}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: typography.heading,
|
|
||||||
fontWeight: "bold",
|
|
||||||
marginBottom: 8,
|
|
||||||
color: "#FFFFFF",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("favorites.noDataTitle")}
|
<Image
|
||||||
</Text>
|
style={{
|
||||||
<Text
|
width: 64,
|
||||||
style={{
|
height: 64,
|
||||||
textAlign: "center",
|
marginBottom: 16,
|
||||||
opacity: 0.7,
|
tintColor: Colors.primary,
|
||||||
fontSize: typography.body,
|
}}
|
||||||
color: "#FFFFFF",
|
contentFit='contain'
|
||||||
}}
|
source={heart}
|
||||||
>
|
/>
|
||||||
{t("favorites.noData")}
|
<Text
|
||||||
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -181,17 +223,22 @@ export const Favorites = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
|
{watchlistEnabled && (
|
||||||
|
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
|
||||||
|
{tabBadges}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteSeries}
|
queryFn={fetchFavoriteSeries}
|
||||||
queryKey={["home", "favorites", "series"]}
|
queryKey={["home", queryKeyBase, "series"]}
|
||||||
title={t("favorites.series")}
|
title={t("favorites.series")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
isFirstSection
|
isFirstSection={!watchlistEnabled}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteMovies}
|
queryFn={fetchFavoriteMovies}
|
||||||
queryKey={["home", "favorites", "movies"]}
|
queryKey={["home", queryKeyBase, "movies"]}
|
||||||
title={t("favorites.movies")}
|
title={t("favorites.movies")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
orientation='vertical'
|
orientation='vertical'
|
||||||
@@ -199,28 +246,28 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteEpisodes}
|
queryFn={fetchFavoriteEpisodes}
|
||||||
queryKey={["home", "favorites", "episodes"]}
|
queryKey={["home", queryKeyBase, "episodes"]}
|
||||||
title={t("favorites.episodes")}
|
title={t("favorites.episodes")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteVideos}
|
queryFn={fetchFavoriteVideos}
|
||||||
queryKey={["home", "favorites", "videos"]}
|
queryKey={["home", queryKeyBase, "videos"]}
|
||||||
title={t("favorites.videos")}
|
title={t("favorites.videos")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteBoxsets}
|
queryFn={fetchFavoriteBoxsets}
|
||||||
queryKey={["home", "favorites", "boxsets"]}
|
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||||
title={t("favorites.boxsets")}
|
title={t("favorites.boxsets")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoritePlaylists}
|
queryFn={fetchFavoritePlaylists}
|
||||||
queryKey={["home", "favorites", "playlists"]}
|
queryKey={["home", queryKeyBase, "playlists"]}
|
||||||
title={t("favorites.playlists")}
|
title={t("favorites.playlists")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
|||||||
@@ -140,11 +140,9 @@ export const Home = () => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Prefetch to disk only - the full-size 1920x1080 backdrop (~8MB
|
// Prefetch the image before starting the crossfade
|
||||||
// decoded ARGB) is too large to pin in the memory cache on every
|
|
||||||
// focus change. Disk cache is fast enough for a 500ms crossfade.
|
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl, "disk");
|
await Image.prefetch(backdropUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -326,9 +326,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onEndReached={handleEndReached}
|
onEndReached={handleEndReached}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
initialNumToRender={4}
|
initialNumToRender={5}
|
||||||
maxToRenderPerBatch={2}
|
maxToRenderPerBatch={3}
|
||||||
windowSize={3}
|
windowSize={5}
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
|
|||||||
@@ -256,11 +256,8 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Disk-only prefetch: backdrops are ~8MB decoded ARGB; keeping them
|
|
||||||
// out of the memory cache avoids bloat when the user cycles through
|
|
||||||
// hero items quickly.
|
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl, "disk");
|
await Image.prefetch(backdropUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,9 +156,9 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
|||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
const performCrossfade = async () => {
|
const performCrossfade = async () => {
|
||||||
// Disk-only prefetch to avoid pinning large backdrops in memory cache.
|
// Prefetch the image before starting the crossfade
|
||||||
try {
|
try {
|
||||||
await Image.prefetch(backdropUrl, "disk");
|
await Image.prefetch(backdropUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Continue even if prefetch fails
|
// Continue even if prefetch fails
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,8 +448,8 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
|||||||
<Image
|
<Image
|
||||||
placeholder={{ blurhash }}
|
placeholder={{ blurhash }}
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
source={{ uri: imageUrl }}
|
source={{ uri: imageUrl }}
|
||||||
recyclingKey={item.Id}
|
|
||||||
cachePolicy='memory-disk'
|
cachePolicy='memory-disk'
|
||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -70,3 +70,5 @@ export { TVTrackCard } from "./TVTrackCard";
|
|||||||
// User switching
|
// User switching
|
||||||
export type { TVUserCardProps } from "./TVUserCard";
|
export type { TVUserCardProps } from "./TVUserCard";
|
||||||
export { TVUserCard } from "./TVUserCard";
|
export { TVUserCard } from "./TVUserCard";
|
||||||
|
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
|
||||||
|
export { TVWatchlistButton } from "./TVWatchlistButton";
|
||||||
|
|||||||
@@ -342,12 +342,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info?.cacheSeconds !== undefined && (
|
{info?.cacheSeconds !== undefined && (
|
||||||
<Text style={textStyle}>
|
<Text style={textStyle}>
|
||||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||||
{info?.demuxerMaxBytes !== undefined
|
|
||||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
|
||||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
|
||||||
`${info?.cacheSecsLimit !== undefined && info.cacheSecsLimit < 3600 ? ` · ${info.cacheSecsLimit.toFixed(0)}s` : ""}` +
|
|
||||||
")"
|
|
||||||
: ""}
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.voDriver && (
|
{info?.voDriver && (
|
||||||
@@ -356,12 +350,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{info?.estimatedVfFps !== undefined && (
|
|
||||||
<Text style={textStyle}>
|
|
||||||
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
|
||||||
{info?.fps ? ` (container ${formatFps(info.fps)})` : ""}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||||
<Text style={[textStyle, styles.warningText]}>
|
<Text style={[textStyle, styles.warningText]}>
|
||||||
Dropped: {info.droppedFrames} frames
|
Dropped: {info.droppedFrames} frames
|
||||||
|
|||||||
149
hooks/useWatchlist.ts
Normal file
149
hooks/useWatchlist.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Shared atom to store watchlist (Likes) status across all components
|
||||||
|
// Maps itemId -> isWatchlisted
|
||||||
|
const watchlistAtom = atom<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KefinTweaks watchlist is backed by Jellyfin's native "Likes" rating.
|
||||||
|
* Toggling watchlist membership toggles UserData.Likes on the item.
|
||||||
|
*/
|
||||||
|
export const useWatchlist = (item: BaseItemDto) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [watchlist, setWatchlist] = useAtom(watchlistAtom);
|
||||||
|
|
||||||
|
const 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) {
|
||||||
|
throw new Error("Cannot update watchlist: not signed in");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watchlist == Jellyfin "Likes" rating:
|
||||||
|
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=true - add to watchlist
|
||||||
|
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=false - remove from watchlist
|
||||||
|
const path = `/UserItems/${currentItem.Id}/Rating`;
|
||||||
|
|
||||||
|
const response = await currentApi.post(
|
||||||
|
path,
|
||||||
|
{},
|
||||||
|
{ params: { userId: currentUser.Id, likes: nextIsWatchlisted } },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onMutate: async (nextIsWatchlisted: boolean) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
|
||||||
|
|
||||||
|
const previousIsWatchlisted = isWatchlisted;
|
||||||
|
const previousQueries = queryClient.getQueriesData<BaseItemDto | null>({
|
||||||
|
queryKey: itemQueryKeyPrefix,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsWatchlisted(nextIsWatchlisted);
|
||||||
|
updateItemInQueries({ UserData: { Likes: nextIsWatchlisted } });
|
||||||
|
|
||||||
|
return { previousIsWatchlisted, previousQueries };
|
||||||
|
},
|
||||||
|
onError: (error: Error, _nextIsWatchlisted, context) => {
|
||||||
|
// Roll back the optimistic Likes flip applied in onMutate.
|
||||||
|
if (context?.previousQueries) {
|
||||||
|
for (const [queryKey, data] of context.previousQueries) {
|
||||||
|
queryClient.setQueryData(queryKey, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsWatchlisted(context?.previousIsWatchlisted);
|
||||||
|
toast.error(error.message || "Failed to update watchlist");
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["home", "watchlist"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleWatchlist = useCallback(() => {
|
||||||
|
watchlistMutation.mutate(!isWatchlisted);
|
||||||
|
}, [watchlistMutation, isWatchlisted]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isWatchlisted,
|
||||||
|
toggleWatchlist,
|
||||||
|
watchlistMutation,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -53,5 +53,5 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// libmpv from Maven Central
|
// libmpv from Maven Central
|
||||||
implementation 'dev.jdtech.mpv:libmpv:1.0.0'
|
implementation 'dev.jdtech.mpv:libmpv:0.5.1'
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
modules/mpv-player/android/src/main/assets/subfont.ttf
Normal file
BIN
modules/mpv-player/android/src/main/assets/subfont.ttf
Normal file
Binary file not shown.
@@ -3,14 +3,14 @@ package expo.modules.mpvplayer
|
|||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.content.res.AssetManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.system.Os
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MPV renderer that wraps libmpv for video playback.
|
* MPV renderer that wraps libmpv for video playback.
|
||||||
@@ -76,15 +76,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
private var surface: Surface? = null
|
private var surface: Surface? = null
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
|
private var isStopping = false
|
||||||
// This renderer's own mpv handle. Per-instance (not singleton) — each
|
|
||||||
// player screen gets a fresh mpv handle and drops the reference on stop.
|
|
||||||
// We intentionally do NOT call a destroy() equivalent: libmpv 1.0's
|
|
||||||
// nativeDestroy has an internal use-after-free we can't fix from Kotlin,
|
|
||||||
// so we mirror Findroid and let the JVM GC + native finalization path
|
|
||||||
// reclaim resources. Only one player is alive at a time in this app.
|
|
||||||
private var mpv: MPVLib? = null
|
|
||||||
|
|
||||||
// Cached state
|
// Cached state
|
||||||
private var cachedPosition: Double = 0.0
|
private var cachedPosition: Double = 0.0
|
||||||
private var cachedDuration: Double = 0.0
|
private var cachedDuration: Double = 0.0
|
||||||
@@ -144,108 +137,106 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
fun start(voDriver: String = "gpu-next") {
|
fun start(voDriver: String = "gpu-next") {
|
||||||
if (isRunning) return
|
if (isRunning) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Per-instance handle — see class-level comment. Each player gets
|
MPVLib.create(context)
|
||||||
// its own mpv; we drop the reference in stop().
|
MPVLib.addObserver(this)
|
||||||
val mpv = MPVLib.create(context)
|
|
||||||
this.mpv = mpv
|
/**
|
||||||
mpv.addObserver(this)
|
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
|
||||||
|
*
|
||||||
// Resolved once — TV gets the memory-pressure customizations
|
* Technical Background:
|
||||||
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
* ====================
|
||||||
// audio-buffer) that would be counterproductive on higher-RAM
|
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
|
||||||
// mobile devices. Demuxer cache sizes are NOT included here —
|
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
|
||||||
// those come from user settings via load().
|
* even when subtitle tracks are properly detected and loaded.
|
||||||
val isTV = isTvDevice()
|
*
|
||||||
|
* Why This Is Necessary:
|
||||||
// mpv config directory — used by the config-dir option below and
|
* =====================
|
||||||
// as XDG_CONFIG_HOME for fontconfig.
|
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
|
||||||
|
* mpv cannot access them directly due to sandboxing and library isolation.
|
||||||
|
*
|
||||||
|
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
|
||||||
|
* configured directory, mpv either:
|
||||||
|
* - Fails silently (subtitles don't appear)
|
||||||
|
* - Falls back to a default font that may not support the required character set
|
||||||
|
* - Crashes or produces rendering errors
|
||||||
|
*
|
||||||
|
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
|
||||||
|
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
|
||||||
|
*
|
||||||
|
* Reference:
|
||||||
|
* =========
|
||||||
|
* This workaround is documented in the mpv-android project:
|
||||||
|
* https://github.com/mpv-android/mpv-android/issues/96
|
||||||
|
*
|
||||||
|
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
|
||||||
|
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
|
||||||
|
*/
|
||||||
|
// Create mpv config directory and copy font files
|
||||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||||
|
//Log.i(TAG, "mpv config dir: $mpvDir")
|
||||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
if (!mpvDir.exists()) mpvDir.mkdirs()
|
||||||
|
// This needs to be named `subfont.ttf` else it won't work
|
||||||
// Point fontconfig (new in libmpv 1.0) at writable app dirs so it
|
arrayOf("subfont.ttf").forEach { fileName ->
|
||||||
// persists its font index across runs instead of re-walking
|
val file = File(mpvDir, fileName)
|
||||||
// /system/fonts on every subtitle/seek event. Each rebuild costs
|
if (file.exists()) return@forEach
|
||||||
// ~1-2 s and ~10-30 MB of scudo:primary memory that scudo then
|
context.assets
|
||||||
// holds onto. Without this we see "No usable fontconfig
|
.open(fileName, AssetManager.ACCESS_STREAMING)
|
||||||
// configuration file found, using fallback" on every re-init.
|
.copyTo(FileOutputStream(file))
|
||||||
try {
|
|
||||||
val cacheDir = context.cacheDir.absolutePath
|
|
||||||
val configDir = (context.getExternalFilesDir(null) ?: context.filesDir).absolutePath
|
|
||||||
Os.setenv("XDG_CACHE_HOME", cacheDir, true)
|
|
||||||
Os.setenv("XDG_CONFIG_HOME", configDir, true)
|
|
||||||
Os.setenv("HOME", configDir, true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Could not set XDG/HOME env for fontconfig: ${e.message}")
|
|
||||||
}
|
}
|
||||||
|
MPVLib.setOptionString("config", "yes")
|
||||||
mpv?.setOptionString("config", "yes")
|
MPVLib.setOptionString("config-dir", mpvDir.path)
|
||||||
mpv?.setOptionString("config-dir", mpvDir.path)
|
|
||||||
|
|
||||||
// Configure mpv options before initialization (based on Findroid)
|
// Configure mpv options before initialization (based on Findroid)
|
||||||
this.voDriver = voDriver
|
this.voDriver = voDriver
|
||||||
mpv?.setOptionString("vo", voDriver)
|
MPVLib.setOptionString("vo", voDriver)
|
||||||
mpv?.setOptionString("gpu-context", "android")
|
MPVLib.setOptionString("gpu-context", "android")
|
||||||
mpv?.setOptionString("opengl-es", "yes")
|
MPVLib.setOptionString("opengl-es", "yes")
|
||||||
|
|
||||||
// Hardware decoder codecs (shared)
|
// Hardware decode path:
|
||||||
mpv?.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
// - Real TV hardware: zero-copy `mediacodec` (fastest on low-power devices).
|
||||||
|
|
||||||
// Pause on initial cache fill (shared default). The actual
|
|
||||||
// cache mode, cache-secs, and demuxer cache sizes come from
|
|
||||||
// user preferences and are applied per-load in load().
|
|
||||||
mpv?.setOptionString("cache-pause-initial", "yes")
|
|
||||||
|
|
||||||
// Hardware decode path + TV-only memory options. Demuxer cache
|
|
||||||
// sizes and cache-secs are NOT set here — they come from user
|
|
||||||
// preferences via load().
|
|
||||||
// - Emulator: software decode. Its MediaCodec can't bind an
|
|
||||||
// output surface (surface 0x0); HEVC then fails cleanly and
|
|
||||||
// mpv auto-falls-back to software, but H.264 "opens"
|
|
||||||
// deceptively and wedges the core with no fallback (black
|
|
||||||
// video, then any command — seek/pause — deadlocks the UI
|
|
||||||
// thread → ANR). hwdec=no makes every codec render via the
|
|
||||||
// gpu-next VO. Real devices unaffected.
|
|
||||||
// - Real TV hardware: zero-copy `mediacodec` (fastest on
|
|
||||||
// low-power devices) + fast profile.
|
|
||||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
||||||
|
// - Emulator: software decode. Its MediaCodec can't bind an output surface
|
||||||
|
// (surface 0x0); HEVC then fails cleanly and mpv auto-falls-back to software,
|
||||||
|
// but H.264 "opens" deceptively and wedges the core with no fallback (black
|
||||||
|
// video, then any command — seek/pause — deadlocks the UI thread → ANR).
|
||||||
|
// hwdec=no makes every codec render via the gpu-next VO. Real devices unaffected.
|
||||||
when {
|
when {
|
||||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
isEmulator() -> MPVLib.setOptionString("hwdec", "no")
|
||||||
isTV -> {
|
isTvDevice() -> {
|
||||||
mpv?.setOptionString("hwdec", "mediacodec")
|
MPVLib.setOptionString("hwdec", "mediacodec")
|
||||||
mpv?.setOptionString("profile", "fast")
|
MPVLib.setOptionString("profile", "fast")
|
||||||
// Don't retain already-played content for backward
|
|
||||||
// seeking over a network source — Jellyfin can re-fetch
|
|
||||||
// on demand. Saves up to ~30 MiB on long seeks and
|
|
||||||
// reduces swap pressure.
|
|
||||||
mpv?.setOptionString("demuxer-seekable-cache", "no")
|
|
||||||
// Larger audio buffer to absorb page-fault stalls
|
|
||||||
// (default ~0.2s). Cheap insurance against the audio
|
|
||||||
// underruns that happen when the kernel is swap-thrashing.
|
|
||||||
mpv?.setOptionString("audio-buffer", "0.5")
|
|
||||||
}
|
}
|
||||||
else -> mpv?.setOptionString("hwdec", "mediacodec-copy")
|
else -> MPVLib.setOptionString("hwdec", "mediacodec-copy")
|
||||||
}
|
}
|
||||||
|
MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1")
|
||||||
|
|
||||||
|
// Cache settings for better network streaming
|
||||||
|
MPVLib.setOptionString("cache", "yes")
|
||||||
|
MPVLib.setOptionString("cache-pause-initial", "yes")
|
||||||
|
MPVLib.setOptionString("demuxer-max-bytes", "150MiB")
|
||||||
|
MPVLib.setOptionString("demuxer-max-back-bytes", "75MiB")
|
||||||
|
MPVLib.setOptionString("demuxer-readahead-secs", "20")
|
||||||
|
|
||||||
// Seeking optimization - faster seeking at the cost of less precision
|
// Seeking optimization - faster seeking at the cost of less precision
|
||||||
// Use keyframe seeking by default (much faster for network streams)
|
// Use keyframe seeking by default (much faster for network streams)
|
||||||
mpv?.setOptionString("hr-seek", "no")
|
MPVLib.setOptionString("hr-seek", "no")
|
||||||
// Drop frames during seeking for faster response
|
// Drop frames during seeking for faster response
|
||||||
mpv?.setOptionString("hr-seek-framedrop", "yes")
|
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
||||||
|
|
||||||
// Subtitle settings
|
// Subtitle settings
|
||||||
mpv?.setOptionString("sub-scale-with-window", "no")
|
MPVLib.setOptionString("sub-scale-with-window", "no")
|
||||||
mpv?.setOptionString("sub-use-margins", "no")
|
MPVLib.setOptionString("sub-use-margins", "no")
|
||||||
mpv?.setOptionString("subs-match-os-language", "yes")
|
MPVLib.setOptionString("subs-match-os-language", "yes")
|
||||||
mpv?.setOptionString("subs-fallback", "yes")
|
MPVLib.setOptionString("subs-fallback", "yes")
|
||||||
|
|
||||||
// Important: Start with force-window=no, will be set to yes when surface is attached
|
// Important: Start with force-window=no, will be set to yes when surface is attached
|
||||||
mpv?.setOptionString("force-window", "no")
|
MPVLib.setOptionString("force-window", "no")
|
||||||
mpv?.setOptionString("keep-open", "always")
|
MPVLib.setOptionString("keep-open", "always")
|
||||||
|
|
||||||
mpv.initialize()
|
MPVLib.initialize()
|
||||||
|
|
||||||
// Observe properties
|
// Observe properties
|
||||||
observeProperties()
|
observeProperties()
|
||||||
|
|
||||||
@@ -258,68 +249,21 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
if (isStopping) return
|
||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
|
|
||||||
|
isStopping = true
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
val m = mpv
|
try {
|
||||||
mpv = null
|
MPVLib.removeObserver(this)
|
||||||
|
MPVLib.detachSurface()
|
||||||
// Clear cached media state on the main thread so the next player
|
MPVLib.destroy()
|
||||||
// screen doesn't observe stale position/duration values during the
|
} catch (e: Exception) {
|
||||||
// (async) teardown below.
|
Log.e(TAG, "Error stopping MPV: ${e.message}")
|
||||||
currentUrl = null
|
}
|
||||||
currentHeaders = null
|
|
||||||
pendingExternalSubtitles = emptyList()
|
isStopping = false
|
||||||
initialSubtitleId = null
|
|
||||||
initialAudioId = null
|
|
||||||
cachedPosition = 0.0
|
|
||||||
cachedDuration = 0.0
|
|
||||||
cachedCacheSeconds = 0.0
|
|
||||||
|
|
||||||
if (m == null) return
|
|
||||||
|
|
||||||
// Teardown runs on a background daemon thread. mpv's "stop" command
|
|
||||||
// flushes the demuxer queue and releases the MediaCodec hardware
|
|
||||||
// decoder — synchronous JNI work that can block for hundreds of ms
|
|
||||||
// on TV hardware. Running it on the main thread produced a visible
|
|
||||||
// delay/stutter between pressing "exit" and the confirm alert
|
|
||||||
// appearing. The local `m` keeps the MPVLib instance alive for the
|
|
||||||
// lifetime of this thread even though we've already nulled `mpv`.
|
|
||||||
Thread {
|
|
||||||
// Drop force-window BEFORE issuing stop. With keep-open=always +
|
|
||||||
// force-window=yes, mpv tears down the decoder at stop time but
|
|
||||||
// tries to keep the VO alive — which fires an internal
|
|
||||||
// video-reconfig. On libmpv 1.0's gpu-next/android backend that
|
|
||||||
// reconfig path crashes with "Missing surface pointer" because we
|
|
||||||
// detach the Surface below before mpv's worker reaches the
|
|
||||||
// reconfig step (command() is async). Setting force-window=no
|
|
||||||
// first makes mpv tear VO down cleanly instead of attempting a
|
|
||||||
// doomed re-init, eliminating the fatal VO error and the
|
|
||||||
// "playback won't restart" aftermath.
|
|
||||||
try {
|
|
||||||
m.setOptionString("force-window", "no")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error clearing force-window: ${e.message}")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Stop playback — flushes demuxer queue and signals MediaCodec
|
|
||||||
// to release its hardware decoders. This is the bulk of what
|
|
||||||
// we can reclaim without calling destroy().
|
|
||||||
m.command(arrayOf("stop"))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error stopping mpv playback: ${e.message}")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
m.removeObserver(this)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error removing mpv observer: ${e.message}")
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
m.detachSurface()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error detaching mpv surface: ${e.message}")
|
|
||||||
}
|
|
||||||
}.also { it.isDaemon = true }.start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,10 +278,10 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.surface = surface
|
this.surface = surface
|
||||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
mpv?.attachSurface(surface)
|
MPVLib.attachSurface(surface)
|
||||||
mpv?.setOptionString("force-window", "yes")
|
MPVLib.setOptionString("force-window", "yes")
|
||||||
// Read back vo to confirm it's still active
|
// Read back vo to confirm it's still active
|
||||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,8 +301,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
this.surface = null
|
this.surface = null
|
||||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
mpv?.detachSurface()
|
MPVLib.detachSurface()
|
||||||
val activeVo = try { mpv?.getPropertyString("vo") } catch (e: Exception) { null }
|
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,7 +313,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
*/
|
*/
|
||||||
fun updateSurfaceSize(width: Int, height: Int) {
|
fun updateSurfaceSize(width: Int, height: Int) {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
mpv?.setPropertyString("android-surface-size", "${width}x$height")
|
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
||||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||||
@@ -385,9 +329,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
val pos = cachedPosition
|
val pos = cachedPosition
|
||||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||||
mpv?.command(arrayOf("frame-step"))
|
MPVLib.command(arrayOf("frame-step"))
|
||||||
if (pos > 0) {
|
if (pos > 0) {
|
||||||
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,43 +341,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
startPosition: Double? = null,
|
startPosition: Double? = null,
|
||||||
externalSubtitles: List<String>? = null,
|
externalSubtitles: List<String>? = null,
|
||||||
initialSubtitleId: Int? = null,
|
initialSubtitleId: Int? = null,
|
||||||
initialAudioId: Int? = null,
|
initialAudioId: Int? = null
|
||||||
cacheEnabled: String? = null,
|
|
||||||
cacheSeconds: Int? = null,
|
|
||||||
demuxerMaxBytes: Int? = null,
|
|
||||||
demuxerMaxBackBytes: Int? = null
|
|
||||||
) {
|
) {
|
||||||
currentUrl = url
|
currentUrl = url
|
||||||
currentHeaders = headers
|
currentHeaders = headers
|
||||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||||
this.initialSubtitleId = initialSubtitleId
|
this.initialSubtitleId = initialSubtitleId
|
||||||
this.initialAudioId = initialAudioId
|
this.initialAudioId = initialAudioId
|
||||||
|
|
||||||
_isLoading = true
|
_isLoading = true
|
||||||
isReadyToSeek = false
|
isReadyToSeek = false
|
||||||
mainHandler.post { delegate?.onLoadingChanged(true) }
|
mainHandler.post { delegate?.onLoadingChanged(true) }
|
||||||
|
|
||||||
// Stop previous playback
|
// Stop previous playback
|
||||||
mpv?.command(arrayOf("stop"))
|
MPVLib.command(arrayOf("stop"))
|
||||||
|
|
||||||
// Set HTTP headers if provided
|
// Set HTTP headers if provided
|
||||||
updateHttpHeaders(headers)
|
updateHttpHeaders(headers)
|
||||||
|
|
||||||
// Apply cache/buffer settings from user preferences (mirrors iOS).
|
|
||||||
// These override the conservative defaults applied in start() so the
|
|
||||||
// TV/mobile settings screen actually takes effect on Android.
|
|
||||||
cacheEnabled?.let { mpv?.setOptionString("cache", it) }
|
|
||||||
cacheSeconds?.let { mpv?.setOptionString("cache-secs", it.toString()) }
|
|
||||||
demuxerMaxBytes?.let { mpv?.setOptionString("demuxer-max-bytes", "${it}MiB") }
|
|
||||||
demuxerMaxBackBytes?.let { mpv?.setOptionString("demuxer-max-back-bytes", "${it}MiB") }
|
|
||||||
|
|
||||||
// Set start position. mpv's time parser requires '.' as the decimal
|
// Set start position
|
||||||
// separator; use Locale.US so devices with other default locales
|
|
||||||
// (e.g. ',' as decimal separator) don't break resume-from-position.
|
|
||||||
if (startPosition != null && startPosition > 0) {
|
if (startPosition != null && startPosition > 0) {
|
||||||
mpv?.setPropertyString("start", String.format(Locale.US, "%.2f", startPosition))
|
MPVLib.setPropertyString("start", String.format("%.2f", startPosition))
|
||||||
} else {
|
} else {
|
||||||
mpv?.setPropertyString("start", "0")
|
MPVLib.setPropertyString("start", "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial audio track if specified
|
// Set initial audio track if specified
|
||||||
@@ -453,7 +383,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load the file
|
// Load the file
|
||||||
mpv?.command(arrayOf("loadfile", url, "replace"))
|
MPVLib.command(arrayOf("loadfile", url, "replace"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reloadCurrentItem() {
|
fun reloadCurrentItem() {
|
||||||
@@ -469,29 +399,29 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
val headerString = headers.entries.joinToString("\r\n") { "${it.key}: ${it.value}" }
|
||||||
mpv?.setPropertyString("http-header-fields", headerString)
|
MPVLib.setPropertyString("http-header-fields", headerString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeProperties() {
|
private fun observeProperties() {
|
||||||
mpv?.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE)
|
||||||
mpv?.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE)
|
||||||
mpv?.observeProperty("pause", MPV_FORMAT_FLAG)
|
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||||
mpv?.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||||
mpv?.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||||
mpv?.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
||||||
// Video dimensions for PiP aspect ratio
|
// Video dimensions for PiP aspect ratio
|
||||||
mpv?.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||||
mpv?.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Playback Controls
|
// MARK: - Playback Controls
|
||||||
|
|
||||||
fun play() {
|
fun play() {
|
||||||
mpv?.setPropertyBoolean("pause", false)
|
MPVLib.setPropertyBoolean("pause", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pause() {
|
fun pause() {
|
||||||
mpv?.setPropertyBoolean("pause", true)
|
MPVLib.setPropertyBoolean("pause", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun togglePause() {
|
fun togglePause() {
|
||||||
@@ -501,22 +431,22 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun seekTo(seconds: Double) {
|
fun seekTo(seconds: Double) {
|
||||||
val clamped = maxOf(0.0, seconds)
|
val clamped = maxOf(0.0, seconds)
|
||||||
cachedPosition = clamped
|
cachedPosition = clamped
|
||||||
mpv?.command(arrayOf("seek", clamped.toString(), "absolute"))
|
MPVLib.command(arrayOf("seek", clamped.toString(), "absolute"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun seekBy(seconds: Double) {
|
fun seekBy(seconds: Double) {
|
||||||
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
val newPosition = maxOf(0.0, cachedPosition + seconds)
|
||||||
cachedPosition = newPosition
|
cachedPosition = newPosition
|
||||||
mpv?.command(arrayOf("seek", seconds.toString(), "relative"))
|
MPVLib.command(arrayOf("seek", seconds.toString(), "relative"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSpeed(speed: Double) {
|
fun setSpeed(speed: Double) {
|
||||||
_playbackSpeed = speed
|
_playbackSpeed = speed
|
||||||
mpv?.setPropertyDouble("speed", speed)
|
MPVLib.setPropertyDouble("speed", speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSpeed(): Double {
|
fun getSpeed(): Double {
|
||||||
return mpv?.getPropertyDouble("speed") ?: _playbackSpeed
|
return MPVLib.getPropertyDouble("speed") ?: _playbackSpeed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Controls
|
// MARK: - Subtitle Controls
|
||||||
@@ -524,19 +454,19 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||||
val tracks = mutableListOf<Map<String, Any>>()
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
for (i in 0 until trackCount) {
|
for (i in 0 until trackCount) {
|
||||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||||
if (trackType != "sub") continue
|
if (trackType != "sub") continue
|
||||||
|
|
||||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
|
|
||||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
|
|
||||||
tracks.add(track)
|
tracks.add(track)
|
||||||
@@ -548,61 +478,61 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun setSubtitleTrack(trackId: Int) {
|
fun setSubtitleTrack(trackId: Int) {
|
||||||
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
Log.i(TAG, "setSubtitleTrack: setting sid to $trackId")
|
||||||
if (trackId < 0) {
|
if (trackId < 0) {
|
||||||
mpv?.setPropertyString("sid", "no")
|
MPVLib.setPropertyString("sid", "no")
|
||||||
} else {
|
} else {
|
||||||
mpv?.setPropertyInt("sid", trackId)
|
MPVLib.setPropertyInt("sid", trackId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disableSubtitles() {
|
fun disableSubtitles() {
|
||||||
mpv?.setPropertyString("sid", "no")
|
MPVLib.setPropertyString("sid", "no")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentSubtitleTrack(): Int {
|
fun getCurrentSubtitleTrack(): Int {
|
||||||
return mpv?.getPropertyInt("sid") ?: 0
|
return MPVLib.getPropertyInt("sid") ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||||
val flag = if (select) "select" else "cached"
|
val flag = if (select) "select" else "cached"
|
||||||
mpv?.command(arrayOf("sub-add", url, flag))
|
MPVLib.command(arrayOf("sub-add", url, flag))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subtitle Positioning
|
// MARK: - Subtitle Positioning
|
||||||
|
|
||||||
fun setSubtitlePosition(position: Int) {
|
fun setSubtitlePosition(position: Int) {
|
||||||
mpv?.setPropertyInt("sub-pos", position)
|
MPVLib.setPropertyInt("sub-pos", position)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleScale(scale: Double) {
|
fun setSubtitleScale(scale: Double) {
|
||||||
mpv?.setPropertyDouble("sub-scale", scale)
|
MPVLib.setPropertyDouble("sub-scale", scale)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleMarginY(margin: Int) {
|
fun setSubtitleMarginY(margin: Int) {
|
||||||
mpv?.setPropertyInt("sub-margin-y", margin)
|
MPVLib.setPropertyInt("sub-margin-y", margin)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignX(alignment: String) {
|
fun setSubtitleAlignX(alignment: String) {
|
||||||
mpv?.setPropertyString("sub-align-x", alignment)
|
MPVLib.setPropertyString("sub-align-x", alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAlignY(alignment: String) {
|
fun setSubtitleAlignY(alignment: String) {
|
||||||
mpv?.setPropertyString("sub-align-y", alignment)
|
MPVLib.setPropertyString("sub-align-y", alignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleFontSize(size: Int) {
|
fun setSubtitleFontSize(size: Int) {
|
||||||
mpv?.setPropertyInt("sub-font-size", size)
|
MPVLib.setPropertyInt("sub-font-size", size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleBorderStyle(style: String) {
|
fun setSubtitleBorderStyle(style: String) {
|
||||||
mpv?.setPropertyString("sub-border-style", style)
|
MPVLib.setPropertyString("sub-border-style", style)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleBackgroundColor(color: String) {
|
fun setSubtitleBackgroundColor(color: String) {
|
||||||
mpv?.setPropertyString("sub-back-color", color)
|
MPVLib.setPropertyString("sub-back-color", color)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSubtitleAssOverride(mode: String) {
|
fun setSubtitleAssOverride(mode: String) {
|
||||||
mpv?.setPropertyString("sub-ass-override", mode)
|
MPVLib.setPropertyString("sub-ass-override", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Track Controls
|
// MARK: - Audio Track Controls
|
||||||
@@ -610,25 +540,25 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
fun getAudioTracks(): List<Map<String, Any>> {
|
fun getAudioTracks(): List<Map<String, Any>> {
|
||||||
val tracks = mutableListOf<Map<String, Any>>()
|
val tracks = mutableListOf<Map<String, Any>>()
|
||||||
|
|
||||||
val trackCount = mpv?.getPropertyInt("track-list/count") ?: 0
|
val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0
|
||||||
|
|
||||||
for (i in 0 until trackCount) {
|
for (i in 0 until trackCount) {
|
||||||
val trackType = mpv?.getPropertyString("track-list/$i/type") ?: continue
|
val trackType = MPVLib.getPropertyString("track-list/$i/type") ?: continue
|
||||||
if (trackType != "audio") continue
|
if (trackType != "audio") continue
|
||||||
|
|
||||||
val trackId = mpv?.getPropertyInt("track-list/$i/id") ?: continue
|
val trackId = MPVLib.getPropertyInt("track-list/$i/id") ?: continue
|
||||||
val track = mutableMapOf<String, Any>("id" to trackId)
|
val track = mutableMapOf<String, Any>("id" to trackId)
|
||||||
|
|
||||||
mpv?.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
MPVLib.getPropertyString("track-list/$i/title")?.let { track["title"] = it }
|
||||||
mpv?.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
MPVLib.getPropertyString("track-list/$i/lang")?.let { track["lang"] = it }
|
||||||
mpv?.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
MPVLib.getPropertyString("track-list/$i/codec")?.let { track["codec"] = it }
|
||||||
|
|
||||||
val channels = mpv?.getPropertyInt("track-list/$i/audio-channels")
|
val channels = MPVLib.getPropertyInt("track-list/$i/audio-channels")
|
||||||
if (channels != null && channels > 0) {
|
if (channels != null && channels > 0) {
|
||||||
track["channels"] = channels
|
track["channels"] = channels
|
||||||
}
|
}
|
||||||
|
|
||||||
val selected = mpv?.getPropertyBoolean("track-list/$i/selected") ?: false
|
val selected = MPVLib.getPropertyBoolean("track-list/$i/selected") ?: false
|
||||||
track["selected"] = selected
|
track["selected"] = selected
|
||||||
|
|
||||||
tracks.add(track)
|
tracks.add(track)
|
||||||
@@ -639,11 +569,11 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
|
|
||||||
fun setAudioTrack(trackId: Int) {
|
fun setAudioTrack(trackId: Int) {
|
||||||
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
Log.i(TAG, "setAudioTrack: setting aid to $trackId")
|
||||||
mpv?.setPropertyInt("aid", trackId)
|
MPVLib.setPropertyInt("aid", trackId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentAudioTrack(): Int {
|
fun getCurrentAudioTrack(): Int {
|
||||||
return mpv?.getPropertyInt("aid") ?: 0
|
return MPVLib.getPropertyInt("aid") ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Scaling
|
// MARK: - Video Scaling
|
||||||
@@ -652,7 +582,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
// panscan: 0.0 = fit (letterbox), 1.0 = fill (crop)
|
||||||
val panscanValue = if (zoomed) 1.0 else 0.0
|
val panscanValue = if (zoomed) 1.0 else 0.0
|
||||||
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
Log.i(TAG, "setZoomedToFill: setting panscan to $panscanValue")
|
||||||
mpv?.setPropertyDouble("panscan", panscanValue)
|
MPVLib.setPropertyDouble("panscan", panscanValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Technical Info
|
// MARK: - Technical Info
|
||||||
@@ -661,79 +591,58 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
val info = mutableMapOf<String, Any>()
|
val info = mutableMapOf<String, Any>()
|
||||||
|
|
||||||
// Video dimensions
|
// Video dimensions
|
||||||
mpv?.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyInt("video-params/w")?.takeIf { it > 0 }?.let {
|
||||||
info["videoWidth"] = it
|
info["videoWidth"] = it
|
||||||
}
|
}
|
||||||
mpv?.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyInt("video-params/h")?.takeIf { it > 0 }?.let {
|
||||||
info["videoHeight"] = it
|
info["videoHeight"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video codec
|
// Video codec
|
||||||
mpv?.getPropertyString("video-format")?.let {
|
MPVLib.getPropertyString("video-format")?.let {
|
||||||
info["videoCodec"] = it
|
info["videoCodec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio codec
|
// Audio codec
|
||||||
mpv?.getPropertyString("audio-codec-name")?.let {
|
MPVLib.getPropertyString("audio-codec-name")?.let {
|
||||||
info["audioCodec"] = it
|
info["audioCodec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// FPS (container fps)
|
// FPS (container fps)
|
||||||
mpv?.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyDouble("container-fps")?.takeIf { it > 0 }?.let {
|
||||||
info["fps"] = it
|
info["fps"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video bitrate (bits per second)
|
// Video bitrate (bits per second)
|
||||||
mpv?.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyInt("video-bitrate")?.takeIf { it > 0 }?.let {
|
||||||
info["videoBitrate"] = it
|
info["videoBitrate"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio bitrate (bits per second)
|
// Audio bitrate (bits per second)
|
||||||
mpv?.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
MPVLib.getPropertyInt("audio-bitrate")?.takeIf { it > 0 }?.let {
|
||||||
info["audioBitrate"] = it
|
info["audioBitrate"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demuxer cache duration (seconds of video buffered)
|
// Demuxer cache duration (seconds of video buffered)
|
||||||
mpv?.getPropertyDouble("demuxer-cache-duration")?.let {
|
MPVLib.getPropertyDouble("demuxer-cache-duration")?.let {
|
||||||
info["cacheSeconds"] = it
|
info["cacheSeconds"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configured cache limits — read back from mpv to confirm user
|
|
||||||
// settings actually took effect. mpv stores byte sizes as int64
|
|
||||||
// (bytes); convert to MiB for display.
|
|
||||||
mpv?.getPropertyInt("demuxer-max-bytes")?.let { bytes ->
|
|
||||||
info["demuxerMaxBytes"] = bytes / (1024 * 1024)
|
|
||||||
}
|
|
||||||
mpv?.getPropertyInt("demuxer-max-back-bytes")?.let { bytes ->
|
|
||||||
info["demuxerMaxBackBytes"] = bytes / (1024 * 1024)
|
|
||||||
}
|
|
||||||
mpv?.getPropertyDouble("cache-secs")?.let { secs ->
|
|
||||||
info["cacheSecsLimit"] = secs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropped frames
|
// Dropped frames
|
||||||
mpv?.getPropertyInt("frame-drop-count")?.let {
|
MPVLib.getPropertyInt("frame-drop-count")?.let {
|
||||||
info["droppedFrames"] = it
|
info["droppedFrames"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active video output driver (read from MPV to confirm what's actually applied)
|
// Active video output driver (read from MPV to confirm what's actually applied)
|
||||||
mpv?.getPropertyString("vo")?.let {
|
MPVLib.getPropertyString("vo")?.let {
|
||||||
info["voDriver"] = it
|
info["voDriver"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active hardware decoder.
|
// Active hardware decoder
|
||||||
// hwdec-current yields e.g. "mediacodec",
|
MPVLib.getPropertyString("hwdec-active")?.let {
|
||||||
// "mediacodec-copy", "auto-copy" or empty when SW decoding.
|
|
||||||
mpv?.getPropertyString("hwdec-current")?.let {
|
|
||||||
info["hwdec"] = it
|
info["hwdec"] = it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estimated video output fps (renderer-side, after filtering).
|
|
||||||
// Useful for diagnosing display/pipeline drops vs container fps.
|
|
||||||
mpv?.getPropertyDouble("estimated-vf-fps")?.takeIf { it > 0 }?.let {
|
|
||||||
info["estimatedVfFps"] = it
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,7 +735,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
|
||||||
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
|
||||||
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
// "auto" flag = add without auto-selecting (order preserved, MPVLib.command is sync)
|
||||||
mpv?.command(arrayOf("sub-add", subUrl, "auto"))
|
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
|
||||||
}
|
}
|
||||||
pendingExternalSubtitles = emptyList()
|
pendingExternalSubtitles = emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,20 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Surface
|
||||||
import dev.jdtech.mpv.MPVLib as LibMPV
|
import dev.jdtech.mpv.MPVLib as LibMPV
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-instance wrapper around the dev.jdtech.mpv.MPVLib class.
|
* Wrapper around the dev.jdtech.mpv.MPVLib class.
|
||||||
*
|
* This provides a consistent interface for the rest of the app.
|
||||||
* libmpv 1.0 exposes an instance-based API: each `LibMPV.create(ctx)` returns
|
|
||||||
* a fresh, independent handle. Each player creates its own MPVLib instance
|
|
||||||
* (Findroid pattern) and on teardown we simply drop the reference. We do NOT
|
|
||||||
* call `LibMPV.destroy()` — its native implementation has an internal
|
|
||||||
* use-after-free on libmpv 1.0 that we cannot fix from Kotlin. Letting the
|
|
||||||
* GC reach the JVM-level finalizer (or never reaching it, since the native
|
|
||||||
* handle lives in process-global state until exit) is strictly safer than
|
|
||||||
* crashing.
|
|
||||||
*
|
|
||||||
* Trade-off: mpv's native footprint (decoder + demuxer cache) for one player
|
|
||||||
* stays allocated until the next player's allocation displaces it in scudo's
|
|
||||||
* arena. On a TV app where the player is the dominant memory consumer and
|
|
||||||
* only one player is alive at a time, this is acceptable.
|
|
||||||
*/
|
*/
|
||||||
class MPVLib private constructor(private val instance: LibMPV) {
|
object MPVLib {
|
||||||
|
private const val TAG = "MPVLib"
|
||||||
// Event observer interface — mirrors dev.jdtech.mpv.MPVLib.EventObserver
|
|
||||||
// so MPVLayerRenderer implements a stable, wrapper-owned signature.
|
private var initialized = false
|
||||||
|
|
||||||
|
// Event observer interface
|
||||||
interface EventObserver {
|
interface EventObserver {
|
||||||
fun eventProperty(property: String)
|
fun eventProperty(property: String)
|
||||||
fun eventProperty(property: String, value: Long)
|
fun eventProperty(property: String, value: Long)
|
||||||
@@ -32,144 +23,198 @@ class MPVLib private constructor(private val instance: LibMPV) {
|
|||||||
fun eventProperty(property: String, value: Double)
|
fun eventProperty(property: String, value: Double)
|
||||||
fun event(eventId: Int)
|
fun event(eventId: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val observers = mutableListOf<EventObserver>()
|
private val observers = mutableListOf<EventObserver>()
|
||||||
|
|
||||||
// Library event observer that forwards LibMPV callbacks to our observers.
|
// Library event observer that forwards to our observers
|
||||||
private val libObserver = object : LibMPV.EventObserver {
|
private val libObserver = object : LibMPV.EventObserver {
|
||||||
override fun eventProperty(property: String) =
|
override fun eventProperty(property: String) {
|
||||||
dispatch { it.eventProperty(property) }
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Long) =
|
|
||||||
dispatch { it.eventProperty(property, value) }
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Boolean) =
|
|
||||||
dispatch { it.eventProperty(property, value) }
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: String) =
|
|
||||||
dispatch { it.eventProperty(property, value) }
|
|
||||||
|
|
||||||
override fun eventProperty(property: String, value: Double) =
|
|
||||||
dispatch { it.eventProperty(property, value) }
|
|
||||||
|
|
||||||
override fun event(eventId: Int) =
|
|
||||||
dispatch { it.event(eventId) }
|
|
||||||
|
|
||||||
private inline fun dispatch(block: (EventObserver) -> Unit) {
|
|
||||||
synchronized(observers) {
|
synchronized(observers) {
|
||||||
observers.forEach(block)
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Long) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Boolean) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: String) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun eventProperty(property: String, value: Double) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.eventProperty(property, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun event(eventId: Int) {
|
||||||
|
synchronized(observers) {
|
||||||
|
for (observer in observers) {
|
||||||
|
observer.event(eventId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addObserver(observer: EventObserver) {
|
fun addObserver(observer: EventObserver) {
|
||||||
synchronized(observers) { observers.add(observer) }
|
synchronized(observers) {
|
||||||
}
|
observers.add(observer)
|
||||||
|
|
||||||
fun removeObserver(observer: EventObserver) {
|
|
||||||
synchronized(observers) { observers.remove(observer) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initialize() {
|
|
||||||
instance.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun attachSurface(surface: android.view.Surface) {
|
|
||||||
instance.attachSurface(surface)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun detachSurface() {
|
|
||||||
instance.detachSurface()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun command(cmd: Array<String>) {
|
|
||||||
instance.command(cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setOptionString(name: String, value: String): Int {
|
|
||||||
return instance.setOptionString(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPropertyInt(name: String): Int? = try {
|
|
||||||
instance.getPropertyInt(name)
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
|
|
||||||
fun getPropertyDouble(name: String): Double? = try {
|
|
||||||
instance.getPropertyDouble(name)
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
|
|
||||||
fun getPropertyBoolean(name: String): Boolean? = try {
|
|
||||||
instance.getPropertyBoolean(name)
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
|
|
||||||
fun getPropertyString(name: String): String? = try {
|
|
||||||
instance.getPropertyString(name)
|
|
||||||
} catch (e: Exception) { null }
|
|
||||||
|
|
||||||
fun setPropertyInt(name: String, value: Int) {
|
|
||||||
instance.setPropertyInt(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyDouble(name: String, value: Double) {
|
|
||||||
instance.setPropertyDouble(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyBoolean(name: String, value: Boolean) {
|
|
||||||
instance.setPropertyBoolean(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPropertyString(name: String, value: String) {
|
|
||||||
instance.setPropertyString(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeProperty(name: String, format: Int) {
|
|
||||||
instance.observeProperty(name, format)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Create a fresh mpv handle. Each call returns an independent instance —
|
|
||||||
* do not share across players. Attach exactly one [EventObserver] per
|
|
||||||
* player via [addObserver].
|
|
||||||
*/
|
|
||||||
fun create(context: Context): MPVLib {
|
|
||||||
val lib = LibMPV.create(context)
|
|
||||||
?: throw IllegalStateException("LibMPV.create returned null")
|
|
||||||
val wrapper = MPVLib(lib)
|
|
||||||
// The libObserver is attached for the lifetime of this MPVLib
|
|
||||||
// instance and forwards every LibMPV callback to our observers
|
|
||||||
// list. Player-specific observers are added/removed via
|
|
||||||
// addObserver/removeObserver.
|
|
||||||
lib.addObserver(wrapper.libObserver)
|
|
||||||
return wrapper
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// MPV Event IDs (kept here so observers can reference them without
|
|
||||||
// holding a reference to an instance).
|
fun removeObserver(observer: EventObserver) {
|
||||||
const val MPV_EVENT_NONE = 0
|
synchronized(observers) {
|
||||||
const val MPV_EVENT_SHUTDOWN = 1
|
observers.remove(observer)
|
||||||
const val MPV_EVENT_LOG_MESSAGE = 2
|
}
|
||||||
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
}
|
||||||
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
|
||||||
const val MPV_EVENT_COMMAND_REPLY = 5
|
// MPV Event IDs
|
||||||
const val MPV_EVENT_START_FILE = 6
|
const val MPV_EVENT_NONE = 0
|
||||||
const val MPV_EVENT_END_FILE = 7
|
const val MPV_EVENT_SHUTDOWN = 1
|
||||||
const val MPV_EVENT_FILE_LOADED = 8
|
const val MPV_EVENT_LOG_MESSAGE = 2
|
||||||
const val MPV_EVENT_IDLE = 11
|
const val MPV_EVENT_GET_PROPERTY_REPLY = 3
|
||||||
const val MPV_EVENT_TICK = 14
|
const val MPV_EVENT_SET_PROPERTY_REPLY = 4
|
||||||
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
const val MPV_EVENT_COMMAND_REPLY = 5
|
||||||
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
const val MPV_EVENT_START_FILE = 6
|
||||||
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
const val MPV_EVENT_END_FILE = 7
|
||||||
const val MPV_EVENT_SEEK = 20
|
const val MPV_EVENT_FILE_LOADED = 8
|
||||||
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
const val MPV_EVENT_IDLE = 11
|
||||||
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
const val MPV_EVENT_TICK = 14
|
||||||
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
const val MPV_EVENT_CLIENT_MESSAGE = 16
|
||||||
|
const val MPV_EVENT_VIDEO_RECONFIG = 17
|
||||||
// End file reason
|
const val MPV_EVENT_AUDIO_RECONFIG = 18
|
||||||
const val MPV_END_FILE_REASON_EOF = 0
|
const val MPV_EVENT_SEEK = 20
|
||||||
const val MPV_END_FILE_REASON_STOP = 2
|
const val MPV_EVENT_PLAYBACK_RESTART = 21
|
||||||
const val MPV_END_FILE_REASON_QUIT = 3
|
const val MPV_EVENT_PROPERTY_CHANGE = 22
|
||||||
const val MPV_END_FILE_REASON_ERROR = 4
|
const val MPV_EVENT_QUEUE_OVERFLOW = 24
|
||||||
const val MPV_END_FILE_REASON_REDIRECT = 5
|
|
||||||
|
// End file reason
|
||||||
|
const val MPV_END_FILE_REASON_EOF = 0
|
||||||
|
const val MPV_END_FILE_REASON_STOP = 2
|
||||||
|
const val MPV_END_FILE_REASON_QUIT = 3
|
||||||
|
const val MPV_END_FILE_REASON_ERROR = 4
|
||||||
|
const val MPV_END_FILE_REASON_REDIRECT = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and initialize the MPV library
|
||||||
|
*/
|
||||||
|
fun create(context: Context, configDir: String? = null) {
|
||||||
|
if (initialized) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
LibMPV.create(context)
|
||||||
|
LibMPV.addObserver(libObserver)
|
||||||
|
initialized = true
|
||||||
|
Log.i(TAG, "libmpv created successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to create libmpv: ${e.message}")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
LibMPV.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
if (!initialized) return
|
||||||
|
try {
|
||||||
|
LibMPV.removeObserver(libObserver)
|
||||||
|
LibMPV.destroy()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error destroying mpv: ${e.message}")
|
||||||
|
}
|
||||||
|
initialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isInitialized(): Boolean = initialized
|
||||||
|
|
||||||
|
fun attachSurface(surface: Surface) {
|
||||||
|
LibMPV.attachSurface(surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detachSurface() {
|
||||||
|
LibMPV.detachSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun command(cmd: Array<String?>) {
|
||||||
|
LibMPV.command(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOptionString(name: String, value: String): Int {
|
||||||
|
return LibMPV.setOptionString(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyInt(name: String): Int? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyInt(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyDouble(name: String): Double? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyDouble(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyBoolean(name: String): Boolean? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyBoolean(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPropertyString(name: String): String? {
|
||||||
|
return try {
|
||||||
|
LibMPV.getPropertyString(name)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyInt(name: String, value: Int) {
|
||||||
|
LibMPV.setPropertyInt(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyDouble(name: String, value: Double) {
|
||||||
|
LibMPV.setPropertyDouble(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyBoolean(name: String, value: Boolean) {
|
||||||
|
LibMPV.setPropertyBoolean(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPropertyString(name: String, value: String) {
|
||||||
|
LibMPV.setPropertyString(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeProperty(name: String, format: Int) {
|
||||||
|
LibMPV.observeProperty(name, format)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,7 @@ class MpvPlayerModule : Module() {
|
|||||||
if (source == null) return@Prop
|
if (source == null) return@Prop
|
||||||
|
|
||||||
val urlString = source["url"] as? String ?: return@Prop
|
val urlString = source["url"] as? String ?: return@Prop
|
||||||
|
|
||||||
// Parse cache config if provided (mirrors iOS)
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val config = VideoLoadConfig(
|
val config = VideoLoadConfig(
|
||||||
url = urlString,
|
url = urlString,
|
||||||
@@ -42,11 +38,7 @@ class MpvPlayerModule : Module() {
|
|||||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||||
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||||
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||||
voDriver = source["voDriver"] as? String,
|
voDriver = source["voDriver"] as? String
|
||||||
cacheEnabled = cacheConfig?.get("enabled") as? String,
|
|
||||||
cacheSeconds = (cacheConfig?.get("cacheSeconds") as? Number)?.toInt(),
|
|
||||||
demuxerMaxBytes = (cacheConfig?.get("maxBytes") as? Number)?.toInt(),
|
|
||||||
demuxerMaxBackBytes = (cacheConfig?.get("maxBackBytes") as? Number)?.toInt()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
view.loadVideo(config)
|
view.loadVideo(config)
|
||||||
@@ -68,15 +60,6 @@ class MpvPlayerModule : Module() {
|
|||||||
view.pause()
|
view.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop playback and release the MediaCodec decoder + demuxer.
|
|
||||||
// Does not synchronously tear down the native mpv handle (see
|
|
||||||
// MPVLib / MpvPlayerView.destroy docs). Call before navigating
|
|
||||||
// away from the player screen to avoid OOM during screen
|
|
||||||
// transitions on low-RAM devices.
|
|
||||||
AsyncFunction("destroy") { view: MpvPlayerView ->
|
|
||||||
view.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Async function to seek to position
|
// Async function to seek to position
|
||||||
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
AsyncFunction("seekTo") { view: MpvPlayerView, position: Double ->
|
||||||
view.seekTo(position)
|
view.seekTo(position)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package expo.modules.mpvplayer
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.SurfaceHolder
|
import android.view.TextureView
|
||||||
import android.view.SurfaceView
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
@@ -24,30 +26,15 @@ data class VideoLoadConfig(
|
|||||||
val autoplay: Boolean = true,
|
val autoplay: Boolean = true,
|
||||||
val initialSubtitleId: Int? = null,
|
val initialSubtitleId: Int? = null,
|
||||||
val initialAudioId: Int? = null,
|
val initialAudioId: Int? = null,
|
||||||
val voDriver: String? = null,
|
val voDriver: String? = null
|
||||||
val cacheEnabled: String? = null,
|
|
||||||
val cacheSeconds: Int? = null,
|
|
||||||
val demuxerMaxBytes: Int? = null,
|
|
||||||
val demuxerMaxBackBytes: Int? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||||
*
|
* Uses TextureView for reliable Picture-in-Picture support.
|
||||||
* Uses SurfaceView (not TextureView) so the surface routes directly to
|
|
||||||
* SurfaceFlinger (the OS compositor) rather than compositing into the
|
|
||||||
* app's window surface. This matches mpv-android's architecture and
|
|
||||||
* gives mpv a standalone surface.
|
|
||||||
*
|
|
||||||
* PiP black-screen mitigation: SurfaceView's surface is destroyed and
|
|
||||||
* recreated on PiP entry/exit, and the new surface's initial dimensions
|
|
||||||
* can be stale until the next layout pass. We push dimension updates to
|
|
||||||
* mpv via both SurfaceHolder.Callback.surfaceChanged AND an
|
|
||||||
* OnLayoutChangeListener, so the PiP transition (which fires layout
|
|
||||||
* passes on the view itself) reaches mpv promptly.
|
|
||||||
*/
|
*/
|
||||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||||
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MpvPlayerView"
|
private const val TAG = "MpvPlayerView"
|
||||||
@@ -61,7 +48,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
val onTracksReady by EventDispatcher()
|
val onTracksReady by EventDispatcher()
|
||||||
val onPictureInPictureChange by EventDispatcher()
|
val onPictureInPictureChange by EventDispatcher()
|
||||||
|
|
||||||
private var surfaceView: SurfaceView
|
private var textureView: TextureView
|
||||||
private var renderer: MPVLayerRenderer? = null
|
private var renderer: MPVLayerRenderer? = null
|
||||||
private var pipController: PiPController? = null
|
private var pipController: PiPController? = null
|
||||||
|
|
||||||
@@ -72,45 +59,30 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private var surfaceReady: Boolean = false
|
private var surfaceReady: Boolean = false
|
||||||
private var pendingConfig: VideoLoadConfig? = null
|
private var pendingConfig: VideoLoadConfig? = null
|
||||||
private var rendererStarted: Boolean = false
|
private var rendererStarted: Boolean = false
|
||||||
private var activeSurface: Surface? = null
|
private var pendingSurface: Surface? = null
|
||||||
|
private var surfaceTexture: SurfaceTexture? = null
|
||||||
|
|
||||||
// PiP state tracking
|
// PiP state tracking
|
||||||
|
private var isWaitingForPiPTransition: Boolean = false
|
||||||
|
private var isPiPSurfaceForced: Boolean = false
|
||||||
private val pipHandler = Handler(Looper.getMainLooper())
|
private val pipHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
// SurfaceView for video rendering. Routes the surface directly to
|
// Create TextureView for video rendering (composites into app window for PiP support)
|
||||||
// SurfaceFlinger (the OS compositor), giving mpv a standalone
|
textureView = TextureView(context).apply {
|
||||||
// surface. TextureView composites into the app's window surface
|
|
||||||
// which is less efficient and breaks PiP transitions.
|
|
||||||
surfaceView = SurfaceView(context).apply {
|
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
|
surfaceTextureListener = this@MpvPlayerView
|
||||||
}
|
}
|
||||||
surfaceView.holder.addCallback(this@MpvPlayerView)
|
addView(textureView)
|
||||||
addView(surfaceView)
|
|
||||||
|
|
||||||
// Push dimension updates to mpv on every view bounds change. This
|
|
||||||
// is the primary PiP black-screen fix: entering PiP fires a layout
|
|
||||||
// pass on the SurfaceView itself, and we proactively tell mpv the
|
|
||||||
// new size so it resizes its EGL swapchain before rendering.
|
|
||||||
surfaceView.addOnLayoutChangeListener { _, left, top, right, bottom,
|
|
||||||
oldLeft, oldTop, oldRight, oldBottom ->
|
|
||||||
val w = right - left
|
|
||||||
val h = bottom - top
|
|
||||||
val oldW = oldRight - oldLeft
|
|
||||||
val oldH = oldBottom - oldTop
|
|
||||||
if (w > 0 && h > 0 && (w != oldW || h != oldH)) {
|
|
||||||
renderer?.updateSurfaceSize(w, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||||
pipController = PiPController(context, appContext)
|
pipController = PiPController(context, appContext)
|
||||||
pipController?.setPlayerView(surfaceView)
|
pipController?.setPlayerView(textureView)
|
||||||
pipController?.delegate = object : PiPController.Delegate {
|
pipController?.delegate = object : PiPController.Delegate {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
play()
|
play()
|
||||||
@@ -126,17 +98,17 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||||
if (isInPiP) {
|
if (isInPiP) {
|
||||||
// Post size syncs after the PiP layout settles. Two passes
|
if (!isWaitingForPiPTransition) {
|
||||||
// catch both the immediate surface re-attach and the
|
isWaitingForPiPTransition = true
|
||||||
// post-animation layout pass. Replaces the old TextureView
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
// measure/layout polling hack (forcePiPBufferSize).
|
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
}
|
||||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 500)
|
}
|
||||||
} else {
|
} else {
|
||||||
// Restore from PiP: surface resized back to fullscreen.
|
isWaitingForPiPTransition = false
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipHandler.postDelayed({ syncSurfaceSizeToView() }, 100)
|
restoreFromPiP()
|
||||||
}
|
}
|
||||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||||
}
|
}
|
||||||
@@ -149,7 +121,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the renderer with the given VO driver.
|
* Start the renderer with the given VO driver.
|
||||||
* Called lazily on first loadVideo so user settings are available.
|
* Called lazily on first loadVideo so the voDriver setting is available.
|
||||||
*/
|
*/
|
||||||
private fun ensureRendererStarted(voDriver: String?) {
|
private fun ensureRendererStarted(voDriver: String?) {
|
||||||
if (rendererStarted) return
|
if (rendererStarted) return
|
||||||
@@ -158,14 +130,9 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
renderer?.start(voDriver ?: "gpu-next")
|
renderer?.start(voDriver ?: "gpu-next")
|
||||||
rendererStarted = true
|
rendererStarted = true
|
||||||
|
|
||||||
// If the surface is already alive (surfaceCreated fired before
|
pendingSurface?.let { surface ->
|
||||||
// loadVideo), attach it now. With SurfaceView the surface is
|
|
||||||
// owned by the holder, so we read it from there directly rather
|
|
||||||
// than stashing it on the side.
|
|
||||||
surfaceView.holder.surface?.takeIf { it.isValid }?.let { surface ->
|
|
||||||
activeSurface = surface
|
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
syncSurfaceSizeToView()
|
pendingSurface = null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||||
@@ -173,20 +140,18 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SurfaceHolder.Callback
|
// MARK: - TextureView.SurfaceTextureListener
|
||||||
|
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||||
val surface = holder.surface
|
this.surfaceTexture = surfaceTexture
|
||||||
|
val surface = Surface(surfaceTexture)
|
||||||
|
surfaceTexture.setDefaultBufferSize(width, height)
|
||||||
surfaceReady = true
|
surfaceReady = true
|
||||||
|
|
||||||
if (rendererStarted) {
|
if (rendererStarted) {
|
||||||
// The previous Surface reference is holder-owned; do NOT release
|
|
||||||
// it (SurfaceView manages its lifecycle). Just track the new one.
|
|
||||||
activeSurface = surface
|
|
||||||
renderer?.attachSurface(surface)
|
renderer?.attachSurface(surface)
|
||||||
// Push the actual view dimensions immediately so mpv doesn't
|
} else {
|
||||||
// render against stale full-screen geometry during PiP transitions.
|
pendingSurface = surface
|
||||||
syncSurfaceSizeToView()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a pending load, execute it now
|
// If we have a pending load, execute it now
|
||||||
@@ -197,36 +162,20 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||||
if (width > 0 && height > 0) {
|
surfaceTexture.setDefaultBufferSize(width, height)
|
||||||
renderer?.updateSurfaceSize(width, height)
|
renderer?.updateSurfaceSize(width, height)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||||
|
this.surfaceTexture = null
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
renderer?.detachSurface()
|
renderer?.detachSurface()
|
||||||
// Do NOT issue mpv "stop" here. Playback continues against the
|
return false // mpv manages the SurfaceTexture
|
||||||
// demuxer; when surfaceCreated fires again (PiP entry/exit, app
|
|
||||||
// background/foreground), we re-attach and frames resume. This
|
|
||||||
// matches the keep-open=always setting in MPVLayerRenderer.
|
|
||||||
//
|
|
||||||
// Do NOT release activeSurface — SurfaceView owns it via the holder.
|
|
||||||
activeSurface = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||||
* Read the actual SurfaceView width/height and push them to mpv.
|
// Called every frame — no action needed, mpv drives rendering directly
|
||||||
* The PiP transition can fire surfaceCreated before the view's layout
|
|
||||||
* has settled to PiP dimensions, so we re-sync after layout passes.
|
|
||||||
*/
|
|
||||||
private fun syncSurfaceSizeToView() {
|
|
||||||
if (!surfaceReady) return
|
|
||||||
val w = surfaceView.width
|
|
||||||
val h = surfaceView.height
|
|
||||||
if (w > 0 && h > 0) {
|
|
||||||
renderer?.updateSurfaceSize(w, h)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Video Loading
|
// MARK: - Video Loading
|
||||||
@@ -258,11 +207,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
startPosition = config.startPosition,
|
startPosition = config.startPosition,
|
||||||
externalSubtitles = config.externalSubtitles,
|
externalSubtitles = config.externalSubtitles,
|
||||||
initialSubtitleId = config.initialSubtitleId,
|
initialSubtitleId = config.initialSubtitleId,
|
||||||
initialAudioId = config.initialAudioId,
|
initialAudioId = config.initialAudioId
|
||||||
cacheEnabled = config.cacheEnabled,
|
|
||||||
cacheSeconds = config.cacheSeconds,
|
|
||||||
demuxerMaxBytes = config.demuxerMaxBytes,
|
|
||||||
demuxerMaxBackBytes = config.demuxerMaxBackBytes
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (config.autoplay) {
|
if (config.autoplay) {
|
||||||
@@ -291,50 +236,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
pipController?.setPlaybackRate(0.0)
|
pipController?.setPlaybackRate(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop playback and release decoder resources.
|
|
||||||
*
|
|
||||||
* Delegates to [MPVLayerRenderer.stop], which issues mpv's "stop" command
|
|
||||||
* on a background thread (flushing the demuxer and releasing the
|
|
||||||
* MediaCodec hardware decoder) and drops the per-instance mpv handle.
|
|
||||||
*
|
|
||||||
* NOTE: this does NOT call `LibMPV.destroy()`. libmpv 1.0's
|
|
||||||
* nativeDestroy has an internal use-after-free on the JNI global ref
|
|
||||||
* path, so the native mpv handle is intentionally left for the JVM GC
|
|
||||||
* / native finalizer rather than torn down synchronously. See
|
|
||||||
* [MPVLib] class doc for the full rationale.
|
|
||||||
*
|
|
||||||
* Call this BEFORE navigating away from the player screen so the
|
|
||||||
* decoder is reclaimed before the next screen (or the next episode's
|
|
||||||
* player) mounts. Otherwise Expo Router renders the new screen first
|
|
||||||
* and you briefly have two mpv instances + two 4K decoders alive —
|
|
||||||
* instant OOM on a 2 GB device.
|
|
||||||
*/
|
|
||||||
fun destroy() {
|
|
||||||
renderer?.stop()
|
|
||||||
|
|
||||||
// Reset view-level state so a subsequent loadVideo() on the SAME view
|
|
||||||
// instance re-creates the mpv handle and re-attaches the still-live
|
|
||||||
// SurfaceView surface. Without this, rendererStarted stays true and
|
|
||||||
// ensureRendererStarted() early-returns, so renderer.start() is never
|
|
||||||
// called again — but stop() already nulled the renderer's mpv handle.
|
|
||||||
// The next loadVideo() then runs loadVideoInternal() -> renderer.load()
|
|
||||||
// against mpv == null, where every mpv?.command() (including the
|
|
||||||
// "stop" and load commands) silently no-ops, leaving a black frame.
|
|
||||||
//
|
|
||||||
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
|
||||||
// which call destroy() immediately before router.replace() to the
|
|
||||||
// same route — Expo Router reuses the same MpvPlayerView instance,
|
|
||||||
// so the next source load happens on this view without a remount.
|
|
||||||
//
|
|
||||||
// SurfaceView note: the surface is owned by the holder and survives
|
|
||||||
// across destroy()/loadVideo() on the same view instance. The next
|
|
||||||
// ensureRendererStarted() reads it from surfaceView.holder.surface.
|
|
||||||
rendererStarted = false
|
|
||||||
currentUrl = null
|
|
||||||
activeSurface = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekTo(position: Double) {
|
fun seekTo(position: Double) {
|
||||||
renderer?.seekTo(position)
|
renderer?.seekTo(position)
|
||||||
}
|
}
|
||||||
@@ -366,10 +267,59 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
// MARK: - Picture in Picture
|
// MARK: - Picture in Picture
|
||||||
|
|
||||||
fun startPictureInPicture() {
|
fun startPictureInPicture() {
|
||||||
|
isWaitingForPiPTransition = true
|
||||||
pipController?.startPictureInPicture()
|
pipController?.startPictureInPicture()
|
||||||
|
|
||||||
|
// Resize buffer to match PiP window after animation settles
|
||||||
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
|
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||||
|
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
||||||
|
* visible rect so mpv renders at the PiP window's actual dimensions.
|
||||||
|
*/
|
||||||
|
private fun forcePiPBufferSize() {
|
||||||
|
if (!isWaitingForPiPTransition || !surfaceReady) return
|
||||||
|
|
||||||
|
val rect = Rect()
|
||||||
|
textureView.getGlobalVisibleRect(rect)
|
||||||
|
val visW = rect.width()
|
||||||
|
val visH = rect.height()
|
||||||
|
val vw = textureView.width
|
||||||
|
val vh = textureView.height
|
||||||
|
|
||||||
|
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
||||||
|
|
||||||
|
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
||||||
|
renderer?.updateSurfaceSize(visW, visH)
|
||||||
|
|
||||||
|
// Force TextureView layout to match PiP visible area.
|
||||||
|
// layoutParams alone doesn't work during PiP because the parent
|
||||||
|
// never re-lays out its children.
|
||||||
|
textureView.measure(
|
||||||
|
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
||||||
|
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
||||||
|
)
|
||||||
|
textureView.layout(0, 0, visW, visH)
|
||||||
|
isPiPSurfaceForced = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreFromPiP() {
|
||||||
|
if (!isPiPSurfaceForced) return
|
||||||
|
isPiPSurfaceForced = false
|
||||||
|
|
||||||
|
val lp = textureView.layoutParams
|
||||||
|
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
textureView.layoutParams = lp
|
||||||
|
textureView.requestLayout()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
}
|
}
|
||||||
@@ -529,24 +479,13 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
/**
|
|
||||||
* Proactively tear down the player. Called from onDetachedFromWindow so
|
|
||||||
* the app releases mpv + decoder buffers when the View detaches from the
|
|
||||||
* window. The JS-facing destroy() is intentionally thinner (just
|
|
||||||
* renderer.stop()) — see this thread for why the full teardown was kept
|
|
||||||
* off the JS path.
|
|
||||||
*/
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
|
isWaitingForPiPTransition = false
|
||||||
pipHandler.removeCallbacksAndMessages(null)
|
pipHandler.removeCallbacksAndMessages(null)
|
||||||
pipController?.stopPictureInPicture()
|
pipController?.stopPictureInPicture()
|
||||||
renderer?.stop()
|
renderer?.stop()
|
||||||
renderer?.delegate = null
|
surfaceTexture = null
|
||||||
|
|
||||||
// SurfaceView owns the Surface via its holder — do NOT release it.
|
|
||||||
activeSurface = null
|
|
||||||
surfaceReady = false
|
surfaceReady = false
|
||||||
currentUrl = null
|
|
||||||
rendererStarted = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
override fun onDetachedFromWindow() {
|
||||||
|
|||||||
@@ -44,11 +44,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
private var currentPosition: Double = 0.0
|
private var currentPosition: Double = 0.0
|
||||||
private var currentDuration: Double = 0.0
|
private var currentDuration: Double = 0.0
|
||||||
private var playbackRate: Double = 1.0
|
private var playbackRate: Double = 1.0
|
||||||
// Independently tracks whether the system should auto-enter PiP on home
|
|
||||||
// press. Decoupled from playbackRate so that disabling auto-enter
|
|
||||||
// (e.g. when the player unmounts) doesn't corrupt the play/pause icon
|
|
||||||
// state that buildPiPActions() derives from playbackRate.
|
|
||||||
private var autoEnterEnabled: Boolean = false
|
|
||||||
|
|
||||||
private var videoWidth: Int = 0
|
private var videoWidth: Int = 0
|
||||||
private var videoHeight: Int = 0
|
private var videoHeight: Int = 0
|
||||||
@@ -111,37 +106,15 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun stopPictureInPicture() {
|
fun stopPictureInPicture() {
|
||||||
// Disable auto-enter eligibility without touching playbackRate.
|
|
||||||
// playbackRate drives the play/pause icon in buildPiPActions();
|
|
||||||
// mutating it here would cause a stale icon if PiP is re-entered
|
|
||||||
// before the next playback state callback corrects it.
|
|
||||||
autoEnterEnabled = false
|
|
||||||
isInPiPMode = false
|
isInPiPMode = false
|
||||||
pipEntryNotified = false
|
pipEntryNotified = false
|
||||||
unregisterLifecycleCallbacks()
|
unregisterLifecycleCallbacks()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val activity = getActivity() ?: return
|
val activity = getActivity()
|
||||||
|
if (activity?.isInPictureInPictureMode == true) {
|
||||||
// Push minimal params with just auto-enter disabled. Do NOT call
|
activity.moveTaskToBack(false)
|
||||||
// buildPiPParams() — it calls ensurePiPReceiverRegistered() and
|
|
||||||
// setActions(), which would re-register the broadcast receiver
|
|
||||||
// (just unregistered above) and attach play/pause/skip actions to
|
|
||||||
// params being torn down. That leaves a live receiver + stale
|
|
||||||
// actions after the player has unmounted.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
try {
|
|
||||||
activity.setPictureInPictureParams(
|
|
||||||
PictureInPictureParams.Builder()
|
|
||||||
.setAutoEnterEnabled(false)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to clear PiP auto-enter params: ${e.message}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activity.isInPictureInPictureMode) {
|
|
||||||
activity.moveTaskToBack(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||||
@@ -153,7 +126,6 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
|
|
||||||
fun setPlaybackRate(rate: Double) {
|
fun setPlaybackRate(rate: Double) {
|
||||||
playbackRate = rate
|
playbackRate = rate
|
||||||
autoEnterEnabled = rate > 0
|
|
||||||
|
|
||||||
if (rate > 0) {
|
if (rate > 0) {
|
||||||
registerLifecycleCallbacks()
|
registerLifecycleCallbacks()
|
||||||
@@ -236,7 +208,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
|||||||
builder.setActions(buildPiPActions())
|
builder.setActions(buildPiPActions())
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
builder.setAutoEnterEnabled(forEntering || autoEnterEnabled)
|
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.build()
|
return builder.build()
|
||||||
|
|||||||
@@ -1020,44 +1020,12 @@ final class MPVLayerRenderer {
|
|||||||
info["cacheSeconds"] = cacheSeconds
|
info["cacheSeconds"] = cacheSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configured cache limits — read back from mpv to confirm user
|
|
||||||
// settings actually took effect. mpv stores byte sizes as int64
|
|
||||||
// (bytes); convert to MiB for display.
|
|
||||||
var demuxerMaxBytes: Int64 = 0
|
|
||||||
if getProperty(handle: handle, name: "demuxer-max-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBytes) >= 0 {
|
|
||||||
info["demuxerMaxBytes"] = Int(demuxerMaxBytes / (1024 * 1024))
|
|
||||||
}
|
|
||||||
var demuxerMaxBackBytes: Int64 = 0
|
|
||||||
if getProperty(handle: handle, name: "demuxer-max-back-bytes", format: MPV_FORMAT_INT64, value: &demuxerMaxBackBytes) >= 0 {
|
|
||||||
info["demuxerMaxBackBytes"] = Int(demuxerMaxBackBytes / (1024 * 1024))
|
|
||||||
}
|
|
||||||
var cacheSecsLimit: Double = 0
|
|
||||||
if getProperty(handle: handle, name: "cache-secs", format: MPV_FORMAT_DOUBLE, value: &cacheSecsLimit) >= 0 {
|
|
||||||
info["cacheSecsLimit"] = cacheSecsLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dropped frames
|
// Dropped frames
|
||||||
var droppedFrames: Int64 = 0
|
var droppedFrames: Int64 = 0
|
||||||
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
if getProperty(handle: handle, name: "frame-drop-count", format: MPV_FORMAT_INT64, value: &droppedFrames) >= 0 {
|
||||||
info["droppedFrames"] = Int(droppedFrames)
|
info["droppedFrames"] = Int(droppedFrames)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active video output driver
|
|
||||||
if let voDriver = getStringProperty(handle: handle, name: "vo") {
|
|
||||||
info["voDriver"] = voDriver
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active hardware decoder
|
|
||||||
if let hwdec = getStringProperty(handle: handle, name: "hwdec-current") {
|
|
||||||
info["hwdec"] = hwdec
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimated video output fps (post-filter)
|
|
||||||
var estimatedVfFps: Double = 0
|
|
||||||
if getProperty(handle: handle, name: "estimated-vf-fps", format: MPV_FORMAT_DOUBLE, value: &estimatedVfFps) >= 0 && estimatedVfFps > 0 {
|
|
||||||
info["estimatedVfFps"] = estimatedVfFps
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,13 +74,7 @@ public class MpvPlayerModule: Module {
|
|||||||
AsyncFunction("pause") { (view: MpvPlayerView) in
|
AsyncFunction("pause") { (view: MpvPlayerView) in
|
||||||
view.pause()
|
view.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronously destroy mpv instance + decoder before navigating
|
|
||||||
// away from the player screen (cross-platform; matches Android).
|
|
||||||
AsyncFunction("destroy") { (view: MpvPlayerView) in
|
|
||||||
view.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Async function to seek to position
|
// Async function to seek to position
|
||||||
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
AsyncFunction("seekTo") { (view: MpvPlayerView, position: Double) in
|
||||||
view.seekTo(position: position)
|
view.seekTo(position: position)
|
||||||
|
|||||||
@@ -289,49 +289,6 @@ class MpvPlayerView: ExpoView {
|
|||||||
pipController?.updatePlaybackState()
|
pipController?.updatePlaybackState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronously stop and destroy the mpv instance + decoder so memory is
|
|
||||||
* freed before the next screen mounts. Safe to call multiple times — the
|
|
||||||
* underlying renderer.stop() guards against re-entry.
|
|
||||||
*
|
|
||||||
* Cross-platform counterpart of MpvPlayerView.destroy() on Android.
|
|
||||||
*/
|
|
||||||
func destroy() {
|
|
||||||
renderer?.stop()
|
|
||||||
|
|
||||||
// Reset view state and re-create the mpv handle so a subsequent
|
|
||||||
// loadVideo() on the SAME view instance can actually load.
|
|
||||||
// Without this, stop() leaves renderer.mpv == nil, and the next
|
|
||||||
// loadVideo(config:) calls renderer.load() which early-returns
|
|
||||||
// at `guard let handle = self.mpv else { return }` — but only
|
|
||||||
// after flipping isLoading = true and dispatching the loading
|
|
||||||
// delegate callback, so the JS layer is stuck in a perpetual
|
|
||||||
// "loading" state with no actual playback.
|
|
||||||
//
|
|
||||||
// This path is hit by direct-player.tsx's goToNextItem()/stop(),
|
|
||||||
// which call destroy() immediately before router.replace() to
|
|
||||||
// the same route — Expo Router reuses the same MpvPlayerView
|
|
||||||
// instance, so the next `source` prop update arrives on this
|
|
||||||
// view without a remount. setupView() is otherwise the only
|
|
||||||
// place start() is called, so without re-starting here the
|
|
||||||
// renderer stays dead until the whole view is unmounted and
|
|
||||||
// recreated.
|
|
||||||
//
|
|
||||||
// start() is idempotent (`guard !isRunning else { return }`)
|
|
||||||
// and stop() has already nulled mpv synchronously before
|
|
||||||
// dispatching the async mpv_terminate_destroy, so creating a
|
|
||||||
// fresh handle here is safe even while the old handle's
|
|
||||||
// teardown is still in flight on a background queue (libmpv
|
|
||||||
// handles are independent).
|
|
||||||
currentURL = nil
|
|
||||||
intendedPlayState = false
|
|
||||||
do {
|
|
||||||
try renderer?.start()
|
|
||||||
} catch {
|
|
||||||
onError(["error": "Failed to restart renderer after destroy: \(error.localizedDescription)"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func seekTo(position: Double) {
|
func seekTo(position: Double) {
|
||||||
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
// Update cached position and Now Playing immediately for smooth Control Center feedback
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
|
|||||||
@@ -89,14 +89,6 @@ export type MpvPlayerViewProps = {
|
|||||||
export interface MpvPlayerViewRef {
|
export interface MpvPlayerViewRef {
|
||||||
play: () => Promise<void>;
|
play: () => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
/**
|
|
||||||
* Synchronously destroy the mpv instance + decoder + surface buffers.
|
|
||||||
* Call before navigating away from the player screen so memory is
|
|
||||||
* freed before the next screen mounts. Safe to call multiple times.
|
|
||||||
*/
|
|
||||||
destroy: () => Promise<void>;
|
|
||||||
// Pre-libmpv-1.0 alias (kept for source-history reference):
|
|
||||||
// stop: () => Promise<void>;
|
|
||||||
seekTo: (position: number) => Promise<void>;
|
seekTo: (position: number) => Promise<void>;
|
||||||
seekBy: (offset: number) => Promise<void>;
|
seekBy: (offset: number) => Promise<void>;
|
||||||
setSpeed: (speed: number) => Promise<void>;
|
setSpeed: (speed: number) => Promise<void>;
|
||||||
@@ -162,17 +154,9 @@ export type TechnicalInfo = {
|
|||||||
videoBitrate?: number;
|
videoBitrate?: number;
|
||||||
audioBitrate?: number;
|
audioBitrate?: number;
|
||||||
cacheSeconds?: number;
|
cacheSeconds?: number;
|
||||||
/** Configured demuxer forward cache cap (MiB), read back from mpv */
|
|
||||||
demuxerMaxBytes?: number;
|
|
||||||
/** Configured demuxer backward cache cap (MiB), read back from mpv */
|
|
||||||
demuxerMaxBackBytes?: number;
|
|
||||||
/** Configured cache-secs floor, read back from mpv */
|
|
||||||
cacheSecsLimit?: number;
|
|
||||||
droppedFrames?: number;
|
droppedFrames?: number;
|
||||||
/** Active video output driver (read from MPV at runtime) */
|
/** Active video output driver (read from MPV at runtime) */
|
||||||
voDriver?: string;
|
voDriver?: string;
|
||||||
/** Active hardware decoder (read from MPV at runtime) */
|
/** Active hardware decoder (read from MPV at runtime) */
|
||||||
hwdec?: string;
|
hwdec?: string;
|
||||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
|
||||||
estimatedVfFps?: number;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
|||||||
pause: async () => {
|
pause: async () => {
|
||||||
await nativeRef.current?.pause();
|
await nativeRef.current?.pause();
|
||||||
},
|
},
|
||||||
destroy: async () => {
|
|
||||||
await nativeRef.current?.destroy();
|
|
||||||
},
|
|
||||||
seekTo: async (position: number) => {
|
seekTo: async (position: number) => {
|
||||||
await nativeRef.current?.seekTo(position);
|
await nativeRef.current?.seekTo(position);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ module.exports = function withCustomPlugin(config) {
|
|||||||
// https://github.com/expo/expo/issues/32558
|
// https://github.com/expo/expo/issues/32558
|
||||||
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
config = setGradlePropertiesValue(config, "android.enableJetifier", "true");
|
||||||
|
|
||||||
// NDK version required by libmpv 1.0.0
|
|
||||||
config = setGradlePropertiesValue(config, "ndkVersion", "29.0.14206865");
|
|
||||||
|
|
||||||
// Increase memory
|
// Increase memory
|
||||||
config = setGradlePropertiesValue(
|
config = setGradlePropertiesValue(
|
||||||
config,
|
config,
|
||||||
|
|||||||
@@ -588,8 +588,25 @@
|
|||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"boxsets": "Box sets",
|
"boxsets": "Box sets",
|
||||||
"playlists": "Playlists",
|
"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",
|
"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": {
|
"custom_links": {
|
||||||
"no_links": "No links"
|
"no_links": "No links"
|
||||||
|
|||||||
@@ -588,8 +588,25 @@
|
|||||||
"videos": "Videor",
|
"videos": "Videor",
|
||||||
"boxsets": "Box Set",
|
"boxsets": "Box Set",
|
||||||
"playlists": "Spellistor",
|
"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",
|
"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": {
|
"custom_links": {
|
||||||
"no_links": "Inga Länkar"
|
"no_links": "Inga Länkar"
|
||||||
|
|||||||
@@ -82,8 +82,6 @@ export const useFilterOptions = () => {
|
|||||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||||
];
|
];
|
||||||
console.log("filterOptions");
|
|
||||||
console.log(filterOptions);
|
|
||||||
return filterOptions;
|
return filterOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { atom, useAtom, useAtomValue } from "jotai";
|
import { atom, useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -362,16 +361,11 @@ export const defaultValues: Settings = {
|
|||||||
mpvSubtitleFontSize: undefined,
|
mpvSubtitleFontSize: undefined,
|
||||||
mpvSubtitleBackgroundEnabled: false,
|
mpvSubtitleBackgroundEnabled: false,
|
||||||
mpvSubtitleBackgroundOpacity: 75,
|
mpvSubtitleBackgroundOpacity: 75,
|
||||||
// MPV buffer/cache defaults.
|
// MPV buffer/cache defaults
|
||||||
// Android TV gets tighter caps — combined with libmpv 1.0's larger
|
|
||||||
// baseline (fontconfig + libxml2 + libplacebo HDR path + scudo
|
|
||||||
// retention) the larger mobile budget pushes 2 GB Android TV boxes
|
|
||||||
// into swap death during 4K HDR playback. Apple TV has more RAM and
|
|
||||||
// keeps the full budget. Users can override via the settings screen.
|
|
||||||
mpvCacheEnabled: "auto",
|
mpvCacheEnabled: "auto",
|
||||||
mpvCacheSeconds: 10,
|
mpvCacheSeconds: 10,
|
||||||
mpvDemuxerMaxBytes: Platform.isTV && Platform.OS === "android" ? 75 : 150, // MB
|
mpvDemuxerMaxBytes: 150, // MB
|
||||||
mpvDemuxerMaxBackBytes: Platform.isTV && Platform.OS === "android" ? 30 : 50, // MB
|
mpvDemuxerMaxBackBytes: 50, // MB
|
||||||
// MPV video output driver defaults (Android only)
|
// MPV video output driver defaults (Android only)
|
||||||
mpvVoDriver: "gpu-next",
|
mpvVoDriver: "gpu-next",
|
||||||
// Gesture controls
|
// Gesture controls
|
||||||
|
|||||||
Reference in New Issue
Block a user