mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 00:43:08 +01:00
Compare commits
9 Commits
feat/andro
...
fix/downlo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9b1f5e47b | ||
|
|
c4a5c05949 | ||
|
|
9b07d39d4d | ||
|
|
04cb88347a | ||
|
|
6ddcc7be40 | ||
|
|
95b5dab293 | ||
|
|
2df160adb7 | ||
|
|
8d54d65b63 | ||
|
|
95f4d15e98 |
7
.github/workflows/pr-title-comment.yml
vendored
7
.github/workflows/pr-title-comment.yml
vendored
@@ -20,7 +20,12 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
# Only run when the lint run actually produced a result artifact: a run
|
||||
# cancelled by concurrency (rapid pushes to the same PR) completes with
|
||||
# conclusion "cancelled" before uploading it, and would fail the download.
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
contains(fromJSON('["success", "failure"]'), github.event.workflow_run.conclusion)
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: ⬇️ Download lint result
|
||||
|
||||
@@ -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={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
value={alignXLabel}
|
||||
// ExoPlayer follows authored cue alignment; hide on ExoPlayer.
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||
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}
|
||||
|
||||
@@ -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;
|
||||
@@ -1311,7 +1305,7 @@ export default function DirectPlayerPage() {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<VideoPlayerView
|
||||
<MpvPlayerView
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
streamType?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const TrackSheet: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
streamType,
|
||||
title,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const streams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||
[source, streamType],
|
||||
);
|
||||
|
||||
const selectedSteam = useMemo(
|
||||
() => streams?.find((x) => x.Index === selected),
|
||||
[streams, selected],
|
||||
);
|
||||
|
||||
const noneOption = useMemo(
|
||||
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
// Creates a modified data array that includes a "None" option for subtitles
|
||||
// We might want to possibly do this for other places, like audio?
|
||||
const addNoneToSubtitles = useMemo(() => {
|
||||
if (streamType === "Subtitle") {
|
||||
const result = streams ? [noneOption, ...streams] : [noneOption];
|
||||
return result;
|
||||
}
|
||||
return streams;
|
||||
}, [streams, streamType, noneOption]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (isTv || (streams && streams.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{selected === -1 && streamType === "Subtitle"
|
||||
? t("common.none")
|
||||
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={title}
|
||||
data={addNoneToSubtitles || []}
|
||||
values={
|
||||
selected === -1 && streamType === "Subtitle"
|
||||
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
||||
: selectedSteam
|
||||
? [selectedSteam]
|
||||
: []
|
||||
}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).DisplayTitle || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => (
|
||||
<Text>{(item as any).DisplayTitle || ""}</Text>
|
||||
)}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as any;
|
||||
if (chosen && chosen.Index !== null && chosen.Index !== undefined) {
|
||||
onChange(chosen.Index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +1,119 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import React from "react";
|
||||
import { Platform, View, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
if (Platform.isTV) {
|
||||
// TV: Show white checkmark when watched
|
||||
if (
|
||||
item.UserData?.Played &&
|
||||
(item.Type === "Movie" || item.Type === "Episode")
|
||||
) {
|
||||
const isAggregateType = (item: BaseItemDto) =>
|
||||
item.Type === "Series" || item.Type === "BoxSet";
|
||||
|
||||
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
|
||||
const tvBadgeBase: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: scaleSize(8),
|
||||
right: scaleSize(8),
|
||||
height: scaleSize(28),
|
||||
borderRadius: scaleSize(14),
|
||||
backgroundColor: "rgba(255,255,255,0.92)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
// Mobile uses raw dp — no scaling.
|
||||
const mobileBadgeBase: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#9333ea",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the unplayed-episode count badge for Series/BoxSet items that still
|
||||
* have episodes left to watch. Returns null for non-aggregate types, fully
|
||||
* watched items, or items with no unplayed count, so it is safe to mount
|
||||
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
|
||||
* the watched checkmark is drawn natively and only the count needs RN).
|
||||
*/
|
||||
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
if (!isAggregateType(item)) return null;
|
||||
if (item.UserData?.Played) return null;
|
||||
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
||||
if (unplayed <= 0) return null;
|
||||
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
|
||||
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
|
||||
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
borderRadius: 14,
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
style={[
|
||||
tvBadgeBase,
|
||||
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name='checkmark' size={18} color='black' />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: scaleSize(15),
|
||||
fontWeight: "700",
|
||||
color: "black",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
return (
|
||||
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
|
||||
const isAggregate = isAggregateType(item);
|
||||
const isPlayed = item.UserData?.Played === true;
|
||||
|
||||
if (Platform.isTV) {
|
||||
// Fully watched → white checkmark badge (top-right)
|
||||
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
|
||||
return (
|
||||
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
||||
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Series/BoxSet with remaining episodes → count badge
|
||||
return <UnplayedCountBadge item={item} />;
|
||||
}
|
||||
|
||||
// Mobile: Show purple triangle for unwatched
|
||||
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
||||
return (
|
||||
<>
|
||||
{item.UserData?.Played === false &&
|
||||
(item.Type === "Movie" || item.Type === "Episode") && (
|
||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||
)}
|
||||
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
|
||||
{isMovieOrEpisode && item.UserData?.Played === false && (
|
||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||
)}
|
||||
|
||||
{/* Fully watched Series/BoxSet → small purple checkmark */}
|
||||
{isAggregate && isPlayed && (
|
||||
<View style={[mobileBadgeBase, { width: 20 }]}>
|
||||
<Ionicons name='checkmark' size={13} color='white' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Series/BoxSet with remaining episodes → count badge */}
|
||||
<UnplayedCountBadge item={item} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const router = useRouter();
|
||||
|
||||
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
||||
// instead of freezing the first-rendered series' image (empty deps bug).
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
const seriesId = items[0]?.SeriesId;
|
||||
return seriesId ? storage.getString(seriesId) : undefined;
|
||||
}, [items[0]?.SeriesId]);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () =>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useState } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { ItemImage } from "../common/ItemImage";
|
||||
import { WatchedIndicator } from "../WatchedIndicator";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -52,6 +53,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
||||
used: (app.total - app.remaining) / app.total,
|
||||
};
|
||||
},
|
||||
// Keep the bar moving while a download is writing to disk.
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
const onDeleteClicked = () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
} finally {
|
||||
// Reflect the freed space immediately instead of waiting for
|
||||
// the next poll.
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const calculatePercentage = (value: number, total: number) => {
|
||||
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
} from "react-native";
|
||||
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import {
|
||||
UnplayedCountBadge,
|
||||
WatchedIndicator,
|
||||
} from "@/components/WatchedIndicator";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
@@ -427,6 +430,12 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
{/*
|
||||
The glass view draws the watched checkmark natively but cannot show
|
||||
an unplayed-episode count, so render it as an RN overlay on top.
|
||||
Returns null when not applicable (non-series / fully watched).
|
||||
*/}
|
||||
{showWatchedIndicator && <UnplayedCountBadge item={item} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -459,7 +468,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
<WatchedIndicator item={item} />
|
||||
{showWatchedIndicator && <WatchedIndicator item={item} />}
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from "react";
|
||||
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.
|
||||
*
|
||||
* The Android-TV capability gate lives in getActiveVideoPlayer so that
|
||||
* the same resolver used for device-profile advertisement guarantees the
|
||||
* rendered backend matches what Jellyfin was told to stream for.
|
||||
*/
|
||||
export const VideoPlayerView = React.forwardRef<
|
||||
MpvPlayerViewRef,
|
||||
MpvPlayerViewProps
|
||||
>(function VideoPlayerView(props, ref) {
|
||||
const { settings } = useSettings();
|
||||
const useExo = getActiveVideoPlayer(settings) === VideoPlayer.ExoPlayer;
|
||||
|
||||
const Player = useExo ? ExoPlayerView : MpvPlayerView;
|
||||
return <Player ref={ref} {...props} />;
|
||||
});
|
||||
@@ -1132,16 +1132,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}
|
||||
@@ -1156,11 +1147,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}
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
TVCancelButton,
|
||||
TVLanguageCard,
|
||||
TVSubtitleResultCard,
|
||||
TVTabButton,
|
||||
TVTrackCard,
|
||||
} from "@/components/tv";
|
||||
import {
|
||||
type SubtitleSearchResult,
|
||||
useRemoteSubtitles,
|
||||
} from "@/hooks/useRemoteSubtitles";
|
||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||
|
||||
interface TVSubtitleSheetProps {
|
||||
visible: boolean;
|
||||
item: BaseItemDto;
|
||||
mediaSourceId?: string | null;
|
||||
subtitleTracks: MediaStream[];
|
||||
currentSubtitleIndex: number;
|
||||
onSubtitleIndexChange: (index: number) => void;
|
||||
onClose: () => void;
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
}
|
||||
|
||||
type TabType = "tracks" | "download";
|
||||
|
||||
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
visible,
|
||||
item,
|
||||
mediaSourceId,
|
||||
subtitleTracks,
|
||||
currentSubtitleIndex,
|
||||
onSubtitleIndexChange,
|
||||
onClose,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
console.log(
|
||||
"[TVSubtitleSheet] visible:",
|
||||
visible,
|
||||
"tracks:",
|
||||
subtitleTracks.length,
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isTabContentReady, setIsTabContentReady] = useState(false);
|
||||
const firstTrackRef = useRef<View>(null);
|
||||
|
||||
const {
|
||||
hasOpenSubtitlesApiKey,
|
||||
isSearching,
|
||||
searchError,
|
||||
searchResults,
|
||||
search,
|
||||
downloadAsync,
|
||||
reset,
|
||||
} = useRemoteSubtitles({
|
||||
itemId: item.Id ?? "",
|
||||
item,
|
||||
mediaSourceId,
|
||||
});
|
||||
|
||||
const resetRef = useRef(reset);
|
||||
resetRef.current = reset;
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
const initialSelectedTrackIndex = useMemo(() => {
|
||||
if (currentSubtitleIndex === -1) return 0;
|
||||
const trackIdx = subtitleTracks.findIndex(
|
||||
(t) => t.Index === currentSubtitleIndex,
|
||||
);
|
||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||
}, [subtitleTracks, currentSubtitleIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(300);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setHasSearchedThisSession(false);
|
||||
setActiveTab("tracks");
|
||||
resetRef.current();
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && activeTab === "download" && !hasSearchedThisSession) {
|
||||
search({ language: selectedLanguage });
|
||||
setHasSearchedThisSession(true);
|
||||
}
|
||||
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
setIsTabContentReady(false);
|
||||
const timer = setTimeout(() => setIsTabContentReady(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsTabContentReady(false);
|
||||
}, [activeTab, isReady]);
|
||||
|
||||
const handleLanguageSelect = useCallback(
|
||||
(code: string) => {
|
||||
setSelectedLanguage(code);
|
||||
search({ language: code });
|
||||
},
|
||||
[search],
|
||||
);
|
||||
|
||||
const handleTrackSelect = useCallback(
|
||||
(index: number) => {
|
||||
onSubtitleIndexChange(index);
|
||||
onClose();
|
||||
},
|
||||
[onSubtitleIndexChange, onClose],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (result: SubtitleSearchResult) => {
|
||||
setDownloadingId(result.id);
|
||||
|
||||
try {
|
||||
const downloadResult = await downloadAsync(result);
|
||||
|
||||
if (downloadResult.type === "server") {
|
||||
onServerSubtitleDownloaded?.();
|
||||
} else if (downloadResult.type === "local" && downloadResult.path) {
|
||||
onLocalSubtitleDownloaded?.(downloadResult.path);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to download subtitle:", error);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
downloadAsync,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
onClose,
|
||||
],
|
||||
);
|
||||
|
||||
const displayLanguages = useMemo(
|
||||
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
||||
[],
|
||||
);
|
||||
|
||||
const trackOptions = useMemo(() => {
|
||||
const noneOption = {
|
||||
label: t("item_card.subtitles.none"),
|
||||
sublabel: undefined as string | undefined,
|
||||
value: -1,
|
||||
selected: currentSubtitleIndex === -1,
|
||||
};
|
||||
const options = subtitleTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
sublabel: track.Codec?.toUpperCase(),
|
||||
value: track.Index!,
|
||||
selected: track.Index === currentSubtitleIndex,
|
||||
}));
|
||||
return [noneOption, ...options];
|
||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Header with tabs */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>
|
||||
{t("item_card.subtitles.label") || "Subtitles"}
|
||||
</Text>
|
||||
|
||||
{/* Tab bar */}
|
||||
<View style={styles.tabRow}>
|
||||
<TVTabButton
|
||||
label={t("item_card.subtitles.tracks") || "Tracks"}
|
||||
active={activeTab === "tracks"}
|
||||
onSelect={() => setActiveTab("tracks")}
|
||||
/>
|
||||
<TVTabButton
|
||||
label={t("player.download") || "Download"}
|
||||
active={activeTab === "download"}
|
||||
onSelect={() => setActiveTab("download")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tracks Tab Content */}
|
||||
{activeTab === "tracks" && isTabContentReady && (
|
||||
<View style={styles.section}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tracksScroll}
|
||||
contentContainerStyle={styles.tracksScrollContent}
|
||||
>
|
||||
{trackOptions.map((option, index) => (
|
||||
<TVTrackCard
|
||||
key={option.value}
|
||||
ref={
|
||||
index === initialSelectedTrackIndex
|
||||
? firstTrackRef
|
||||
: undefined
|
||||
}
|
||||
label={option.label}
|
||||
sublabel={option.sublabel}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedTrackIndex}
|
||||
onPress={() => handleTrackSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Download Tab Content */}
|
||||
{activeTab === "download" && isTabContentReady && (
|
||||
<>
|
||||
{/* Language Selector */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.language") || "Language"}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.languageScroll}
|
||||
contentContainerStyle={styles.languageScrollContent}
|
||||
>
|
||||
{displayLanguages.map((lang, index) => (
|
||||
<TVLanguageCard
|
||||
key={lang.code}
|
||||
code={lang.code}
|
||||
name={lang.name}
|
||||
selected={selectedLanguage === lang.code}
|
||||
hasTVPreferredFocus={
|
||||
index === 0 &&
|
||||
(!searchResults || searchResults.length === 0)
|
||||
}
|
||||
onPress={() => handleLanguageSelect(lang.code)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Results Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.results") || "Results"}
|
||||
{searchResults && ` (${searchResults.length})`}
|
||||
</Text>
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size='large' color='#fff' />
|
||||
<Text style={styles.loadingText}>
|
||||
{t("player.searching") || "Searching..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{searchError && !isSearching && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons
|
||||
name='alert-circle-outline'
|
||||
size={32}
|
||||
color='rgba(255,100,100,0.8)'
|
||||
/>
|
||||
<Text style={styles.errorText}>
|
||||
{t("player.search_failed") || "Search failed"}
|
||||
</Text>
|
||||
<Text style={styles.errorHint}>
|
||||
{!hasOpenSubtitlesApiKey
|
||||
? t("player.no_subtitle_provider") ||
|
||||
"No subtitle provider configured on server"
|
||||
: String(searchError)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{searchResults &&
|
||||
searchResults.length === 0 &&
|
||||
!isSearching &&
|
||||
!searchError && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name='document-text-outline'
|
||||
size={32}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.emptyText}>
|
||||
{t("player.no_subtitles_found") ||
|
||||
"No subtitles found"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
{searchResults &&
|
||||
searchResults.length > 0 &&
|
||||
!isSearching && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.resultsScroll}
|
||||
contentContainerStyle={styles.resultsScrollContent}
|
||||
>
|
||||
{searchResults.map((result, index) => (
|
||||
<TVSubtitleResultCard
|
||||
key={result.id}
|
||||
result={result}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
isDownloading={downloadingId === result.id}
|
||||
onPress={() => handleDownload(result)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* API Key hint if no fallback available */}
|
||||
{!hasOpenSubtitlesApiKey && (
|
||||
<View style={styles.apiKeyHint}>
|
||||
<Ionicons
|
||||
name='information-circle-outline'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.apiKeyHintText}>
|
||||
{t("player.add_opensubtitles_key_hint") ||
|
||||
"Add OpenSubtitles API key in settings for client-side fallback"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
{isReady && (
|
||||
<View style={styles.cancelButtonContainer}>
|
||||
<TVCancelButton
|
||||
onPress={onClose}
|
||||
label={t("common.cancel") || "Cancel"}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
maxHeight: "70%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
marginBottom: 16,
|
||||
},
|
||||
tabRow: {
|
||||
flexDirection: "row",
|
||||
gap: 24,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 48,
|
||||
},
|
||||
tracksScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
tracksScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
languageScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
languageScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 10,
|
||||
},
|
||||
resultsScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
resultsScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
errorContainer: {
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: "rgba(255,100,100,0.9)",
|
||||
marginTop: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
errorHint: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
},
|
||||
apiKeyHint: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 8,
|
||||
},
|
||||
apiKeyHintText: {
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
fontSize: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 20,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
@@ -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,14 +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}`
|
||||
: (() => {
|
||||
const videoRange = formatVideoRange(streamInfo?.videoRange);
|
||||
return videoRange ? ` ${videoRange}` : "";
|
||||
})()}
|
||||
{formatVideoRange(streamInfo?.videoRange)
|
||||
? ` ${formatVideoRange(streamInfo?.videoRange)}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.videoCodec && (
|
||||
@@ -321,17 +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. */}
|
||||
{(() => {
|
||||
const audioChannels =
|
||||
info.audioChannels ?? streamInfo?.audioChannels;
|
||||
return audioChannels
|
||||
? ` ${formatAudioChannels(audioChannels)}`
|
||||
: "";
|
||||
})()}
|
||||
{info.audioSampleRate
|
||||
? ` @ ${(info.audioSampleRate / 1000).toFixed(1)}kHz`
|
||||
{streamInfo?.audioChannels
|
||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
@@ -350,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
|
||||
@@ -378,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)}
|
||||
|
||||
@@ -20,7 +20,6 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getFinishTime = () => {
|
||||
if (!Number.isFinite(remainingTime)) return "—";
|
||||
const now = new Date();
|
||||
// remainingTime is in ms
|
||||
const finishTime = new Date(now.getTime() + remainingTime);
|
||||
|
||||
@@ -17,10 +17,7 @@ interface UseVideoTimeProps {
|
||||
*/
|
||||
export function useVideoTime({ progress, max, isSeeking }: UseVideoTimeProps) {
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
// Start at 0 (not Infinity) so the controls' first paint — before the first
|
||||
// progress/max update — shows "0:00" instead of formatting Infinity into
|
||||
// "Infinityh NaNm NaNs" and an Invalid Date for "ends at".
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||
|
||||
const lastCurrentTimeRef = useRef(0);
|
||||
const lastRemainingTimeRef = useRef(0);
|
||||
|
||||
@@ -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,976 +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
|
||||
|
||||
// Resume position handed to setMediaSource() on load. seekTo() uses it to
|
||||
// drop the redundant re-seek the JS layer fires once tracks are ready
|
||||
// (direct-player seeks to the same resume position). ExoPlayer re-buffers
|
||||
// on every seek — even a no-op to the current position — so without this
|
||||
// the start stutters.
|
||||
private var loadStartPositionMs: Long = 0L
|
||||
|
||||
// Side-loaded subtitle configurations accumulated across loadVideo and
|
||||
// addSubtitleFile. Media3 doesn't expose the live SubtitleConfiguration
|
||||
// list on a playing MediaItem, so we shadow it here to preserve prior
|
||||
// side-loaded subs when addSubtitleFile rebuilds the MediaItem.
|
||||
private var sideLoadedSubs: List<MediaItem.SubtitleConfiguration> = emptyList()
|
||||
|
||||
// 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)
|
||||
// currentSubtitleId is a hand-maintained cache (ExoPlayer has no
|
||||
// mpv-style "sid" property to read live), so re-derive it from the
|
||||
// actual selection on every track change. Without this, any path
|
||||
// that selects a track without going through setSubtitleTrack —
|
||||
// notably addSubtitleFile(select=true), which clears the text
|
||||
// override and lets the new SELECTION_FLAG_DEFAULT sub render —
|
||||
// leaves the cache stale and getCurrentSubtitleTrack() reporting
|
||||
// the old id. Run before applyInitialTrackSelections() so an
|
||||
// explicit initial selection still wins.
|
||||
syncCurrentSubtitleIdFromSelection(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)
|
||||
|
||||
// Resolve the resume position once and hand it to setMediaSource() so
|
||||
// ExoPlayer prepares from that position directly. Calling prepare()
|
||||
// first (which buffers from 0) and seekTo() afterwards makes the
|
||||
// opening seconds replay from the start before jumping to the resume
|
||||
// point — the "repeats the first few seconds" symptom. C.TIME_UNSET
|
||||
// falls back to the media's default start position (0).
|
||||
val startMs = config.startPosition?.let { sp ->
|
||||
if (sp > 0) (sp * 1000).toLong() else C.TIME_UNSET
|
||||
} ?: C.TIME_UNSET
|
||||
|
||||
loadStartPositionMs = if (startMs != C.TIME_UNSET) startMs else 0L
|
||||
|
||||
p.setMediaSource(mediaSource, startMs)
|
||||
p.prepare()
|
||||
|
||||
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()) {
|
||||
sideLoadedSubs = subtitleConfigs
|
||||
builder.setSubtitleConfigurations(subtitleConfigs)
|
||||
} else {
|
||||
sideLoadedSubs = emptyList()
|
||||
}
|
||||
} else {
|
||||
sideLoadedSubs = emptyList()
|
||||
}
|
||||
|
||||
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
|
||||
loadStartPositionMs = 0L
|
||||
sideLoadedSubs = emptyList()
|
||||
subtitleTrackList = emptyList()
|
||||
audioTrackList = emptyList()
|
||||
currentSubtitleId = 0
|
||||
currentAudioId = 0
|
||||
videoDecoderName = null
|
||||
audioDecoderName = null
|
||||
cumulativeDroppedFrames = 0
|
||||
}
|
||||
|
||||
fun seekTo(positionSec: Double) {
|
||||
val targetMs = (positionSec * 1000).toLong()
|
||||
// Drop the redundant initial seek that mirrors the resume position we
|
||||
// already applied via setMediaSource() — see loadInternal. Only the
|
||||
// first seek after load is eligible, so a genuine seek to ~the start
|
||||
// later on still works.
|
||||
if (loadStartPositionMs > 0 && Math.abs(targetMs - loadStartPositionMs) < 1000L) {
|
||||
loadStartPositionMs = 0L
|
||||
return
|
||||
}
|
||||
loadStartPositionMs = 0L
|
||||
player?.seekTo(targetMs)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the player's actually-selected text track into [currentSubtitleId].
|
||||
* Must run after [rebuildTrackMaps] so the 1-based IDs in [subtitleTrackList]
|
||||
* are current for this [tracks] snapshot. Falls back to 0 (subs off) when no
|
||||
* text track is selected.
|
||||
*/
|
||||
private fun syncCurrentSubtitleIdFromSelection(tracks: Tracks) {
|
||||
val groups = tracks.groups
|
||||
val selected = subtitleTrackList.firstOrNull { entry ->
|
||||
groups[entry.trackGroupIndex].isTrackSelected(entry.trackIndex)
|
||||
}
|
||||
currentSubtitleId = selected?.id ?: 0
|
||||
}
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
// Rebuild with the full accumulated list so previously loaded
|
||||
// side-loaded subs (from VideoLoadConfig.externalSubtitles or
|
||||
// earlier addSubtitleFile calls) survive.
|
||||
val combined = sideLoadedSubs + newSubConfig
|
||||
sideLoadedSubs = combined
|
||||
|
||||
val rebuilt = currentMediaItem.buildUpon()
|
||||
.setSubtitleConfigurations(combined)
|
||||
.build()
|
||||
|
||||
val wasPlaying = p.isPlaying
|
||||
val pos = p.currentPosition
|
||||
p.setMediaItem(rebuilt, pos)
|
||||
p.prepare()
|
||||
if (wasPlaying) p.play()
|
||||
|
||||
// Re-enabling text tracks alone isn't enough: a prior text override
|
||||
// (e.g. left in place by disableSubtitles, or set by setSubtitleTrack
|
||||
// before this add) survives the MediaItem rebuild and, since embedded
|
||||
// subtitle groups persist across it, would win over the new sub's
|
||||
// SELECTION_FLAG_DEFAULT. Clear any text override so the new default
|
||||
// sub is the one that renders.
|
||||
if (select) {
|
||||
val params = p.trackSelectionParameters.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
.clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
.build()
|
||||
p.trackSelectionParameters = params
|
||||
}
|
||||
}
|
||||
|
||||
// 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 "@/modules/mpv-player";
|
||||
|
||||
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";
|
||||
|
||||
@@ -125,14 +125,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
private var currentUrl: String? = null
|
||||
private var currentHeaders: Map<String, String>? = null
|
||||
private var pendingExternalSubtitles: List<String> = emptyList()
|
||||
// Persistent record of the external subtitle URLs attached to the
|
||||
// current item. pendingExternalSubtitles above is a one-shot staging
|
||||
// list: load() fills it and the FILE_LOADED handler drains it via
|
||||
// sub-add, so it is always empty by the time a resume-recovery reload
|
||||
// runs. This copy survives that drain so recoverVideoOutput() can hand
|
||||
// the same sidecar URLs back to load() and the tracks re-attach after
|
||||
// the decoder reset.
|
||||
private var activeExternalSubtitles: List<String> = emptyList()
|
||||
private var initialSubtitleId: Int? = null
|
||||
private var initialAudioId: Int? = null
|
||||
|
||||
@@ -150,13 +142,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
*/
|
||||
private var voDriver: String = "gpu-next"
|
||||
|
||||
/**
|
||||
* True on Android TV form-factor devices. Drives both the hwdec selection
|
||||
* in [start] and the TV-only resume-recovery gating in [MpvPlayerView].
|
||||
* Computed once from the system UI mode.
|
||||
*/
|
||||
val isTv: Boolean = isTvDevice()
|
||||
|
||||
fun start(voDriver: String = "gpu-next") {
|
||||
if (isRunning) return
|
||||
|
||||
@@ -167,6 +152,13 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
this.mpv = mpv
|
||||
mpv.addObserver(this)
|
||||
|
||||
// Resolved once — TV gets the memory-pressure customizations
|
||||
// (SCUDO_OPTIONS, hwdec/profile, demuxer-seekable-cache, larger
|
||||
// audio-buffer) that would be counterproductive on higher-RAM
|
||||
// mobile devices. Demuxer cache sizes are NOT included here —
|
||||
// those come from user settings via load().
|
||||
val isTV = isTvDevice()
|
||||
|
||||
// mpv config directory — used by the config-dir option below and
|
||||
// as XDG_CONFIG_HOME for fontconfig.
|
||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
||||
@@ -220,7 +212,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
// - Real phone: `mediacodec-copy` (broadest compatibility).
|
||||
when {
|
||||
isEmulator() -> mpv?.setOptionString("hwdec", "no")
|
||||
isTv -> {
|
||||
isTV -> {
|
||||
mpv?.setOptionString("hwdec", "mediacodec")
|
||||
mpv?.setOptionString("profile", "fast")
|
||||
// Don't retain already-played content for backward
|
||||
@@ -278,7 +270,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
currentUrl = null
|
||||
currentHeaders = null
|
||||
pendingExternalSubtitles = emptyList()
|
||||
activeExternalSubtitles = emptyList()
|
||||
initialSubtitleId = null
|
||||
initialAudioId = null
|
||||
cachedPosition = 0.0
|
||||
@@ -386,67 +377,18 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore video after a system-initiated surface loss (Android TV
|
||||
* screensaver / app background while paused). Triggered from the host
|
||||
* activity's onResume via [MpvPlayerView.runResumeRecovery].
|
||||
*
|
||||
* On TV, `hwdec=mediacodec` (zero-copy) binds MediaCodec directly to the
|
||||
* display surface. When the screensaver invalidates that surface, the
|
||||
* decoder is left bound to dead buffers and mpv auto-disables the video
|
||||
* track (vid=no). Re-attaching the surface + cycling hwdec + re-selecting
|
||||
* vid + seeking rebuilds the pipeline but leaves the video chain at EOF
|
||||
* ("video=eof" in playback-restart) — the recreated MediaCodec produces no
|
||||
* frames. Only a fresh `loadfile` deterministically recreates the decoder
|
||||
* against the live surface, so we reload at the cached position.
|
||||
*
|
||||
* This is the Android counterpart to iOS's `performDecoderReset()`, which
|
||||
* can get away with a `hwdec` cycle because VideoToolbox reinitializes
|
||||
* cleanly; zero-copy MediaCodec bound to a lost surface does not.
|
||||
* Force mpv to render a frame to the current surface.
|
||||
* Steps forward one frame then seeks back to the original position.
|
||||
* Used after PiP entry to work around mpv stopping pixel output.
|
||||
*/
|
||||
fun recoverVideoOutput(surface: Surface?) {
|
||||
if (!isRunning) {
|
||||
Log.w(TAG, "[Recover] recoverVideoOutput — renderer not running, skipping")
|
||||
return
|
||||
fun forceRedraw() {
|
||||
if (!isRunning) return
|
||||
val pos = cachedPosition
|
||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||
mpv?.command(arrayOf("frame-step"))
|
||||
if (pos > 0) {
|
||||
mpv?.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
}
|
||||
val url = currentUrl ?: run {
|
||||
Log.w(TAG, "[Recover] recoverVideoOutput — no URL loaded, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"[Recover] reload recovery — pos=$cachedPosition, aid=${getCurrentAudioTrack()}, sid=${getCurrentSubtitleTrack()}"
|
||||
)
|
||||
|
||||
// Re-attach the surface first (covers the case where the surface
|
||||
// survived and surfaceCreated never fired). The reload below fully
|
||||
// rebuilds the pipeline anyway, but this keeps the VO target current
|
||||
// while the load is in flight.
|
||||
surface?.takeIf { it.isValid }?.let { mpv?.attachSurface(it) }
|
||||
|
||||
// Preserve the user's current audio/subtitle selection across the
|
||||
// reload — pass them INTO load() (not via the field: load() overwrites
|
||||
// the initial IDs from its parameters). They're re-applied when
|
||||
// FILE_LOADED fires. (0 / "no" means "off", which setSubtitleTrack /
|
||||
// setAudioTrack handle correctly.)
|
||||
val savedAid = getCurrentAudioTrack()
|
||||
val savedSid = getCurrentSubtitleTrack()
|
||||
|
||||
// Full reload at the paused position. With keep-open=always +
|
||||
// cache-pause-initial=yes, mpv seeks to the position and holds on the
|
||||
// first decoded frame, so the paused frame reappears.
|
||||
load(
|
||||
url = url,
|
||||
headers = currentHeaders,
|
||||
startPosition = cachedPosition,
|
||||
initialAudioId = savedAid,
|
||||
initialSubtitleId = savedSid,
|
||||
externalSubtitles = activeExternalSubtitles
|
||||
)
|
||||
|
||||
// Hold the paused state explicitly — we only get here while paused,
|
||||
// and load() doesn't touch the pause property.
|
||||
pause()
|
||||
}
|
||||
|
||||
fun load(
|
||||
@@ -464,7 +406,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
currentUrl = url
|
||||
currentHeaders = headers
|
||||
pendingExternalSubtitles = externalSubtitles ?: emptyList()
|
||||
activeExternalSubtitles = pendingExternalSubtitles
|
||||
this.initialSubtitleId = initialSubtitleId
|
||||
this.initialAudioId = initialAudioId
|
||||
|
||||
@@ -624,11 +565,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||
val flag = if (select) "select" else "cached"
|
||||
mpv?.command(arrayOf("sub-add", url, flag))
|
||||
// Track runtime side-loads too, so they survive a resume-recovery
|
||||
// reload just like external subs passed to load().
|
||||
if (url.isNotEmpty() && url !in activeExternalSubtitles) {
|
||||
activeExternalSubtitles = activeExternalSubtitles + url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
@@ -55,12 +51,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerView"
|
||||
|
||||
// Grace window after onActivityResumed before running the resume
|
||||
// recovery, so surfaceCreated (surface-destroyed case) has fired and
|
||||
// the holder has a valid surface. If the surface survived the
|
||||
// screensaver and surfaceCreated never fires, this still runs.
|
||||
private const val RESUME_RECOVERY_DELAY_MS = 300L
|
||||
}
|
||||
|
||||
// Event dispatchers
|
||||
@@ -87,15 +77,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
// PiP state tracking
|
||||
private val pipHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// Resume-recovery state: recreate the decoder when returning from the
|
||||
// screensaver / app background while paused. See
|
||||
// MPVLayerRenderer.recoverVideoOutput for why zero-copy hwdec=mediacodec
|
||||
// needs this.
|
||||
private var hostActivity: Activity? = null
|
||||
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||
private var lifecycleRegistered = false
|
||||
private val recoverResumeRunnable = Runnable { runResumeRecovery() }
|
||||
|
||||
init {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
|
||||
@@ -164,10 +145,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||
renderer = MPVLayerRenderer(context)
|
||||
renderer?.delegate = this
|
||||
|
||||
// Watch the host activity's lifecycle to recover the video pipeline
|
||||
// when returning from the screensaver while paused.
|
||||
registerLifecycleCallbacks()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,107 +527,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
onError(mapOf("error" to message))
|
||||
}
|
||||
|
||||
// MARK: - Resume Recovery
|
||||
|
||||
/**
|
||||
* Recreate the decoder when returning from the Android TV screensaver (or
|
||||
* app background) while paused. Triggered from the host activity's
|
||||
* onResume; the work is in [MPVLayerRenderer.recoverVideoOutput]. The
|
||||
* playing case is skipped — the render thread re-primes the VO on surface
|
||||
* reattach by itself — as is PiP (it owns its surface lifecycle).
|
||||
*/
|
||||
private fun runResumeRecovery() {
|
||||
if (!rendererStarted) return
|
||||
if (pipController?.isPictureInPictureActive() == true) return
|
||||
if (intendedPlayState) return // playing self-heals
|
||||
val surface = surfaceView.holder.surface?.takeIf { it.isValid }
|
||||
Log.i(TAG, "[Recover] onResume recovery — paused, surfaceValid=${surface != null}")
|
||||
renderer?.recoverVideoOutput(surface)
|
||||
}
|
||||
|
||||
private fun registerLifecycleCallbacks() {
|
||||
if (lifecycleRegistered) return
|
||||
// Resume-recovery is TV-only. Only TV's zero-copy hwdec=mediacodec
|
||||
// binds MediaCodec directly to the display surface, so only TV needs
|
||||
// decoder recreation after a system-initiated surface loss (screensaver
|
||||
// / app background while paused). Phones (mediacodec-copy) and the
|
||||
// emulator self-heal via the surfaceCreated → attachSurface path, so we
|
||||
// don't even register there — no callback overhead, no spurious resets.
|
||||
if (renderer?.isTv != true) {
|
||||
Log.i(TAG, "[Recover] skipping lifecycle registration (isTv=${renderer?.isTv})")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "[Recover] registering lifecycle recovery (TV)")
|
||||
val app = context.applicationContext as? Application ?: run {
|
||||
Log.w(TAG, "Cannot register lifecycle callbacks: no Application")
|
||||
return
|
||||
}
|
||||
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
val host = hostActivity ?: findActivity().also { hostActivity = it }
|
||||
if (activity !== host) {
|
||||
if (host == null) {
|
||||
Log.i(TAG, "[Recover] onActivityResumed — host unresolved, activity=${activity.javaClass.simpleName}; skipping")
|
||||
}
|
||||
return
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"[Recover] onActivityResumed — host resumed, hasMedia=${currentUrl != null}, pip=${pipController?.isPictureInPictureActive()}, paused=${!intendedPlayState}"
|
||||
)
|
||||
// Only recover when there's loaded media, we're paused, and not
|
||||
// in PiP. Playing self-heals; nothing loaded yet = nothing to
|
||||
// recover.
|
||||
if (currentUrl == null) return
|
||||
if (pipController?.isPictureInPictureActive() == true) return
|
||||
if (intendedPlayState) return
|
||||
// Post past the resume/surfaceCreated race so the holder has a
|
||||
// valid surface, then recreate the decoder against it.
|
||||
pipHandler.removeCallbacks(recoverResumeRunnable)
|
||||
pipHandler.postDelayed(recoverResumeRunnable, RESUME_RECOVERY_DELAY_MS)
|
||||
}
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
if (activity === hostActivity) {
|
||||
Log.i(TAG, "[Recover] onActivityPaused — host paused")
|
||||
}
|
||||
}
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
if (activity === hostActivity) {
|
||||
Log.i(TAG, "[Recover] onActivityStopped — host stopped")
|
||||
}
|
||||
}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
}
|
||||
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
||||
lifecycleRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterLifecycleCallbacks() {
|
||||
pipHandler.removeCallbacks(recoverResumeRunnable)
|
||||
if (!lifecycleRegistered) return
|
||||
val app = context.applicationContext as? Application
|
||||
lifecycleCallbacks?.let { app?.unregisterActivityLifecycleCallbacks(it) }
|
||||
lifecycleCallbacks = null
|
||||
lifecycleRegistered = false
|
||||
}
|
||||
|
||||
private fun findActivity(): Activity? {
|
||||
// Prefer Expo's currentActivity. The view's Context is a ReactContext
|
||||
// whose base is the Application, not the Activity, so walking the
|
||||
// context chain does not reliably reach the Activity. Mirrors
|
||||
// PiPController.getActivity().
|
||||
appContext.currentActivity?.let { return it }
|
||||
var ctx: Context = context
|
||||
while (ctx is ContextWrapper) {
|
||||
if (ctx is Activity) return ctx
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/**
|
||||
@@ -662,7 +538,6 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
*/
|
||||
fun cleanup() {
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
unregisterLifecycleCallbacks()
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
renderer?.delegate = null
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
|
||||
*/
|
||||
export function calculateTotalDownloadedSize(): number {
|
||||
const items = getAllDownloadedItems();
|
||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||
return items.reduce((sum, item) => {
|
||||
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
||||
// as video + trickplay, the total must match.
|
||||
const trickplaySize = item.trickPlayData?.size ?? 0;
|
||||
// Read the live file size on disk so the total reflects actual usage and
|
||||
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
||||
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
||||
// value if the file can't be stat'd.
|
||||
if (item.videoFilePath) {
|
||||
try {
|
||||
const file = new File(filePathToUri(item.videoFilePath));
|
||||
if (file.exists) {
|
||||
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to stat downloaded file for size:", error);
|
||||
}
|
||||
}
|
||||
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -289,7 +289,24 @@ export function useDownloadOperations({
|
||||
);
|
||||
|
||||
const appSizeUsage = useCallback(async () => {
|
||||
const totalSize = calculateTotalDownloadedSize();
|
||||
let totalSize = calculateTotalDownloadedSize();
|
||||
|
||||
// Also count in-progress downloads (they write straight to their final
|
||||
// path) so the growing file shows up as app usage instead of drifting
|
||||
// into the generic device share until completion.
|
||||
for (const process of processes) {
|
||||
try {
|
||||
const file = new File(
|
||||
Paths.document,
|
||||
`${generateFilename(process.item)}.mp4`,
|
||||
);
|
||||
if (file.exists) {
|
||||
totalSize += file.size ?? 0;
|
||||
}
|
||||
} catch {
|
||||
// File not created yet — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||
@@ -310,7 +327,7 @@ export function useDownloadOperations({
|
||||
appSize: totalSize,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
}, [processes]);
|
||||
|
||||
return {
|
||||
startBackgroundDownload,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "اختر",
|
||||
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
||||
"video": "فيديو",
|
||||
"audio": "الصوت",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Vídeo",
|
||||
"audio": "Àudio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Vybrat",
|
||||
"no_trailer_available": "Přípojné vozidlo není k dispozici",
|
||||
"video": "Video",
|
||||
"audio": "Zvuk",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Vælg",
|
||||
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
|
||||
"video": "Video",
|
||||
"audio": "Lyd",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Auswählen",
|
||||
"no_trailer_available": "Kein Trailer verfügbar",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Sprache",
|
||||
"results": "Ergebnisse",
|
||||
"searching": "Suche ...",
|
||||
"search_failed": "Suche fehlgeschlagen",
|
||||
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
|
||||
"no_subtitles_found": "Keine Untertitel gefunden",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Επιλογή",
|
||||
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
|
||||
"video": "Βίντεο",
|
||||
"audio": "Ήχος",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -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",
|
||||
@@ -391,6 +384,8 @@
|
||||
"device_usage": "Device {{availableSpace}}%",
|
||||
"size_used": "{{used}} of {{total}} used",
|
||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||
"music_cache_title": "Music cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"clear_music_cache": "Clear music cache",
|
||||
@@ -491,7 +486,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -628,7 +622,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Seleccionar",
|
||||
"no_trailer_available": "No hay tráiler disponible",
|
||||
"video": "Vídeo",
|
||||
"audio": "Audio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Valitse",
|
||||
"no_trailer_available": "Perävaunua ei saatavilla",
|
||||
"video": "Video",
|
||||
"audio": "Ääni",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
"error_title": "Erreur",
|
||||
"login_title": "Se connecter",
|
||||
"login_to_title": "Se connecter à",
|
||||
"select_user": "Select a user to log in",
|
||||
"add_user_to_login": "Add a user to log in",
|
||||
"add_user": "Add User",
|
||||
"select_user": "Sélectionnez un utilisateur pour vous connecter",
|
||||
"add_user_to_login": "Ajouter un utilisateur pour se connecter",
|
||||
"add_user": "Ajouter un utilisateur",
|
||||
"username_placeholder": "Nom d'utilisateur",
|
||||
"password_placeholder": "Mot de passe",
|
||||
"login_button": "Se connecter",
|
||||
@@ -42,19 +42,19 @@
|
||||
"please_login_again": "Votre session enregistrée a expiré. Veuillez vous connecter à nouveau.",
|
||||
"remove_saved_login": "Supprimer l'identifiant enregistré",
|
||||
"remove_saved_login_description": "Cela supprimera vos identifiants enregistrés pour ce serveur. Vous devrez saisir à nouveau votre nom d’utilisateur et votre mot de passe la prochaine fois.",
|
||||
"accounts_count": "Comptes {{count}}",
|
||||
"accounts_count": "{{count}} comptes",
|
||||
"select_account": "Sélectionnez un compte",
|
||||
"add_account": "Ajouter un compte",
|
||||
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
|
||||
"remove_server": "Remove Server",
|
||||
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||
"select_your_server": "Select Your Server",
|
||||
"add_server_to_get_started": "Add a server to get started",
|
||||
"add_server": "Add Server",
|
||||
"change_server": "Change Server"
|
||||
"remove_server": "Supprimer le serveur",
|
||||
"remove_server_description": "Ceci supprimera {{server}} et tous les comptes enregistrés de votre liste.",
|
||||
"select_your_server": "Sélectionnez votre serveur",
|
||||
"add_server_to_get_started": "Ajouter un serveur pour commencer",
|
||||
"add_server": "Ajouter un serveur",
|
||||
"change_server": "Changer de serveur"
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Sauvegarder le compte",
|
||||
"title": "Enregistrer le compte",
|
||||
"save_for_later": "Enregistrer ce compte",
|
||||
"security_option": "Options de sécurité",
|
||||
"no_protection": "Aucune protection",
|
||||
@@ -95,9 +95,9 @@
|
||||
"oops": "Oups !",
|
||||
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
|
||||
"continue_watching": "Continuer à regarder",
|
||||
"continue": "Continue",
|
||||
"continue": "Continuer",
|
||||
"next_up": "À suivre",
|
||||
"continue_and_next_up": "Continuer de regarder et à suivre",
|
||||
"continue_and_next_up": "Continuer à regarder et à suivre",
|
||||
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
|
||||
"suggested_movies": "Films suggérés",
|
||||
"suggested_episodes": "Épisodes suggérés",
|
||||
@@ -120,10 +120,10 @@
|
||||
"settings_title": "Paramètres",
|
||||
"log_out_button": "Déconnexion",
|
||||
"switch_user": {
|
||||
"title": "Switch User",
|
||||
"account": "Account",
|
||||
"switch_user": "Switch User on This Server",
|
||||
"current": "current"
|
||||
"title": "Changer d'utilisateur",
|
||||
"account": "Compte",
|
||||
"switch_user": "Changer d'utilisateur sur ce serveur",
|
||||
"current": "actuel"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Catégories"
|
||||
@@ -136,32 +136,32 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence",
|
||||
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
|
||||
"merge_next_up_continue_watching": "Fusionner « Continuer à regarder » et « À suivre »",
|
||||
"hide_remote_session_button": "Masquer le bouton de session distante",
|
||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||
"show_hero_carousel": "Hero Carousel",
|
||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||
"theme_music": "Theme Music",
|
||||
"display_size": "Display Size",
|
||||
"display_size_small": "Small",
|
||||
"display_size_default": "Default",
|
||||
"display_size_large": "Large",
|
||||
"display_size_extra_large": "Extra Large"
|
||||
"show_home_backdrop": "Arrière-plan d'accueil dynamique",
|
||||
"show_hero_carousel": "Carrousel principal",
|
||||
"show_series_poster_on_episode": "Afficher l'affiche de la série sur les épisodes",
|
||||
"theme_music": "Musique de thème",
|
||||
"display_size": "Taille d'affichage",
|
||||
"display_size_small": "Petite",
|
||||
"display_size_default": "Par défaut",
|
||||
"display_size_large": "Grande",
|
||||
"display_size_extra_large": "Très grande"
|
||||
},
|
||||
"network": {
|
||||
"title": "Réseau",
|
||||
"local_network": "Réseau local",
|
||||
"auto_switch_enabled": "Basculement automatique quand à la maison",
|
||||
"auto_switch_enabled": "Basculement automatique à la maison",
|
||||
"auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison",
|
||||
"local_url": "URL locale",
|
||||
"local_url_hint": "Entrez l'adresse de votre serveur local (exemple, http://192.168.1.100:8096)",
|
||||
"local_url_hint": "Entrez l'adresse de votre serveur local (par exemple http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Réseaux Wi-Fi domestiques",
|
||||
"add_current_network": "Ajouter \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Non connecté au WiFi",
|
||||
"not_connected_to_wifi": "Non connecté au Wi-Fi",
|
||||
"no_networks_configured": "Pas de réseau configuré",
|
||||
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer la commutation automatique",
|
||||
"current_wifi": "WiFi actuel",
|
||||
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer le basculement automatique",
|
||||
"current_wifi": "Wi-Fi actuel",
|
||||
"using_url": "Utilisant",
|
||||
"local": "URL locale",
|
||||
"remote": "URL à distance",
|
||||
@@ -172,9 +172,9 @@
|
||||
"not_configured": "Non configuré",
|
||||
"network_added": "Réseau ajouté",
|
||||
"network_already_added": "Réseau déjà ajouté",
|
||||
"no_wifi_connected": "Non connecté au WiFi",
|
||||
"no_wifi_connected": "Non connecté au Wi-Fi",
|
||||
"permission_denied": "Autorisation de localisation refusée",
|
||||
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wifi afin de changer automatiquement. Veuillez l’activer dans les paramètres."
|
||||
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wi-Fi afin de basculer automatiquement. Veuillez l'activer dans les paramètres."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Informations utilisateur",
|
||||
@@ -184,35 +184,35 @@
|
||||
"app_version": "Version de l'application"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Connexion Rapide",
|
||||
"authorize_button": "Autoriser une Connexion Rapide",
|
||||
"enter_the_quick_connect_code": "Entrez le code de Connexion Rapide...",
|
||||
"quick_connect_title": "Connexion rapide",
|
||||
"authorize_button": "Autoriser une connexion rapide",
|
||||
"enter_the_quick_connect_code": "Entrez le code de connexion rapide...",
|
||||
"success": "Succès",
|
||||
"quick_connect_autorized": "Connexion Rapide autorisé",
|
||||
"quick_connect_autorized": "Connexion rapide autorisée",
|
||||
"error": "Erreur",
|
||||
"invalid_code": "Code invalide",
|
||||
"authorize": "Autoriser"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Contrôles Média",
|
||||
"media_controls_title": "Contrôles média",
|
||||
"forward_skip_length": "Durée de saut en avant",
|
||||
"rewind_length": "Durée de retour en arrière",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"buffer": {
|
||||
"title": "Buffer Settings",
|
||||
"cache_mode": "Cache Mode",
|
||||
"title": "Paramètres du tampon",
|
||||
"cache_mode": "Mode de cache",
|
||||
"cache_auto": "Auto",
|
||||
"cache_yes": "Enabled",
|
||||
"cache_no": "Disabled",
|
||||
"buffer_duration": "Buffer Duration",
|
||||
"max_cache_size": "Max Cache Size",
|
||||
"max_backward_cache": "Max Backward Cache"
|
||||
"cache_yes": "Activé",
|
||||
"cache_no": "Désactivé",
|
||||
"buffer_duration": "Durée du tampon",
|
||||
"max_cache_size": "Taille maximale du cache",
|
||||
"max_backward_cache": "Cache arrière maximal"
|
||||
},
|
||||
"vo_driver": {
|
||||
"title": "Video Output",
|
||||
"vo_mode": "VO Driver",
|
||||
"gpu_next": "gpu-next (Recommended)",
|
||||
"title": "Sortie vidéo",
|
||||
"vo_mode": "Pilote VO",
|
||||
"gpu_next": "gpu-next (Recommandé)",
|
||||
"gpu": "gpu"
|
||||
},
|
||||
"gesture_controls": {
|
||||
@@ -225,8 +225,8 @@
|
||||
"right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume",
|
||||
"hide_volume_slider": "Masquer le curseur de volume",
|
||||
"hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo",
|
||||
"hide_brightness_slider": "Cacher le curseur de luminosité",
|
||||
"hide_brightness_slider_description": "Masquer le curseur de volume dans le lecteur vidéo"
|
||||
"hide_brightness_slider": "Masquer le curseur de luminosité",
|
||||
"hide_brightness_slider_description": "Masquer le curseur de luminosité dans le lecteur vidéo"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
@@ -234,14 +234,14 @@
|
||||
"audio_language": "Langue audio",
|
||||
"audio_hint": "Choisissez une langue audio par défaut.",
|
||||
"none": "Aucune",
|
||||
"language": "Langage",
|
||||
"language": "Langue",
|
||||
"transcode_mode": {
|
||||
"title": "Transcodage audio",
|
||||
"description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)",
|
||||
"auto": "Auto",
|
||||
"stereo": "Forcer la stéréo",
|
||||
"5_1": "Autoriser 5.1",
|
||||
"passthrough": "Intercommunication"
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
@@ -252,7 +252,7 @@
|
||||
"set_subtitle_track": "Piste de sous-titres de l'élément précédent",
|
||||
"subtitle_size": "Taille des sous-titres",
|
||||
"none": "Aucune",
|
||||
"language": "Langage",
|
||||
"language": "Langue",
|
||||
"loading": "Chargement",
|
||||
"modes": {
|
||||
"Default": "Par défaut",
|
||||
@@ -262,20 +262,20 @@
|
||||
"OnlyForced": "Forcés seulement"
|
||||
},
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||
"mpv_subtitle_scale": "Subtitle Scale",
|
||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||
"mpv_subtitle_align_x": "Horizontal Align",
|
||||
"mpv_subtitle_align_y": "Vertical Align",
|
||||
"opensubtitles_hint": "Entrez votre clé API OpenSubtitles pour activer la recherche de sous-titres côté client comme solution de secours lorsque votre serveur Jellyfin n'a pas de fournisseur de sous-titres configuré.",
|
||||
"opensubtitles_api_key": "Clé API",
|
||||
"opensubtitles_api_key_placeholder": "Entrez la clé API...",
|
||||
"opensubtitles_get_key": "Obtenez votre clé API gratuite sur opensubtitles.com/fr/consumers",
|
||||
"mpv_subtitle_scale": "Échelle des sous-titres",
|
||||
"mpv_subtitle_margin_y": "Marge verticale",
|
||||
"mpv_subtitle_align_x": "Alignement horizontal",
|
||||
"mpv_subtitle_align_y": "Alignement vertical",
|
||||
"align": {
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
"left": "Gauche",
|
||||
"center": "Centre",
|
||||
"right": "Droite",
|
||||
"top": "Haut",
|
||||
"bottom": "Bas"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
@@ -283,38 +283,38 @@
|
||||
"video_orientation": "Orientation vidéo",
|
||||
"orientation": "Orientation",
|
||||
"orientations": {
|
||||
"DEFAULT": "Par défaut",
|
||||
"DEFAULT": "Suivre l'orientation de l'appareil",
|
||||
"ALL": "Toutes",
|
||||
"PORTRAIT": "Portrait",
|
||||
"PORTRAIT_UP": "Portrait Haut",
|
||||
"PORTRAIT_DOWN": "Portrait Bas",
|
||||
"LANDSCAPE": "Paysage",
|
||||
"LANDSCAPE_LEFT": "Paysage Gauche",
|
||||
"LANDSCAPE_RIGHT": "Paysage Droite",
|
||||
"PORTRAIT": "Portrait auto",
|
||||
"PORTRAIT_UP": "Portrait haut",
|
||||
"PORTRAIT_DOWN": "Portrait bas",
|
||||
"LANDSCAPE": "Paysage auto",
|
||||
"LANDSCAPE_LEFT": "Paysage gauche",
|
||||
"LANDSCAPE_RIGHT": "Paysage droite",
|
||||
"OTHER": "Autre",
|
||||
"UNKNOWN": "Inconnu"
|
||||
},
|
||||
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
||||
"show_custom_menu_links": "Afficher les liens personnalisés",
|
||||
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
||||
"hide_libraries": "Cacher des bibliothèques",
|
||||
"hide_libraries": "Masquer les bibliothèques",
|
||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
|
||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
||||
"default_quality": "Qualité par défaut",
|
||||
"default_playback_speed": "Vitesse de lecture par défaut",
|
||||
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
|
||||
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||
"max_auto_play_episode_count": "Nombre max d'épisodes en lecture automatique",
|
||||
"disabled": "Désactivé"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musique",
|
||||
"playback_title": "Lecture",
|
||||
"playback_description": "Configurer le mode de lecture de la musique.",
|
||||
"prefer_downloaded": "Supprimer toutes les musiques téléchargées",
|
||||
"prefer_downloaded": "Préférer les musiques téléchargées",
|
||||
"caching_title": "Mise en cache",
|
||||
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
|
||||
"lookahead_enabled": "Activer la mise en cache guidée",
|
||||
"lookahead_count": "Pistes à pré-mettre en cache",
|
||||
"lookahead_enabled": "Activer la mise en cache anticipée",
|
||||
"lookahead_count": "Pistes à mettre en cache à l'avance",
|
||||
"max_cache_size": "Taille max de cache"
|
||||
},
|
||||
"plugins": {
|
||||
@@ -333,7 +333,7 @@
|
||||
"tv_quota_days": "Jours de quota de séries",
|
||||
"reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr",
|
||||
"unlimited": "Illimité",
|
||||
"plus_n_more": "+{{n}} Plus",
|
||||
"plus_n_more": "+{{n}} de plus",
|
||||
"order_by": {
|
||||
"DEFAULT": "Par défaut",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne",
|
||||
@@ -341,7 +341,7 @@
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Activer Marlin Search",
|
||||
"enable_marlin_search": "Activer la recherche Marlin",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domaine.org:port",
|
||||
"marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
|
||||
@@ -362,10 +362,10 @@
|
||||
"features_title": "Fonctionnalités",
|
||||
"enable_movie_recommendations": "Recommandations de films",
|
||||
"enable_series_recommendations": "Recommandations de séries",
|
||||
"enable_promoted_watchlists": "Listes de lecture promues",
|
||||
"hide_watchlists_tab": "Masquer l'onglet des listes de lecture",
|
||||
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de lecture promues de Streamystats sur la page d’accueil.",
|
||||
"recommended_movies": "Films Recommandés",
|
||||
"enable_promoted_watchlists": "Listes de suivi promues",
|
||||
"hide_watchlists_tab": "Masquer l'onglet des listes de suivi",
|
||||
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de suivi promues de Streamystats sur la page d’accueil.",
|
||||
"recommended_movies": "Films recommandés",
|
||||
"recommended_series": "Séries recommandées",
|
||||
"toasts": {
|
||||
"saved": "Enregistré",
|
||||
@@ -375,7 +375,7 @@
|
||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Activer l'intégration de notre liste de lecture"
|
||||
"watchlist_enabler": "Activer l'intégration de la liste de suivi"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -392,10 +392,10 @@
|
||||
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
|
||||
"downloaded_songs_size": "{{size}} téléchargé",
|
||||
"downloaded_songs_deleted": "Chansons téléchargées supprimées",
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
"clear_all_cache": "Effacer tout le cache",
|
||||
"clear_all_cache_confirm": "Effacer tout le cache ?",
|
||||
"clear_all_cache_confirm_desc": "Êtes-vous sûr de vouloir effacer toutes les données en cache ? Cela supprimera toutes les images, fichiers musicaux, sous-titres et caches de requêtes mis en cache. Vos paramètres et votre session de connexion seront conservés.",
|
||||
"clear_all_cache_error_desc": "Une erreur est survenue lors de l'effacement du cache."
|
||||
},
|
||||
"intro": {
|
||||
"title": "Introduction",
|
||||
@@ -419,17 +419,17 @@
|
||||
"error_deleting_files": "Erreur lors de la suppression des fichiers"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"title": "Sécurité",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"disabled": "Disabled",
|
||||
"title": "Délai d'inactivité",
|
||||
"disabled": "Désactivé",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
"15_minutes": "15 minutes",
|
||||
"30_minutes": "30 minutes",
|
||||
"1_hour": "1 hour",
|
||||
"4_hours": "4 hours",
|
||||
"24_hours": "24 hours"
|
||||
"1_hour": "1 heure",
|
||||
"4_hours": "4 heures",
|
||||
"24_hours": "24 heures"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -446,7 +446,7 @@
|
||||
"delete_all_movies_button": "Supprimer tous les films",
|
||||
"delete_all_series_button": "Supprimer toutes les séries",
|
||||
"delete_all_button": "Supprimer tous les médias",
|
||||
"delete_all_other_media_button": "Supprimer un autre média",
|
||||
"delete_all_other_media_button": "Supprimer les autres médias",
|
||||
"active_download": "Téléchargement actif",
|
||||
"no_active_downloads": "Pas de téléchargements actifs",
|
||||
"active_downloads": "Téléchargements actifs",
|
||||
@@ -454,7 +454,7 @@
|
||||
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
|
||||
"back": "Retour",
|
||||
"delete": "Supprimer",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_download": "Supprimer le téléchargement",
|
||||
"something_went_wrong": "Quelque chose s'est mal passé",
|
||||
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
@@ -465,7 +465,7 @@
|
||||
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès !",
|
||||
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
|
||||
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
||||
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
||||
"failed_to_delete_media": "Échec de la suppression des autres médias",
|
||||
"download_cancelled": "Téléchargement annulé",
|
||||
"could_not_delete_download": "Impossible de supprimer le téléchargement",
|
||||
"download_completed": "Téléchargement terminé",
|
||||
@@ -483,17 +483,16 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Sélectionner",
|
||||
"no_results": "Aucun résultat",
|
||||
"no_trailer_available": "Aucune bande-annonce disponible",
|
||||
"video": "Vidéo",
|
||||
"audio": "Audio",
|
||||
"subtitle": "Sous-titres",
|
||||
"play": "Lecture",
|
||||
"mark_as_played": "Mark as Played",
|
||||
"mark_as_not_played": "Mark as not Played",
|
||||
"mark_as_played": "Marquer comme vu",
|
||||
"mark_as_not_played": "Marquer comme non vu",
|
||||
"none": "Aucun",
|
||||
"track": "Suivre",
|
||||
"track": "Piste",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"ok": "Ok",
|
||||
@@ -501,15 +500,15 @@
|
||||
"back": "Précédent",
|
||||
"continue": "Continuer",
|
||||
"verifying": "Vérification...",
|
||||
"login": "Login",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
"login": "Connexion",
|
||||
"episodes": "Épisodes",
|
||||
"movies": "Films",
|
||||
"loading": "Chargement…",
|
||||
"seeAll": "Tout afficher"
|
||||
},
|
||||
"search": {
|
||||
"search": "Rechercher...",
|
||||
"x_items": "{{count}} Médias",
|
||||
"x_items": "{{count}} médias",
|
||||
"library": "Bibliothèque",
|
||||
"discover": "Découvrir",
|
||||
"no_results": "Aucun résultat",
|
||||
@@ -527,7 +526,7 @@
|
||||
"request_series": "Demander une série",
|
||||
"recently_added": "Ajoutés récemment",
|
||||
"recent_requests": "Demandes récentes",
|
||||
"plex_watchlist": "Liste de lecture Plex",
|
||||
"plex_watchlist": "Liste de suivi Plex",
|
||||
"trending": "Tendance",
|
||||
"popular_movies": "Films populaires",
|
||||
"movie_genres": "Genres de films",
|
||||
@@ -536,7 +535,7 @@
|
||||
"popular_tv": "Séries populaires",
|
||||
"tv_genres": "Genres des séries",
|
||||
"upcoming_tv": "Séries à venir",
|
||||
"networks": "Studios",
|
||||
"networks": "Chaînes",
|
||||
"tmdb_movie_keyword": "Mots-clés de film TMDB",
|
||||
"tmdb_movie_genre": "Genre de film TMDB",
|
||||
"tmdb_tv_keyword": "Mots-clés de séries TMDB",
|
||||
@@ -553,7 +552,7 @@
|
||||
"item_types": {
|
||||
"movies": "Films",
|
||||
"series": "Séries",
|
||||
"boxsets": "Coffrets ",
|
||||
"boxsets": "Coffrets",
|
||||
"playlists": "Playlists",
|
||||
"items": "Médias"
|
||||
},
|
||||
@@ -575,10 +574,10 @@
|
||||
"filter_by": "Filtrer par",
|
||||
"sort_order": "Ordre de tri",
|
||||
"tags": "Tags",
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
"all": "Tout",
|
||||
"reset": "Réinitialiser",
|
||||
"asc": "Croissant",
|
||||
"desc": "Décroissant"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
@@ -592,11 +591,11 @@
|
||||
"noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Aucuns liens"
|
||||
"no_links": "Aucun lien"
|
||||
},
|
||||
"player": {
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"live": "EN DIRECT",
|
||||
"mpv_player_title": "Lecteur MPV",
|
||||
"error": "Erreur",
|
||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
||||
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
||||
@@ -611,72 +610,71 @@
|
||||
"downloaded_file_yes": "Oui",
|
||||
"downloaded_file_no": "Non",
|
||||
"downloaded_file_cancel": "Annuler",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
"swipe_down_settings": "Balayez vers le bas pour les paramètres",
|
||||
"ends_at": "Se termine à {{time}}",
|
||||
"search_subtitles": "Rechercher des sous-titres",
|
||||
"subtitle_tracks": "Pistes",
|
||||
"subtitle_search": "Rechercher et télécharger",
|
||||
"download": "Télécharger",
|
||||
"subtitle_download_hint": "Les sous-titres téléchargés seront enregistrés dans votre bibliothèque",
|
||||
"using_jellyfin_server": "Utilisation du serveur Jellyfin",
|
||||
"language": "Langue",
|
||||
"results": "Résultats",
|
||||
"search_failed": "Recherche échouée",
|
||||
"no_subtitle_provider": "Aucun fournisseur de sous-titres configuré sur le serveur",
|
||||
"no_subtitles_found": "Aucun sous-titre trouvé",
|
||||
"add_opensubtitles_key_hint": "Ajoutez une clé API OpenSubtitles dans les paramètres pour une solution de secours côté client",
|
||||
"settings": "Paramètres",
|
||||
"skip_intro": "Passer l'intro",
|
||||
"skip_credits": "Passer le générique",
|
||||
"stopPlayback": "Arrêter la lecture",
|
||||
"stopPlayingTitle": "Arrêter de lire \"{{title}}\" ?",
|
||||
"stopPlayingConfirm": "Êtes-vous sûr de vouloir arrêter la lecture ?",
|
||||
"downloaded": "Téléchargé",
|
||||
"missing_parameters": "Paramètres de lecture manquants"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
"title": "Chapitres",
|
||||
"chapter_number": "Chapitre {{number}}",
|
||||
"open": "Ouvrir les chapitres",
|
||||
"close": "Fermer les chapitres"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "À suivre",
|
||||
"no_items_to_display": "Aucuns médias à afficher",
|
||||
"no_items_to_display": "Aucun média à afficher",
|
||||
"cast_and_crew": "Distribution et équipe",
|
||||
"series": "Séries",
|
||||
"seasons": "Saisons",
|
||||
"season": "Saison",
|
||||
"from_this_series": "From This Series",
|
||||
"more_from_this_season": "More from this Season",
|
||||
"view_series": "View Series",
|
||||
"view_season": "View Season",
|
||||
"select_season": "Select Season",
|
||||
"from_this_series": "De cette série",
|
||||
"more_from_this_season": "Plus de cette saison",
|
||||
"view_series": "Voir la série",
|
||||
"view_season": "Voir la saison",
|
||||
"select_season": "Sélectionner une saison",
|
||||
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
|
||||
"overview": "Aperçu",
|
||||
"more_with": "Plus avec {{name}}",
|
||||
"similar_items": "Médias similaires",
|
||||
"no_similar_items_found": "Aucuns médias similaires trouvés",
|
||||
"no_similar_items_found": "Aucun média similaire trouvé",
|
||||
"video": "Vidéo",
|
||||
"more_details": "Plus de détails",
|
||||
"media_options": "Options média",
|
||||
"quality": "Qualité",
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Subtitle",
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
"label": "Sous-titres",
|
||||
"none": "Aucun",
|
||||
"tracks": "Pistes"
|
||||
},
|
||||
"show_more": "Afficher plus",
|
||||
"show_less": "Afficher moins",
|
||||
"left": "left",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
"left": "restant",
|
||||
"director": "Réalisateur",
|
||||
"cast": "Distribution",
|
||||
"technical_details": "Détails techniques",
|
||||
"appeared_in": "Apparu dans",
|
||||
"movies": "Movies",
|
||||
"shows": "Shows",
|
||||
"movies": "Films",
|
||||
"shows": "Séries",
|
||||
"could_not_load_item": "Impossible de charger le média",
|
||||
"none": "Aucun",
|
||||
"download": {
|
||||
@@ -688,13 +686,13 @@
|
||||
"download_unwatched_only": "Non visionné uniquement",
|
||||
"download_button": "Télécharger"
|
||||
},
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched",
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
"mark_played": "Marquer comme vu",
|
||||
"mark_unplayed": "Marquer comme non vu",
|
||||
"resume_playback": "Reprendre la lecture",
|
||||
"resume_playback_description": "Voulez-vous continuer où vous vous êtes arrêté ou commencer à partir du début ?",
|
||||
"play_from_start": "Lire depuis le début",
|
||||
"continue_from": "Continuer depuis {{time}}",
|
||||
"no_data_available": "Aucune donnée disponible"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Suivant",
|
||||
@@ -706,16 +704,16 @@
|
||||
"sports": "Sports",
|
||||
"for_kids": "Pour enfants",
|
||||
"news": "Actualités",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"page_of": "Page {{current}} sur {{total}}",
|
||||
"no_programs": "Aucun programme disponible",
|
||||
"no_channels": "Aucune chaîne disponible",
|
||||
"tabs": {
|
||||
"programs": "Programs",
|
||||
"programs": "Programmes",
|
||||
"guide": "Guide",
|
||||
"channels": "Channels",
|
||||
"recordings": "Recordings",
|
||||
"schedule": "Schedule",
|
||||
"series": "Series"
|
||||
"channels": "Chaînes",
|
||||
"recordings": "Enregistrements",
|
||||
"schedule": "Programmation",
|
||||
"series": "Séries"
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
@@ -725,7 +723,7 @@
|
||||
"whats_wrong": "Quel est le problème ?",
|
||||
"issue_type": "Type de problème",
|
||||
"select_an_issue": "Sélectionnez un problème",
|
||||
"types": "Types de fichiers",
|
||||
"types": "Types",
|
||||
"describe_the_issue": "(optionnel) Décrivez le problème...",
|
||||
"submit_button": "Soumettre",
|
||||
"report_issue_button": "Signaler un problème",
|
||||
@@ -734,7 +732,7 @@
|
||||
"failed_to_login": "Échec de la connexion",
|
||||
"cast": "Distribution",
|
||||
"details": "Détails",
|
||||
"status": "Statuts",
|
||||
"status": "Statut",
|
||||
"original_title": "Titre original",
|
||||
"series_type": "Type de série",
|
||||
"release_dates": "Dates de sortie",
|
||||
@@ -752,32 +750,32 @@
|
||||
"tags": "Tags",
|
||||
"quality_profile": "Profil de qualité",
|
||||
"root_folder": "Dossier racine",
|
||||
"season_all": "Saison (Tous)",
|
||||
"season_all": "Saison (Toutes)",
|
||||
"season_number": "Saison {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Épisodes",
|
||||
"number_episodes": "{{episode_number}} épisodes",
|
||||
"born": "Né(e) le",
|
||||
"appearances": "Apparences",
|
||||
"appearances": "Apparitions",
|
||||
"approve": "Valider",
|
||||
"decline": "Refuser",
|
||||
"requested_by": "Demandé par {{user}}",
|
||||
"unknown_user": "Utilisateur inconnu",
|
||||
"select": "Select",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selected",
|
||||
"select": "Sélectionner",
|
||||
"request_all": "Tout demander",
|
||||
"request_seasons": "Demander des saisons",
|
||||
"select_seasons": "Sélectionner les saisons",
|
||||
"request_selected": "Demander la sélection",
|
||||
"n_selected": "{{count}} sélectionnés",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences ! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
||||
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
|
||||
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr",
|
||||
"issue_submitted": "Problème soumis !",
|
||||
"requested_item": "{{item}}} demandé !",
|
||||
"requested_item": "{{item}} demandé !",
|
||||
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !",
|
||||
"something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média !",
|
||||
"something_went_wrong_requesting_media": "Une erreur s'est produite lors de la demande du média !",
|
||||
"request_approved": "Demande approuvée !",
|
||||
"request_declined": "Demande déclinée !",
|
||||
"failed_to_approve_request": "Échec d'approbation de la demande",
|
||||
"request_declined": "Demande refusée !",
|
||||
"failed_to_approve_request": "Échec de l'approbation de la demande",
|
||||
"failed_to_decline_request": "Échec du refus de la demande"
|
||||
}
|
||||
},
|
||||
@@ -787,7 +785,7 @@
|
||||
"library": "Bibliothèque",
|
||||
"custom_links": "Liens personnalisés",
|
||||
"favorites": "Favoris",
|
||||
"settings": "Settings"
|
||||
"settings": "Réglages"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musique",
|
||||
@@ -807,13 +805,13 @@
|
||||
"play_top_tracks": "Jouer les pistes les plus populaires",
|
||||
"no_suggestions": "Pas de suggestion disponible",
|
||||
"no_albums": "Pas d'albums trouvés",
|
||||
"no_artists": "Pas d'artistes trouvé",
|
||||
"no_artists": "Pas d'artistes trouvés",
|
||||
"no_playlists": "Pas de playlists trouvées",
|
||||
"album_not_found": "Album introuvable",
|
||||
"artist_not_found": "Artiste introuvable",
|
||||
"playlist_not_found": "Playlist introuvable",
|
||||
"track_options": {
|
||||
"play_next": "Lecture suivante",
|
||||
"play_next": "Lire ensuite",
|
||||
"add_to_queue": "Ajouter à la file d'attente",
|
||||
"add_to_playlist": "Ajouter à la playlist",
|
||||
"download": "Télécharger",
|
||||
@@ -822,15 +820,15 @@
|
||||
"cached": "En cache",
|
||||
"delete_download": "Supprimer un téléchargement",
|
||||
"delete_cache": "Supprimer du cache",
|
||||
"go_to_artist": "Voir l'artiste",
|
||||
"go_to_album": "Aller à l’album",
|
||||
"go_to_artist": "Aller à l'artiste",
|
||||
"go_to_album": "Aller à l'album",
|
||||
"add_to_favorites": "Ajouter aux favoris",
|
||||
"remove_from_favorites": "Retirer des favoris",
|
||||
"remove_from_playlist": "Retirer de la playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Créer une Playlist",
|
||||
"playlist_name": "Nom de la Playlist",
|
||||
"create_playlist": "Créer une playlist",
|
||||
"playlist_name": "Nom de la playlist",
|
||||
"enter_name": "Entrer le nom de la playlist",
|
||||
"create": "Créer",
|
||||
"search_playlists": "Rechercher des playlists...",
|
||||
@@ -841,10 +839,10 @@
|
||||
"created": "Playlist créée",
|
||||
"create_new": "Créer une nouvelle playlist",
|
||||
"failed_to_add": "Échec de l'ajout à la playlist",
|
||||
"failed_to_remove": "Échec de la suppression de la playlist",
|
||||
"failed_to_create": "Échec de la suppression de la playlist",
|
||||
"failed_to_remove": "Échec du retrait de la playlist",
|
||||
"failed_to_create": "Échec de la création de la playlist",
|
||||
"delete_playlist": "Supprimer la playlist",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{name}} » ? Cette action est irréversible.",
|
||||
"deleted": "Playlist supprimée",
|
||||
"failed_to_delete": "Échec de la suppression de la playlist"
|
||||
},
|
||||
@@ -855,46 +853,46 @@
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Listes de lecture",
|
||||
"my_watchlists": "Mes listes de lecture",
|
||||
"public_watchlists": "Watchlist publique",
|
||||
"create_title": "Créer une Watchlist",
|
||||
"edit_title": "Modifier la Watchlist",
|
||||
"create_button": "Créer une Watchlist",
|
||||
"title": "Listes de suivi",
|
||||
"my_watchlists": "Mes listes de suivi",
|
||||
"public_watchlists": "Listes de suivi publiques",
|
||||
"create_title": "Créer une liste de suivi",
|
||||
"edit_title": "Modifier la liste de suivi",
|
||||
"create_button": "Créer une liste de suivi",
|
||||
"save_button": "Enregistrer les modifications",
|
||||
"delete_button": "Supprimer",
|
||||
"remove_button": "Retirer",
|
||||
"cancel_button": "Annuler",
|
||||
"name_label": "Nom",
|
||||
"name_placeholder": "Entrer le nom de la playlist",
|
||||
"name_placeholder": "Entrez le nom de la liste de suivi",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Entrez la description (facultatif)",
|
||||
"is_public_label": "Liste de lecture Publique",
|
||||
"is_public_label": "Liste de suivi publique",
|
||||
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
|
||||
"allowed_type_label": "Type de contenu",
|
||||
"sort_order_label": "Ordre de tri par défaut",
|
||||
"empty_title": "Pas de Watchlists",
|
||||
"empty_title": "Aucune liste de suivi",
|
||||
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
|
||||
"empty_watchlist": "Cette liste de suivi est vide",
|
||||
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
|
||||
"not_configured_title": "Streamystats non configuré",
|
||||
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
|
||||
"go_to_settings": "Accédez aux Paramètres",
|
||||
"add_to_watchlist": "Ajouter à la Watchlist",
|
||||
"remove_from_watchlist": "Retirer de la Watchlist",
|
||||
"go_to_settings": "Accédez aux paramètres",
|
||||
"add_to_watchlist": "Ajouter à la liste de suivi",
|
||||
"remove_from_watchlist": "Retirer de la liste de suivi",
|
||||
"select_watchlist": "Sélectionner la liste de suivi",
|
||||
"create_new": "Créer une Watchlist",
|
||||
"item": "médias",
|
||||
"items": "élément",
|
||||
"create_new": "Créer une nouvelle liste de suivi",
|
||||
"item": "élément",
|
||||
"items": "éléments",
|
||||
"public": "Publique",
|
||||
"private": "Privée",
|
||||
"you": "Vous-même",
|
||||
"you": "Vous",
|
||||
"by_owner": "Par un autre utilisateur",
|
||||
"not_found": "Playlist introuvable",
|
||||
"delete_confirm_title": "Supprimer la Watchlist",
|
||||
"delete_confirm_message": "Tous les médias (par défaut)",
|
||||
"remove_item_title": "Retirer de la Watchlist",
|
||||
"remove_item_message": "Retirer «{{name}}» de cette liste de suivi?",
|
||||
"not_found": "Liste de suivi introuvable",
|
||||
"delete_confirm_title": "Supprimer la liste de suivi",
|
||||
"delete_confirm_message": "Voulez-vous vraiment supprimer « {{name}} » ? Cette action est irréversible.",
|
||||
"remove_item_title": "Retirer de la liste de suivi",
|
||||
"remove_item_message": "Retirer « {{name}} » de cette liste de suivi ?",
|
||||
"loading": "Chargement des listes de suivi...",
|
||||
"no_compatible_watchlists": "Aucune liste de suivi compatible",
|
||||
"create_one_first": "Créer une liste de suivi qui accepte ce type de contenu"
|
||||
@@ -910,33 +908,33 @@
|
||||
}
|
||||
},
|
||||
"companion_login": {
|
||||
"title": "Pair with TV",
|
||||
"align_qr": "Align the QR code within the frame",
|
||||
"enter_code_manually": "Enter code manually",
|
||||
"pairing_enter_credentials": "Enter credentials for TV",
|
||||
"pairing_code_label": "Pairing code",
|
||||
"server": "Server",
|
||||
"authorize_button": "Authorize",
|
||||
"authorizing": "Authorizing...",
|
||||
"scan_again": "Scan Again",
|
||||
"done": "Done",
|
||||
"success_title": "Authorization Sent",
|
||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||
"error_title": "Authorization Failed",
|
||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||
"error_generic": "Something went wrong. Please try again.",
|
||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||
"login_as": "Log in as {{username}}?",
|
||||
"on_server": "on {{server}}",
|
||||
"use_different_user": "Use a different user",
|
||||
"open_settings": "Open Settings"
|
||||
"title": "Associer à la TV",
|
||||
"align_qr": "Alignez le code QR dans le cadre",
|
||||
"enter_code_manually": "Saisir le code manuellement",
|
||||
"pairing_enter_credentials": "Entrez les identifiants pour le téléviseur",
|
||||
"pairing_code_label": "Code d'appairage",
|
||||
"server": "Serveur",
|
||||
"authorize_button": "Autoriser",
|
||||
"authorizing": "Autorisation en cours...",
|
||||
"scan_again": "Scanner à nouveau",
|
||||
"done": "Terminé",
|
||||
"success_title": "Autorisation envoyée",
|
||||
"pairing_tv_connecting": "La TV se connecte à votre compte",
|
||||
"error_title": "Échec de l'autorisation",
|
||||
"error_invalid_qr": "Code QR invalide. Veuillez scanner le code d'appairage du téléviseur.",
|
||||
"error_generic": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"error_permission_denied": "L'accès à la caméra est nécessaire pour scanner les QR codes.",
|
||||
"login_as": "Se connecter en tant que {{username}} ?",
|
||||
"on_server": "sur {{server}}",
|
||||
"use_different_user": "Utiliser un autre utilisateur",
|
||||
"open_settings": "Ouvrir les paramètres"
|
||||
},
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
"logging_in_description": "Connecting to your server"
|
||||
"pair_with_phone": "Associer au téléphone",
|
||||
"pair_with_phone_title": "Se connecter sur la TV",
|
||||
"waiting_for_phone": "En attente du téléphone...",
|
||||
"scan_with_phone": "Scanner avec l'application Streamyfin sur votre téléphone",
|
||||
"logging_in": "Connexion en cours...",
|
||||
"logging_in_description": "Connexion à votre serveur"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "בחר",
|
||||
"no_trailer_available": "אין טריילר זמין",
|
||||
"video": "וידאו",
|
||||
"audio": "שמע",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Videó",
|
||||
"audio": "Hang",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
"hide_brightness_slider_description": "Nascondi il cursore della luminosità nel lettore video"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
@@ -237,10 +237,10 @@
|
||||
"language": "Lingua",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"description": "Controlla come viene gestito l'audio surround (7.1, TrueHD, DTS-HD)",
|
||||
"auto": "Automatico",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"5_1": "Consenti 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
@@ -262,20 +262,20 @@
|
||||
"OnlyForced": "Solo forzati"
|
||||
},
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_hint": "Inserisci la tua chiave API OpenSubtitles per abilitare la ricerca dei sottotitoli quando il tuo server Jellyfin non ha un provider di sottotitoli configurato.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||
"opensubtitles_api_key_placeholder": "Inserisci la chiave API...",
|
||||
"opensubtitles_get_key": "Ottieni la tua chiave API gratuita su opensubtitles.com/en/consumers",
|
||||
"mpv_subtitle_scale": "Subtitle Scale",
|
||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||
"mpv_subtitle_align_x": "Horizontal Align",
|
||||
"mpv_subtitle_align_y": "Vertical Align",
|
||||
"align": {
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
"left": "Sinistra",
|
||||
"center": "Centro",
|
||||
"right": "Destra",
|
||||
"top": "Alto",
|
||||
"bottom": "Basso"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
@@ -307,9 +307,9 @@
|
||||
"disabled": "Disabilitato"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"title": "Musica",
|
||||
"playback_title": "Riproduzione",
|
||||
"playback_description": "Configura come viene riprodotta la musica.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
@@ -333,7 +333,7 @@
|
||||
"tv_quota_days": "Giorni di quota per le serie TV",
|
||||
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
|
||||
"unlimited": "Illimitato",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"plus_n_more": "+{{n}} altro",
|
||||
"order_by": {
|
||||
"DEFAULT": "Predefinito",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media",
|
||||
@@ -352,25 +352,25 @@
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"disable_streamystats": "Disabilita Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"streamystats_search_hint": "Inserisci l'URL per il tuo server Streamystats. L'URL dovrebbe includere http o https ed eventualmente la porta.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"save": "Salva",
|
||||
"features_title": "Funzionalità",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"home_sections_hint": "Mostra consigli personalizzati e watchlist promosse da Streamystats nella home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
"saved": "Salvato",
|
||||
"refreshed": "Impostazioni aggiornate dal server",
|
||||
"disabled": "Streamystats disabilitato"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
@@ -385,17 +385,17 @@
|
||||
"size_used": "{{used}} di {{total}} usato",
|
||||
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"music_cache_size": "{{size}} nella cache",
|
||||
"music_cache_cleared": "Cache musicale cancellata",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||
"downloaded_songs_size": "{{size}} scaricato",
|
||||
"downloaded_songs_deleted": "Brani scaricati eliminati",
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
"clear_all_cache_confirm_desc": "Sei sicuro di voler cancellare tutti i dati nella cache? Questo cancellerà tutte le immagini nella cache, i file musicali, i sottotitoli e le cache delle interrogazioni. Le impostazioni e la sessione di login verranno mantenute.",
|
||||
"clear_all_cache_error_desc": "Si è verificato un errore durante la cancellazione della cache."
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro",
|
||||
@@ -404,8 +404,8 @@
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Log",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"export_logs": "Esporta i logs",
|
||||
"click_for_more_info": "Clicca per maggiori informazioni",
|
||||
"level": "Livello",
|
||||
"no_logs_available": "Nessun log disponibile",
|
||||
"delete_all_logs": "Cancella tutti i log"
|
||||
@@ -419,17 +419,17 @@
|
||||
"error_deleting_files": "Errore nella cancellazione dei file"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"title": "Sicurezza",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
"15_minutes": "15 minutes",
|
||||
"30_minutes": "30 minutes",
|
||||
"1_hour": "1 hour",
|
||||
"4_hours": "4 hours",
|
||||
"24_hours": "24 hours"
|
||||
"disabled": "Disabilitato",
|
||||
"1_minute": "1 minuto",
|
||||
"5_minutes": "5 minuti",
|
||||
"15_minutes": "15 minuti",
|
||||
"30_minutes": "30 minuti",
|
||||
"1_hour": "1 ora",
|
||||
"4_hours": "4 ore",
|
||||
"24_hours": "24 ore"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Seleziona",
|
||||
"no_trailer_available": "Nessun trailer disponibile",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -494,18 +493,18 @@
|
||||
"mark_as_not_played": "Mark as not Played",
|
||||
"none": "Nulla",
|
||||
"track": "Traccia",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Cancella",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
"remove": "Rimuovi",
|
||||
"back": "Indietro",
|
||||
"continue": "Continua",
|
||||
"verifying": "Verifica in corso...",
|
||||
"login": "Accedi",
|
||||
"episodes": "Episodi",
|
||||
"movies": "Film",
|
||||
"loading": "Caricamento…",
|
||||
"seeAll": "Visualizza tutti"
|
||||
},
|
||||
"search": {
|
||||
"search": "Cerca...",
|
||||
@@ -519,10 +518,10 @@
|
||||
"episodes": "Episodi",
|
||||
"collections": "Collezioni",
|
||||
"actors": "Attori",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"artists": "Artisti",
|
||||
"albums": "Album",
|
||||
"songs": "Tracce",
|
||||
"playlists": "Playlist",
|
||||
"request_movies": "Film Richiesti",
|
||||
"request_series": "Serie Richieste",
|
||||
"recently_added": "Aggiunti di Recente",
|
||||
@@ -554,7 +553,7 @@
|
||||
"movies": "film",
|
||||
"series": "serie TV",
|
||||
"boxsets": "cofanetti",
|
||||
"playlists": "Playlists",
|
||||
"playlists": "Playlist",
|
||||
"items": "elementi"
|
||||
},
|
||||
"options": {
|
||||
@@ -566,7 +565,7 @@
|
||||
"cover": "Copertina",
|
||||
"show_titles": "Mostra titoli",
|
||||
"show_stats": "Mostra statistiche",
|
||||
"options_title": "Options"
|
||||
"options_title": "Impostazioni"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Generi",
|
||||
@@ -575,10 +574,10 @@
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Criterio di ordinamento",
|
||||
"tags": "Tag",
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
"all": "Tutto",
|
||||
"reset": "Ripristina",
|
||||
"asc": "Crescente",
|
||||
"desc": "Decrescente"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
@@ -595,7 +594,7 @@
|
||||
"no_links": "Nessun link"
|
||||
},
|
||||
"player": {
|
||||
"live": "LIVE",
|
||||
"live": "IN DIRETTA",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Errore",
|
||||
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
|
||||
@@ -606,40 +605,39 @@
|
||||
"next_episode": "Prossimo Episodio",
|
||||
"continue_watching": "Continua a guardare",
|
||||
"go_back": "Indietro",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_title": "Questo file è stato scaricato",
|
||||
"downloaded_file_message": "Vuoi riprodurre il file scaricato?",
|
||||
"downloaded_file_yes": "Si",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"downloaded_file_cancel": "Annulla",
|
||||
"swipe_down_settings": "Scorri in basso per le impostazioni",
|
||||
"ends_at": "Termina alle {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_tracks": "Tracce",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"download": "Scarica",
|
||||
"subtitle_download_hint": "I sottotitoli scaricati verranno salvati nella tua libreria",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"language": "Lingua",
|
||||
"results": "Risultati",
|
||||
"search_failed": "Ricerca fallita",
|
||||
"no_subtitle_provider": "Nessun provider di sottotitoli configurato sul server",
|
||||
"no_subtitles_found": "Nessun sottotitolo trovato",
|
||||
"add_opensubtitles_key_hint": "Aggiungi la chiave API OpenSubtitles nelle impostazioni",
|
||||
"settings": "Impostazioni",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
"stopPlayingTitle": "Interrompere la riproduzione \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Sei sicuro di voler interrompere la riproduzione?",
|
||||
"downloaded": "Scaricato",
|
||||
"missing_parameters": "Parametri di riproduzione mancanti"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
"title": "Capitoli",
|
||||
"chapter_number": "Capitolo {{number}}",
|
||||
"open": "Apri capitoli",
|
||||
"close": "Chiudi i capitoli"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Il prossimo",
|
||||
@@ -664,19 +662,19 @@
|
||||
"quality": "Qualità",
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Subtitle",
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
"label": "Sottotitoli",
|
||||
"none": "Vuoto",
|
||||
"tracks": "Tracce"
|
||||
},
|
||||
"show_more": "Mostra di più",
|
||||
"show_less": "Mostra di meno",
|
||||
"left": "left",
|
||||
"director": "Director",
|
||||
"left": "sinistra",
|
||||
"director": "Regista",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
"appeared_in": "Apparso in",
|
||||
"movies": "Movies",
|
||||
"shows": "Shows",
|
||||
"movies": "Film",
|
||||
"shows": "Serie",
|
||||
"could_not_load_item": "Impossibile caricare l'elemento",
|
||||
"none": "Nessuno",
|
||||
"download": {
|
||||
@@ -691,10 +689,10 @@
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched",
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"resume_playback_description": "Vuoi continuare da dove hai lasciato o riniziare da capo?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
"continue_from": "Continua da {{time}}",
|
||||
"no_data_available": "Nessun dato disponibile"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Prossimo",
|
||||
@@ -706,16 +704,16 @@
|
||||
"sports": "Sport",
|
||||
"for_kids": "Per Bambini",
|
||||
"news": "Notiziari",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"page_of": "Pagina {{current}} di {{total}}",
|
||||
"no_programs": "Nessun programma disponibile",
|
||||
"no_channels": "Nessun canale disponibile",
|
||||
"tabs": {
|
||||
"programs": "Programs",
|
||||
"guide": "Guide",
|
||||
"channels": "Channels",
|
||||
"recordings": "Recordings",
|
||||
"schedule": "Schedule",
|
||||
"series": "Series"
|
||||
"programs": "Programmi",
|
||||
"guide": "Guida",
|
||||
"channels": "Canali",
|
||||
"recordings": "Registrazioni",
|
||||
"schedule": "Pianifica",
|
||||
"series": "Serie Tv"
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
@@ -761,12 +759,12 @@
|
||||
"decline": "Rifiuta",
|
||||
"requested_by": "Richiesto da {{user}}",
|
||||
"unknown_user": "Utente Sconosciuto",
|
||||
"select": "Select",
|
||||
"select": "Seleziona",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selected",
|
||||
"n_selected": "{{count}} selezionati",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
|
||||
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
|
||||
@@ -787,39 +785,39 @@
|
||||
"library": "Libreria",
|
||||
"custom_links": "Collegamenti personalizzati",
|
||||
"favorites": "Preferiti",
|
||||
"settings": "Settings"
|
||||
"settings": "Impostazioni"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"title": "Musica",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"suggestions": "Suggerimenti",
|
||||
"albums": "Album",
|
||||
"artists": "Artisti",
|
||||
"playlists": "Playlist",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play": "Riproduci",
|
||||
"shuffle": "Riproduzione casuale",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"no_suggestions": "Nessun suggerimento disponibile",
|
||||
"no_albums": "Nessun album trovato",
|
||||
"no_artists": "Artista non trovato",
|
||||
"no_playlists": "Nessuna playlist trovata",
|
||||
"album_not_found": "Album non trovato",
|
||||
"artist_not_found": "Artista non trovato",
|
||||
"playlist_not_found": "Playlist non trovata",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"download": "Scarica",
|
||||
"downloaded": "Scaricato",
|
||||
"downloading": "Scaricamento...",
|
||||
"cached": "Memorizzato nella cache",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
@@ -831,112 +829,112 @@
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"enter_name": "Inserisci il nome della playlist",
|
||||
"create": "Crea",
|
||||
"search_playlists": "Cerca playlist...",
|
||||
"added_to": "Aggiunto a {{name}}",
|
||||
"added": "Aggiunto alla playlist",
|
||||
"removed_from": "Rimosso da {{name}}",
|
||||
"removed": "Rimosso dalla playlist",
|
||||
"created": "Playlist creata",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"failed_to_add": "Impossibile aggiungere alla playlist",
|
||||
"failed_to_remove": "Impossibile rimuovere dalla playlist",
|
||||
"failed_to_create": "Impossibile creare la playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
"delete_confirm": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
||||
"deleted": "Playlist eliminata",
|
||||
"failed_to_delete": "Impossibile eliminare la playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"alphabetical": "Alfabetico",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
"title": "Da vedere",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"delete_button": "Cancella",
|
||||
"remove_button": "Rimuovi",
|
||||
"cancel_button": "Annulla",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "Inserisci il nome della lista \"Da vedere\"",
|
||||
"description_label": "Descrizione",
|
||||
"description_placeholder": "Inserisci descrizione (opzionale)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"is_public_description": "Permetti ad altri di vedere questa lista",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"empty_description": "Crea la tua prima lista \"Da vedere\" per iniziare a organizzare i tuoi media",
|
||||
"empty_watchlist": "Questa lista è vuota",
|
||||
"empty_watchlist_hint": "Aggiungi elementi dalla tua libreria a questa lista",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"not_configured_description": "Configura Streamystats nelle impostazioni per utilizzare le watchlist",
|
||||
"go_to_settings": "Vai alle impostazioni",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"item": "elemento",
|
||||
"items": "elementi",
|
||||
"public": "Pubblico",
|
||||
"private": "Privato",
|
||||
"you": "Tu",
|
||||
"by_owner": "Da un altro utente",
|
||||
"not_found": "\"Da vedere\" non trovata",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"delete_confirm_message": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
"remove_item_message": "Rimuovere \"{{name}}\" da questa lista?",
|
||||
"loading": "Caricamento liste...",
|
||||
"no_compatible_watchlists": "Nessuna lista compatibile",
|
||||
"create_one_first": "Crea una lista che accetti questo tipo di contenuto"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"speed": "Velocità",
|
||||
"scope": {
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
"media": "Solo questo media",
|
||||
"show": "Questo show",
|
||||
"all": "Tutti i media (predefinito)"
|
||||
}
|
||||
},
|
||||
"companion_login": {
|
||||
"title": "Pair with TV",
|
||||
"align_qr": "Align the QR code within the frame",
|
||||
"enter_code_manually": "Enter code manually",
|
||||
"pairing_enter_credentials": "Enter credentials for TV",
|
||||
"pairing_code_label": "Pairing code",
|
||||
"title": "Associa con la TV",
|
||||
"align_qr": "Allinea il QR code all'interno del riquadro",
|
||||
"enter_code_manually": "Inserisci il codice manualmente",
|
||||
"pairing_enter_credentials": "Inserire le credenziali per la TV",
|
||||
"pairing_code_label": "Codice di associazione",
|
||||
"server": "Server",
|
||||
"authorize_button": "Authorize",
|
||||
"authorizing": "Authorizing...",
|
||||
"authorize_button": "Autorizza",
|
||||
"authorizing": "Autorizzando...",
|
||||
"scan_again": "Scan Again",
|
||||
"done": "Done",
|
||||
"done": "Fatto",
|
||||
"success_title": "Authorization Sent",
|
||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||
"pairing_tv_connecting": "La TV si sta collegando al tuo account",
|
||||
"error_title": "Authorization Failed",
|
||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||
"error_generic": "Something went wrong. Please try again.",
|
||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||
"login_as": "Log in as {{username}}?",
|
||||
"on_server": "on {{server}}",
|
||||
"use_different_user": "Use a different user",
|
||||
"open_settings": "Open Settings"
|
||||
"error_invalid_qr": "QR code non valido. Scansiona il codice di associazione della TV.",
|
||||
"error_generic": "Si è verificato un errore. Riprova.",
|
||||
"error_permission_denied": "Per scansionare i codici QR è necessaria l'autorizzazione della fotocamera.",
|
||||
"login_as": "Accedi come {{username}}?",
|
||||
"on_server": "su {{server}}",
|
||||
"use_different_user": "Usa un altro utente",
|
||||
"open_settings": "Apri le impostazioni"
|
||||
},
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
"logging_in_description": "Connecting to your server"
|
||||
"waiting_for_phone": "In attesa del telefono...",
|
||||
"scan_with_phone": "Scansiona con l'applicazione Streamyfin sul tuo telefono",
|
||||
"logging_in": "Accesso in corso...",
|
||||
"logging_in_description": "Sto connettendo al server"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "選択",
|
||||
"no_trailer_available": "トレーラーがありません",
|
||||
"video": "映像",
|
||||
"audio": "音声",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Selecteren",
|
||||
"no_trailer_available": "Geen trailer beschikbaar",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Velg",
|
||||
"no_trailer_available": "Ingen trailer tilgjengelig",
|
||||
"video": "Video",
|
||||
"audio": "Lyd",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Wybierz",
|
||||
"no_trailer_available": "Brak dostępnego zwiastunu",
|
||||
"video": "Wideo",
|
||||
"audio": "Dźwięk",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Selecionar",
|
||||
"no_trailer_available": "Nenhum trailer disponível",
|
||||
"video": "Vídeo",
|
||||
"audio": "Áudio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Selectare",
|
||||
"no_trailer_available": "Nicio remorcă disponibilă",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Выбрать",
|
||||
"no_trailer_available": "Трейлер недоступен",
|
||||
"video": "Видео",
|
||||
"audio": "Звук",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "Inga resultat",
|
||||
"select": "Välj",
|
||||
"no_trailer_available": "Ingen trailer tillgänglig",
|
||||
"video": "Video",
|
||||
"audio": "Ljud",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Använder Jellyfin-server",
|
||||
"language": "Språk",
|
||||
"results": "Resultat",
|
||||
"searching": "Söker...",
|
||||
"search_failed": "Sökningen misslyckades",
|
||||
"no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern",
|
||||
"no_subtitles_found": "Inga undertexter hittades",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "mu'tlhegh",
|
||||
"audio": "QoQ",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Seç",
|
||||
"no_trailer_available": "Fragman mevcut değil",
|
||||
"video": "Video",
|
||||
"audio": "Ses",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Âm thanh",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,7 +484,6 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -621,7 +620,6 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -171,52 +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,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether ExoPlayer's native module is available on the current platform.
|
||||
* ExoPlayer only ships for Android TV; on any other platform a persisted
|
||||
* `videoPlayer: ExoPlayer` preference (e.g. MMKV roaming) must fall back
|
||||
* to MPV rather than crash on requireNativeView().
|
||||
*/
|
||||
export const isExoPlayerSupported =
|
||||
Platform.OS === "android" && Platform.isTV === true;
|
||||
|
||||
/**
|
||||
* 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. The Android-TV capability gate is
|
||||
* folded in here so callers (VideoPlayerView, direct-player's device
|
||||
* profile, PlaySettingsProvider) can never advertise ExoPlayer on a
|
||||
* platform where MPV is actually rendering — that mismatch would let
|
||||
* Jellyfin pick a stream for the wrong renderer.
|
||||
*/
|
||||
export const getActiveVideoPlayer = (
|
||||
settings: Pick<Settings, "videoPlayer"> | null | undefined,
|
||||
): VideoPlayer => {
|
||||
if (isExoPlayerSupported && settings?.videoPlayer === VideoPlayer.ExoPlayer) {
|
||||
return VideoPlayer.ExoPlayer;
|
||||
}
|
||||
return 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",
|
||||
@@ -259,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;
|
||||
@@ -358,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,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" },
|
||||
],
|
||||
} satisfies DeviceProfile;
|
||||
}
|
||||
|
||||
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
||||
platform,
|
||||
@@ -299,3 +198,6 @@ export const generateDeviceProfile = (options: ProfileOptions = {}) => {
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
// Default export for backward compatibility
|
||||
export default generateDeviceProfile();
|
||||
|
||||
@@ -41,7 +41,7 @@ export const formatTimeString = (
|
||||
t: number | null | undefined,
|
||||
unit: "s" | "ms" | "tick" = "ms",
|
||||
): string => {
|
||||
if (t === null || t === undefined || !Number.isFinite(t)) return "0:00";
|
||||
if (t === null || t === undefined) return "0:00";
|
||||
|
||||
let seconds: number;
|
||||
switch (unit) {
|
||||
|
||||
Reference in New Issue
Block a user