mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: Sessions view (#537)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ npm-debug.*
|
|||||||
*.orig.*
|
*.orig.*
|
||||||
web-build/
|
web-build/
|
||||||
modules/vlc-player/android/build
|
modules/vlc-player/android/build
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
6
Makefile
Normal file
6
Makefile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
e2e:
|
||||||
|
maestro start-device --platform android
|
||||||
|
maestro test login.yaml
|
||||||
|
|
||||||
|
e2e-setup:
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
@@ -4,10 +4,15 @@ import { Stack, useRouter } from "expo-router";
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -27,13 +32,10 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast />
|
<Chromecast.Chromecast />
|
||||||
<TouchableOpacity
|
{user.Policy?.IsAdministrator && (
|
||||||
onPress={() => {
|
<SessionsButton />
|
||||||
router.push("/(auth)/settings");
|
)}
|
||||||
}}
|
<SettingsButton />
|
||||||
>
|
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -52,6 +54,12 @@ export default function IndexLayout() {
|
|||||||
title: t("home.downloads.tvseries"),
|
title: t("home.downloads.tvseries"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="sessions/index"
|
||||||
|
options={{
|
||||||
|
title: t("home.sessions.title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
@@ -70,6 +78,12 @@ export default function IndexLayout() {
|
|||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/dashboard/sessions"
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.dashboard.sessions_title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/jellyseerr/page"
|
name="settings/jellyseerr/page"
|
||||||
options={{
|
options={{
|
||||||
@@ -112,3 +126,38 @@ export default function IndexLayout() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SettingsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/settings");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { sessions = [], _ } = useSessions({} as useSessionsProps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/sessions");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="mr-4">
|
||||||
|
<Feather
|
||||||
|
name="play"
|
||||||
|
color={sessions.length === 0 ? "white" : "purple"}
|
||||||
|
size={22}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
360
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
360
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
import {
|
||||||
|
Ionicons,
|
||||||
|
Entypo,
|
||||||
|
AntDesign,
|
||||||
|
MaterialCommunityIcons,
|
||||||
|
} from "@expo/vector-icons";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessions || sessions.length == 0)
|
||||||
|
return (
|
||||||
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
|
<Text className="text-lg text-neutral-500">
|
||||||
|
{t("home.sessions.no_active_sessions")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 17,
|
||||||
|
paddingHorizontal: 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
}}
|
||||||
|
data={sessions}
|
||||||
|
renderItem={({ item }) => <SessionCard session={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionCardProps {
|
||||||
|
session: SessionInfoDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionCard = ({ session }: SessionCardProps) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [remainingTicks, setRemainingTicks] = useState<number>(0);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (session.PlayState?.IsPaused) return;
|
||||||
|
setRemainingTicks(remainingTicks - 10000000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressPercentage = () => {
|
||||||
|
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(
|
||||||
|
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||||
|
(session.NowPlayingItem?.RunTimeTicks - remainingTicks)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTime = session.PlayState?.PositionTicks;
|
||||||
|
const duration = session.NowPlayingItem?.RunTimeTicks;
|
||||||
|
if (
|
||||||
|
duration !== null &&
|
||||||
|
duration !== undefined &&
|
||||||
|
currentTime !== null &&
|
||||||
|
currentTime !== undefined
|
||||||
|
) {
|
||||||
|
const remainingTimeTicks = duration - currentTime;
|
||||||
|
setRemainingTicks(remainingTimeTicks);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
useInterval(tick, 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4">
|
||||||
|
<View className="flex flex-row p-4">
|
||||||
|
<View className="w-20 pr-4">
|
||||||
|
<Poster
|
||||||
|
id={session.NowPlayingItem?.Id}
|
||||||
|
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="w-full flex-1">
|
||||||
|
<View className="flex flex-row justify-between">
|
||||||
|
<View className="flex-1 pr-4">
|
||||||
|
<Text className="font-bold">{session.NowPlayingItem?.Name}</Text>
|
||||||
|
{!session.NowPlayingItem?.SeriesName && (
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{session.NowPlayingItem?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{session.NowPlayingItem?.SeriesName && (
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{session.NowPlayingItem?.SeriesName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className="text-xs opacity-50 align-right text-right">
|
||||||
|
{session.UserName}
|
||||||
|
{"\n"}
|
||||||
|
{session.Client}
|
||||||
|
{"\n"}
|
||||||
|
{session.DeviceName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1" />
|
||||||
|
<View className="flex flex-col align-bottom">
|
||||||
|
<View className="flex flex-row justify-between align-bottom">
|
||||||
|
<Text className="-ml-1 text-xs opacity-50 align-left text-left">
|
||||||
|
{!session.PlayState?.IsPaused ? (
|
||||||
|
<Entypo name="controller-play" size={14} color="white" />
|
||||||
|
) : (
|
||||||
|
<AntDesign name="pause" size={14} color="white" />
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50 align-right text-right">
|
||||||
|
{formatTimeString(remainingTicks, "tick")} left
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="align-bottom bg-gray-800 h-1">
|
||||||
|
<View
|
||||||
|
className={`bg-purple-600 h-full`}
|
||||||
|
style={{
|
||||||
|
width: getProgressPercentage() + "%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TranscodingView session={session} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TranscodingBadgesProps {
|
||||||
|
properties: Array<StreamProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingBadges = ({ properties = [] }: TranscodingBadgesProps) => {
|
||||||
|
const icon = (val: string) => {
|
||||||
|
switch (val) {
|
||||||
|
case "bitrate":
|
||||||
|
return <Ionicons name="speedometer-outline" size={12} color="white" />;
|
||||||
|
break;
|
||||||
|
case "codec":
|
||||||
|
return <Ionicons name="layers-outline" size={12} color="white" />;
|
||||||
|
break;
|
||||||
|
case "videoRange":
|
||||||
|
return (
|
||||||
|
<Ionicons name="color-palette-outline" size={12} color="white" />
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "resolution":
|
||||||
|
return <Ionicons name="film-outline" size={12} color="white" />;
|
||||||
|
break;
|
||||||
|
case "language":
|
||||||
|
return <Ionicons name="language-outline" size={12} color="white" />;
|
||||||
|
break;
|
||||||
|
case "audioChannels":
|
||||||
|
return <Ionicons name="mic-outline" size={12} color="white" />;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return <Ionicons name="layers-outline" size={12} color="white" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatVal = (key: String, val: any) => {
|
||||||
|
switch (key) {
|
||||||
|
case "bitrate":
|
||||||
|
return formatBitrate(val);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.keys(properties)
|
||||||
|
.filter(
|
||||||
|
(key) => !(properties[key] === undefined || properties[key] === null)
|
||||||
|
)
|
||||||
|
.map((key) => (
|
||||||
|
<Badge
|
||||||
|
key={key}
|
||||||
|
variant="gray"
|
||||||
|
className="m-0 p-0 pt-0.5 mr-1"
|
||||||
|
text={formatVal(key, properties[key])}
|
||||||
|
iconLeft={icon(key)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StreamProps {
|
||||||
|
resolution: String | null | undefined;
|
||||||
|
language: String | null | undefined;
|
||||||
|
codec: String | null | undefined;
|
||||||
|
bitrate: number | null | undefined;
|
||||||
|
videoRange: String | null | undefined;
|
||||||
|
audioChannels: String | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscodingStreamViewProps {
|
||||||
|
title: String | undefined;
|
||||||
|
value: String;
|
||||||
|
isTranscoding: Boolean;
|
||||||
|
transcodeValue: String | undefined | null;
|
||||||
|
properties: Array<StreamProps>;
|
||||||
|
transcodeProperties: Array<StreamProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingStreamView = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
isTranscoding,
|
||||||
|
transcodeValue,
|
||||||
|
properties = [],
|
||||||
|
transcodeProperties = [],
|
||||||
|
}: TranscodingStreamViewProps) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col pt-2 first:pt-0">
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<Text className="text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className="flex-1">
|
||||||
|
<TranscodingBadges properties={properties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isTranscoding && (
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<Text className="-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="arrow-right-bottom"
|
||||||
|
size={14}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<Text className="flex-1 text-sm mt-1">
|
||||||
|
<TranscodingBadges properties={transcodeProperties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||||
|
const videoStream = useMemo(() => {
|
||||||
|
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type == "Video"
|
||||||
|
)[0];
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const audioStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.AudioStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.AudioStreamIndex]);
|
||||||
|
|
||||||
|
const subtitleStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.SubtitleStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||||
|
|
||||||
|
const isTranscoding = useMemo(() => {
|
||||||
|
return session.PlayState?.PlayMethod == "Transcode";
|
||||||
|
}, [session.PlayState?.PlayMethod]);
|
||||||
|
|
||||||
|
const videoStreamTitle = () => {
|
||||||
|
return videoStream?.DisplayTitle?.split(" ")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2">
|
||||||
|
<TranscodingStreamView
|
||||||
|
title="Video"
|
||||||
|
properties={{
|
||||||
|
resolution: videoStreamTitle(),
|
||||||
|
bitrate: videoStream?.BitRate,
|
||||||
|
codec: videoStream?.Codec,
|
||||||
|
//videoRange: videoStream?.VideoRange
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
|
codec: session.TranscodingInfo?.VideoCodec,
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TranscodingStreamView
|
||||||
|
title="Audio"
|
||||||
|
properties={{
|
||||||
|
language: audioStream?.Language,
|
||||||
|
bitrate: audioStream?.BitRate,
|
||||||
|
codec: audioStream?.Codec,
|
||||||
|
audioChannels: audioStream?.ChannelLayout,
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
|
codec: session.TranscodingInfo?.AudioCodec,
|
||||||
|
audioChannels: session.TranscodingInfo?.AudioChannels,
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{subtitleStream && (
|
||||||
|
<>
|
||||||
|
<TranscodingStreamView
|
||||||
|
title="Subtitle"
|
||||||
|
isTranscoding={false}
|
||||||
|
properties={{
|
||||||
|
language: subtitleStream?.Language,
|
||||||
|
codec: subtitleStream?.Codec,
|
||||||
|
}}
|
||||||
|
transcodeValue={null}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import { QuickConnect } from "@/components/settings/QuickConnect";
|
|||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import { Dashboard } from "@/components/settings/Dashboard";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
@@ -21,10 +22,13 @@ import { t } from "i18next";
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
@@ -59,6 +63,9 @@ export default function settings() {
|
|||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
|
||||||
|
{user && user.Policy?.IsAdministrator && <Dashboard className="mb-4" />}
|
||||||
|
|
||||||
<QuickConnect className="mb-4" />
|
<QuickConnect className="mb-4" />
|
||||||
|
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -54,14 +55,18 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
<BottomSheetScrollView>
|
<BottomSheetScrollView>
|
||||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||||
<View className="">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
|
<Text className="text-lg font-bold mb-4">
|
||||||
|
{t("item_card.video")}
|
||||||
|
</Text>
|
||||||
<View className="flex flex-row space-x-2">
|
<View className="flex flex-row space-x-2">
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
|
<Text className="text-lg font-bold mb-2">
|
||||||
|
{t("item_card.audio")}
|
||||||
|
</Text>
|
||||||
<AudioStreamInfo
|
<AudioStreamInfo
|
||||||
audioStreams={
|
audioStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
@@ -72,7 +77,9 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
|
<Text className="text-lg font-bold mb-2">
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</Text>
|
||||||
<SubtitleStreamInfo
|
<SubtitleStreamInfo
|
||||||
subtitleStreams={
|
subtitleStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
@@ -229,12 +236,3 @@ const formatFileSize = (bytes?: number | null) => {
|
|||||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatBitrate = (bitrate?: number | null) => {
|
|
||||||
if (!bitrate) return "N/A";
|
|
||||||
|
|
||||||
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
|
||||||
if (bitrate === 0) return "0 bps";
|
|
||||||
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
|
||||||
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<View
|
<View
|
||||||
style={[]}
|
style={[]}
|
||||||
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900"
|
className="flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900"
|
||||||
>
|
>
|
||||||
{Children.map(childrenArray, (child, index) => {
|
{Children.map(childrenArray, (child, index) => {
|
||||||
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||||
disabled ? "opacity-50" : ""
|
disabled ? "opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -55,7 +55,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||||
disabled ? "opacity-50" : ""
|
disabled ? "opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
30
components/settings/Dashboard.tsx
Normal file
30
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const { sessions = [], isLoading } = useSessions({} as useSessionsProps);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.dashboard.title")} className="mt-4">
|
||||||
|
<ListItem
|
||||||
|
className={sessions.length != 0 ? "bg-purple-900" : ""}
|
||||||
|
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||||
|
title={t("home.settings.dashboard.sessions_title")}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
hooks/useSessions.ts
Normal file
36
hooks/useSessions.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
export interface useSessionsProps {
|
||||||
|
refetchInterval: number;
|
||||||
|
activeWithinSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessions = ({
|
||||||
|
refetchInterval = 5 * 1000,
|
||||||
|
activeWithinSeconds = 360,
|
||||||
|
}: useSessionsProps) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["sessions"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const response = await getSessionApi(api).getSessions({
|
||||||
|
activeWithinSeconds: activeWithinSeconds,
|
||||||
|
});
|
||||||
|
return response.data.filter((s) => s.NowPlayingItem);
|
||||||
|
},
|
||||||
|
refetchInterval: refetchInterval,
|
||||||
|
//enabled: !!user || !!user.Policy?.IsAdministrator,
|
||||||
|
//cacheTime: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sessions: data, isLoading };
|
||||||
|
};
|
||||||
6
login.yaml
Normal file
6
login.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# login.yaml
|
||||||
|
|
||||||
|
appId: your.app.id
|
||||||
|
---
|
||||||
|
- launchApp
|
||||||
|
- tapOn: "Text on the screen"
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"default": "Default",
|
"default": "Default",
|
||||||
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
|
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_optimized_server": "Read more about the optimize server.",
|
"read_more_about_optimized_server": "Read more about the optimize server.",
|
||||||
"url":"URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://domain.org:port"
|
"server_url_placeholder": "http(s)://domain.org:port"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
@@ -204,14 +204,18 @@
|
|||||||
"app_language_description": "Select the language for the app.",
|
"app_language_description": "Select the language for the app.",
|
||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts":{
|
"toasts": {
|
||||||
"error_deleting_files": "Error deleting files",
|
"error_deleting_files": "Error deleting files",
|
||||||
"background_downloads_enabled": "Background downloads enabled",
|
"background_downloads_enabled": "Background downloads enabled",
|
||||||
"background_downloads_disabled": "Background downloads disabled",
|
"background_downloads_disabled": "Background downloads disabled",
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"could_not_connect": "Could not connect",
|
"could_not_connect": "Could not connect",
|
||||||
"invalid_url": "Invalid URL"
|
"invalid_url": "Invalid URL"
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Sessions",
|
||||||
|
"no_active_sessions": "No active sessions"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
@@ -399,7 +403,7 @@
|
|||||||
"for_kids": "For Kids",
|
"for_kids": "For Kids",
|
||||||
"news": "News"
|
"news": "News"
|
||||||
},
|
},
|
||||||
"jellyseerr":{
|
"jellyseerr": {
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
|
|||||||
8
utils/bitrate.ts
Normal file
8
utils/bitrate.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const formatBitrate = (bitrate?: number | null) => {
|
||||||
|
if (!bitrate) return "N/A";
|
||||||
|
|
||||||
|
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
||||||
|
if (bitrate === 0) return "0 bps";
|
||||||
|
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
||||||
|
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user