mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-15 17:56:21 +01:00
✨ Plex and Jellyfin widget (#713)
This commit is contained in:
@@ -15,6 +15,7 @@ import { Serie, Datum, ResponsiveLine } from '@nivo/line';
|
||||
import { IconDownload, IconUpload } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { AppAvatar } from '../../components/AppAvatar';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { useGetDownloadClientsQueue } from '../../hooks/widgets/download-speed/useGetNetworkSpeed';
|
||||
import { useColorTheme } from '../../tools/color';
|
||||
@@ -258,17 +259,3 @@ export default function TorrentNetworkTrafficTile({ widget }: TorrentNetworkTraf
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const AppAvatar = ({ iconUrl }: { iconUrl: string }) => {
|
||||
const { colors, colorScheme } = useMantineTheme();
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
src={iconUrl}
|
||||
bg={colorScheme === 'dark' ? colors.gray[8] : colors.gray[2]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
p={4}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import calendar from './calendar/CalendarTile';
|
||||
import dashdot from './dashDot/DashDotTile';
|
||||
import date from './date/DateTile';
|
||||
import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile';
|
||||
import mediaServer from './media-server/MediaServerTile';
|
||||
import rss from './rss/RssWidgetTile';
|
||||
import torrent from './torrent/TorrentTile';
|
||||
import usenet from './useNet/UseNetTile';
|
||||
@@ -18,4 +19,5 @@ export default {
|
||||
date,
|
||||
rss,
|
||||
'video-stream': videoStream,
|
||||
'media-server': mediaServer,
|
||||
};
|
||||
|
||||
128
src/widgets/media-server/DetailCollapseable.tsx
Normal file
128
src/widgets/media-server/DetailCollapseable.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Card, Divider, Flex, Grid, Group, Text } from '@mantine/core';
|
||||
import { IconDeviceMobile, IconId } from '@tabler/icons';
|
||||
import { GenericSessionInfo } from '../../types/api/media-server/session-info';
|
||||
|
||||
export const DetailCollapseable = ({ session }: { session: GenericSessionInfo }) => {
|
||||
let details: { title: string; metrics: { name: string; value: string | undefined }[] }[] = [];
|
||||
|
||||
if (session.currentlyPlaying) {
|
||||
if (session.currentlyPlaying.metadata.video) {
|
||||
details = [
|
||||
...details,
|
||||
{
|
||||
title: 'Video',
|
||||
metrics: [
|
||||
{
|
||||
name: 'Resolution',
|
||||
value: `${session.currentlyPlaying.metadata.video.width}x${session.currentlyPlaying.metadata.video.height}`,
|
||||
},
|
||||
{
|
||||
name: 'Framerate',
|
||||
value: session.currentlyPlaying.metadata.video.videoFrameRate,
|
||||
},
|
||||
{
|
||||
name: 'Codec',
|
||||
value: session.currentlyPlaying.metadata.video.videoCodec,
|
||||
},
|
||||
{
|
||||
name: 'Bitrate',
|
||||
value: session.currentlyPlaying.metadata.video.bitrate
|
||||
? String(session.currentlyPlaying.metadata.video.bitrate)
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
if (session.currentlyPlaying.metadata.audio) {
|
||||
details = [
|
||||
...details,
|
||||
{
|
||||
title: 'Audio',
|
||||
metrics: [
|
||||
{
|
||||
name: 'Audio channels',
|
||||
value: `${session.currentlyPlaying.metadata.audio.audioChannels}`,
|
||||
},
|
||||
{
|
||||
name: 'Audio codec',
|
||||
value: session.currentlyPlaying.metadata.audio.audioCodec,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (session.currentlyPlaying.metadata.transcoding) {
|
||||
details = [
|
||||
...details,
|
||||
{
|
||||
title: 'Transcoding',
|
||||
metrics: [
|
||||
{
|
||||
name: 'Resolution',
|
||||
value: `${session.currentlyPlaying.metadata.transcoding.width}x${session.currentlyPlaying.metadata.transcoding.height}`,
|
||||
},
|
||||
{
|
||||
name: 'Context',
|
||||
value: session.currentlyPlaying.metadata.transcoding.context,
|
||||
},
|
||||
{
|
||||
name: 'Hardware encoding requested',
|
||||
value: session.currentlyPlaying.metadata.transcoding.transcodeHwRequested
|
||||
? 'yes'
|
||||
: 'no',
|
||||
},
|
||||
{
|
||||
name: 'Source codec',
|
||||
value:
|
||||
session.currentlyPlaying.metadata.transcoding.sourceAudioCodec ||
|
||||
session.currentlyPlaying.metadata.transcoding.sourceVideoCodec
|
||||
? `${session.currentlyPlaying.metadata.transcoding.sourceVideoCodec} ${session.currentlyPlaying.metadata.transcoding.sourceAudioCodec}`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
name: 'Target codec',
|
||||
value: `${session.currentlyPlaying.metadata.transcoding.videoCodec} ${session.currentlyPlaying.metadata.transcoding.audioCodec}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Flex justify="space-between" mb="xs">
|
||||
<Group>
|
||||
<IconId size={16} />
|
||||
<Text>ID</Text>
|
||||
</Group>
|
||||
<Text>{session.id}</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between" mb="md">
|
||||
<Group>
|
||||
<IconDeviceMobile size={16} />
|
||||
<Text>Device</Text>
|
||||
</Group>
|
||||
<Text>{session.sessionName}</Text>
|
||||
</Flex>
|
||||
{details.length > 0 && <Divider label="Stats for nerds" labelPosition="center" mt="lg" mb="sm" />}
|
||||
<Grid>
|
||||
{details.map((detail, index) => (
|
||||
<Grid.Col xs={12} sm={6} key={index}>
|
||||
<Text weight="bold">{detail.title}</Text>
|
||||
{detail.metrics
|
||||
.filter((x) => x.value !== undefined)
|
||||
.map((metric, index2) => (
|
||||
<Group position="apart" key={index2}>
|
||||
<Text>{metric.name}</Text>
|
||||
<Text>{metric.value}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
110
src/widgets/media-server/MediaServerTile.tsx
Normal file
110
src/widgets/media-server/MediaServerTile.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Avatar,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconMovie } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { AppAvatar } from '../../components/AppAvatar';
|
||||
import { useConfigContext } from '../../config/provider';
|
||||
import { useGetMediaServers } from '../../hooks/widgets/media-servers/useGetMediaServers';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { TableRow } from './TableRow';
|
||||
|
||||
const definition = defineWidget({
|
||||
id: 'media-server',
|
||||
icon: IconMovie,
|
||||
options: {},
|
||||
component: MediaServerTile,
|
||||
gridstack: {
|
||||
minWidth: 3,
|
||||
minHeight: 2,
|
||||
maxWidth: 12,
|
||||
maxHeight: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export type MediaServerWidget = IWidget<(typeof definition)['id'], typeof definition>;
|
||||
|
||||
interface MediaServerWidgetProps {
|
||||
widget: MediaServerWidget;
|
||||
}
|
||||
|
||||
function MediaServerTile({ widget }: MediaServerWidgetProps) {
|
||||
const { t } = useTranslation('modules/media-server');
|
||||
const { config } = useConfigContext();
|
||||
|
||||
const { data, isError } = useGetMediaServers({
|
||||
enabled: config !== undefined,
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center">
|
||||
<IconAlertTriangle />
|
||||
<Title order={6}>{t('card.errors.general.title')}</Title>
|
||||
<Text>{t('card.errors.general.text')}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
<Center h="100%">
|
||||
<Loader />
|
||||
</Center>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%">
|
||||
<ScrollArea offsetScrollbars>
|
||||
<Table highlightOnHover striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('card.table.header.session')}</th>
|
||||
<th>{t('card.table.header.user')}</th>
|
||||
<th>{t('card.table.header.currentlyPlaying')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.servers.map((server) => {
|
||||
const app = config?.apps.find((x) => x.id === server.appId);
|
||||
return server.sessions.map((session, index) => (
|
||||
<TableRow session={session} app={app} key={index} />
|
||||
));
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
<Group position="right" mt="auto">
|
||||
<Avatar.Group>
|
||||
{data?.servers.map((server) => {
|
||||
const app = config?.apps.find((x) => x.id === server.appId);
|
||||
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppAvatar
|
||||
iconUrl={app.appearance.iconUrl}
|
||||
color={server.success === true ? undefined : 'red'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Avatar.Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default definition;
|
||||
50
src/widgets/media-server/NowPlayingDisplay.tsx
Normal file
50
src/widgets/media-server/NowPlayingDisplay.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Flex, Group, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconDeviceTv,
|
||||
IconHeadphones,
|
||||
IconQuestionMark,
|
||||
IconVideo,
|
||||
TablerIcon,
|
||||
} from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { GenericSessionInfo } from '../../types/api/media-server/session-info';
|
||||
|
||||
export const NowPlayingDisplay = ({ session }: { session: GenericSessionInfo }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!session.currentlyPlaying) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Icon = (): TablerIcon => {
|
||||
switch (session.currentlyPlaying?.type) {
|
||||
case 'audio':
|
||||
return IconHeadphones;
|
||||
case 'tv':
|
||||
return IconDeviceTv;
|
||||
case 'video':
|
||||
return IconVideo;
|
||||
default:
|
||||
return IconQuestionMark;
|
||||
}
|
||||
};
|
||||
|
||||
const Test = Icon();
|
||||
|
||||
return (
|
||||
<Flex wrap="nowrap" gap="sm" align="center">
|
||||
<Test size={16} />
|
||||
<Stack spacing={0}>
|
||||
<Text lineClamp={1}>{session.currentlyPlaying.name}</Text>
|
||||
|
||||
{session.currentlyPlaying.albumName ? (
|
||||
<Text lineClamp={1} color="dimmed" size="xs">{session.currentlyPlaying.albumName}</Text>
|
||||
) : (
|
||||
session.currentlyPlaying.seasonName && (
|
||||
<Text lineClamp={1} color="dimmed" size="xs">{session.currentlyPlaying.seasonName}</Text>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
73
src/widgets/media-server/TableRow.tsx
Normal file
73
src/widgets/media-server/TableRow.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Avatar,
|
||||
Card,
|
||||
Collapse,
|
||||
createStyles,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import { AppAvatar } from '../../components/AppAvatar';
|
||||
import { GenericSessionInfo } from '../../types/api/media-server/session-info';
|
||||
import { AppType } from '../../types/app';
|
||||
import { DetailCollapseable } from './DetailCollapseable';
|
||||
import { NowPlayingDisplay } from './NowPlayingDisplay';
|
||||
|
||||
interface TableRowProps {
|
||||
session: GenericSessionInfo;
|
||||
app: AppType | undefined;
|
||||
}
|
||||
|
||||
export const TableRow = ({ session, app }: TableRowProps) => {
|
||||
const [collapseOpen, setCollapseOpen] = useState(false);
|
||||
const hasUserThumb = session.userProfilePicture !== undefined;
|
||||
const { classes } = useStyles();
|
||||
return (
|
||||
<>
|
||||
<tr className={classes.dataRow} onClick={() => setCollapseOpen(!collapseOpen)}>
|
||||
<td>
|
||||
<Flex wrap="nowrap" gap="xs">
|
||||
{app?.appearance.iconUrl && <AppAvatar iconUrl={app.appearance.iconUrl} />}
|
||||
<Text lineClamp={1}>{session.sessionName}</Text>
|
||||
</Flex>
|
||||
</td>
|
||||
<td>
|
||||
<Flex wrap="nowrap" gap="sm">
|
||||
{hasUserThumb ? (
|
||||
<Avatar src={session.userProfilePicture} size="sm" />
|
||||
) : (
|
||||
<Avatar src={null} alt={session.username} size="sm">
|
||||
{session.username?.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
)}
|
||||
<Text>{session.username}</Text>
|
||||
</Flex>
|
||||
</td>
|
||||
<td>
|
||||
<NowPlayingDisplay session={session} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={classes.collapseTableDataCell} colSpan={3}>
|
||||
<Collapse in={collapseOpen} w="100%">
|
||||
<DetailCollapseable session={session} />
|
||||
</Collapse>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = createStyles(() => ({
|
||||
dataRow: {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
collapseTableDataCell: {
|
||||
border: 'none !important',
|
||||
padding: '0 !important',
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user