mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-01 18:12:51 +01:00
Compare commits
13 Commits
feat/andro
...
feat/kefin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f1b8fea31 | ||
|
|
41c2631b35 | ||
|
|
a8698a5c11 | ||
|
|
f2e54cd230 | ||
|
|
14c84f5ec2 | ||
|
|
803ee368ad | ||
|
|
000e873922 | ||
|
|
bc13317f00 | ||
|
|
c024d1ed05 | ||
|
|
c648134954 | ||
|
|
97eec2438b | ||
|
|
1d0c2f0a31 | ||
|
|
eba72e9d73 |
@@ -1,14 +1,24 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||
"Favorites",
|
||||
);
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await invalidateCache();
|
||||
@@ -20,6 +30,8 @@ export default function FavoritesPage() {
|
||||
return <TVFavorites />;
|
||||
}
|
||||
|
||||
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
@@ -34,7 +46,26 @@ export default function FavoritesPage() {
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||
<Favorites />
|
||||
{watchlistEnabled && (
|
||||
<View className='pl-4 pr-4 flex flex-row mb-2'>
|
||||
<FavoritesTabButtons
|
||||
viewType={viewType}
|
||||
setViewType={setViewType}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{isWatchlist ? (
|
||||
<Favorites
|
||||
filter='Likes'
|
||||
queryKeyBase='watchlist'
|
||||
seeAllNamespace='kefintweaksWatchlist'
|
||||
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
|
||||
emptyTextKey='kefintweaksWatchlist.noData'
|
||||
/>
|
||||
) : (
|
||||
<Favorites />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
@@ -10,7 +11,7 @@ import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useWindowDimensions, View } from "react-native";
|
||||
import { Platform, useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
@@ -52,9 +53,13 @@ export default function FavoritesSeeAllScreen() {
|
||||
const searchParams = useLocalSearchParams<{
|
||||
type?: string;
|
||||
title?: string;
|
||||
filter?: string;
|
||||
}>();
|
||||
const typeParam = searchParams.type;
|
||||
const titleParam = searchParams.title;
|
||||
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
|
||||
const filter: ItemFilter =
|
||||
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
if (!isFavoriteType(typeParam)) return null;
|
||||
@@ -77,7 +82,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
userId: user.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -90,12 +95,12 @@ export default function FavoritesSeeAllScreen() {
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, itemType, user?.Id],
|
||||
[api, itemType, user?.Id, filter],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["favorites", "see-all", itemType],
|
||||
queryKey: ["favorites", "see-all", itemType, filter],
|
||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||
@@ -155,7 +160,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
options={{
|
||||
headerTitle: headerTitle,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, ScrollView, View } from "react-native";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||
@@ -33,16 +33,13 @@ import {
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
AudioTranscodeMode,
|
||||
getActiveVideoPlayer,
|
||||
InactivityTimeout,
|
||||
type MpvCacheMode,
|
||||
type MpvVoDriver,
|
||||
TVTypographyScale,
|
||||
useSettings,
|
||||
VideoPlayer,
|
||||
} from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
import {
|
||||
getPreviousServers,
|
||||
type SavedServer,
|
||||
@@ -265,25 +262,6 @@ export default function SettingsTV() {
|
||||
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
|
||||
const currentLanguage = settings.preferedLanguage;
|
||||
|
||||
// Video player selection. MPV is the default; ExoPlayer is only offered
|
||||
// as an opt-in alternative on Android TV. The selector is hidden on
|
||||
// other platforms.
|
||||
const isAndroidTv = Platform.OS === "android" && Platform.isTV;
|
||||
const currentVideoPlayer = getActiveVideoPlayer(settings);
|
||||
const isMpv = currentVideoPlayer !== VideoPlayer.ExoPlayer;
|
||||
|
||||
// Shared style for the ExoPlayer / MPV limitation notes shown under the
|
||||
// selector when the respective player is active. All pixel values scaled
|
||||
// so the layout holds on 4K TVs (see utils/scaleSize.ts).
|
||||
const playerNoteStyle = {
|
||||
color: "#9CA3AF",
|
||||
fontSize: typography.callout - 2,
|
||||
marginTop: scaleSize(4),
|
||||
marginBottom: scaleSize(12),
|
||||
marginLeft: scaleSize(8),
|
||||
marginRight: scaleSize(8),
|
||||
} as const;
|
||||
|
||||
// Audio transcoding options
|
||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||
() => [
|
||||
@@ -413,23 +391,6 @@ export default function SettingsTV() {
|
||||
[t, currentVoDriver],
|
||||
);
|
||||
|
||||
// Video player backend options (Android TV only)
|
||||
const videoPlayerOptions: TVOptionItem<VideoPlayer>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.video_player.exoplayer"),
|
||||
value: VideoPlayer.ExoPlayer,
|
||||
selected: currentVideoPlayer === VideoPlayer.ExoPlayer,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.video_player.mpv"),
|
||||
value: VideoPlayer.MPV,
|
||||
selected: currentVideoPlayer === VideoPlayer.MPV,
|
||||
},
|
||||
],
|
||||
[t, currentVideoPlayer],
|
||||
);
|
||||
|
||||
// Typography scale options
|
||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||
() => [
|
||||
@@ -561,11 +522,6 @@ export default function SettingsTV() {
|
||||
return option?.label || t("home.settings.vo_driver.gpu_next");
|
||||
}, [voDriverOptions, t]);
|
||||
|
||||
const videoPlayerLabel = useMemo(() => {
|
||||
const option = videoPlayerOptions.find((o) => o.selected);
|
||||
return option?.label || "MPV";
|
||||
}, [videoPlayerOptions]);
|
||||
|
||||
const languageLabel = useMemo(() => {
|
||||
if (!currentLanguage) return t("home.settings.languages.system");
|
||||
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
|
||||
@@ -630,34 +586,6 @@ export default function SettingsTV() {
|
||||
|
||||
{/* Audio Section */}
|
||||
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
||||
|
||||
{/* Video Player selector — Android TV only */}
|
||||
{isAndroidTv && (
|
||||
<>
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.video_player.title")}
|
||||
value={videoPlayerLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.video_player.title"),
|
||||
options: videoPlayerOptions,
|
||||
onSelect: (value) => updateSettings({ videoPlayer: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{!isMpv && (
|
||||
<Text style={playerNoteStyle}>
|
||||
{t("home.settings.video_player.exoplayer_note")}
|
||||
</Text>
|
||||
)}
|
||||
{isMpv && (
|
||||
<Text style={playerNoteStyle}>
|
||||
{t("home.settings.video_player.mpv_note")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.audio.transcode_mode.title")}
|
||||
value={audioTranscodeLabel}
|
||||
@@ -734,23 +662,20 @@ export default function SettingsTV() {
|
||||
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||
}}
|
||||
/>
|
||||
{isMpv && (
|
||||
<TVSettingsOptionButton
|
||||
label='Horizontal Alignment'
|
||||
value={alignXLabel}
|
||||
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: "Horizontal Alignment",
|
||||
options: alignXOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<TVSettingsOptionButton
|
||||
label='Horizontal Alignment'
|
||||
value={alignXLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: "Horizontal Alignment",
|
||||
options: alignXOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label='Vertical Alignment'
|
||||
value={alignYLabel}
|
||||
@@ -823,24 +748,19 @@ export default function SettingsTV() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Video Output Section — MPV only (gpu-next/gpu is a libmpv concept) */}
|
||||
{isMpv && (
|
||||
<>
|
||||
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.vo_driver.vo_mode")}
|
||||
value={voDriverLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.vo_driver.vo_mode"),
|
||||
options: voDriverOptions,
|
||||
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Video Output Section */}
|
||||
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.vo_driver.vo_mode")}
|
||||
value={voDriverLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.vo_driver.vo_mode"),
|
||||
options: voDriverOptions,
|
||||
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TVSettingsStepper
|
||||
label={t("home.settings.buffer.buffer_duration")}
|
||||
value={settings.mpvCacheSeconds ?? 10}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||
import { AddToKefinWatchlist } from "@/components/AddToKefinWatchlist";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
@@ -18,6 +19,7 @@ import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
buildOfflineSeriesFromEpisodes,
|
||||
getDownloadedEpisodesForSeries,
|
||||
@@ -30,6 +32,7 @@ import { storage } from "@/utils/mmkv";
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
id: seriesId,
|
||||
@@ -137,6 +140,7 @@ const page: React.FC = () => {
|
||||
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{settings?.useKefinTweaks && <AddToKefinWatchlist item={item} />}
|
||||
{!Platform.isTV && (
|
||||
<DownloadItems
|
||||
size='large'
|
||||
@@ -157,7 +161,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
) : null,
|
||||
});
|
||||
}, [allEpisodes, isLoading, item, isOffline]);
|
||||
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
|
||||
|
||||
// For offline mode, we can show the page even without backdropUrl
|
||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
PlaybackSpeedScope,
|
||||
updatePlaybackSpeedSettings,
|
||||
} from "@/components/video-player/controls/utils/playback-speed-settings";
|
||||
import { VideoPlayerView } from "@/components/video-player/VideoPlayerView";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
@@ -41,6 +40,7 @@ import {
|
||||
type MpvOnErrorEventPayload,
|
||||
type MpvOnPlaybackStateChangePayload,
|
||||
type MpvOnProgressEventPayload,
|
||||
MpvPlayerView,
|
||||
type MpvPlayerViewRef,
|
||||
type MpvVideoSource,
|
||||
} from "@/modules";
|
||||
@@ -51,7 +51,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { getActivePlayerType, useSettings } from "@/utils/atoms/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
@@ -364,13 +364,7 @@ export default function DirectPlayerPage() {
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
// Match the device profile to the player that will render the
|
||||
// stream so the server picks a codec/container the player can
|
||||
// actually decode.
|
||||
deviceProfile: generateDeviceProfile({
|
||||
player: getActivePlayerType(settings),
|
||||
audioMode: settings.audioTranscodeMode,
|
||||
}),
|
||||
deviceProfile: generateDeviceProfile(),
|
||||
});
|
||||
if (!res) return null;
|
||||
const { mediaSource, sessionId, url, requiredHttpHeaders } = res;
|
||||
@@ -1283,7 +1277,7 @@ export default function DirectPlayerPage() {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<VideoPlayerView
|
||||
<MpvPlayerView
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
|
||||
28
components/AddToKefinWatchlist.tsx
Normal file
28
components/AddToKefinWatchlist.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* KefinTweaks watchlist toggle, backed by Jellyfin's "Likes" rating.
|
||||
* Render only when settings.useKefinTweaks is enabled.
|
||||
*/
|
||||
export const AddToKefinWatchlist: FC<Props> = ({ item, ...props }) => {
|
||||
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||
color={isWatchlisted ? "purple" : "white"}
|
||||
onPress={toggleWatchlist}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -29,6 +29,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { AddToKefinWatchlist } from "./AddToKefinWatchlist";
|
||||
import { AddToWatchlist } from "./AddToWatchlist";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
@@ -138,6 +139,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.useKefinTweaks && (
|
||||
<AddToKefinWatchlist item={item} />
|
||||
)}
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
@@ -160,6 +164,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.useKefinTweaks && (
|
||||
<AddToKefinWatchlist item={item} />
|
||||
)}
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
@@ -178,6 +185,7 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
settings.hideRemoteSessionButton,
|
||||
settings.streamyStatsServerUrl,
|
||||
settings.hideWatchlistsTab,
|
||||
settings.useKefinTweaks,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
TVRefreshButton,
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
TVWatchlistButton,
|
||||
} from "@/components/tv";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -752,6 +753,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
</Text>
|
||||
</TVButton>
|
||||
<TVFavoriteButton item={item} />
|
||||
{settings.useKefinTweaks && <TVWatchlistButton item={item} />}
|
||||
<TVPlayedButton item={item} />
|
||||
<TVRefreshButton itemId={item.Id} />
|
||||
</View>
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -155,6 +157,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { deleteFile } = useDownload();
|
||||
@@ -183,36 +187,66 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
)
|
||||
return;
|
||||
|
||||
const options: string[] = [
|
||||
t("common.mark_as_played"),
|
||||
t("common.mark_as_not_played"),
|
||||
isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites"),
|
||||
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
||||
t("common.cancel"),
|
||||
// Build options as { label, action } so dynamic entries (watchlist,
|
||||
// offline delete) don't break index-based handling.
|
||||
const actions: {
|
||||
label: string;
|
||||
action: () => void;
|
||||
destructive?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
label: t("common.mark_as_played"),
|
||||
action: () => {
|
||||
markAsPlayedStatus(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("common.mark_as_not_played"),
|
||||
action: () => {
|
||||
markAsPlayedStatus(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites"),
|
||||
action: toggleFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
if (settings?.useKefinTweaks) {
|
||||
actions.push({
|
||||
label: isWatchlisted
|
||||
? t("watchlists.remove_from_watchlist")
|
||||
: t("watchlists.add_to_watchlist"),
|
||||
action: toggleWatchlist,
|
||||
});
|
||||
}
|
||||
|
||||
if (isOffline && item.Id) {
|
||||
const id = item.Id;
|
||||
actions.push({
|
||||
label: t("home.downloads.delete_download"),
|
||||
action: () => deleteFile(id),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const options = [...actions.map((a) => a.label), t("common.cancel")];
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const destructiveButtonIndex = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
const destructiveButtonIndex = actions.findIndex((a) => a.destructive);
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
destructiveButtonIndex:
|
||||
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
await markAsPlayedStatus(true);
|
||||
} else if (selectedIndex === 1) {
|
||||
await markAsPlayedStatus(false);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite();
|
||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
(selectedIndex) => {
|
||||
if (selectedIndex === undefined || selectedIndex >= actions.length)
|
||||
return;
|
||||
actions[selectedIndex].action();
|
||||
},
|
||||
);
|
||||
}, [
|
||||
@@ -220,6 +254,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isWatchlisted,
|
||||
toggleWatchlist,
|
||||
settings?.useKefinTweaks,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
|
||||
74
components/favorites/FavoritesTabButtons.tsx
Normal file
74
components/favorites/FavoritesTabButtons.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
|
||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||
// A static top-level import crashes the route tree on tvOS at module load.
|
||||
// Load it lazily and only off-TV; TV never renders this component.
|
||||
const { Button, Host, HStack, Spacer } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||
: require("@expo/ui/swift-ui");
|
||||
const { buttonStyle } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
||||
: require("@expo/ui/swift-ui/modifiers");
|
||||
|
||||
type ViewType = "Favorites" | "Watchlist";
|
||||
|
||||
interface FavoritesTabButtonsProps {
|
||||
viewType: ViewType;
|
||||
setViewType: (type: ViewType) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export const FavoritesTabButtons: React.FC<FavoritesTabButtonsProps> = ({
|
||||
viewType,
|
||||
setViewType,
|
||||
t,
|
||||
}) => {
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<Host style={{ height: 40, flex: 1 }}>
|
||||
<HStack spacing={8}>
|
||||
<Button
|
||||
modifiers={[
|
||||
buttonStyle(
|
||||
viewType === "Favorites" ? "glassProminent" : "glass",
|
||||
),
|
||||
]}
|
||||
onPress={() => setViewType("Favorites")}
|
||||
label={t("tabs.favorites")}
|
||||
/>
|
||||
<Button
|
||||
modifiers={[
|
||||
buttonStyle(
|
||||
viewType === "Watchlist" ? "glassProminent" : "glass",
|
||||
),
|
||||
]}
|
||||
onPress={() => setViewType("Watchlist")}
|
||||
label={t("favorites.watchlist")}
|
||||
/>
|
||||
<Spacer />
|
||||
</HStack>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
// Android UI
|
||||
return (
|
||||
<View className='flex flex-row gap-1 mr-1'>
|
||||
<TouchableOpacity onPress={() => setViewType("Favorites")}>
|
||||
<Tag
|
||||
text={t("tabs.favorites")}
|
||||
textClass='p-1'
|
||||
className={viewType === "Favorites" ? "bg-purple-600" : undefined}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setViewType("Watchlist")}>
|
||||
<Tag
|
||||
text={t("favorites.watchlist")}
|
||||
textClass='p-1'
|
||||
className={viewType === "Watchlist" ? "bg-purple-600" : undefined}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
type ViewType = "Favorites" | "Watchlist";
|
||||
|
||||
interface TVFavoritesTabBadgeProps {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
hasTVPreferredFocus = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ duration: 150 });
|
||||
|
||||
// Design language: white for focused/selected, transparent white for unfocused
|
||||
const getBackgroundColor = () => {
|
||||
if (focused) return "#fff";
|
||||
if (isSelected) return "rgba(255,255,255,0.25)";
|
||||
return "rgba(255,255,255,0.1)";
|
||||
};
|
||||
|
||||
const getTextColor = () => {
|
||||
if (focused) return "#000";
|
||||
return "#fff";
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 24,
|
||||
backgroundColor: getBackgroundColor(),
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: getTextColor(),
|
||||
fontWeight: isSelected || focused ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TVFavoritesTabBadgesProps {
|
||||
viewType: ViewType;
|
||||
setViewType: (type: ViewType) => void;
|
||||
/** Only render the toggle when the KefinTweaks watchlist is enabled. */
|
||||
enabled: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
export const TVFavoritesTabBadges: React.FC<TVFavoritesTabBadgesProps> = ({
|
||||
viewType,
|
||||
setViewType,
|
||||
enabled,
|
||||
hasTVPreferredFocus = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<TVFavoritesTabBadge
|
||||
label={t("tabs.favorites")}
|
||||
isSelected={viewType === "Favorites"}
|
||||
onPress={() => setViewType("Favorites")}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Favorites"}
|
||||
/>
|
||||
<TVFavoritesTabBadge
|
||||
label={t("favorites.watchlist")}
|
||||
isSelected={viewType === "Watchlist"}
|
||||
onPress={() => setViewType("Watchlist")}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Watchlist"}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
@@ -22,7 +25,24 @@ type FavoriteTypes =
|
||||
| "Playlist";
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
|
||||
export const Favorites = () => {
|
||||
interface FavoritesProps {
|
||||
/** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */
|
||||
filter?: ItemFilter;
|
||||
/** Query key segment used to keep favorites/watchlist caches separate. */
|
||||
queryKeyBase?: string;
|
||||
emptyTitleKey?: string;
|
||||
emptyTextKey?: string;
|
||||
/** Namespace for the see-all page headers ("favorites" or "kefintweaksWatchlist"). */
|
||||
seeAllNamespace?: "kefintweaksWatchlist" | "favorites";
|
||||
}
|
||||
|
||||
export const Favorites = ({
|
||||
filter = "IsFavorite",
|
||||
queryKeyBase = "favorites",
|
||||
emptyTitleKey = "favorites.noDataTitle",
|
||||
emptyTextKey = "favorites.noData",
|
||||
seeAllNamespace = "favorites",
|
||||
}: FavoritesProps = {}) => {
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -46,7 +66,7 @@ export const Favorites = () => {
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -68,10 +88,13 @@ export const Favorites = () => {
|
||||
|
||||
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(() => {
|
||||
setEmptyState({
|
||||
Series: false,
|
||||
@@ -81,7 +104,7 @@ export const Favorites = () => {
|
||||
BoxSet: false,
|
||||
Playlist: false,
|
||||
});
|
||||
}, [api, user]);
|
||||
}, [api, user, filter]);
|
||||
|
||||
// Check if all categories that have been loaded are empty
|
||||
const areAllEmpty = () => {
|
||||
@@ -123,47 +146,26 @@ export const Favorites = () => {
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
const handleSeeAllSeries = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Series", title: t("favorites.series") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllMovies = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Movie", title: t("favorites.movies") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllEpisodes = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Episode", title: t("favorites.episodes") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllVideos = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Video", title: t("favorites.videos") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllBoxsets = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllPlaylists = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Playlist", title: t("favorites.playlists") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
// Navigate to the shared see-all screen. `name` is the capitalized type
|
||||
// suffix of the see-all header key (e.g. "Series" -> "seeAllSeries").
|
||||
// The namespace is branched explicitly so each t() call has a static prefix
|
||||
// (favorites.seeAll* / kefintweaksWatchlist.seeAll*) that the i18n usage
|
||||
// checker can detect — see scripts/check-i18n-keys.mjs. The `as any` is
|
||||
// needed because the route's custom params aren't part of expo-router's
|
||||
// typed Href.
|
||||
const seeAll = useCallback(
|
||||
(type: FavoriteTypes, name: string) => {
|
||||
const title =
|
||||
seeAllNamespace === "kefintweaksWatchlist"
|
||||
? t(`kefintweaksWatchlist.seeAll${name}`)
|
||||
: t(`favorites.seeAll${name}`);
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type, title, filter },
|
||||
} as any);
|
||||
},
|
||||
[router, filter, seeAllNamespace],
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='flex flex-co gap-y-4'>
|
||||
@@ -176,61 +178,61 @@ export const Favorites = () => {
|
||||
source={heart}
|
||||
/>
|
||||
<Text className='text-xl font-semibold text-white mb-2'>
|
||||
{t("favorites.noDataTitle")}
|
||||
{t(emptyTitleKey)}
|
||||
</Text>
|
||||
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
|
||||
{t("favorites.noData")}
|
||||
{t(emptyTextKey)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllSeries}
|
||||
onPressSeeAll={() => seeAll("Series", "Series")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllMovies}
|
||||
onPressSeeAll={() => seeAll("Movie", "Movies")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllEpisodes}
|
||||
onPressSeeAll={() => seeAll("Episode", "Episodes")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllVideos}
|
||||
onPressSeeAll={() => seeAll("Video", "Videos")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllBoxsets}
|
||||
onPressSeeAll={() => seeAll("BoxSet", "Boxsets")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllPlaylists}
|
||||
onPressSeeAll={() => seeAll("Playlist", "Playlists")}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -9,10 +12,12 @@ import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
@@ -33,7 +38,27 @@ export const Favorites = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const pageSize = 20;
|
||||
|
||||
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||
"Favorites",
|
||||
);
|
||||
const filter: ItemFilter =
|
||||
watchlistEnabled && viewType === "Watchlist" ? "Likes" : "IsFavorite";
|
||||
const queryKeyBase =
|
||||
watchlistEnabled && viewType === "Watchlist" ? "watchlist" : "favorites";
|
||||
// Translation namespace for the empty state, swapped for the KefinTweaks
|
||||
// watchlist (Likes-backed) view. Section titles stay generic ("Series").
|
||||
const emptyNamespace =
|
||||
watchlistEnabled && viewType === "Watchlist"
|
||||
? "kefintweaksWatchlist"
|
||||
: "favorites";
|
||||
const emptyTitleKey = `${emptyNamespace}.noDataTitle`;
|
||||
const emptyTextKey = `${emptyNamespace}.noData`;
|
||||
|
||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
@@ -53,7 +78,7 @@ export const Favorites = () => {
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -74,7 +99,7 @@ export const Favorites = () => {
|
||||
|
||||
return items;
|
||||
},
|
||||
[api, user],
|
||||
[api, user, filter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -86,7 +111,7 @@ export const Favorites = () => {
|
||||
BoxSet: false,
|
||||
Playlist: false,
|
||||
});
|
||||
}, [api, user]);
|
||||
}, [api, user, viewType]);
|
||||
|
||||
const areAllEmpty = () => {
|
||||
const loadedCategories = Object.values(emptyState);
|
||||
@@ -127,46 +152,63 @@ export const Favorites = () => {
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
const tabBadges = (
|
||||
<TVFavoritesTabBadges
|
||||
viewType={viewType}
|
||||
setViewType={setViewType}
|
||||
enabled={watchlistEnabled}
|
||||
hasTVPreferredFocus={watchlistEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
if (areAllEmpty()) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
{tabBadges}
|
||||
<View
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
marginBottom: 16,
|
||||
tintColor: Colors.primary,
|
||||
}}
|
||||
contentFit='contain'
|
||||
source={heart}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noDataTitle")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noData")}
|
||||
</Text>
|
||||
<Image
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
marginBottom: 16,
|
||||
tintColor: Colors.primary,
|
||||
}}
|
||||
contentFit='contain'
|
||||
source={heart}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t(emptyTitleKey)}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t(emptyTextKey)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -181,17 +223,22 @@ export const Favorites = () => {
|
||||
}}
|
||||
>
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{watchlistEnabled && (
|
||||
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
|
||||
{tabBadges}
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
isFirstSection
|
||||
isFirstSection={!watchlistEnabled}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
@@ -199,28 +246,28 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -44,10 +44,8 @@ export interface TVNextEpisodeCountdownProps {
|
||||
playButtonRef?: RNView | null;
|
||||
}
|
||||
|
||||
// Position constants — kept in sync with TVSkipSegmentCard (the two are
|
||||
// mutually exclusive). See TVSkipSegmentCard for the BOTTOM_WITH_CONTROLS
|
||||
// rationale (220 sits just above the controls bar; 300 floated too high).
|
||||
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||
// Position constants
|
||||
const BOTTOM_WITH_CONTROLS = scaleSize(300);
|
||||
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||
|
||||
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
|
||||
@@ -33,15 +33,9 @@ export interface TVSkipSegmentCardProps {
|
||||
playButtonRef?: View | null;
|
||||
}
|
||||
|
||||
// Position constants — kept in sync with TVNextEpisodeCountdown (the two
|
||||
// are mutually exclusive). Scaled to the screen so 4K TVs don't get a
|
||||
// card that floats far above the controls.
|
||||
//
|
||||
// BOTTOM_WITH_CONTROLS is tuned to sit just above the bottom controls bar
|
||||
// (metadata + seekbar + buttons ≈ 200px on 1080p). Previously 300, which
|
||||
// left the card hovering ~100px above the controls.
|
||||
const BOTTOM_WITH_CONTROLS = scaleSize(220);
|
||||
const BOTTOM_WITHOUT_CONTROLS = scaleSize(120);
|
||||
// Position constants - same as TVNextEpisodeCountdown (they're mutually exclusive)
|
||||
const BOTTOM_WITH_CONTROLS = 300;
|
||||
const BOTTOM_WITHOUT_CONTROLS = 120;
|
||||
|
||||
export const TVSkipSegmentCard: FC<TVSkipSegmentCardProps> = ({
|
||||
show,
|
||||
|
||||
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
|
||||
export type { TVUserCardProps } from "./TVUserCard";
|
||||
export { TVUserCard } from "./TVUserCard";
|
||||
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
|
||||
export { TVWatchlistButton } from "./TVWatchlistButton";
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { MpvPlayerViewProps, MpvPlayerViewRef } from "@/modules";
|
||||
import { MpvPlayerView } from "@/modules";
|
||||
import { ExoPlayerView } from "@/modules/exoplayer-player";
|
||||
import {
|
||||
getActiveVideoPlayer,
|
||||
useSettings,
|
||||
VideoPlayer,
|
||||
} from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* Unified video player view. MPV is the default on every platform; users
|
||||
* can opt into ExoPlayer on Android TV via settings.videoPlayer. Both
|
||||
* children conform to the same `MpvPlayerViewRef` interface, so the ref
|
||||
* is forwarded transparently regardless of which player is rendered.
|
||||
*/
|
||||
export const VideoPlayerView = React.forwardRef<
|
||||
MpvPlayerViewRef,
|
||||
MpvPlayerViewProps
|
||||
>(function VideoPlayerView(props, ref) {
|
||||
const { settings } = useSettings();
|
||||
|
||||
// ExoPlayer's native module only ships for Android TV. Even if a user
|
||||
// somehow ends up with `videoPlayer: ExoPlayer` set on another platform
|
||||
// (shouldn't happen — the selector is hidden outside Android TV — but
|
||||
// MMKV-persisted settings can roam), fall back to MPV rather than
|
||||
// crash on requireNativeView().
|
||||
const isExoSupported = Platform.OS === "android" && Platform.isTV;
|
||||
const useExo =
|
||||
isExoSupported && getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer;
|
||||
|
||||
const Player = useExo ? ExoPlayerView : MpvPlayerView;
|
||||
return <Player ref={ref} {...props} />;
|
||||
});
|
||||
@@ -1129,16 +1129,7 @@ export const Controls: FC<Props> = ({
|
||||
{/* Skip intro card */}
|
||||
<TVSkipSegmentCard
|
||||
show={showSkipButton && !isCountdownActive}
|
||||
onPress={() => {
|
||||
// After the seek lands, showSkipButton flips false and this card
|
||||
// unmounts. With controls visible the focus-stealing overlay is
|
||||
// disabled, so without an explicit handoff the focus engine is
|
||||
// stranded. Prime the play button to receive focus on the next
|
||||
// render — when controls are hidden the focus overlay takes over
|
||||
// naturally and this is a harmless no-op.
|
||||
if (showControls) setFocusPlayButton(true);
|
||||
skipIntro();
|
||||
}}
|
||||
onPress={skipIntro}
|
||||
type='intro'
|
||||
controlsVisible={showControls}
|
||||
refSetter={setSkipSegmentRef}
|
||||
@@ -1153,11 +1144,7 @@ export const Controls: FC<Props> = ({
|
||||
(hasContentAfterCredits || !nextItem) &&
|
||||
!isCountdownActive
|
||||
}
|
||||
onPress={() => {
|
||||
// See the intro card above for the focus-handoff rationale.
|
||||
if (showControls) setFocusPlayButton(true);
|
||||
skipCredit();
|
||||
}}
|
||||
onPress={skipCredit}
|
||||
type='credits'
|
||||
controlsVisible={showControls}
|
||||
refSetter={setSkipSegmentRef}
|
||||
|
||||
@@ -213,10 +213,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
);
|
||||
|
||||
return {
|
||||
container: mediaSource.Container,
|
||||
videoRange: videoStream?.VideoRangeType,
|
||||
bitDepth: videoStream?.BitDepth,
|
||||
audioChannels: audioStream?.Channels,
|
||||
audioCodecFromSource: audioStream?.Codec,
|
||||
subtitleCodec: subtitleStream?.Codec,
|
||||
subtitleTitle: subtitleStream?.DisplayTitle,
|
||||
};
|
||||
}, [mediaSource, currentAudioIndex, currentSubtitleIndex]);
|
||||
|
||||
@@ -302,13 +305,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
<Text style={textStyle}>
|
||||
{info.videoWidth}x{info.videoHeight}
|
||||
{streamInfo?.bitDepth ? ` ${streamInfo.bitDepth}bit` : ""}
|
||||
{/* Prefer the player-reported HDR format (authoritative —
|
||||
what's actually being decoded) over Jellyfin metadata. */}
|
||||
{info?.hdrFormat
|
||||
? ` ${info.hdrFormat}`
|
||||
: formatVideoRange(streamInfo?.videoRange)
|
||||
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
||||
: ""}
|
||||
{formatVideoRange(streamInfo?.videoRange)
|
||||
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.videoCodec && (
|
||||
@@ -320,15 +319,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info?.audioCodec && (
|
||||
<Text style={textStyle}>
|
||||
Audio: {formatCodec(info.audioCodec)}
|
||||
{/* Prefer player-reported channel count; fall back to
|
||||
Jellyfin metadata for MPV which doesn't populate it. */}
|
||||
{(info.audioChannels ?? streamInfo?.audioChannels)
|
||||
? ` ${formatAudioChannels(
|
||||
info.audioChannels ?? streamInfo!.audioChannels!,
|
||||
)}`
|
||||
: ""}
|
||||
{info.audioSampleRate
|
||||
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
|
||||
{streamInfo?.audioChannels
|
||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
@@ -347,17 +339,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
: "N/A"}
|
||||
</Text>
|
||||
)}
|
||||
{(info?.colorSpace || info?.colorRange || info?.colorTransfer) && (
|
||||
<Text style={textStyle}>
|
||||
Color:
|
||||
{[info.colorSpace, info.colorRange, info.colorTransfer]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
</Text>
|
||||
)}
|
||||
{info?.videoCodecs && (
|
||||
<Text style={textStyle}>Codec tag: {info.videoCodecs}</Text>
|
||||
)}
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||
@@ -375,12 +356,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.decoderName && (
|
||||
<Text style={textStyle}>
|
||||
Decoder: {info.decoderName}
|
||||
{info.decoderType ? ` (${info.decoderType})` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.estimatedVfFps !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
Output FPS: {info.estimatedVfFps.toFixed(2)}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
group = 'expo.modules.exoplayerplayer'
|
||||
version = '0.1.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
def useManagedAndroidSdkVersions = false
|
||||
if (useManagedAndroidSdkVersions) {
|
||||
useDefaultAndroidSdkVersions()
|
||||
} else {
|
||||
buildscript {
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
}
|
||||
project.android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 26)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.exoplayerplayer"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Media3 (ExoPlayer). The default tracks react-native-track-player's
|
||||
// pinned version (currently 1.10.1) so we don't end up with two media3
|
||||
// versions on the classpath and duplicate-class errors. The
|
||||
// DASH/SmoothStreaming/RTSP artifacts that RNTP pulls in are excluded
|
||||
// globally via plugins/withExcludeMedia3Dash.js.
|
||||
def media3Version = safeExtGet('media3Version', '1.10.1')
|
||||
implementation "androidx.media3:media3-exoplayer:${media3Version}"
|
||||
implementation "androidx.media3:media3-exoplayer-hls:${media3Version}"
|
||||
implementation "androidx.media3:media3-ui:${media3Version}"
|
||||
implementation "androidx.media3:media3-common:${media3Version}"
|
||||
|
||||
// FFmpeg software decoders — DTS / TrueHD / AC-4 / WMA / older video
|
||||
// codecs that MediaCodec doesn't ship with on most Android TVs.
|
||||
//
|
||||
// This is the Jellyfin-published distribution of media3-decoder-ffmpeg
|
||||
// with prebuilt native libraries (the upstream androidx artifact is a
|
||||
// stub that requires building FFmpeg yourself). RNTP already pulls
|
||||
// 1.9.0+1 in transitively, so declaring it here is mostly defensive —
|
||||
// it guarantees we still get it if RNTP ever drops the dep.
|
||||
//
|
||||
// Version skew: this is built against media3 1.9.0 but RNTP (and we)
|
||||
// resolve media3 core to 1.10.1. RNTP ships the same combination in
|
||||
// production, and Media3 maintains binary compat for Renderer /
|
||||
// RenderersFactory APIs across minor versions, so this works in
|
||||
// practice. Re-evaluate when Jellyfin publishes a 1.10.x build.
|
||||
implementation "org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1"
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package expo.modules.exoplayerplayer
|
||||
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class ExoPlayerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExoPlayer")
|
||||
|
||||
// Enables the module to be used as a native view.
|
||||
View(ExoPlayerView::class) {
|
||||
// All video load options are passed via a single "source" prop,
|
||||
// mirroring MpvPlayerView. MPV-only fields (voDriver, extra
|
||||
// cacheConfig fields) are silently ignored.
|
||||
Prop("source") { view: ExoPlayerView, source: Map<String, Any?>? ->
|
||||
if (source == null) return@Prop
|
||||
|
||||
val urlString = source["url"] as? String ?: return@Prop
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val cacheConfig = source["cacheConfig"] as? Map<String, Any?>
|
||||
|
||||
val config = VideoLoadConfig(
|
||||
url = urlString,
|
||||
headers = source["headers"] as? Map<String, String>,
|
||||
externalSubtitles = source["externalSubtitles"] as? List<String>,
|
||||
startPosition = (source["startPosition"] as? Number)?.toDouble(),
|
||||
autoplay = (source["autoplay"] as? Boolean) ?: true,
|
||||
initialSubtitleId = (source["initialSubtitleId"] as? Number)?.toInt(),
|
||||
initialAudioId = (source["initialAudioId"] as? Number)?.toInt(),
|
||||
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)
|
||||
}
|
||||
|
||||
// Now Playing metadata is iOS-only on MPV; no-op here (TV has
|
||||
// no Control Center equivalent — Android handles media sessions
|
||||
// via MediaSessionCompat which we don't wire up for TV).
|
||||
Prop("nowPlayingMetadata") { _: ExoPlayerView, _: Map<String, String>? ->
|
||||
// No-op
|
||||
}
|
||||
|
||||
AsyncFunction("play") { view: ExoPlayerView ->
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { view: ExoPlayerView ->
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("destroy") { view: ExoPlayerView ->
|
||||
view.destroy()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { view: ExoPlayerView, position: Double ->
|
||||
view.seekTo(position)
|
||||
}
|
||||
|
||||
AsyncFunction("seekBy") { view: ExoPlayerView, offset: Double ->
|
||||
view.seekBy(offset)
|
||||
}
|
||||
|
||||
AsyncFunction("setSpeed") { view: ExoPlayerView, speed: Double ->
|
||||
view.setSpeed(speed)
|
||||
}
|
||||
|
||||
AsyncFunction("getSpeed") { view: ExoPlayerView ->
|
||||
view.getSpeed()
|
||||
}
|
||||
|
||||
AsyncFunction("isPaused") { view: ExoPlayerView ->
|
||||
view.isPaused()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentPosition") { view: ExoPlayerView ->
|
||||
view.getCurrentPosition()
|
||||
}
|
||||
|
||||
AsyncFunction("getDuration") { view: ExoPlayerView ->
|
||||
view.getDuration()
|
||||
}
|
||||
|
||||
// Picture in Picture — TV does not use PiP; safe no-ops.
|
||||
AsyncFunction("startPictureInPicture") { _: ExoPlayerView ->
|
||||
// No-op
|
||||
}
|
||||
|
||||
AsyncFunction("stopPictureInPicture") { _: ExoPlayerView ->
|
||||
// No-op
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureSupported") { _: ExoPlayerView ->
|
||||
false
|
||||
}
|
||||
|
||||
AsyncFunction("isPictureInPictureActive") { _: ExoPlayerView ->
|
||||
false
|
||||
}
|
||||
|
||||
// Subtitle functions
|
||||
AsyncFunction("getSubtitleTracks") { view: ExoPlayerView ->
|
||||
view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { view: ExoPlayerView, trackId: Int ->
|
||||
view.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("disableSubtitles") { view: ExoPlayerView ->
|
||||
view.disableSubtitles()
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentSubtitleTrack") { view: ExoPlayerView ->
|
||||
view.getCurrentSubtitleTrack()
|
||||
}
|
||||
|
||||
AsyncFunction("addSubtitleFile") { view: ExoPlayerView, url: String, select: Boolean ->
|
||||
view.addSubtitleFile(url, select)
|
||||
}
|
||||
|
||||
// Subtitle positioning / styling
|
||||
AsyncFunction("setSubtitlePosition") { view: ExoPlayerView, position: Int ->
|
||||
view.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleScale") { view: ExoPlayerView, scale: Double ->
|
||||
view.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleMarginY") { view: ExoPlayerView, margin: Int ->
|
||||
view.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignX") { _: ExoPlayerView, _: String ->
|
||||
// No-op — SubtitleView follows authored cue alignment.
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAlignY") { view: ExoPlayerView, alignment: String ->
|
||||
view.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleFontSize") { view: ExoPlayerView, size: Int ->
|
||||
view.setSubtitleFontSize(size)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleBorderStyle") { view: ExoPlayerView, style: String ->
|
||||
view.setSubtitleBorderStyle(style)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleBackgroundColor") { view: ExoPlayerView, color: String ->
|
||||
view.setSubtitleBackgroundColor(color)
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleAssOverride") { _: ExoPlayerView, _: String ->
|
||||
// No-op — libass-specific, no Media3 equivalent.
|
||||
}
|
||||
|
||||
// Audio track functions
|
||||
AsyncFunction("getAudioTracks") { view: ExoPlayerView ->
|
||||
view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { view: ExoPlayerView, trackId: Int ->
|
||||
view.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
AsyncFunction("getCurrentAudioTrack") { view: ExoPlayerView ->
|
||||
view.getCurrentAudioTrack()
|
||||
}
|
||||
|
||||
// Video scaling
|
||||
AsyncFunction("setZoomedToFill") { view: ExoPlayerView, zoomed: Boolean ->
|
||||
view.setZoomedToFill(zoomed)
|
||||
}
|
||||
|
||||
AsyncFunction("isZoomedToFill") { view: ExoPlayerView ->
|
||||
view.isZoomedToFill()
|
||||
}
|
||||
|
||||
// Technical info
|
||||
AsyncFunction("getTechnicalInfo") { view: ExoPlayerView ->
|
||||
view.getTechnicalInfo()
|
||||
}
|
||||
|
||||
// Events that the view can send to JavaScript — same set as MPV.
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,905 +0,0 @@
|
||||
@file:OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
|
||||
package expo.modules.exoplayerplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ColorInfo
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
/**
|
||||
* Configuration for loading a video. Mirrors MpvPlayerView.VideoLoadConfig —
|
||||
* MPV-only fields are accepted and ignored.
|
||||
*/
|
||||
data class VideoLoadConfig(
|
||||
val url: String,
|
||||
val headers: Map<String, String>? = null,
|
||||
val externalSubtitles: List<String>? = null,
|
||||
val startPosition: Double? = null,
|
||||
val autoplay: Boolean = true,
|
||||
val initialSubtitleId: Int? = null,
|
||||
val initialAudioId: Int? = null,
|
||||
val cacheEnabled: String? = null,
|
||||
val cacheSeconds: Int? = null,
|
||||
val demuxerMaxBytes: Int? = null,
|
||||
val demuxerMaxBackBytes: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* ExoPlayerView — ExpoView that hosts a Media3 ExoPlayer instance.
|
||||
*
|
||||
* Implements the same JS contract (events, ref methods, 1-based track IDs)
|
||||
* as MpvPlayerView so the React layer can swap between the two without
|
||||
* changes. Subtitle styling is mapped to androidx.media3.ui.SubtitleView +
|
||||
* CaptionStyleCompat. PiP methods are no-ops (TV doesn't use PiP).
|
||||
*/
|
||||
class ExoPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerView"
|
||||
private const val PROGRESS_INTERVAL_MS = 1000L
|
||||
}
|
||||
|
||||
// Event dispatchers — names must match the Events() declaration in the module.
|
||||
val onLoad by EventDispatcher()
|
||||
val onPlaybackStateChange by EventDispatcher()
|
||||
val onProgress by EventDispatcher()
|
||||
val onError by EventDispatcher()
|
||||
val onTracksReady by EventDispatcher()
|
||||
val onPictureInPictureChange by EventDispatcher()
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private var player: ExoPlayer? = null
|
||||
private val playerView: PlayerView
|
||||
private val subtitleView: SubtitleView?
|
||||
|
||||
private var currentUrl: String? = null
|
||||
private var pendingConfig: VideoLoadConfig? = null
|
||||
private var tracksReadyFired: Boolean = false
|
||||
|
||||
// 1-based track ID mappings (matching MPV's contract).
|
||||
// Each list is rebuilt on Tracks changed.
|
||||
private var subtitleTrackList: List<TrackEntry> = emptyList()
|
||||
private var audioTrackList: List<TrackEntry> = emptyList()
|
||||
private var currentSubtitleId: Int = 0
|
||||
private var currentAudioId: Int = 0
|
||||
|
||||
// Subtitle styling state — applied to the embedded SubtitleView.
|
||||
private var subtitleScale: Float = 1f
|
||||
private var subtitleFontSizePct: Int? = null // 0-100
|
||||
// Last-write-wins override of the vertical position fraction
|
||||
// (null = fall back to subtitleAlignY). Both setSubtitlePosition
|
||||
// (0-100, MPV convention where 100 = bottom) and setSubtitleMarginY
|
||||
// (px) funnel into this single SubtitleView API.
|
||||
private var subtitleBottomFraction: Float? = null
|
||||
private var subtitleAlignY: String = "bottom"
|
||||
// Background color carries its own alpha (parsed from #RRGGBBAA in
|
||||
// setSubtitleBackgroundColor) so no separate enabled/opacity flags.
|
||||
private var subtitleBackgroundColor: Int = Color.argb(0, 0, 0, 0)
|
||||
private var subtitleBorderStyle: String = "outline-and-shadow"
|
||||
|
||||
private var isZoomedToFill: Boolean = false
|
||||
|
||||
// Captured by analyticsListener; surfaced via getTechnicalInfo().
|
||||
// Reset on destroy() and (for decoder names) on track changes.
|
||||
private var videoDecoderName: String? = null
|
||||
private var audioDecoderName: String? = null
|
||||
private var cumulativeDroppedFrames: Int = 0
|
||||
|
||||
private val analyticsListener = object : AnalyticsListener {
|
||||
override fun onVideoDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
initializedTimestampMs: Long,
|
||||
) {
|
||||
videoDecoderName = decoderName
|
||||
}
|
||||
|
||||
override fun onAudioDecoderInitialized(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
decoderName: String,
|
||||
initializedTimestampMs: Long,
|
||||
) {
|
||||
audioDecoderName = decoderName
|
||||
}
|
||||
|
||||
override fun onDroppedVideoFrames(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
droppedFrames: Int,
|
||||
elapsedMs: Long,
|
||||
) {
|
||||
// Incremental count since last call; accumulate for a cumulative
|
||||
// total that matches MPV's droppedFrames semantics.
|
||||
cumulativeDroppedFrames += droppedFrames
|
||||
}
|
||||
}
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING -> {
|
||||
onPlaybackStateChange(mapOf("isLoading" to true))
|
||||
}
|
||||
Player.STATE_READY -> {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isLoading" to false,
|
||||
"isReadyToSeek" to true
|
||||
))
|
||||
if (!tracksReadyFired) {
|
||||
tracksReadyFired = true
|
||||
rebuildTrackMaps(player?.currentTracks)
|
||||
onTracksReady(emptyMap<String, Any>())
|
||||
}
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isPlaying" to false,
|
||||
"isPaused" to true
|
||||
))
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isPlaying" to isPlaying,
|
||||
"isPaused" to !isPlaying
|
||||
))
|
||||
}
|
||||
|
||||
override fun onPlayerErrorChanged(error: androidx.media3.common.PlaybackException?) {
|
||||
val message = error?.message ?: "Unknown playback error"
|
||||
Log.e(TAG, "Player error: $message", error)
|
||||
onError(mapOf("error" to message))
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
rebuildTrackMaps(tracks)
|
||||
applyInitialTrackSelections()
|
||||
// A track change can re-initialize the codec under a different
|
||||
// name (e.g. adaptive switch from HEVC to AV1). Clear stale
|
||||
// decoder names so getTechnicalInfo() doesn't report the
|
||||
// previous codec until the next onVideoDecoderInitialized fires.
|
||||
videoDecoderName = null
|
||||
audioDecoderName = null
|
||||
}
|
||||
}
|
||||
|
||||
private val progressRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
val p = player ?: return
|
||||
val positionMs = p.currentPosition
|
||||
val durationMs = p.duration
|
||||
val bufferedMs = p.bufferedPosition
|
||||
|
||||
val positionSec = positionMs / 1000.0
|
||||
val durationSec = if (durationMs > 0) durationMs / 1000.0 else 0.0
|
||||
val cacheSec = if (bufferedMs > positionMs) (bufferedMs - positionMs) / 1000.0 else 0.0
|
||||
|
||||
onProgress(mapOf(
|
||||
"position" to positionSec,
|
||||
"duration" to durationSec,
|
||||
"progress" to if (durationSec > 0) positionSec / durationSec else 0.0,
|
||||
"cacheSeconds" to cacheSec
|
||||
))
|
||||
|
||||
mainHandler.postDelayed(this, PROGRESS_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
|
||||
playerView = PlayerView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
// SurfaceView-backed for parity with MPV (direct surface to
|
||||
// SurfaceFlinger). PlayerView defaults to a SurfaceView, so no
|
||||
// explicit setSurfaceType() call is needed; the int constants
|
||||
// backing it are @IntDef private in Media3.
|
||||
setUseController(false)
|
||||
setResizeMode(androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT)
|
||||
}
|
||||
subtitleView = playerView.subtitleView
|
||||
addView(playerView)
|
||||
}
|
||||
|
||||
// MARK: - Video Loading
|
||||
|
||||
fun loadVideo(config: VideoLoadConfig) {
|
||||
if (currentUrl == config.url) return
|
||||
currentUrl = config.url
|
||||
pendingConfig = config
|
||||
ensurePlayer(config)
|
||||
loadInternal(config)
|
||||
}
|
||||
|
||||
private fun ensurePlayer(config: VideoLoadConfig) {
|
||||
if (player != null) return
|
||||
|
||||
val loadControl = buildLoadControl(config)
|
||||
|
||||
// PREFER extension renderers so the FFmpeg decoder (DTS / TrueHD /
|
||||
// AC-4 / WMA / etc.) takes over when MediaCodec doesn't ship a
|
||||
// hardware decoder for the format. MediaCodec remains the fallback.
|
||||
val renderersFactory = androidx.media3.exoplayer.DefaultRenderersFactory(context)
|
||||
.setExtensionRendererMode(
|
||||
androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
)
|
||||
.setEnableDecoderFallback(true)
|
||||
|
||||
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||
.setLoadControl(loadControl)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true
|
||||
)
|
||||
.build()
|
||||
|
||||
exo.addListener(playerListener)
|
||||
exo.addAnalyticsListener(analyticsListener)
|
||||
exo.repeatMode = Player.REPEAT_MODE_OFF
|
||||
player = exo
|
||||
playerView.player = exo
|
||||
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
private fun buildLoadControl(config: VideoLoadConfig): DefaultLoadControl {
|
||||
// Map MPV-style cache config to ExoPlayer's LoadControl.
|
||||
val cacheEnabled = when (config.cacheEnabled) {
|
||||
"no" -> false
|
||||
"yes" -> true
|
||||
else -> true // "auto"
|
||||
}
|
||||
|
||||
// Buffer thresholds used as fallbacks when the user's cache config
|
||||
// doesn't override them. Media3's own defaults changed in 1.6.0
|
||||
// (bufferForPlaybackMs 2500→1000, afterRebuffer 5000→2000) for a
|
||||
// faster start; we intentionally keep the older 2500/5000 here
|
||||
// because low-RAM Android TVs with slow tuners benefit from the
|
||||
// extra headroom before playback kicks in. Media3's DEFAULT_*
|
||||
// IntDef fields are private, hence the literals.
|
||||
val defaultMinBufferMs = 15000
|
||||
val defaultBufferForPlaybackMs = 2500
|
||||
val defaultBufferForPlaybackAfterRebufferMs = 5000
|
||||
|
||||
val targetBufferMs = if (!cacheEnabled) {
|
||||
50000
|
||||
} else {
|
||||
val seconds = config.cacheSeconds?.coerceIn(5, 120) ?: 10
|
||||
seconds * 1000
|
||||
}
|
||||
|
||||
val backBufferMs = if (!cacheEnabled) {
|
||||
0
|
||||
} else {
|
||||
val mb = config.demuxerMaxBackBytes ?: 50
|
||||
// Heuristic: 1 MB ≈ 1s of typical 1080p bitrate.
|
||||
(mb * 1000).coerceAtLeast(1000)
|
||||
}
|
||||
|
||||
val builder = DefaultLoadControl.Builder()
|
||||
.setTargetBufferBytes(if (!cacheEnabled) 0 else ((config.demuxerMaxBytes ?: 150) * 1024 * 1024))
|
||||
.setBufferDurationsMs(
|
||||
/* minBufferMs = */ defaultMinBufferMs,
|
||||
/* maxBufferMs = */ targetBufferMs,
|
||||
/* bufferForPlaybackMs = */ defaultBufferForPlaybackMs,
|
||||
/* bufferForPlaybackAfterRebufferMs = */ defaultBufferForPlaybackAfterRebufferMs
|
||||
)
|
||||
if (cacheEnabled) {
|
||||
builder.setBackBuffer(backBufferMs, /* retainBackBufferFromKeyframe = */ true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun loadInternal(config: VideoLoadConfig) {
|
||||
val p = player ?: return
|
||||
|
||||
val httpFactory = androidx.media3.datasource.DefaultHttpDataSource.Factory()
|
||||
.setDefaultRequestProperties(config.headers ?: emptyMap())
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context, httpFactory)
|
||||
|
||||
val mediaItem = buildMediaItem(config)
|
||||
|
||||
val mediaSource = DefaultMediaSourceFactory(dataSourceFactory)
|
||||
.createMediaSource(mediaItem)
|
||||
|
||||
p.setMediaSource(mediaSource)
|
||||
p.prepare()
|
||||
|
||||
// Apply initial playback position
|
||||
config.startPosition?.let { startPosSec ->
|
||||
if (startPosSec > 0) {
|
||||
p.seekTo((startPosSec * 1000).toLong())
|
||||
}
|
||||
}
|
||||
|
||||
if (config.autoplay) {
|
||||
p.play()
|
||||
}
|
||||
|
||||
onLoad(mapOf("url" to config.url))
|
||||
startProgressLoop()
|
||||
}
|
||||
|
||||
private fun buildMediaItem(config: VideoLoadConfig): MediaItem {
|
||||
val builder = MediaItem.Builder().setUri(config.url)
|
||||
|
||||
// External subtitles: add as side-loaded SubtitleConfigurations.
|
||||
// MIME-type sniffed from the file extension.
|
||||
val subs = config.externalSubtitles
|
||||
if (!subs.isNullOrEmpty()) {
|
||||
val subtitleConfigs = subs.mapNotNull { subUrl ->
|
||||
val mime = mimeTypeForSubtitleUrl(subUrl) ?: return@mapNotNull null
|
||||
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subUrl))
|
||||
.setMimeType(mime)
|
||||
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||
.build()
|
||||
}
|
||||
if (subtitleConfigs.isNotEmpty()) {
|
||||
builder.setSubtitleConfigurations(subtitleConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun mimeTypeForSubtitleUrl(url: String): String? {
|
||||
val lower = url.substringBeforeLast('?').lowercase()
|
||||
return when {
|
||||
lower.endsWith(".vtt") || lower.endsWith(".webvtt") -> "text/vtt"
|
||||
lower.endsWith(".srt") -> "application/x-subrip"
|
||||
lower.endsWith(".ssa") || lower.endsWith(".ass") -> "text/x-ssa"
|
||||
lower.endsWith(".ttml") || lower.endsWith(".xml") -> "application/ttml+xml"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
fun play() {
|
||||
player?.play()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
player?.pause()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
stopProgressLoop()
|
||||
player?.release()
|
||||
player = null
|
||||
playerView.player = null
|
||||
tracksReadyFired = false
|
||||
currentUrl = null
|
||||
subtitleTrackList = emptyList()
|
||||
audioTrackList = emptyList()
|
||||
currentSubtitleId = 0
|
||||
currentAudioId = 0
|
||||
videoDecoderName = null
|
||||
audioDecoderName = null
|
||||
cumulativeDroppedFrames = 0
|
||||
}
|
||||
|
||||
fun seekTo(positionSec: Double) {
|
||||
player?.seekTo((positionSec * 1000).toLong())
|
||||
}
|
||||
|
||||
fun seekBy(offsetSec: Double) {
|
||||
val p = player ?: return
|
||||
val target = (p.currentPosition + offsetSec * 1000).coerceAtLeast(0.0)
|
||||
p.seekTo(target.toLong())
|
||||
}
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
player?.playbackParameters = PlaybackParameters(speed.toFloat())
|
||||
}
|
||||
|
||||
fun getSpeed(): Float {
|
||||
return player?.playbackParameters?.speed ?: 1f
|
||||
}
|
||||
|
||||
fun isPaused(): Boolean {
|
||||
return player?.isPlaying == false
|
||||
}
|
||||
|
||||
fun getCurrentPosition(): Double {
|
||||
return (player?.currentPosition ?: 0L) / 1000.0
|
||||
}
|
||||
|
||||
fun getDuration(): Double {
|
||||
val d = player?.duration ?: 0L
|
||||
return if (d > 0) d / 1000.0 else 0.0
|
||||
}
|
||||
|
||||
// MARK: - Track Mapping (1-based IDs to match MPV's contract)
|
||||
|
||||
data class TrackEntry(
|
||||
val id: Int, // 1-based JS-facing ID
|
||||
val trackGroupIndex: Int,
|
||||
val trackIndex: Int,
|
||||
val format: Format,
|
||||
)
|
||||
|
||||
private fun rebuildTrackMaps(tracks: Tracks?) {
|
||||
if (tracks == null) return
|
||||
|
||||
val subtitles = mutableListOf<TrackEntry>()
|
||||
val audios = mutableListOf<TrackEntry>()
|
||||
|
||||
tracks.groups.forEachIndexed { groupIndex, group ->
|
||||
val rendererType = group.type
|
||||
// Skip groups that have no tracks the player supports
|
||||
for (trackIdx in 0 until group.length) {
|
||||
if (!group.isTrackSupported(trackIdx)) continue
|
||||
val format = group.getTrackFormat(trackIdx)
|
||||
val entry = TrackEntry(
|
||||
id = 0, // assigned per-list below
|
||||
trackGroupIndex = groupIndex,
|
||||
trackIndex = trackIdx,
|
||||
format = format
|
||||
)
|
||||
when (rendererType) {
|
||||
C.TRACK_TYPE_TEXT -> subtitles.add(entry)
|
||||
C.TRACK_TYPE_AUDIO -> audios.add(entry)
|
||||
else -> { /* video / metadata ignored */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign 1-based IDs per track kind.
|
||||
subtitles.forEachIndexed { i, e -> subtitles[i] = e.copy(id = i + 1) }
|
||||
audios.forEachIndexed { i, e -> audios[i] = e.copy(id = i + 1) }
|
||||
|
||||
subtitleTrackList = subtitles
|
||||
audioTrackList = audios
|
||||
}
|
||||
|
||||
private fun applyInitialTrackSelections() {
|
||||
val p = player ?: return
|
||||
val cfg = pendingConfig ?: return
|
||||
|
||||
// Initial subtitle/audio selection by 1-based ID.
|
||||
if (cfg.initialAudioId != null && cfg.initialAudioId > 0) {
|
||||
setAudioTrack(cfg.initialAudioId)
|
||||
}
|
||||
if (cfg.initialSubtitleId == null || cfg.initialSubtitleId <= 0) {
|
||||
disableSubtitles()
|
||||
} else {
|
||||
setSubtitleTrack(cfg.initialSubtitleId)
|
||||
}
|
||||
|
||||
// Only apply once per source load.
|
||||
pendingConfig = null
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||
return subtitleTrackList.map { entry ->
|
||||
mapOf(
|
||||
"id" to entry.id,
|
||||
"title" to (entry.format.label ?: ""),
|
||||
"lang" to (entry.format.language ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
val p = player ?: return
|
||||
val entry = subtitleTrackList.firstOrNull { it.id == trackId } ?: return
|
||||
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||
|
||||
// setOverrideForType replaces any existing override of the same
|
||||
// track type — exactly what we want for single-track subtitle pickers.
|
||||
val params = p.trackSelectionParameters.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||
.build()
|
||||
p.trackSelectionParameters = params
|
||||
currentSubtitleId = trackId
|
||||
}
|
||||
|
||||
fun disableSubtitles() {
|
||||
val p = player ?: return
|
||||
val params = p.trackSelectionParameters.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||
.build()
|
||||
p.trackSelectionParameters = params
|
||||
currentSubtitleId = 0
|
||||
}
|
||||
|
||||
fun getCurrentSubtitleTrack(): Int = currentSubtitleId
|
||||
|
||||
fun addSubtitleFile(url: String, select: Boolean) {
|
||||
val p = player ?: return
|
||||
// Media3 does not expose the current MediaItem's existing
|
||||
// SubtitleConfigurations, so we cannot append a side-loaded
|
||||
// subtitle to a running item without losing the originals.
|
||||
// For TV, external subs are bundled at load time via
|
||||
// VideoLoadConfig.externalSubtitles (see buildMediaItem). This
|
||||
// method rebuilds the current MediaItem with just the new
|
||||
// subtitle config — acceptable when no other external subs are
|
||||
// in play, which is the typical TV case.
|
||||
val mime = mimeTypeForSubtitleUrl(url) ?: return
|
||||
val currentMediaItem = p.currentMediaItem ?: return
|
||||
val newSubConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url))
|
||||
.setMimeType(mime)
|
||||
.setSelectionFlags(if (select) C.SELECTION_FLAG_DEFAULT else 0)
|
||||
.build()
|
||||
|
||||
val rebuilt = currentMediaItem.buildUpon()
|
||||
.setSubtitleConfigurations(listOf(newSubConfig))
|
||||
.build()
|
||||
|
||||
val wasPlaying = p.isPlaying
|
||||
val pos = p.currentPosition
|
||||
p.setMediaItem(rebuilt, pos)
|
||||
p.prepare()
|
||||
if (wasPlaying) p.play()
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning / Styling
|
||||
|
||||
fun setSubtitlePosition(position: Int) {
|
||||
// position is 0-100 (MPV convention: 100 = bottom, 0 = top).
|
||||
// Map to SubtitleView's bottom-padding fraction. Reserve a small
|
||||
// margin so 100 doesn't hug the very bottom edge.
|
||||
val clamped = position.coerceIn(0, 100)
|
||||
subtitleBottomFraction = 0.95f - (clamped / 100f) * 0.87f
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
subtitleScale = scale.toFloat()
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleMarginY(margin: Int) {
|
||||
// Margin in px (approximate). SubtitleView only accepts a single
|
||||
// bottom-padding fraction, so convert via a heuristic (1px ≈ 0.1%
|
||||
// of view height, capped). Last-write-wins vs. setSubtitlePosition.
|
||||
val fraction = (margin / 1000f).coerceIn(0.02f, 0.95f)
|
||||
subtitleBottomFraction = fraction
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleAlignY(alignment: String) {
|
||||
subtitleAlignY = alignment
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleFontSize(size: Int) {
|
||||
subtitleFontSizePct = size
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleBackgroundColor(colorHex: String) {
|
||||
subtitleBackgroundColor = parseColor(colorHex, subtitleBackgroundColor)
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
fun setSubtitleBorderStyle(style: String) {
|
||||
subtitleBorderStyle = style
|
||||
applySubtitleStyle()
|
||||
}
|
||||
|
||||
private fun parseColor(hex: String, fallback: Int): Int {
|
||||
return try {
|
||||
when {
|
||||
hex.startsWith("#") && hex.length == 9 -> {
|
||||
// #RRGGBBAA
|
||||
val r = hex.substring(1, 3).toInt(16)
|
||||
val g = hex.substring(3, 5).toInt(16)
|
||||
val b = hex.substring(5, 7).toInt(16)
|
||||
val a = hex.substring(7, 9).toInt(16)
|
||||
Color.argb(a, r, g, b)
|
||||
}
|
||||
hex.startsWith("#") && hex.length == 7 -> Color.parseColor(hex)
|
||||
else -> fallback
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySubtitleStyle() {
|
||||
val sv = subtitleView ?: return
|
||||
|
||||
// Text size: explicit % wins; otherwise scale the default.
|
||||
val textSizeFraction = if (subtitleFontSizePct != null) {
|
||||
(subtitleFontSizePct!! / 100f) * SubtitleView.DEFAULT_TEXT_SIZE_FRACTION
|
||||
} else {
|
||||
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * subtitleScale
|
||||
}
|
||||
sv.setFractionalTextSize(textSizeFraction)
|
||||
|
||||
// Vertical position: explicit fraction (from setSubtitlePosition /
|
||||
// setSubtitleMarginY) wins; otherwise fall back to alignY mapping.
|
||||
val alignYFraction = when (subtitleAlignY) {
|
||||
"top" -> 0.9f
|
||||
"center" -> 0.5f
|
||||
else -> 0.08f // bottom
|
||||
}
|
||||
val bottomFraction = subtitleBottomFraction ?: alignYFraction
|
||||
sv.setBottomPaddingFraction(bottomFraction.coerceIn(0.02f, 0.95f))
|
||||
|
||||
// Edge / background style.
|
||||
val foreground = Color.WHITE
|
||||
val edgeType: Int
|
||||
val backgroundColor: Int
|
||||
when (subtitleBorderStyle) {
|
||||
"background-box" -> {
|
||||
edgeType = CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
// subtitleBackgroundColor already carries its own alpha
|
||||
// (parsed from #RRGGBBAA by setSubtitleBackgroundColor).
|
||||
// Alpha 0 → transparent, matching user intent.
|
||||
backgroundColor = subtitleBackgroundColor
|
||||
}
|
||||
else -> {
|
||||
// "outline-and-shadow"
|
||||
edgeType = if (subtitleAlignY == "center")
|
||||
CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
else
|
||||
CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
backgroundColor = Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
|
||||
val style = CaptionStyleCompat(
|
||||
foreground,
|
||||
backgroundColor,
|
||||
Color.TRANSPARENT,
|
||||
edgeType,
|
||||
Color.BLACK,
|
||||
Typeface.SANS_SERIF
|
||||
)
|
||||
sv.setApplyEmbeddedStyles(false)
|
||||
sv.setApplyEmbeddedFontSizes(false)
|
||||
sv.setStyle(style)
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>> {
|
||||
return audioTrackList.map { entry ->
|
||||
// channelCount is Format.NO_VALUE (-1) when unknown — report 0.
|
||||
val channels = if (entry.format.channelCount == Format.NO_VALUE) 0
|
||||
else entry.format.channelCount
|
||||
mapOf(
|
||||
"id" to entry.id,
|
||||
"title" to (entry.format.label ?: ""),
|
||||
"lang" to (entry.format.language ?: ""),
|
||||
"codec" to (entry.format.sampleMimeType ?: ""),
|
||||
"channels" to channels
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
val p = player ?: return
|
||||
val entry = audioTrackList.firstOrNull { it.id == trackId } ?: return
|
||||
val matchedGroup = p.currentTracks.groups[entry.trackGroupIndex].mediaTrackGroup
|
||||
|
||||
val params = p.trackSelectionParameters.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
|
||||
.setOverrideForType(TrackSelectionOverride(matchedGroup, entry.trackIndex))
|
||||
.build()
|
||||
p.trackSelectionParameters = params
|
||||
currentAudioId = trackId
|
||||
}
|
||||
|
||||
fun getCurrentAudioTrack(): Int = currentAudioId
|
||||
|
||||
// MARK: - Video Scaling
|
||||
|
||||
fun setZoomedToFill(zoomed: Boolean) {
|
||||
isZoomedToFill = zoomed
|
||||
val resizeMode = if (zoomed) {
|
||||
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
} else {
|
||||
androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
|
||||
fun isZoomedToFill(): Boolean = isZoomedToFill
|
||||
|
||||
// MARK: - Technical Info
|
||||
|
||||
fun getTechnicalInfo(): Map<String, Any> {
|
||||
val p = player ?: return emptyMap()
|
||||
val tracks = p.currentTracks
|
||||
|
||||
// Prefer the currently-selected track within each renderer group;
|
||||
// fall back to the first supported track if none is selected yet.
|
||||
val videoFormat = pickFormat(tracks, C.TRACK_TYPE_VIDEO)
|
||||
val audioFormat = pickFormat(tracks, C.TRACK_TYPE_AUDIO)
|
||||
|
||||
val cacheSec = if (p.bufferedPosition > p.currentPosition) {
|
||||
(p.bufferedPosition - p.currentPosition) / 1000.0
|
||||
} else 0.0
|
||||
|
||||
val info = LinkedHashMap<String, Any>()
|
||||
info["cacheSeconds"] = cacheSec
|
||||
|
||||
// Dropped frames — populated by analyticsListener.onDroppedVideoFrames.
|
||||
if (cumulativeDroppedFrames > 0) {
|
||||
info["droppedFrames"] = cumulativeDroppedFrames
|
||||
}
|
||||
|
||||
// Decoder info — populated by analyticsListener.onVideo/AudioDecoderInitialized.
|
||||
// For ExoPlayer this replaces MPV's voDriver/hwdec pairing. The
|
||||
// FFmpeg extension reports names beginning with "FFmpeg", which we
|
||||
// classify as software; everything else is MediaCodec (hardware).
|
||||
videoDecoderName?.let { name ->
|
||||
info["decoderName"] = name
|
||||
info["decoderType"] = if (name.lowercase().startsWith("ffmpeg")) {
|
||||
"software"
|
||||
} else {
|
||||
"hardware"
|
||||
}
|
||||
}
|
||||
|
||||
videoFormat?.let { f ->
|
||||
if (f.width != Format.NO_VALUE) info["videoWidth"] = f.width
|
||||
if (f.height != Format.NO_VALUE) info["videoHeight"] = f.height
|
||||
f.sampleMimeType?.let { info["videoCodec"] = it }
|
||||
// FPS: Format.NO_VALUE (-1f) means unknown — omit so the
|
||||
// overlay skips the row instead of showing "-1".
|
||||
if (f.frameRate > 0f) {
|
||||
info["fps"] = f.frameRate.toDouble()
|
||||
}
|
||||
// Bitrate: prefer average, fall back to peak. Both can be
|
||||
// NO_VALUE for adaptive HLS renditions — omit when unknown
|
||||
// rather than reporting 0 Kbps.
|
||||
val vBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||
f.averageBitrate
|
||||
} else {
|
||||
f.peakBitrate
|
||||
}
|
||||
if (vBitrate != Format.NO_VALUE && vBitrate > 0) {
|
||||
info["videoBitrate"] = vBitrate.toDouble()
|
||||
}
|
||||
|
||||
// Raw codec tag from the container (e.g. "hev1.2.4.L153.B0").
|
||||
// Carries profile / tier / level / constraint bytes — power
|
||||
// users can decode it manually to see why a stream hit our
|
||||
// HEVC level cap.
|
||||
f.codecs?.let { info["videoCodecs"] = it }
|
||||
|
||||
// HDR / color metadata. Format.colorInfo is the authoritative
|
||||
// source — the file/Jellyfin may claim HDR but the player is
|
||||
// what decides whether the decoder+surface path is HDR-capable.
|
||||
f.colorInfo?.let { ci ->
|
||||
val hdr = deriveHdrFormat(ci)
|
||||
if (hdr != null) info["hdrFormat"] = hdr
|
||||
colorSpaceName(ci.colorSpace)?.let { info["colorSpace"] = it }
|
||||
colorRangeName(ci.colorRange)?.let { info["colorRange"] = it }
|
||||
colorTransferName(ci.colorTransfer)?.let { info["colorTransfer"] = it }
|
||||
}
|
||||
}
|
||||
|
||||
audioFormat?.let { f ->
|
||||
f.sampleMimeType?.let { info["audioCodec"] = it }
|
||||
val aBitrate = if (f.averageBitrate != Format.NO_VALUE) {
|
||||
f.averageBitrate
|
||||
} else {
|
||||
f.peakBitrate
|
||||
}
|
||||
if (aBitrate != Format.NO_VALUE && aBitrate > 0) {
|
||||
info["audioBitrate"] = aBitrate.toDouble()
|
||||
}
|
||||
if (f.channelCount > 0) info["audioChannels"] = f.channelCount
|
||||
if (f.sampleRate > 0) info["audioSampleRate"] = f.sampleRate
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the active color transfer to a human-readable HDR format string.
|
||||
* Returns null for SDR / unknown so the overlay can skip the row.
|
||||
*
|
||||
* HDR10 vs HDR10+ distinction isn't possible from Format alone in
|
||||
* Media3 — HDR10+ is signaled via ST2094-40 SEI metadata which isn't
|
||||
* exposed on Format. Both report as "HDR10" here; that matches what
|
||||
* Media3 actually decodes (no HDR10+ tone-mapping).
|
||||
*/
|
||||
private fun deriveHdrFormat(ci: ColorInfo): String? {
|
||||
return when (ci.colorTransfer) {
|
||||
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||
C.COLOR_TRANSFER_ST2084 -> "HDR10"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorSpaceName(value: Int): String? = when (value) {
|
||||
Format.NO_VALUE -> null
|
||||
C.COLOR_SPACE_BT709 -> "BT.709"
|
||||
C.COLOR_SPACE_BT601 -> "BT.601"
|
||||
C.COLOR_SPACE_BT2020 -> "BT.2020"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
private fun colorRangeName(value: Int): String? = when (value) {
|
||||
Format.NO_VALUE -> null
|
||||
C.COLOR_RANGE_LIMITED -> "Limited"
|
||||
C.COLOR_RANGE_FULL -> "Full"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
private fun colorTransferName(value: Int): String? = when (value) {
|
||||
Format.NO_VALUE -> null
|
||||
C.COLOR_TRANSFER_SDR -> "SDR"
|
||||
C.COLOR_TRANSFER_ST2084 -> "ST2084 (PQ)"
|
||||
C.COLOR_TRANSFER_HLG -> "HLG"
|
||||
C.COLOR_TRANSFER_GAMMA_2_2 -> "Gamma 2.2"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
private fun pickFormat(tracks: Tracks, type: Int): Format? {
|
||||
val group = tracks.groups.firstOrNull { it.type == type } ?: return null
|
||||
// Selected track wins.
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSelected(i)) return group.getTrackFormat(i)
|
||||
}
|
||||
// Otherwise the first supported track.
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSupported(i)) return group.getTrackFormat(i)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// MARK: - Progress Loop
|
||||
|
||||
private fun startProgressLoop() {
|
||||
stopProgressLoop()
|
||||
mainHandler.postDelayed(progressRunnable, PROGRESS_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private fun stopProgressLoop() {
|
||||
mainHandler.removeCallbacks(progressRunnable)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["expo.modules.exoplayerplayer.ExoPlayerModule"]
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// Re-export the shared player contract from mpv-player so ExoPlayer
|
||||
// and MPV present identical surfaces to React. The MPV-prefixed setting
|
||||
// keys keep their names to avoid migrating existing installs.
|
||||
export type {
|
||||
AudioTrack,
|
||||
MpvPlayerViewProps,
|
||||
MpvPlayerViewRef,
|
||||
NowPlayingMetadata,
|
||||
OnErrorEventPayload,
|
||||
OnLoadEventPayload,
|
||||
OnPictureInPictureChangePayload,
|
||||
OnPlaybackStateChangePayload,
|
||||
OnProgressEventPayload,
|
||||
OnTracksReadyEventPayload,
|
||||
SubtitleTrack,
|
||||
TechnicalInfo,
|
||||
VideoSource,
|
||||
} from "../mpv-player/src/MpvPlayer.types";
|
||||
export { default as ExoPlayerView } from "./src/ExoPlayerView";
|
||||
@@ -1,132 +0,0 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
|
||||
import type {
|
||||
MpvPlayerViewProps,
|
||||
MpvPlayerViewRef,
|
||||
} from "../mpv-player/src/MpvPlayer.types";
|
||||
|
||||
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||
requireNativeView("ExoPlayer");
|
||||
|
||||
/**
|
||||
* ExoPlayer view wrapper. Exposes the same `MpvPlayerViewRef` interface as
|
||||
* `MpvPlayerView` so callers can swap between the two players without
|
||||
* changing code. PiP / ASS-override methods are forwarded to the native
|
||||
* module which implements them as no-ops.
|
||||
*/
|
||||
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
function ExoPlayerView(props, ref) {
|
||||
const nativeRef = useRef<any>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
destroy: async () => {
|
||||
await nativeRef.current?.destroy();
|
||||
},
|
||||
seekTo: async (position: number) => {
|
||||
await nativeRef.current?.seekTo(position);
|
||||
},
|
||||
seekBy: async (offset: number) => {
|
||||
await nativeRef.current?.seekBy(offset);
|
||||
},
|
||||
setSpeed: async (speed: number) => {
|
||||
await nativeRef.current?.setSpeed(speed);
|
||||
},
|
||||
getSpeed: async () => {
|
||||
return await nativeRef.current?.getSpeed();
|
||||
},
|
||||
isPaused: async () => {
|
||||
return await nativeRef.current?.isPaused();
|
||||
},
|
||||
getCurrentPosition: async () => {
|
||||
return await nativeRef.current?.getCurrentPosition();
|
||||
},
|
||||
getDuration: async () => {
|
||||
return await nativeRef.current?.getDuration();
|
||||
},
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
},
|
||||
stopPictureInPicture: async () => {
|
||||
await nativeRef.current?.stopPictureInPicture();
|
||||
},
|
||||
isPictureInPictureSupported: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureSupported();
|
||||
},
|
||||
isPictureInPictureActive: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureActive();
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
return await nativeRef.current?.getSubtitleTracks();
|
||||
},
|
||||
setSubtitleTrack: async (trackId: number) => {
|
||||
await nativeRef.current?.setSubtitleTrack(trackId);
|
||||
},
|
||||
disableSubtitles: async () => {
|
||||
await nativeRef.current?.disableSubtitles();
|
||||
},
|
||||
getCurrentSubtitleTrack: async () => {
|
||||
return await nativeRef.current?.getCurrentSubtitleTrack();
|
||||
},
|
||||
addSubtitleFile: async (url: string, select = true) => {
|
||||
await nativeRef.current?.addSubtitleFile(url, select);
|
||||
},
|
||||
setSubtitlePosition: async (position: number) => {
|
||||
await nativeRef.current?.setSubtitlePosition(position);
|
||||
},
|
||||
setSubtitleScale: async (scale: number) => {
|
||||
await nativeRef.current?.setSubtitleScale(scale);
|
||||
},
|
||||
setSubtitleMarginY: async (margin: number) => {
|
||||
await nativeRef.current?.setSubtitleMarginY(margin);
|
||||
},
|
||||
setSubtitleAlignX: async (alignment: "left" | "center" | "right") => {
|
||||
await nativeRef.current?.setSubtitleAlignX(alignment);
|
||||
},
|
||||
setSubtitleAlignY: async (alignment: "top" | "center" | "bottom") => {
|
||||
await nativeRef.current?.setSubtitleAlignY(alignment);
|
||||
},
|
||||
setSubtitleFontSize: async (size: number) => {
|
||||
await nativeRef.current?.setSubtitleFontSize(size);
|
||||
},
|
||||
setSubtitleBackgroundColor: async (color: string) => {
|
||||
await nativeRef.current?.setSubtitleBackgroundColor(color);
|
||||
},
|
||||
setSubtitleBorderStyle: async (
|
||||
style: "outline-and-shadow" | "background-box",
|
||||
) => {
|
||||
await nativeRef.current?.setSubtitleBorderStyle(style);
|
||||
},
|
||||
setSubtitleAssOverride: async (mode: "no" | "force") => {
|
||||
await nativeRef.current?.setSubtitleAssOverride(mode);
|
||||
},
|
||||
getAudioTracks: async () => {
|
||||
return await nativeRef.current?.getAudioTracks();
|
||||
},
|
||||
setAudioTrack: async (trackId: number) => {
|
||||
await nativeRef.current?.setAudioTrack(trackId);
|
||||
},
|
||||
getCurrentAudioTrack: async () => {
|
||||
return await nativeRef.current?.getCurrentAudioTrack();
|
||||
},
|
||||
setZoomedToFill: async (zoomed: boolean) => {
|
||||
await nativeRef.current?.setZoomedToFill(zoomed);
|
||||
},
|
||||
isZoomedToFill: async () => {
|
||||
return await nativeRef.current?.isZoomedToFill();
|
||||
},
|
||||
getTechnicalInfo: async () => {
|
||||
return await nativeRef.current?.getTechnicalInfo();
|
||||
},
|
||||
}));
|
||||
|
||||
return <NativeView ref={nativeRef} {...props} />;
|
||||
},
|
||||
);
|
||||
@@ -7,8 +7,6 @@ export type {
|
||||
DownloadStartedEvent,
|
||||
} from "./background-downloader";
|
||||
export { default as BackgroundDownloader } from "./background-downloader";
|
||||
// ExoPlayer (Android TV)
|
||||
export { ExoPlayerView } from "./exoplayer-player";
|
||||
// Glass Poster (tvOS 26+)
|
||||
export type { GlassPosterViewProps } from "./glass-poster";
|
||||
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
||||
|
||||
@@ -175,28 +175,4 @@ export type TechnicalInfo = {
|
||||
hwdec?: string;
|
||||
/** Estimated video output fps (mpv "estimated-vf-fps") */
|
||||
estimatedVfFps?: number;
|
||||
// ---- Extended fields (primarily ExoPlayer-backed; MPV may fill some) ----
|
||||
/** Derived HDR format: "SDR" | "HDR10" | "HDR10+" | "HLG" | null */
|
||||
hdrFormat?: string;
|
||||
/** Color space, e.g. "BT.709" / "BT.2020" */
|
||||
colorSpace?: string;
|
||||
/** Color range: "Limited" / "Full" */
|
||||
colorRange?: string;
|
||||
/** Color transfer: "SDR" / "ST2084 (PQ)" / "HLG" */
|
||||
colorTransfer?: string;
|
||||
/** Decoder path: "hardware" (MediaCodec) or "software" (FFmpeg extension) */
|
||||
decoderType?: string;
|
||||
/** Instantiated decoder name, e.g. "c2.amlogic.hevc.decoder" */
|
||||
decoderName?: string;
|
||||
/** Active audio channel count (2 = stereo, 6 = 5.1, 8 = 7.1) */
|
||||
audioChannels?: number;
|
||||
/** Active audio sample rate in Hz */
|
||||
audioSampleRate?: number;
|
||||
/**
|
||||
* Raw codec tag from the container, e.g. "hev1.2.4.L153.B0". Encodes
|
||||
* profile / tier / level / constraint bytes per ISO/IEC 14496-15. Power
|
||||
* users can decode this manually; it's how Jellyfin's HEVC level cap
|
||||
* (153 = Level 5.1) is checked against the file.
|
||||
*/
|
||||
videoCodecs?: string;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import type React from "react";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type { Bitrate } from "@/components/BitrateSelector";
|
||||
import { getActivePlayerType, settingsAtom } from "@/utils/atoms/settings";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { generateDeviceProfile } from "../utils/profiles/native";
|
||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||
@@ -78,11 +78,10 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Match the device profile to the actually-active player so the
|
||||
// server picks codecs/containers the player can decode.
|
||||
// Generate device profile for MPV player
|
||||
const native = generateDeviceProfile({
|
||||
platform: Platform.OS as "ios" | "android",
|
||||
player: getActivePlayerType(settings),
|
||||
player: "mpv",
|
||||
audioMode: settings.audioTranscodeMode,
|
||||
});
|
||||
const data = await getStreamUrl({
|
||||
|
||||
@@ -199,13 +199,6 @@
|
||||
"rewind_length": "Rewind length",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"exoplayer": "ExoPlayer",
|
||||
"mpv": "MPV",
|
||||
"exoplayer_note": "ExoPlayer does not support advanced ASS/SSA subtitle styling or horizontal subtitle alignment. Switch to MPV if you need those.",
|
||||
"mpv_note": "MPV on TV does not currently pass HDR metadata to the display — HDR10/HDR10+ content is tone-mapped to SDR. Switch to ExoPlayer for HDR output."
|
||||
},
|
||||
"buffer": {
|
||||
"title": "Buffer settings",
|
||||
"cache_mode": "Cache mode",
|
||||
@@ -595,8 +588,25 @@
|
||||
"videos": "Videos",
|
||||
"boxsets": "Box sets",
|
||||
"playlists": "Playlists",
|
||||
"seeAllSeries": "Favorited Series",
|
||||
"seeAllMovies": "Favorited Movies",
|
||||
"seeAllEpisodes": "Favorited Episodes",
|
||||
"seeAllVideos": "Favorited Videos",
|
||||
"seeAllBoxsets": "Favorited Box sets",
|
||||
"seeAllPlaylists": "Favorited Playlists",
|
||||
"noDataTitle": "No favorites yet",
|
||||
"noData": "Mark items as favorites to see them appear here for quick access."
|
||||
"noData": "Mark items as favorites to see them appear here for quick access.",
|
||||
"watchlist": "Watchlist"
|
||||
},
|
||||
"kefintweaksWatchlist": {
|
||||
"seeAllSeries": "Watchlisted Series",
|
||||
"seeAllMovies": "Watchlisted Movies",
|
||||
"seeAllEpisodes": "Watchlisted Episodes",
|
||||
"seeAllVideos": "Watchlisted Videos",
|
||||
"seeAllBoxsets": "Watchlisted Box sets",
|
||||
"seeAllPlaylists": "Watchlisted Playlists",
|
||||
"noDataTitle": "No watchlisted items yet",
|
||||
"noData": "Add items to your watchlist to see them appear here."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "No links"
|
||||
|
||||
@@ -588,8 +588,25 @@
|
||||
"videos": "Videor",
|
||||
"boxsets": "Box Set",
|
||||
"playlists": "Spellistor",
|
||||
"seeAllSeries": "Favoritmarkerade serier",
|
||||
"seeAllMovies": "Favoritmarkerade filmer",
|
||||
"seeAllEpisodes": "Favoritmarkerade avsnitt",
|
||||
"seeAllVideos": "Favoritmarkerade videor",
|
||||
"seeAllBoxsets": "Favoritmarkerade box set",
|
||||
"seeAllPlaylists": "Favoritmarkerade spellistor",
|
||||
"noDataTitle": "Inga favoriter än",
|
||||
"noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst."
|
||||
"noData": "Markera objekt som favoriter för att se dem visas här för snabb åtkomst.",
|
||||
"watchlist": "Bevakningslista"
|
||||
},
|
||||
"kefintweaksWatchlist": {
|
||||
"seeAllSeries": "Bevakade serier",
|
||||
"seeAllMovies": "Bevakade filmer",
|
||||
"seeAllEpisodes": "Bevakade avsnitt",
|
||||
"seeAllVideos": "Bevakade videor",
|
||||
"seeAllBoxsets": "Bevakade box set",
|
||||
"seeAllPlaylists": "Bevakade spellistor",
|
||||
"noDataTitle": "Inga bevakade objekt än",
|
||||
"noData": "Lägg till objekt i din bevakningslista för att se dem visas här."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Inga Länkar"
|
||||
|
||||
@@ -82,8 +82,6 @@ export const useFilterOptions = () => {
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
];
|
||||
console.log("filterOptions");
|
||||
console.log(filterOptions);
|
||||
return filterOptions;
|
||||
};
|
||||
|
||||
|
||||
@@ -171,38 +171,11 @@ export type HomeSectionLatestResolver = {
|
||||
includeItemTypes?: Array<BaseItemKind>;
|
||||
};
|
||||
|
||||
// Video player enum. MPV is the universal default; ExoPlayer is an
|
||||
// opt-in alternative on Android TV, selectable via settings.videoPlayer.
|
||||
// Video player enum - currently only MPV is supported
|
||||
export enum VideoPlayer {
|
||||
MPV = 0,
|
||||
ExoPlayer = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the actually-active video player for the current settings.
|
||||
* MPV is the default on every platform; users can opt into ExoPlayer on
|
||||
* Android TV via settings.videoPlayer. Centralized here so the rule has
|
||||
* one source of truth (used by VideoPlayerView, direct-player's device
|
||||
* profile, and the TV settings UI).
|
||||
*/
|
||||
export const getActiveVideoPlayer = (
|
||||
settings: Pick<Settings, "videoPlayer"> | null | undefined,
|
||||
): VideoPlayer => {
|
||||
return settings?.videoPlayer ?? VideoPlayer.MPV;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same selection as getActiveVideoPlayer but returns the lowercase
|
||||
* player-type identifier that `generateDeviceProfile` expects.
|
||||
*/
|
||||
export const getActivePlayerType = (
|
||||
settings: Pick<Settings, "videoPlayer"> | null | undefined,
|
||||
): "mpv" | "exoplayer" => {
|
||||
return getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer
|
||||
? "exoplayer"
|
||||
: "mpv";
|
||||
};
|
||||
|
||||
// TV Typography scale presets
|
||||
export enum TVTypographyScale {
|
||||
Small = "small",
|
||||
@@ -245,8 +218,6 @@ export type Settings = {
|
||||
mediaListCollectionIds?: string[];
|
||||
preferedLanguage?: string;
|
||||
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
|
||||
/** Video player backend. Defaults to MPV when unset (see getActiveVideoPlayer). */
|
||||
videoPlayer?: VideoPlayer;
|
||||
marlinServerUrl?: string;
|
||||
streamyStatsServerUrl?: string;
|
||||
streamyStatsMovieRecommendations?: boolean;
|
||||
@@ -344,8 +315,6 @@ export const defaultValues: Settings = {
|
||||
mediaListCollectionIds: [],
|
||||
preferedLanguage: undefined,
|
||||
searchEngine: "Jellyfin",
|
||||
// videoPlayer intentionally undefined — resolved at runtime via
|
||||
// getActiveVideoPlayer() so existing installs are unaffected.
|
||||
marlinServerUrl: "",
|
||||
streamyStatsServerUrl: "",
|
||||
streamyStatsMovieRecommendations: false,
|
||||
|
||||
@@ -9,7 +9,7 @@ import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
|
||||
export type PlatformType = "ios" | "android";
|
||||
export type PlayerType = "mpv" | "exoplayer";
|
||||
export type PlayerType = "mpv";
|
||||
export type AudioTranscodeModeType = "auto" | "stereo" | "5.1" | "passthrough";
|
||||
|
||||
export interface ProfileOptions {
|
||||
@@ -63,26 +63,6 @@ const getAudioCodecProfile = (platform: PlatformType) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the MaxAudioChannels string for a given audio transcoding mode.
|
||||
* Used by both the MPV and ExoPlayer profile branches — the channel-cap
|
||||
* rule is player-agnostic (the player decodes; the cap just tells the
|
||||
* server when to transcode down).
|
||||
*/
|
||||
const maxChannelsForMode = (audioMode: AudioTranscodeModeType): string => {
|
||||
switch (audioMode) {
|
||||
case "stereo":
|
||||
return "2";
|
||||
case "5.1":
|
||||
return "6";
|
||||
case "passthrough":
|
||||
return "8";
|
||||
default:
|
||||
// Auto: default to 5.1 (6 channels)
|
||||
return "6";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the video audio codec configuration based on platform and audio mode.
|
||||
*
|
||||
@@ -109,59 +89,35 @@ const getVideoAudioCodecs = (
|
||||
// MPV can decode all codecs - only channel count varies by mode
|
||||
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
||||
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: maxChannelsForMode(audioMode),
|
||||
};
|
||||
};
|
||||
switch (audioMode) {
|
||||
case "stereo":
|
||||
// Limit to 2 channels - MPV will decode and downmix
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "2",
|
||||
};
|
||||
|
||||
/**
|
||||
* ExoPlayer (Media3 1.10.1) direct-play profile for Android TV.
|
||||
*
|
||||
* Codec set aligned with Media3's documented supported-formats list:
|
||||
* - Video: H.263, H.264, H.265, VP8, VP9, AV1
|
||||
* - Audio: Vorbis, Opus, FLAC, ALAC, PCM, MP3, AAC, AC-3, E-AC-3, DTS,
|
||||
* DTS-HD, TrueHD
|
||||
*
|
||||
* Hardware decode (MediaCodec) handles whatever the device ships with;
|
||||
* the rest fall through to FFmpeg software decode via the Jellyfin-published
|
||||
* `org.jellyfin.media3:media3-ffmpeg-decoder` extension wired up with
|
||||
* `DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER` (see
|
||||
* ExoPlayerView.kt:ensurePlayer).
|
||||
*
|
||||
* Cross-checked against the reference-device probe in
|
||||
* docs/research/hdr-dv-atmos-tv-plan.md (Amlogic Android 14 TV; HDMI sink
|
||||
* accepts AC3/EAC3 as bitstream and multichannel PCM up to 7.1 @ 192 kHz,
|
||||
* so software-decoded DTS/DTS-HD/TrueHD reach the sink as PCM).
|
||||
*
|
||||
* Dolby Vision: the CodecProfile below uses `NotEquals VideoRangeType
|
||||
* DOVI`, which in Jellyfin's semantics blocks ONLY pure Profile 5
|
||||
* (IPTPQc2 — the stream that renders purple/green without a DV-aware
|
||||
* decoder). DV Profiles 7/8 with HDR10 or SDR base layers (Jellyfin
|
||||
* reports these as `DOVIWithHDR10`, `DOVIWithHDR10Plus`, `DOVIWithEL`)
|
||||
* are NOT blocked — Media3 1.9.1+ correctly falls back to the AVC/HEVC
|
||||
* base layer.
|
||||
*
|
||||
* Containers limited to Media3's bundled extractors. FLV is intentionally
|
||||
* absent — Media3 has no FLV extractor (MPV claims it via FFmpeg).
|
||||
*/
|
||||
const getExoPlayerDirectPlayProfile = () => {
|
||||
const audioCodecs =
|
||||
"vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd";
|
||||
case "5.1":
|
||||
// Limit to 6 channels
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "6",
|
||||
};
|
||||
|
||||
return {
|
||||
video: {
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,webm,ts,mpegts,mov",
|
||||
VideoCodec: "h263,h264,hevc,vp8,vp9,av1",
|
||||
AudioCodec: audioCodecs,
|
||||
},
|
||||
audio: {
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
|
||||
AudioCodec: "vorbis,opus,flac,alac,pcm,mp3,aac",
|
||||
},
|
||||
};
|
||||
case "passthrough":
|
||||
// Allow up to 8 channels - for external DAC/receiver setups
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "8",
|
||||
};
|
||||
|
||||
default:
|
||||
// Auto mode: default to 5.1 (6 channels)
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "6",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -170,63 +126,6 @@ const getExoPlayerDirectPlayProfile = () => {
|
||||
export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
const platform = (options.platform || Platform.OS) as PlatformType;
|
||||
const audioMode = options.audioMode || "auto";
|
||||
const player = options.player || "mpv";
|
||||
|
||||
// ExoPlayer branch — Media3 capabilities on Android TV.
|
||||
if (player === "exoplayer" && platform === "android") {
|
||||
const exoDirect = getExoPlayerDirectPlayProfile();
|
||||
|
||||
return {
|
||||
Name: "1. ExoPlayer",
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h263,h264,hevc,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "hevc,h265",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
Property: "VideoRangeType",
|
||||
// Blocks ONLY pure DV Profile 5 (IPTPQc2). Profiles 7/8 with
|
||||
// HDR10/SDR base layers fall through to Media3's HEVC fallback
|
||||
// (1.9.1+). See getExoPlayerDirectPlayProfile doc above.
|
||||
Value: "DOVI",
|
||||
IsRequired: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "vorbis,opus,flac,alac,pcm,mp3,aac,ac3,eac3,dts,dtshd,truehd",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [exoDirect.video, exoDirect.audio],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264,hevc",
|
||||
AudioCodec: "aac,mp3,ac3",
|
||||
MaxAudioChannels: maxChannelsForMode(audioMode),
|
||||
},
|
||||
],
|
||||
// Text-only subtitles for direct play. PGS delivered as Encode
|
||||
// (burn-in) because Media3's PGS support is inconsistent.
|
||||
SubtitleProfiles: [
|
||||
{ Format: "srt", Method: "External" },
|
||||
{ Format: "vtt", Method: "External" },
|
||||
{ Format: "ttml", Method: "External" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
||||
platform,
|
||||
@@ -299,3 +198,6 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
// Default export for backward compatibility
|
||||
export default generateDeviceProfile();
|
||||
|
||||
Reference in New Issue
Block a user