feat: Sessions view (#537)

This commit is contained in:
lostb1t
2025-02-21 13:14:57 +01:00
committed by GitHub
parent b0c5255bd7
commit 5b8418cd82
14 changed files with 534 additions and 30 deletions

3
.gitignore vendored
View File

@@ -10,6 +10,7 @@ npm-debug.*
*.orig.*
web-build/
modules/vlc-player/android/build
bun.lockb
# macOS
.DS_Store
@@ -42,4 +43,4 @@ credentials.json
.vscode/
.idea/
.ruby-lsp
modules/hls-downloader/android/build
modules/hls-downloader/android/build

6
Makefile Normal file
View 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

View File

@@ -4,10 +4,15 @@ import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
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() {
const router = useRouter();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
@@ -27,13 +32,10 @@ export default function IndexLayout() {
{!Platform.isTV && (
<>
<Chromecast.Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
{user.Policy?.IsAdministrator && (
<SessionsButton />
)}
<SettingsButton />
</>
)}
</View>
@@ -52,6 +54,12 @@ export default function IndexLayout() {
title: t("home.downloads.tvseries"),
}}
/>
<Stack.Screen
name="sessions/index"
options={{
title: t("home.sessions.title"),
}}
/>
<Stack.Screen
name="settings"
options={{
@@ -70,6 +78,12 @@ export default function IndexLayout() {
title: "",
}}
/>
<Stack.Screen
name="settings/dashboard/sessions"
options={{
title: t("home.settings.dashboard.sessions_title"),
}}
/>
<Stack.Screen
name="settings/jellyseerr/page"
options={{
@@ -112,3 +126,38 @@ export default function IndexLayout() {
</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>
);
};

View 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>
);
};

View File

@@ -12,6 +12,7 @@ import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
import { Dashboard } from "@/components/settings/Dashboard";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
@@ -21,10 +22,13 @@ import { t } from "i18next";
import React, { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
@@ -59,6 +63,9 @@ export default function settings() {
>
<View className="p-4 flex flex-col gap-y-4">
<UserInfo />
{user && user.Policy?.IsAdministrator && <Dashboard className="mb-4" />}
<QuickConnect className="mb-4" />
<MediaProvider>

View File

@@ -10,7 +10,6 @@ import {
} from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
@@ -138,4 +137,4 @@ export default function TabLayout() {
</NativeTabs>
</>
);
}
}

View File

@@ -16,6 +16,7 @@ import {
} from "@gorhom/bottom-sheet";
import { Button } from "./Button";
import { useTranslation } from "react-i18next";
import { formatBitrate } from "@/utils/bitrate";
interface Props {
source?: MediaSourceInfo;
@@ -54,14 +55,18 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
<BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4">
<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">
<VideoStreamInfo source={source} />
</View>
</View>
<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
audioStreams={
source?.MediaStreams?.filter(
@@ -72,7 +77,9 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
</View>
<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
subtitleStreams={
source?.MediaStreams?.filter(
@@ -229,12 +236,3 @@ const formatFileSize = (bytes?: number | null) => {
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
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];
};

View File

@@ -29,7 +29,7 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
</Text>
<View
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) => {
if (isValidElement<{ style?: ViewStyle }>(child)) {

View File

@@ -36,7 +36,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
disabled={disabled}
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" : ""
}`}
{...props}
@@ -55,7 +55,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
);
return (
<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" : ""
}`}
{...props}

View 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
View 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
View File

@@ -0,0 +1,6 @@
# login.yaml
appId: your.app.id
---
- launchApp
- tapOn: "Text on the screen"

View File

@@ -147,7 +147,7 @@
"default": "Default",
"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.",
"url":"URL",
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
@@ -204,14 +204,18 @@
"app_language_description": "Select the language for the app.",
"system": "System"
},
"toasts":{
"toasts": {
"error_deleting_files": "Error deleting files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled",
"connected": "Connected",
"could_not_connect": "Could not connect",
"invalid_url": "Invalid URL"
}
},
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "No active sessions"
},
"downloads": {
"downloads_title": "Downloads",
@@ -399,7 +403,7 @@
"for_kids": "For Kids",
"news": "News"
},
"jellyseerr":{
"jellyseerr": {
"confirm": "Confirm",
"cancel": "Cancel",
"yes": "Yes",
@@ -455,4 +459,4 @@
"custom_links": "Custom Links",
"favorites": "Favorites"
}
}
}

8
utils/bitrate.ts Normal file
View 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];
};