From 5b8418cd82ff0a1d270985b8661ae90205095e97 Mon Sep 17 00:00:00 2001 From: lostb1t Date: Fri, 21 Feb 2025 13:14:57 +0100 Subject: [PATCH] feat: Sessions view (#537) --- .gitignore | 3 +- Makefile | 6 + app/(auth)/(tabs)/(home)/_layout.tsx | 63 +++- app/(auth)/(tabs)/(home)/sessions/index.tsx | 360 ++++++++++++++++++++ app/(auth)/(tabs)/(home)/settings.tsx | 7 + app/(auth)/(tabs)/_layout.tsx | 3 +- components/ItemTechnicalDetails.tsx | 22 +- components/list/ListGroup.tsx | 2 +- components/list/ListItem.tsx | 4 +- components/settings/Dashboard.tsx | 30 ++ hooks/useSessions.ts | 36 ++ login.yaml | 6 + translations/en.json | 14 +- utils/bitrate.ts | 8 + 14 files changed, 534 insertions(+), 30 deletions(-) create mode 100644 Makefile create mode 100644 app/(auth)/(tabs)/(home)/sessions/index.tsx create mode 100644 components/settings/Dashboard.tsx create mode 100644 hooks/useSessions.ts create mode 100644 login.yaml create mode 100644 utils/bitrate.ts diff --git a/.gitignore b/.gitignore index d7428e98..2a0ce8db 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +modules/hls-downloader/android/build diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..c2f6701a --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +e2e: + maestro start-device --platform android + maestro test login.yaml + +e2e-setup: +curl -fsSL "https://get.maestro.mobile.dev" | bash diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 0b9b9c11..7589d247 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -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 ( - { - router.push("/(auth)/settings"); - }} - > - - + {user.Policy?.IsAdministrator && ( + + )} + )} @@ -52,6 +54,12 @@ export default function IndexLayout() { title: t("home.downloads.tvseries"), }} /> + + ); } + +const SettingsButton = () => { + const router = useRouter(); + + return ( + { + router.push("/(auth)/settings"); + }} + > + + + ); +}; + +const SessionsButton = () => { + const router = useRouter(); + const { sessions = [], _ } = useSessions({} as useSessionsProps); + + return ( + { + router.push("/(auth)/sessions"); + }} + > + + + + + ); +}; diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx new file mode 100644 index 00000000..097205da --- /dev/null +++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx @@ -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 ( + + + + ); + + if (!sessions || sessions.length == 0) + return ( + + + {t("home.sessions.no_active_sessions")} + + + ); + + return ( + } + keyExtractor={(item) => item.Id || ""} + estimatedItemSize={200} + /> + ); +} + +interface SessionCardProps { + session: SessionInfoDto; +} + +const SessionCard = ({ session }: SessionCardProps) => { + const api = useAtomValue(apiAtom); + const [remainingTicks, setRemainingTicks] = useState(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 ( + + + + + + + + + {session.NowPlayingItem?.Name} + {!session.NowPlayingItem?.SeriesName && ( + + {session.NowPlayingItem?.ProductionYear} + + )} + {session.NowPlayingItem?.SeriesName && ( + + {session.NowPlayingItem?.SeriesName} + + )} + + + {session.UserName} + {"\n"} + {session.Client} + {"\n"} + {session.DeviceName} + + + + + + + {!session.PlayState?.IsPaused ? ( + + ) : ( + + )} + + + {formatTimeString(remainingTicks, "tick")} left + + + + + + + + + + + ); +}; + +interface TranscodingBadgesProps { + properties: Array; +} + +const TranscodingBadges = ({ properties = [] }: TranscodingBadgesProps) => { + const icon = (val: string) => { + switch (val) { + case "bitrate": + return ; + break; + case "codec": + return ; + break; + case "videoRange": + return ( + + ); + break; + case "resolution": + return ; + break; + case "language": + return ; + break; + case "audioChannels": + return ; + break; + default: + return ; + } + }; + + 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) => ( + + )); +}; + +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; + transcodeProperties: Array; +} + +const TranscodingStreamView = ({ + title, + value, + isTranscoding, + transcodeValue, + properties = [], + transcodeProperties = [], +}: TranscodingStreamViewProps) => { + return ( + + + + {title} + + + + + + {isTranscoding && ( + <> + + + + + + + + + + )} + + ); +}; + +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 ( + + + + + + {subtitleStream && ( + <> + + + )} + + ); +}; diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index aba54ae1..49346baa 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -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() { > + + {user && user.Policy?.IsAdministrator && } + diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 011ae3fa..1b789c62 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -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() { ); -} +} \ No newline at end of file diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx index 6b5852a4..e2570dc8 100644 --- a/components/ItemTechnicalDetails.tsx +++ b/components/ItemTechnicalDetails.tsx @@ -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 = ({ source, ...props }) => { - {t("item_card.video")} + + {t("item_card.video")} + - {t("item_card.audio")} + + {t("item_card.audio")} + = ({ source, ...props }) => { - {t("item_card.subtitles")} + + {t("item_card.subtitles")} + { 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]; -}; diff --git a/components/list/ListGroup.tsx b/components/list/ListGroup.tsx index c1d706a4..03f218d1 100644 --- a/components/list/ListGroup.tsx +++ b/components/list/ListGroup.tsx @@ -29,7 +29,7 @@ export const ListGroup: React.FC> = ({ {Children.map(childrenArray, (child, index) => { if (isValidElement<{ style?: ViewStyle }>(child)) { diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx index 403b33dc..ea7774a4 100644 --- a/components/list/ListItem.tsx +++ b/components/list/ListItem.tsx @@ -36,7 +36,7 @@ export const ListItem: React.FC> = ({ > = ({ ); return ( { + const [settings, updateSettings] = useSettings(); + const { sessions = [], isLoading } = useSessions({} as useSessionsProps); + const router = useRouter(); + + const { t } = useTranslation(); + + if (!settings) return null; + return ( + + + router.push("/settings/dashboard/sessions")} + title={t("home.settings.dashboard.sessions_title")} + showArrow + /> + + + ); +}; diff --git a/hooks/useSessions.ts b/hooks/useSessions.ts new file mode 100644 index 00000000..ec8d3189 --- /dev/null +++ b/hooks/useSessions.ts @@ -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 }; +}; diff --git a/login.yaml b/login.yaml new file mode 100644 index 00000000..54418a9b --- /dev/null +++ b/login.yaml @@ -0,0 +1,6 @@ +# login.yaml + +appId: your.app.id +--- +- launchApp +- tapOn: "Text on the screen" diff --git a/translations/en.json b/translations/en.json index d2f54e99..615c3e40 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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" } -} +} \ No newline at end of file diff --git a/utils/bitrate.ts b/utils/bitrate.ts new file mode 100644 index 00000000..7f1d0f47 --- /dev/null +++ b/utils/bitrate.ts @@ -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]; +};