mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
194 lines
5.5 KiB
TypeScript
194 lines
5.5 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import type {
|
|
BaseItemDto,
|
|
MediaSourceInfo,
|
|
} from "@jellyfin/sdk/lib/generated-client";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
|
import { BITRATES } from "./BitRateSheet";
|
|
import type { SelectedOptions } from "./ItemContent";
|
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
|
|
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
|
item?: BaseItemDto | null;
|
|
selectedOptions: SelectedOptions;
|
|
setSelectedOptions: React.Dispatch<
|
|
React.SetStateAction<SelectedOptions | undefined>
|
|
>;
|
|
colors?: ThemeColors;
|
|
}
|
|
|
|
export const MediaSourceButton: React.FC<Props> = ({
|
|
item,
|
|
selectedOptions,
|
|
setSelectedOptions,
|
|
colors,
|
|
}: Props) => {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const effectiveColors = colors || {
|
|
primary: "#7c3aed",
|
|
text: "#000000",
|
|
};
|
|
|
|
useEffect(() => {
|
|
const firstMediaSource = item?.MediaSources?.[0];
|
|
if (!firstMediaSource) return;
|
|
setSelectedOptions((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
mediaSource: firstMediaSource,
|
|
};
|
|
});
|
|
}, [item, setSelectedOptions]);
|
|
|
|
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
|
if (source.Name) return source.Name;
|
|
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
|
return `Source ${source.Id}`;
|
|
}, []);
|
|
|
|
const audioStreams = useMemo(
|
|
() =>
|
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
|
(x) => x.Type === "Audio",
|
|
) || [],
|
|
[selectedOptions.mediaSource],
|
|
);
|
|
|
|
const subtitleStreams = useMemo(
|
|
() =>
|
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
|
(x) => x.Type === "Subtitle",
|
|
) || [],
|
|
[selectedOptions.mediaSource],
|
|
);
|
|
|
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
|
const groups: OptionGroup[] = [];
|
|
|
|
// Bitrate group
|
|
groups.push({
|
|
title: t("item_card.quality"),
|
|
options: BITRATES.map((bitrate) => ({
|
|
type: "radio" as const,
|
|
label: bitrate.key,
|
|
value: bitrate,
|
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
|
onPress: () =>
|
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
|
})),
|
|
});
|
|
|
|
// Media Source group (only if multiple sources)
|
|
if (item?.MediaSources && item.MediaSources.length > 1) {
|
|
groups.push({
|
|
title: t("item_card.video"),
|
|
options: item.MediaSources.map((source) => ({
|
|
type: "radio" as const,
|
|
label: getMediaSourceDisplayName(source),
|
|
value: source,
|
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
|
onPress: () =>
|
|
setSelectedOptions(
|
|
(prev) => prev && { ...prev, mediaSource: source },
|
|
),
|
|
})),
|
|
});
|
|
}
|
|
|
|
// Audio track group
|
|
if (audioStreams.length > 0) {
|
|
groups.push({
|
|
title: t("item_card.audio"),
|
|
options: audioStreams.map((stream) => ({
|
|
type: "radio" as const,
|
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
|
value: stream.Index,
|
|
selected: stream.Index === selectedOptions.audioIndex,
|
|
onPress: () =>
|
|
setSelectedOptions(
|
|
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
|
|
),
|
|
})),
|
|
});
|
|
}
|
|
|
|
// Subtitle track group (with None option)
|
|
if (subtitleStreams.length > 0) {
|
|
const noneOption = {
|
|
type: "radio" as const,
|
|
label: t("common.none"),
|
|
value: -1,
|
|
selected: selectedOptions.subtitleIndex === -1,
|
|
onPress: () =>
|
|
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
|
|
};
|
|
|
|
const subtitleOptions = subtitleStreams.map((stream) => ({
|
|
type: "radio" as const,
|
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
|
value: stream.Index,
|
|
selected: stream.Index === selectedOptions.subtitleIndex,
|
|
onPress: () =>
|
|
setSelectedOptions(
|
|
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
|
|
),
|
|
}));
|
|
|
|
groups.push({
|
|
title: t("item_card.subtitles"),
|
|
options: [noneOption, ...subtitleOptions],
|
|
});
|
|
}
|
|
|
|
return groups;
|
|
}, [
|
|
item,
|
|
selectedOptions,
|
|
audioStreams,
|
|
subtitleStreams,
|
|
getMediaSourceDisplayName,
|
|
t,
|
|
setSelectedOptions,
|
|
]);
|
|
|
|
const trigger = (
|
|
<TouchableOpacity
|
|
disabled={!item}
|
|
onPress={() => setOpen(true)}
|
|
className='relative'
|
|
>
|
|
<View
|
|
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
|
|
className='absolute w-12 h-12 rounded-full'
|
|
/>
|
|
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
|
{!item ? (
|
|
<ActivityIndicator size='small' color={effectiveColors.text} />
|
|
) : (
|
|
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
|
|
return (
|
|
<PlatformDropdown
|
|
groups={optionGroups}
|
|
trigger={trigger}
|
|
title={t("item_card.media_options")}
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
bottomSheetConfig={{
|
|
enablePanDownToClose: true,
|
|
}}
|
|
/>
|
|
);
|
|
};
|