Merge pull request #6939 from viown/react-dashboard
This commit is contained in:
50
src/apps/dashboard/components/widgets/ActivityLogWidget.tsx
Normal file
50
src/apps/dashboard/components/widgets/ActivityLogWidget.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import globalize from 'lib/globalize';
|
||||
import Widget from './Widget';
|
||||
import List from '@mui/material/List';
|
||||
import ActivityListItem from 'apps/dashboard/features/activity/components/ActivityListItem';
|
||||
import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries';
|
||||
import subSeconds from 'date-fns/subSeconds';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
const ActivityLogWidget = () => {
|
||||
const dayBefore = useMemo(() => (
|
||||
subSeconds(new Date(), 24 * 60 * 60).toISOString()
|
||||
), []);
|
||||
|
||||
const { data: logs, isPending } = useLogEntries({
|
||||
startIndex: 0,
|
||||
limit: 7,
|
||||
minDate: dayBefore,
|
||||
hasUserId: true
|
||||
});
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={globalize.translate('HeaderActivity')}
|
||||
href='/dashboard/activity?useractivity=true'
|
||||
>
|
||||
{isPending ? (
|
||||
<Stack spacing={2}>
|
||||
<Skeleton variant='rounded' height={60} />
|
||||
<Skeleton variant='rounded' height={60} />
|
||||
<Skeleton variant='rounded' height={60} />
|
||||
<Skeleton variant='rounded' height={60} />
|
||||
</Stack>
|
||||
) : (
|
||||
<List sx={{ bgcolor: 'background.paper' }}>
|
||||
{logs?.Items?.map(entry => (
|
||||
<ActivityListItem
|
||||
key={entry.Id}
|
||||
item={entry}
|
||||
displayShortOverview={true}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityLogWidget;
|
||||
41
src/apps/dashboard/components/widgets/AlertsLogWidget.tsx
Normal file
41
src/apps/dashboard/components/widgets/AlertsLogWidget.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import globalize from 'lib/globalize';
|
||||
import Widget from './Widget';
|
||||
import List from '@mui/material/List';
|
||||
import ActivityListItem from 'apps/dashboard/features/activity/components/ActivityListItem';
|
||||
import subSeconds from 'date-fns/subSeconds';
|
||||
import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries';
|
||||
|
||||
const AlertsLogWidget = () => {
|
||||
const weekBefore = useMemo(() => (
|
||||
subSeconds(new Date(), 7 * 24 * 60 * 60).toISOString()
|
||||
), []);
|
||||
|
||||
const { data: alerts, isPending } = useLogEntries({
|
||||
startIndex: 0,
|
||||
limit: 4,
|
||||
minDate: weekBefore,
|
||||
hasUserId: false
|
||||
});
|
||||
|
||||
if (isPending || alerts?.Items?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={globalize.translate('Alerts')}
|
||||
href='/dashboard/activity?useractivity=false'
|
||||
>
|
||||
<List sx={{ bgcolor: 'background.paper' }}>
|
||||
{alerts?.Items?.map(entry => (
|
||||
<ActivityListItem
|
||||
key={entry.Id}
|
||||
item={entry}
|
||||
displayShortOverview={false}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertsLogWidget;
|
||||
28
src/apps/dashboard/components/widgets/DevicesWidget.tsx
Normal file
28
src/apps/dashboard/components/widgets/DevicesWidget.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import globalize from 'lib/globalize';
|
||||
import Widget from './Widget';
|
||||
import DeviceCard from 'apps/dashboard/features/devices/components/DeviceCard';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import useLiveSessions from 'apps/dashboard/features/sessions/hooks/useLiveSessions';
|
||||
|
||||
const DevicesWidget = () => {
|
||||
const { data: devices } = useLiveSessions();
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={globalize.translate('HeaderDevices')}
|
||||
href='/dashboard/devices'
|
||||
>
|
||||
<Stack direction='row' flexWrap='wrap' gap={2}>
|
||||
{devices?.map(device => (
|
||||
<DeviceCard
|
||||
key={device.Id}
|
||||
device={device}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevicesWidget;
|
||||
42
src/apps/dashboard/components/widgets/RunningTasksWidget.tsx
Normal file
42
src/apps/dashboard/components/widgets/RunningTasksWidget.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import globalize from 'lib/globalize';
|
||||
import Widget from './Widget';
|
||||
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
type RunningTasksWidgetProps = {
|
||||
tasks?: TaskInfo[];
|
||||
};
|
||||
|
||||
const RunningTasksWidget = ({ tasks }: RunningTasksWidgetProps) => {
|
||||
const runningTasks = useMemo(() => {
|
||||
return tasks?.filter(v => v.State == TaskState.Running) || [];
|
||||
}, [ tasks ]);
|
||||
|
||||
if (runningTasks.length == 0) return null;
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={globalize.translate('HeaderRunningTasks')}
|
||||
href='/dashboard/tasks'
|
||||
>
|
||||
<Paper sx={{ padding: 2 }}>
|
||||
<Stack spacing={2} maxWidth={'330px'}>
|
||||
{runningTasks.map((task => (
|
||||
<Box key={task.Id}>
|
||||
<Typography>{task.Name}</Typography>
|
||||
<TaskProgress task={task} />
|
||||
</Box>
|
||||
)))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default RunningTasksWidget;
|
||||
91
src/apps/dashboard/components/widgets/ServerInfoWidget.tsx
Normal file
91
src/apps/dashboard/components/widgets/ServerInfoWidget.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import globalize from 'lib/globalize';
|
||||
import Widget from './Widget';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Button from '@mui/material/Button';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import { useSystemInfo } from 'hooks/useSystemInfo';
|
||||
|
||||
type ServerInfoWidgetProps = {
|
||||
onScanLibrariesClick?: () => void;
|
||||
onRestartClick?: () => void;
|
||||
onShutdownClick?: () => void;
|
||||
};
|
||||
|
||||
const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: ServerInfoWidgetProps) => {
|
||||
const { data: systemInfo, isPending } = useSystemInfo();
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={globalize.translate('TabServer')}
|
||||
href='/dashboard/settings'
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Paper sx={{
|
||||
padding: 2
|
||||
}}>
|
||||
<Stack direction='row'>
|
||||
<Stack flexGrow={1} gap={1}>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelServerName')}</Typography>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelServerVersion')}</Typography>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelWebVersion')}</Typography>
|
||||
<Typography fontWeight='bold'>{globalize.translate('LabelBuildVersion')}</Typography>
|
||||
</Stack>
|
||||
<Stack flexGrow={5} gap={1}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography>{systemInfo?.ServerName}</Typography>
|
||||
<Typography>{systemInfo?.Version}</Typography>
|
||||
<Typography>{__PACKAGE_JSON_VERSION__}</Typography>
|
||||
<Typography>{__JF_BUILD_VERSION__}</Typography>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack direction='row' gap={1.5} flexWrap={'wrap'}>
|
||||
<Button
|
||||
onClick={onScanLibrariesClick}
|
||||
sx={{
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ButtonScanAllLibraries')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onRestartClick}
|
||||
color='error'
|
||||
sx={{
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('Restart')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onShutdownClick}
|
||||
color='error'
|
||||
sx={{
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{globalize.translate('ButtonShutdown')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerInfoWidget;
|
||||
@@ -1,35 +1,18 @@
|
||||
import ChevronRight from '@mui/icons-material/ChevronRight';
|
||||
import Button from '@mui/material/Button';
|
||||
import List from '@mui/material/List';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React from 'react';
|
||||
|
||||
import { useSystemStorage } from 'apps/dashboard/features/storage/api/useSystemStorage';
|
||||
import StorageListItem from 'apps/dashboard/features/storage/components/StorageListItem';
|
||||
import globalize from 'lib/globalize';
|
||||
import Widget from './Widget';
|
||||
import { useSystemStorage } from 'apps/dashboard/features/storage/api/useSystemStorage';
|
||||
|
||||
const ServerPathWidget = () => {
|
||||
const { data: systemStorage } = useSystemStorage();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='text'
|
||||
color='inherit'
|
||||
endIcon={<ChevronRight />}
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
marginBottom: 1
|
||||
}}
|
||||
// NOTE: We should use a react-router Link component, but components rendered in legacy views lack the
|
||||
// routing context
|
||||
href='#/dashboard/settings'
|
||||
>
|
||||
<Typography variant='h3' component='span'>
|
||||
{globalize.translate('HeaderPaths')}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
<Widget
|
||||
title={globalize.translate('HeaderPaths')}
|
||||
href='/dashboard/settings'
|
||||
>
|
||||
<List sx={{ bgcolor: 'background.paper' }}>
|
||||
<StorageListItem
|
||||
label={globalize.translate('LabelCache')}
|
||||
@@ -60,7 +43,7 @@ const ServerPathWidget = () => {
|
||||
folder={systemStorage?.WebFolder}
|
||||
/>
|
||||
</List>
|
||||
</>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
38
src/apps/dashboard/components/widgets/Widget.tsx
Normal file
38
src/apps/dashboard/components/widgets/Widget.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ChevronRight from '@mui/icons-material/ChevronRight';
|
||||
import React from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
type WidgetProps = {
|
||||
title: string;
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const Widget = ({ title, href, children }: WidgetProps) => {
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to={href}
|
||||
variant='text'
|
||||
color='inherit'
|
||||
endIcon={<ChevronRight />}
|
||||
sx={{
|
||||
marginTop: 1,
|
||||
marginBottom: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant='h3' component='span'>
|
||||
{title}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Widget;
|
||||
@@ -1,91 +0,0 @@
|
||||
<div id="dashboardPage" data-role="page" class="page type-interior dashboardHomePage fullWidthContent" data-title="${TabDashboard}">
|
||||
<div class="content-primary">
|
||||
<div class="dashboardSections" style="padding-top:.5em;">
|
||||
<div class="dashboardColumn dashboardColumn-2-60 dashboardColumn-3-46">
|
||||
<div class="dashboardSection">
|
||||
<a is="emby-linkbutton" href="#/dashboard/settings" class="button-flat sectionTitleTextButton">
|
||||
<h3>${TabServer}</h3>
|
||||
<span class="material-icons chevron_right" aria-hidden="true"></span>
|
||||
</a>
|
||||
|
||||
<div class="serverInfo paperList">
|
||||
<div>${LabelServerName}</div>
|
||||
<div id="serverName"></div>
|
||||
<div>${LabelServerVersion}</div>
|
||||
<div id="versionNumber"></div>
|
||||
<div>${LabelWebVersion}</div>
|
||||
<div id="webVersion"></div>
|
||||
<div>${LabelBuildVersion}</div>
|
||||
<div id="buildVersion"></div>
|
||||
</div>
|
||||
|
||||
<div class="dashboardActionsContainer">
|
||||
<button is="emby-button" type="button" class="raised btnRefresh button-submit">
|
||||
<span>${ButtonScanAllLibraries}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="btnRestartServer" class="raised button-delete">
|
||||
<span>${Restart}</span>
|
||||
</button>
|
||||
<button is="emby-button" type="button" id="btnShutdown" class="raised button-delete">
|
||||
<span>${ButtonShutdown}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2em;" class="runningTasksContainer hide">
|
||||
<h3>${HeaderRunningTasks}</h3>
|
||||
<div id="divRunningTasks" class="paperList" style="padding: 1em;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="itemCounts"></div>
|
||||
</div>
|
||||
|
||||
<div class="dashboardSection">
|
||||
<a is="emby-linkbutton" href="#/dashboard/devices" class="button-flat sectionTitleTextButton">
|
||||
<h3>${HeaderActiveDevices}</h3>
|
||||
<span class="material-icons chevron_right" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div class="activeDevices itemsContainer vertical-wrap">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboardColumn dashboardColumn-2-40 dashboardColumn-3-27">
|
||||
<div class="dashboardSection">
|
||||
<a is="emby-linkbutton" href="#/dashboard/activity?useractivity=true" class="button-flat sectionTitleTextButton">
|
||||
<h3>${HeaderActivity}</h3>
|
||||
<span class="material-icons chevron_right" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div class="paperList userActivityItems" data-activitylimit="7" data-useractivity="true">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboardColumn dashboardColumn-3-27">
|
||||
|
||||
<div class="dashboardSection activeRecordingsSection hide">
|
||||
<h3>${HeaderActiveRecordings}</h3>
|
||||
<div class="activeRecordingItems vertical-wrap" is="emby-itemscontainer">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboardSection serverActivitySection hide activityContainer">
|
||||
<a is="emby-linkbutton" href="#/dashboard/activity?useractivity=false" class="button-flat sectionTitleTextButton">
|
||||
<h3>${Alerts}</h3>
|
||||
<span class="material-icons chevron_right" aria-hidden="true"></span>
|
||||
</a>
|
||||
<div class="paperList serverActivityItems" data-activitylimit="4" data-useractivity="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="serverPaths" class="dashboardSection"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboardFooter">
|
||||
<div style="height:1px;" class="ui-bar-inherit"></div>
|
||||
<div style="margin-top:1em;">
|
||||
<a is="emby-linkbutton" rel="noopener noreferrer" class="button-link" href="https://jellyfin.org" target="_blank">Jellyfin</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,880 +0,0 @@
|
||||
import escapeHtml from 'escape-html';
|
||||
|
||||
import ServerPathWidget from 'apps/dashboard/components/widgets/ServerPathWidget';
|
||||
import ActivityLog from 'components/activitylog';
|
||||
import alert from 'components/alert';
|
||||
import cardBuilder from 'components/cardbuilder/cardBuilder';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
import confirm from 'components/confirm/confirm';
|
||||
import imageLoader from 'components/images/imageLoader';
|
||||
import indicators from 'components/indicators/indicators';
|
||||
import itemHelper from 'components/itemHelper';
|
||||
import loading from 'components/loading/loading';
|
||||
import playMethodHelper from 'components/playback/playmethodhelper';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { getSystemInfoQuery } from 'hooks/useSystemInfo';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import datetime from 'scripts/datetime';
|
||||
import dom from 'scripts/dom';
|
||||
import serverNotifications from 'scripts/serverNotifications';
|
||||
import taskButton from 'scripts/taskbutton';
|
||||
import Dashboard from 'utils/dashboard';
|
||||
import { getLocaleWithSuffix } from 'utils/dateFnsLocale.ts';
|
||||
import Events from 'utils/events.ts';
|
||||
import imageHelper from 'utils/image';
|
||||
import { toApi } from 'utils/jellyfin-apiclient/compat';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { renderComponent } from 'utils/reactUtils';
|
||||
|
||||
import 'elements/emby-button/emby-button';
|
||||
import 'elements/emby-itemscontainer/emby-itemscontainer';
|
||||
|
||||
import 'components/listview/listview.scss';
|
||||
import 'styles/flexstyles.scss';
|
||||
import './dashboard.scss';
|
||||
import ItemCountsWidget from '../components/widgets/ItemCountsWidget';
|
||||
|
||||
function showPlaybackInfo(btn, session) {
|
||||
let title;
|
||||
const text = [];
|
||||
const displayPlayMethod = playMethodHelper.getDisplayPlayMethod(session);
|
||||
|
||||
if (displayPlayMethod === 'Remux') {
|
||||
title = globalize.translate('Remuxing');
|
||||
text.push(globalize.translate('RemuxHelp1'));
|
||||
text.push('<br/>');
|
||||
text.push(globalize.translate('RemuxHelp2'));
|
||||
} else if (displayPlayMethod === 'DirectStream') {
|
||||
title = globalize.translate('DirectStreaming');
|
||||
text.push(globalize.translate('DirectStreamHelp1'));
|
||||
text.push('<br/>');
|
||||
text.push(globalize.translate('DirectStreamHelp2'));
|
||||
} else if (displayPlayMethod === 'DirectPlay') {
|
||||
title = globalize.translate('DirectPlaying');
|
||||
text.push(globalize.translate('DirectPlayHelp'));
|
||||
} else if (displayPlayMethod === 'Transcode') {
|
||||
title = globalize.translate('Transcoding');
|
||||
text.push(globalize.translate('MediaIsBeingConverted'));
|
||||
text.push(DashboardPage.getSessionNowPlayingStreamInfo(session));
|
||||
|
||||
if (session.TranscodingInfo?.TranscodeReasons?.length) {
|
||||
text.push('<br/>');
|
||||
text.push(globalize.translate('LabelReasonForTranscoding'));
|
||||
session.TranscodingInfo.TranscodeReasons.forEach(function (transcodeReason) {
|
||||
text.push(globalize.translate(transcodeReason));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
alert({
|
||||
text: text.join('<br/>'),
|
||||
title: title
|
||||
});
|
||||
}
|
||||
|
||||
function showSendMessageForm(btn, session) {
|
||||
import('components/prompt/prompt').then(({ default: prompt }) => {
|
||||
prompt({
|
||||
title: globalize.translate('HeaderSendMessage'),
|
||||
label: globalize.translate('LabelMessageText'),
|
||||
confirmText: globalize.translate('ButtonSend')
|
||||
}).then(function (text) {
|
||||
if (text) {
|
||||
ServerConnections.getApiClient(session.ServerId).sendMessageCommand(session.Id, {
|
||||
Text: text,
|
||||
TimeoutMs: 5e3
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showOptionsMenu(btn, session) {
|
||||
import('components/actionSheet/actionSheet').then(({ default: actionsheet }) => {
|
||||
const menuItems = [];
|
||||
|
||||
if (session.ServerId && session.DeviceId !== ServerConnections.deviceId()) {
|
||||
menuItems.push({
|
||||
name: globalize.translate('SendMessage'),
|
||||
id: 'sendmessage'
|
||||
});
|
||||
}
|
||||
|
||||
if (session.TranscodingInfo?.TranscodeReasons?.length) {
|
||||
menuItems.push({
|
||||
name: globalize.translate('ViewPlaybackInfo'),
|
||||
id: 'transcodinginfo'
|
||||
});
|
||||
}
|
||||
|
||||
return actionsheet.show({
|
||||
items: menuItems,
|
||||
positionTo: btn
|
||||
}).then(function (id) {
|
||||
switch (id) {
|
||||
case 'sendmessage':
|
||||
showSendMessageForm(btn, session);
|
||||
break;
|
||||
case 'transcodinginfo':
|
||||
showPlaybackInfo(btn, session);
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onActiveDevicesClick(evt) {
|
||||
const btn = dom.parentWithClass(evt.target, 'sessionCardButton');
|
||||
|
||||
if (btn) {
|
||||
const card = dom.parentWithClass(btn, 'card');
|
||||
|
||||
if (card) {
|
||||
const sessionId = card.id;
|
||||
const session = (DashboardPage.sessionsList || []).filter(function (dashboardSession) {
|
||||
return 'session' + dashboardSession.Id === sessionId;
|
||||
})[0];
|
||||
|
||||
if (session) {
|
||||
if (btn.classList.contains('btnCardOptions')) {
|
||||
showOptionsMenu(btn, session);
|
||||
} else if (btn.classList.contains('btnSessionInfo')) {
|
||||
showPlaybackInfo(btn, session);
|
||||
} else if (btn.classList.contains('btnSessionSendMessage')) {
|
||||
showSendMessageForm(btn, session);
|
||||
} else if (btn.classList.contains('btnSessionStop')) {
|
||||
ServerConnections.getApiClient(session.ServerId).sendPlayStateCommand(session.Id, 'Stop');
|
||||
} else if (btn.classList.contains('btnSessionPlayPause') && session.PlayState) {
|
||||
ServerConnections.getApiClient(session.ServerId).sendPlayStateCommand(session.Id, 'PlayPause');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterSessions(sessions) {
|
||||
const list = [];
|
||||
const minActiveDate = new Date().getTime() - 9e5;
|
||||
|
||||
for (let i = 0, length = sessions.length; i < length; i++) {
|
||||
const session = sessions[i];
|
||||
|
||||
if (!session.NowPlayingItem && !session.UserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (datetime.parseISO8601Date(session.LastActivityDate, true).getTime() >= minActiveDate) {
|
||||
list.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function refreshActiveRecordings(view, apiClient) {
|
||||
apiClient.getLiveTvRecordings({
|
||||
UserId: Dashboard.getCurrentUserId(),
|
||||
IsInProgress: true,
|
||||
Fields: 'CanDelete,PrimaryImageAspectRatio',
|
||||
EnableTotalRecordCount: false,
|
||||
EnableImageTypes: 'Primary,Thumb,Backdrop'
|
||||
}).then(function (result) {
|
||||
const itemsContainer = view.querySelector('.activeRecordingItems');
|
||||
|
||||
if (!result.Items.length) {
|
||||
view.querySelector('.activeRecordingsSection').classList.add('hide');
|
||||
itemsContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
view.querySelector('.activeRecordingsSection').classList.remove('hide');
|
||||
itemsContainer.innerHTML = cardBuilder.getCardsHtml({
|
||||
items: result.Items,
|
||||
shape: 'auto',
|
||||
defaultShape: 'backdrop',
|
||||
showTitle: true,
|
||||
showParentTitle: true,
|
||||
coverImage: true,
|
||||
cardLayout: false,
|
||||
centerText: true,
|
||||
preferThumb: 'auto',
|
||||
overlayText: false,
|
||||
overlayMoreButton: true,
|
||||
action: 'none',
|
||||
centerPlayButton: true
|
||||
});
|
||||
imageLoader.lazyChildren(itemsContainer);
|
||||
});
|
||||
}
|
||||
|
||||
function reloadSystemInfo(view, apiClient) {
|
||||
view.querySelector('#buildVersion').innerText = __JF_BUILD_VERSION__;
|
||||
|
||||
let webVersion = __PACKAGE_JSON_VERSION__;
|
||||
if (__COMMIT_SHA__) {
|
||||
webVersion += ` (${__COMMIT_SHA__})`;
|
||||
}
|
||||
view.querySelector('#webVersion').innerText = webVersion;
|
||||
|
||||
queryClient
|
||||
.fetchQuery(getSystemInfoQuery(toApi(apiClient)))
|
||||
.then(systemInfo => {
|
||||
view.querySelector('#serverName').innerText = systemInfo.ServerName;
|
||||
view.querySelector('#versionNumber').innerText = systemInfo.Version;
|
||||
});
|
||||
}
|
||||
|
||||
function renderInfo(view, sessions) {
|
||||
sessions = filterSessions(sessions);
|
||||
renderActiveConnections(view, sessions);
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
function pollForInfo(view, apiClient) {
|
||||
apiClient.getSessions({
|
||||
ActiveWithinSeconds: 960
|
||||
}).then(function (sessions) {
|
||||
renderInfo(view, sessions);
|
||||
});
|
||||
apiClient.getScheduledTasks().then(function (tasks) {
|
||||
renderRunningTasks(view, tasks);
|
||||
});
|
||||
}
|
||||
|
||||
function renderActiveConnections(view, sessions) {
|
||||
let html = '';
|
||||
DashboardPage.sessionsList = sessions;
|
||||
const parentElement = view.querySelector('.activeDevices');
|
||||
const cardElem = parentElement.querySelector('.card');
|
||||
|
||||
if (cardElem) {
|
||||
cardElem.classList.add('deadSession');
|
||||
}
|
||||
|
||||
for (let i = 0, length = sessions.length; i < length; i++) {
|
||||
const session = sessions[i];
|
||||
const rowId = 'session' + session.Id;
|
||||
const elem = view.querySelector('#' + rowId);
|
||||
|
||||
if (elem) {
|
||||
DashboardPage.updateSession(elem, session);
|
||||
} else {
|
||||
const nowPlayingItem = session.NowPlayingItem;
|
||||
const className = 'scalableCard card activeSession backdropCard backdropCard-scalable';
|
||||
const imgUrl = DashboardPage.getNowPlayingImageUrl(nowPlayingItem);
|
||||
|
||||
html += '<div class="' + className + '" id="' + rowId + '">';
|
||||
html += '<div class="cardBox visualCardBox">';
|
||||
html += '<div class="cardScalable visualCardBox-cardScalable">';
|
||||
html += '<div class="cardPadder cardPadder-backdrop"></div>';
|
||||
html += `<div class="cardContent ${getDefaultBackgroundClass()}">`;
|
||||
|
||||
if (imgUrl) {
|
||||
html += '<div class="sessionNowPlayingContent sessionNowPlayingContent-withbackground"';
|
||||
html += ' data-src="' + imgUrl + '" style="display:inline-block;background-image:url(\'' + imgUrl + "');\"></div>";
|
||||
} else {
|
||||
html += '<div class="sessionNowPlayingContent"></div>';
|
||||
}
|
||||
|
||||
html += `<div class="sessionNowPlayingInnerContent ${imgUrl ? 'darkenContent' : ''}">`;
|
||||
html += '<div class="sessionAppInfo">';
|
||||
const clientImage = DashboardPage.getClientImage(session);
|
||||
|
||||
if (clientImage) {
|
||||
html += clientImage;
|
||||
}
|
||||
|
||||
html += '<div class="sessionAppName" style="display:inline-block; text-align:left;" dir="ltr" >';
|
||||
html += '<div class="sessionDeviceName">' + escapeHtml(session.DeviceName) + '</div>';
|
||||
html += '<div class="sessionAppSecondaryText">' + escapeHtml(DashboardPage.getAppSecondaryText(session)) + '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="sessionNowPlayingDetails">';
|
||||
const nowPlayingName = DashboardPage.getNowPlayingName(session);
|
||||
html += '<div class="sessionNowPlayingInfo" data-imgsrc="' + nowPlayingName.image + '">';
|
||||
html += '<span class="sessionNowPlayingName">' + nowPlayingName.html + '</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="sessionNowPlayingTime">' + escapeHtml(DashboardPage.getSessionNowPlayingTime(session)) + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
let percent = 100 * session?.PlayState?.PositionTicks / nowPlayingItem?.RunTimeTicks;
|
||||
html += indicators.getProgressHtml(percent || 0, {
|
||||
containerClass: 'playbackProgress'
|
||||
});
|
||||
|
||||
percent = session?.TranscodingInfo?.CompletionPercentage?.toFixed(1);
|
||||
html += indicators.getProgressHtml(percent || 0, {
|
||||
containerClass: 'transcodingProgress'
|
||||
});
|
||||
|
||||
html += indicators.getProgressHtml(100, {
|
||||
containerClass: 'backgroundProgress'
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '<div class="sessionCardFooter cardFooter">';
|
||||
html += '<div class="sessionCardButtons flex align-items-center justify-content-center">';
|
||||
|
||||
let btnCssClass = session.ServerId && session.NowPlayingItem && session.SupportsRemoteControl ? '' : ' hide';
|
||||
const playIcon = session.PlayState.IsPaused ? 'play_arrow' : 'pause';
|
||||
|
||||
html += '<button is="paper-icon-button-light" class="sessionCardButton btnSessionPlayPause paper-icon-button-light ' + btnCssClass + '"><span class="material-icons ' + playIcon + '" aria-hidden="true"></span></button>';
|
||||
html += '<button is="paper-icon-button-light" class="sessionCardButton btnSessionStop paper-icon-button-light ' + btnCssClass + '"><span class="material-icons stop" aria-hidden="true"></span></button>';
|
||||
html += '<button is="paper-icon-button-light" class="sessionCardButton btnSessionInfo paper-icon-button-light ' + btnCssClass + '" title="' + globalize.translate('ViewPlaybackInfo') + '"><span class="material-icons info" aria-hidden="true"></span></button>';
|
||||
|
||||
btnCssClass = session.ServerId && session.SupportedCommands.indexOf('DisplayMessage') !== -1 && session.DeviceId !== ServerConnections.deviceId() ? '' : ' hide';
|
||||
html += '<button is="paper-icon-button-light" class="sessionCardButton btnSessionSendMessage paper-icon-button-light ' + btnCssClass + '" title="' + globalize.translate('SendMessage') + '"><span class="material-icons message" aria-hidden="true"></span></button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="flex align-items-center justify-content-center">';
|
||||
const userImage = DashboardPage.getUserImage(session);
|
||||
html += userImage ? '<div class="activitylogUserPhoto" style="background-image:url(\'' + userImage + "');\"></div>" : '<div style="height:1.71em;"></div>';
|
||||
html += '<div class="sessionUserName">';
|
||||
html += DashboardPage.getUsersHtml(session);
|
||||
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
parentElement.insertAdjacentHTML('beforeend', html);
|
||||
const deadSessionElem = parentElement.querySelector('.deadSession');
|
||||
|
||||
if (deadSessionElem) {
|
||||
deadSessionElem.parentNode.removeChild(deadSessionElem);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRunningTasks(view, tasks) {
|
||||
let html = '';
|
||||
tasks = tasks.filter(function (task) {
|
||||
if (task.State != 'Idle') {
|
||||
return !task.IsHidden;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (tasks.length) {
|
||||
view.querySelector('.runningTasksContainer').classList.remove('hide');
|
||||
} else {
|
||||
view.querySelector('.runningTasksContainer').classList.add('hide');
|
||||
}
|
||||
|
||||
for (let i = 0, length = tasks.length; i < length; i++) {
|
||||
const task = tasks[i];
|
||||
html += '<p>';
|
||||
html += task.Name + '<br/>';
|
||||
|
||||
if (task.State === 'Running') {
|
||||
const progress = (task.CurrentProgressPercentage || 0).toFixed(1);
|
||||
html += '<progress max="100" value="' + progress + '" title="' + progress + '%">';
|
||||
html += progress + '%';
|
||||
html += '</progress>';
|
||||
html += "<span style='color:#00a4dc;margin-left:5px;margin-right:5px;'>" + progress + '%</span>';
|
||||
html += '<button type="button" is="paper-icon-button-light" title="' + globalize.translate('ButtonStop') + '" data-task-id="' + task.Id + '" class="autoSize btnTaskCancel"><span class="material-icons cancel" aria-hidden="true"></span></button>';
|
||||
} else if (task.State === 'Cancelling') {
|
||||
html += '<span style="color:#cc0000;">' + globalize.translate('LabelStopping') + '</span>';
|
||||
}
|
||||
|
||||
html += '</p>';
|
||||
}
|
||||
|
||||
const runningTasks = view.querySelector('#divRunningTasks');
|
||||
|
||||
runningTasks.innerHTML = html;
|
||||
runningTasks.querySelectorAll('.btnTaskCancel').forEach(function (btn) {
|
||||
btn.addEventListener('click', () => DashboardPage.stopTask(btn, btn.dataset.taskId));
|
||||
});
|
||||
}
|
||||
|
||||
const DashboardPage = {
|
||||
startInterval: function (apiClient) {
|
||||
apiClient.sendMessage('SessionsStart', '0,1500');
|
||||
apiClient.sendMessage('ScheduledTasksInfoStart', '0,1000');
|
||||
},
|
||||
stopInterval: function (apiClient) {
|
||||
apiClient.sendMessage('SessionsStop');
|
||||
apiClient.sendMessage('ScheduledTasksInfoStop');
|
||||
},
|
||||
getSessionNowPlayingStreamInfo: function (session) {
|
||||
let html = '';
|
||||
let showTranscodingInfo = false;
|
||||
const displayPlayMethod = playMethodHelper.getDisplayPlayMethod(session);
|
||||
|
||||
if (displayPlayMethod === 'DirectPlay') {
|
||||
html += globalize.translate('DirectPlaying');
|
||||
} else if (displayPlayMethod === 'Remux') {
|
||||
html += globalize.translate('Remuxing');
|
||||
} else if (displayPlayMethod === 'DirectStream') {
|
||||
html += globalize.translate('DirectStreaming');
|
||||
} else if (displayPlayMethod === 'Transcode') {
|
||||
if (session.TranscodingInfo?.Framerate) {
|
||||
html += `${globalize.translate('Framerate')}: ${session.TranscodingInfo.Framerate}fps`;
|
||||
}
|
||||
|
||||
showTranscodingInfo = true;
|
||||
}
|
||||
|
||||
if (showTranscodingInfo) {
|
||||
const line = [];
|
||||
|
||||
if (session.TranscodingInfo) {
|
||||
if (session.TranscodingInfo.Bitrate) {
|
||||
if (session.TranscodingInfo.Bitrate > 1e6) {
|
||||
line.push((session.TranscodingInfo.Bitrate / 1e6).toFixed(1) + ' Mbps');
|
||||
} else {
|
||||
line.push(Math.floor(session.TranscodingInfo.Bitrate / 1e3) + ' Kbps');
|
||||
}
|
||||
}
|
||||
|
||||
if (session.TranscodingInfo.Container) {
|
||||
line.push(session.TranscodingInfo.Container.toUpperCase());
|
||||
}
|
||||
|
||||
if (session.TranscodingInfo.VideoCodec) {
|
||||
line.push(session.TranscodingInfo.VideoCodec.toUpperCase());
|
||||
}
|
||||
|
||||
if (session.TranscodingInfo.AudioCodec && session.TranscodingInfo.AudioCodec != session.TranscodingInfo.Container) {
|
||||
line.push(session.TranscodingInfo.AudioCodec.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
if (line.length) {
|
||||
html += '<br/><br/>' + line.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
getSessionNowPlayingTime: function (session) {
|
||||
const nowPlayingItem = session.NowPlayingItem;
|
||||
let html = '';
|
||||
|
||||
if (nowPlayingItem) {
|
||||
if (session.PlayState.PositionTicks) {
|
||||
html += datetime.getDisplayRunningTime(session.PlayState.PositionTicks);
|
||||
} else {
|
||||
html += '0:00';
|
||||
}
|
||||
|
||||
html += ' / ';
|
||||
|
||||
if (nowPlayingItem.RunTimeTicks) {
|
||||
html += datetime.getDisplayRunningTime(nowPlayingItem.RunTimeTicks);
|
||||
} else {
|
||||
html += '0:00';
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
getAppSecondaryText: function (session) {
|
||||
return session.Client + ' ' + session.ApplicationVersion;
|
||||
},
|
||||
getNowPlayingName: function (session) {
|
||||
let imgUrl = '';
|
||||
const nowPlayingItem = session.NowPlayingItem;
|
||||
// FIXME: It seems that, sometimes, server sends date in the future, so date-fns displays messages like 'in less than a minute'. We should fix
|
||||
// how dates are returned by the server when the session is active and show something like 'Active now', instead of past/future sentences
|
||||
if (!nowPlayingItem) {
|
||||
return {
|
||||
html: globalize.translate('LastSeen', formatDistanceToNow(Date.parse(session.LastActivityDate), getLocaleWithSuffix())),
|
||||
image: imgUrl
|
||||
};
|
||||
}
|
||||
|
||||
let topText = escapeHtml(itemHelper.getDisplayName(nowPlayingItem));
|
||||
let bottomText = '';
|
||||
|
||||
if (nowPlayingItem.Artists?.length) {
|
||||
bottomText = topText;
|
||||
topText = escapeHtml(nowPlayingItem.Artists[0]);
|
||||
} else if (nowPlayingItem.SeriesName || nowPlayingItem.Album) {
|
||||
bottomText = topText;
|
||||
topText = escapeHtml(nowPlayingItem.SeriesName || nowPlayingItem.Album);
|
||||
} else if (nowPlayingItem.ProductionYear) {
|
||||
bottomText = nowPlayingItem.ProductionYear;
|
||||
}
|
||||
|
||||
if (nowPlayingItem.ImageTags?.Logo) {
|
||||
imgUrl = ApiClient.getScaledImageUrl(nowPlayingItem.Id, {
|
||||
tag: nowPlayingItem.ImageTags.Logo,
|
||||
maxHeight: 24,
|
||||
maxWidth: 130,
|
||||
type: 'Logo'
|
||||
});
|
||||
} else if (nowPlayingItem.ParentLogoImageTag) {
|
||||
imgUrl = ApiClient.getScaledImageUrl(nowPlayingItem.ParentLogoItemId, {
|
||||
tag: nowPlayingItem.ParentLogoImageTag,
|
||||
maxHeight: 24,
|
||||
maxWidth: 130,
|
||||
type: 'Logo'
|
||||
});
|
||||
}
|
||||
|
||||
if (imgUrl) {
|
||||
topText = '<img src="' + imgUrl + '" style="max-height:24px;max-width:130px;" />';
|
||||
}
|
||||
|
||||
return {
|
||||
html: bottomText ? topText + '<br/>' + bottomText : topText,
|
||||
image: imgUrl
|
||||
};
|
||||
},
|
||||
getUsersHtml: function (session) {
|
||||
const html = [];
|
||||
|
||||
if (session.UserId) {
|
||||
html.push(escapeHtml(session.UserName));
|
||||
}
|
||||
|
||||
for (let i = 0, length = session.AdditionalUsers.length; i < length; i++) {
|
||||
html.push(escapeHtml(session.AdditionalUsers[i].UserName));
|
||||
}
|
||||
|
||||
return html.join(', ');
|
||||
},
|
||||
getUserImage: function (session) {
|
||||
if (session.UserId && session.UserPrimaryImageTag) {
|
||||
return ApiClient.getUserImageUrl(session.UserId, {
|
||||
tag: session.UserPrimaryImageTag,
|
||||
type: 'Primary'
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
updateSession: function (row, session) {
|
||||
row.classList.remove('deadSession');
|
||||
const nowPlayingItem = session.NowPlayingItem;
|
||||
|
||||
if (nowPlayingItem) {
|
||||
row.classList.add('playingSession');
|
||||
row.querySelector('.btnSessionInfo').classList.remove('hide');
|
||||
} else {
|
||||
row.classList.remove('playingSession');
|
||||
row.querySelector('.btnSessionInfo').classList.add('hide');
|
||||
}
|
||||
|
||||
if (session.ServerId && session.SupportedCommands.indexOf('DisplayMessage') !== -1) {
|
||||
row.querySelector('.btnSessionSendMessage').classList.remove('hide');
|
||||
} else {
|
||||
row.querySelector('.btnSessionSendMessage').classList.add('hide');
|
||||
}
|
||||
|
||||
const btnSessionPlayPause = row.querySelector('.btnSessionPlayPause');
|
||||
|
||||
if (session.ServerId && nowPlayingItem && session.SupportsRemoteControl) {
|
||||
btnSessionPlayPause.classList.remove('hide');
|
||||
row.querySelector('.btnSessionStop').classList.remove('hide');
|
||||
} else {
|
||||
btnSessionPlayPause.classList.add('hide');
|
||||
row.querySelector('.btnSessionStop').classList.add('hide');
|
||||
}
|
||||
|
||||
const btnSessionPlayPauseIcon = btnSessionPlayPause.querySelector('.material-icons');
|
||||
btnSessionPlayPauseIcon.classList.remove('play_arrow', 'pause');
|
||||
btnSessionPlayPauseIcon.classList.add(session.PlayState?.IsPaused ? 'play_arrow' : 'pause');
|
||||
|
||||
row.querySelector('.sessionNowPlayingTime').innerText = DashboardPage.getSessionNowPlayingTime(session);
|
||||
row.querySelector('.sessionUserName').innerHTML = DashboardPage.getUsersHtml(session);
|
||||
row.querySelector('.sessionAppSecondaryText').innerText = DashboardPage.getAppSecondaryText(session);
|
||||
const nowPlayingName = DashboardPage.getNowPlayingName(session);
|
||||
const nowPlayingInfoElem = row.querySelector('.sessionNowPlayingInfo');
|
||||
|
||||
if (!(nowPlayingName.image && nowPlayingName.image == nowPlayingInfoElem.getAttribute('data-imgsrc'))) {
|
||||
nowPlayingInfoElem.innerHTML = nowPlayingName.html;
|
||||
nowPlayingInfoElem.setAttribute('data-imgsrc', nowPlayingName.image || '');
|
||||
}
|
||||
|
||||
const playbackProgressElem = row.querySelector('.playbackProgress');
|
||||
const transcodingProgress = row.querySelector('.transcodingProgress');
|
||||
|
||||
let percent = 100 * session?.PlayState?.PositionTicks / nowPlayingItem?.RunTimeTicks;
|
||||
playbackProgressElem.outerHTML = indicators.getProgressHtml(percent || 0, {
|
||||
containerClass: 'playbackProgress'
|
||||
});
|
||||
|
||||
percent = session?.TranscodingInfo?.CompletionPercentage?.toFixed(1);
|
||||
transcodingProgress.outerHTML = indicators.getProgressHtml(percent || 0, {
|
||||
containerClass: 'transcodingProgress'
|
||||
});
|
||||
|
||||
const imgUrl = DashboardPage.getNowPlayingImageUrl(nowPlayingItem) || '';
|
||||
const imgElem = row.querySelector('.sessionNowPlayingContent');
|
||||
|
||||
if (imgUrl != imgElem.getAttribute('data-src')) {
|
||||
imgElem.style.backgroundImage = imgUrl ? "url('" + imgUrl + "')" : '';
|
||||
imgElem.setAttribute('data-src', imgUrl);
|
||||
|
||||
if (imgUrl) {
|
||||
imgElem.classList.add('sessionNowPlayingContent-withbackground');
|
||||
row.querySelector('.sessionNowPlayingInnerContent').classList.add('darkenContent');
|
||||
} else {
|
||||
imgElem.classList.remove('sessionNowPlayingContent-withbackground');
|
||||
row.querySelector('.sessionNowPlayingInnerContent').classList.remove('darkenContent');
|
||||
}
|
||||
}
|
||||
},
|
||||
getClientImage: function (connection) {
|
||||
const iconUrl = imageHelper.getDeviceIcon(connection);
|
||||
return "<img src='" + iconUrl + "' />";
|
||||
},
|
||||
getNowPlayingImageUrl: function (item) {
|
||||
/* Screen width is multiplied by 0.2, as the there is currently no way to get the width of
|
||||
elements that aren't created yet. */
|
||||
if (item?.BackdropImageTags?.length) {
|
||||
return ApiClient.getScaledImageUrl(item.Id, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: 'Backdrop',
|
||||
tag: item.BackdropImageTags[0]
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.ParentBackdropImageTags?.length) {
|
||||
return ApiClient.getScaledImageUrl(item.ParentBackdropItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: 'Backdrop',
|
||||
tag: item.ParentBackdropImageTags[0]
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.BackdropImageTag) {
|
||||
return ApiClient.getScaledImageUrl(item.BackdropItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: 'Backdrop',
|
||||
tag: item.BackdropImageTag
|
||||
});
|
||||
}
|
||||
|
||||
const imageTags = item?.ImageTags || {};
|
||||
|
||||
if (item && imageTags.Thumb) {
|
||||
return ApiClient.getScaledImageUrl(item.Id, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: 'Thumb',
|
||||
tag: imageTags.Thumb
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.ParentThumbImageTag) {
|
||||
return ApiClient.getScaledImageUrl(item.ParentThumbItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: 'Thumb',
|
||||
tag: item.ParentThumbImageTag
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.ThumbImageTag) {
|
||||
return ApiClient.getScaledImageUrl(item.ThumbItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: 'Thumb',
|
||||
tag: item.ThumbImageTag
|
||||
});
|
||||
}
|
||||
|
||||
if (item && imageTags.Primary) {
|
||||
return ApiClient.getScaledImageUrl(item.Id, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: 'Primary',
|
||||
tag: imageTags.Primary
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.PrimaryImageTag) {
|
||||
return ApiClient.getScaledImageUrl(item.PrimaryImageItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: 'Primary',
|
||||
tag: item.PrimaryImageTag
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.AlbumPrimaryImageTag) {
|
||||
return ApiClient.getScaledImageUrl(item.AlbumId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: 'Primary',
|
||||
tag: item.AlbumPrimaryImageTag
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
systemUpdateTaskKey: 'SystemUpdateTask',
|
||||
stopTask: function (btn, id) {
|
||||
const page = dom.parentWithClass(btn, 'page');
|
||||
ApiClient.stopScheduledTask(id).then(function () {
|
||||
pollForInfo(page, ApiClient);
|
||||
});
|
||||
},
|
||||
restart: function (event) {
|
||||
confirm({
|
||||
title: globalize.translate('Restart'),
|
||||
text: globalize.translate('MessageConfirmRestart'),
|
||||
confirmText: globalize.translate('Restart'),
|
||||
primary: 'delete'
|
||||
}).then(() => {
|
||||
const page = dom.parentWithClass(event.target, 'page');
|
||||
page.querySelector('#btnRestartServer').disabled = true;
|
||||
page.querySelector('#btnShutdown').disabled = true;
|
||||
ApiClient.restartServer();
|
||||
}).catch(() => {
|
||||
// Confirm dialog closed
|
||||
});
|
||||
},
|
||||
shutdown: function (event) {
|
||||
confirm({
|
||||
title: globalize.translate('ButtonShutdown'),
|
||||
text: globalize.translate('MessageConfirmShutdown'),
|
||||
confirmText: globalize.translate('ButtonShutdown'),
|
||||
primary: 'delete'
|
||||
}).then(() => {
|
||||
const page = dom.parentWithClass(event.target, 'page');
|
||||
page.querySelector('#btnRestartServer').disabled = true;
|
||||
page.querySelector('#btnShutdown').disabled = true;
|
||||
ApiClient.shutdownServer();
|
||||
}).catch(() => {
|
||||
// Confirm dialog closed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function (view) {
|
||||
const serverId = ApiClient.serverId();
|
||||
let unmountWidgetFns = [];
|
||||
|
||||
function onRestartRequired(evt, apiClient) {
|
||||
console.debug('onRestartRequired not implemented', evt, apiClient);
|
||||
}
|
||||
|
||||
function onServerShuttingDown(evt, apiClient) {
|
||||
console.debug('onServerShuttingDown not implemented', evt, apiClient);
|
||||
}
|
||||
|
||||
function onServerRestarting(evt, apiClient) {
|
||||
console.debug('onServerRestarting not implemented', evt, apiClient);
|
||||
}
|
||||
|
||||
function onPackageInstall(_, apiClient) {
|
||||
if (apiClient.serverId() === serverId) {
|
||||
pollForInfo(view, apiClient);
|
||||
reloadSystemInfo(view, apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
function onSessionsUpdate(evt, apiClient, info) {
|
||||
if (apiClient.serverId() === serverId) {
|
||||
renderInfo(view, info);
|
||||
}
|
||||
}
|
||||
|
||||
function onScheduledTasksUpdate(evt, apiClient, info) {
|
||||
if (apiClient.serverId() === serverId) {
|
||||
renderRunningTasks(view, info);
|
||||
}
|
||||
}
|
||||
|
||||
view.querySelector('.activeDevices').addEventListener('click', onActiveDevicesClick);
|
||||
view.addEventListener('viewshow', function () {
|
||||
const page = this;
|
||||
const apiClient = ApiClient;
|
||||
|
||||
if (apiClient) {
|
||||
loading.show();
|
||||
pollForInfo(page, apiClient);
|
||||
DashboardPage.startInterval(apiClient);
|
||||
Events.on(serverNotifications, 'RestartRequired', onRestartRequired);
|
||||
Events.on(serverNotifications, 'ServerShuttingDown', onServerShuttingDown);
|
||||
Events.on(serverNotifications, 'ServerRestarting', onServerRestarting);
|
||||
Events.on(serverNotifications, 'PackageInstalling', onPackageInstall);
|
||||
Events.on(serverNotifications, 'PackageInstallationCompleted', onPackageInstall);
|
||||
Events.on(serverNotifications, 'Sessions', onSessionsUpdate);
|
||||
Events.on(serverNotifications, 'ScheduledTasksInfo', onScheduledTasksUpdate);
|
||||
DashboardPage.lastAppUpdateCheck = null;
|
||||
reloadSystemInfo(page, ApiClient);
|
||||
|
||||
if (!page.userActivityLog) {
|
||||
page.userActivityLog = new ActivityLog({
|
||||
serverId: ApiClient.serverId(),
|
||||
element: page.querySelector('.userActivityItems')
|
||||
});
|
||||
}
|
||||
|
||||
if (!page.serverActivityLog) {
|
||||
page.serverActivityLog = new ActivityLog({
|
||||
serverId: ApiClient.serverId(),
|
||||
element: page.querySelector('.serverActivityItems')
|
||||
});
|
||||
}
|
||||
|
||||
refreshActiveRecordings(view, apiClient);
|
||||
loading.hide();
|
||||
}
|
||||
|
||||
taskButton({
|
||||
mode: 'on',
|
||||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
|
||||
unmountWidgetFns.push(renderComponent(ItemCountsWidget, {}, page.querySelector('#itemCounts')));
|
||||
unmountWidgetFns.push(renderComponent(ServerPathWidget, {}, page.querySelector('#serverPaths')));
|
||||
|
||||
page.querySelector('#btnRestartServer').addEventListener('click', DashboardPage.restart);
|
||||
page.querySelector('#btnShutdown').addEventListener('click', DashboardPage.shutdown);
|
||||
});
|
||||
|
||||
view.addEventListener('viewbeforehide', function () {
|
||||
const apiClient = ApiClient;
|
||||
const page = this;
|
||||
|
||||
Events.off(serverNotifications, 'RestartRequired', onRestartRequired);
|
||||
Events.off(serverNotifications, 'ServerShuttingDown', onServerShuttingDown);
|
||||
Events.off(serverNotifications, 'ServerRestarting', onServerRestarting);
|
||||
Events.off(serverNotifications, 'PackageInstalling', onPackageInstall);
|
||||
Events.off(serverNotifications, 'PackageInstallationCompleted', onPackageInstall);
|
||||
Events.off(serverNotifications, 'Sessions', onSessionsUpdate);
|
||||
Events.off(serverNotifications, 'ScheduledTasksInfo', onScheduledTasksUpdate);
|
||||
|
||||
if (apiClient) {
|
||||
DashboardPage.stopInterval(apiClient);
|
||||
}
|
||||
|
||||
taskButton({
|
||||
mode: 'off',
|
||||
taskKey: 'RefreshLibrary',
|
||||
button: page.querySelector('.btnRefresh')
|
||||
});
|
||||
|
||||
unmountWidgetFns.forEach(unmount => {
|
||||
unmount();
|
||||
});
|
||||
unmountWidgetFns = [];
|
||||
|
||||
page.querySelector('#btnRestartServer').removeEventListener('click', DashboardPage.restart);
|
||||
page.querySelector('#btnShutdown').removeEventListener('click', DashboardPage.shutdown);
|
||||
});
|
||||
view.addEventListener('viewdestroy', function () {
|
||||
const page = this;
|
||||
const userActivityLog = page.userActivityLog;
|
||||
|
||||
if (userActivityLog) {
|
||||
userActivityLog.destroy();
|
||||
}
|
||||
|
||||
const serverActivityLog = page.serverActivityLog;
|
||||
|
||||
if (serverActivityLog) {
|
||||
serverActivityLog.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
.serverInfo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
padding: 1em;
|
||||
|
||||
> *:nth-child(odd) {
|
||||
flex: 1 0 20%;
|
||||
min-width: 7.5em;
|
||||
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> *:nth-child(even) {
|
||||
flex: 1 0 70%;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const useLogEntries = (
|
||||
queryKey: ['ActivityLogEntries', requestParams],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchLogEntries(api!, requestParams, { signal }),
|
||||
enabled: !!api
|
||||
enabled: !!api,
|
||||
refetchOnMount: false
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry';
|
||||
import Notifications from '@mui/icons-material/Notifications';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemAvatar from '@mui/material/ListItemAvatar';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import formatRelative from 'date-fns/formatRelative';
|
||||
import { getLocale } from 'utils/dateFnsLocale';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import getLogLevelColor from '../utils/getLogLevelColor';
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
|
||||
type ActivityListItemProps = {
|
||||
item: ActivityLogEntry;
|
||||
displayShortOverview: boolean;
|
||||
};
|
||||
|
||||
const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps) => {
|
||||
const relativeDate = useMemo(() => {
|
||||
if (item.Date) {
|
||||
return formatRelative(Date.parse(item.Date), Date.now(), { locale: getLocale() });
|
||||
} else {
|
||||
return 'N/A';
|
||||
}
|
||||
}, [ item ]);
|
||||
|
||||
return (
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: getLogLevelColor(item.Severity || LogLevel.Information) + '.main' }}>
|
||||
<Notifications sx={{ color: '#fff' }} />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
|
||||
<ListItemText
|
||||
primary={<Typography>{item.Name}</Typography>}
|
||||
secondary={(
|
||||
<Stack>
|
||||
<Typography variant='body1' color='text.secondary'>
|
||||
{relativeDate}
|
||||
</Typography>
|
||||
{displayShortOverview && (
|
||||
<Typography variant='body1' color='text.secondary'>
|
||||
{item.ShortOverview}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
disableTypography
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityListItem;
|
||||
@@ -1,30 +1,17 @@
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import globalize from 'lib/globalize';
|
||||
import getLogLevelColor from '../utils/getLogLevelColor';
|
||||
|
||||
const LogLevelChip = ({ level }: { level: LogLevel }) => {
|
||||
let color: 'info' | 'warning' | 'error' | undefined;
|
||||
switch (level) {
|
||||
case LogLevel.Information:
|
||||
color = 'info';
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
color = 'warning';
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Critical:
|
||||
color = 'error';
|
||||
break;
|
||||
}
|
||||
|
||||
const levelText = globalize.translate(`LogLevel.${level}`);
|
||||
const levelText = useMemo(() => globalize.translate(`LogLevel.${level}`), [level]);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
size='small'
|
||||
color={color}
|
||||
color={getLogLevelColor(level)}
|
||||
label={levelText}
|
||||
title={levelText}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level';
|
||||
|
||||
const getLogLevelColor = (level: LogLevel) => {
|
||||
switch (level) {
|
||||
case LogLevel.Information:
|
||||
return 'info';
|
||||
case LogLevel.Warning:
|
||||
return 'warning';
|
||||
case LogLevel.Error:
|
||||
case LogLevel.Critical:
|
||||
return 'error';
|
||||
}
|
||||
};
|
||||
|
||||
export default getLogLevelColor;
|
||||
254
src/apps/dashboard/features/devices/components/DeviceCard.tsx
Normal file
254
src/apps/dashboard/features/devices/components/DeviceCard.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import type { SessionInfo } from '@jellyfin/sdk/lib/generated-client/models/session-info';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardMedia from '@mui/material/CardMedia';
|
||||
import { getDeviceIcon } from 'utils/image';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import getNowPlayingName from '../../sessions/utils/getNowPlayingName';
|
||||
import getSessionNowPlayingTime from '../../sessions/utils/getSessionNowPlayingTime';
|
||||
import getNowPlayingImageUrl from '../../sessions/utils/getNowPlayingImageUrl';
|
||||
import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils';
|
||||
import Comment from '@mui/icons-material/Comment';
|
||||
import PlayArrow from '@mui/icons-material/PlayArrow';
|
||||
import Pause from '@mui/icons-material/Pause';
|
||||
import Stop from '@mui/icons-material/Stop';
|
||||
import Info from '@mui/icons-material/Info';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import CardActions from '@mui/material/CardActions';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import SimpleAlert from 'components/SimpleAlert';
|
||||
import playmethodhelper from 'components/playback/playmethodhelper';
|
||||
import globalize from 'lib/globalize';
|
||||
import getSessionNowPlayingStreamInfo from '../../sessions/utils/getSessionNowPlayingStreamInfo';
|
||||
import { useSendPlayStateCommand } from '../../sessions/api/usePlayPauseSession';
|
||||
import { PlaystateCommand } from '@jellyfin/sdk/lib/generated-client/models/playstate-command';
|
||||
import InputDialog from 'components/InputDialog';
|
||||
import { useSendMessage } from '../../sessions/api/useSendMessage';
|
||||
|
||||
type DeviceCardProps = {
|
||||
device: SessionInfo;
|
||||
};
|
||||
|
||||
const DeviceCard = ({ device }: DeviceCardProps) => {
|
||||
const [ playbackInfoTitle, setPlaybackInfoTitle ] = useState('');
|
||||
const [ playbackInfoDesc, setPlaybackInfoDesc ] = useState('');
|
||||
const [ isPlaybackInfoOpen, setIsPlaybackInfoOpen ] = useState(false);
|
||||
const [ isMessageDialogOpen, setIsMessageDialogOpen ] = useState(false);
|
||||
const sendMessage = useSendMessage();
|
||||
const playStateCommand = useSendPlayStateCommand();
|
||||
|
||||
const onPlayPauseSession = useCallback(() => {
|
||||
if (device.Id) {
|
||||
playStateCommand.mutate({
|
||||
sessionId: device.Id,
|
||||
command: PlaystateCommand.PlayPause
|
||||
});
|
||||
}
|
||||
}, [ device, playStateCommand ]);
|
||||
|
||||
const onStopSession = useCallback(() => {
|
||||
if (device.Id) {
|
||||
playStateCommand.mutate({
|
||||
sessionId: device.Id,
|
||||
command: PlaystateCommand.Stop
|
||||
});
|
||||
}
|
||||
}, [ device, playStateCommand ]);
|
||||
|
||||
const onMessageSend = useCallback((message: string) => {
|
||||
if (device.Id) {
|
||||
sendMessage.mutate({
|
||||
sessionId: device.Id,
|
||||
messageCommand: {
|
||||
Text: message,
|
||||
TimeoutMs: 5000
|
||||
}
|
||||
});
|
||||
setIsMessageDialogOpen(false);
|
||||
}
|
||||
}, [ sendMessage, device ]);
|
||||
|
||||
const showMessageDialog = useCallback(() => {
|
||||
setIsMessageDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const onMessageDialogClose = useCallback(() => {
|
||||
setIsMessageDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const closePlaybackInfo = useCallback(() => {
|
||||
setIsPlaybackInfoOpen(false);
|
||||
}, []);
|
||||
|
||||
const showPlaybackInfo = useCallback(() => {
|
||||
const displayPlayMethod = playmethodhelper.getDisplayPlayMethod(device);
|
||||
|
||||
switch (displayPlayMethod) {
|
||||
case 'Remux':
|
||||
setPlaybackInfoTitle(globalize.translate('Remuxing'));
|
||||
setPlaybackInfoDesc(globalize.translate('RemuxHelp1') + '\n' + globalize.translate('RemuxHelp2'));
|
||||
break;
|
||||
case 'DirectStream':
|
||||
setPlaybackInfoTitle(globalize.translate('DirectStreaming'));
|
||||
setPlaybackInfoDesc(globalize.translate('DirectStreamHelp1') + '\n' + globalize.translate('DirectStreamHelp2'));
|
||||
break;
|
||||
case 'DirectPlay':
|
||||
setPlaybackInfoTitle(globalize.translate('DirectPlaying'));
|
||||
setPlaybackInfoDesc(globalize.translate('DirectPlayHelp'));
|
||||
break;
|
||||
case 'Transcode': {
|
||||
const transcodeReasons = device.TranscodingInfo?.TranscodeReasons as string[] | undefined;
|
||||
const localizedTranscodeReasons = transcodeReasons?.map(transcodeReason => globalize.translate(transcodeReason)) || [];
|
||||
setPlaybackInfoTitle(globalize.translate('Transcoding'));
|
||||
setPlaybackInfoDesc(
|
||||
globalize.translate('MediaIsBeingConverted')
|
||||
+ '\n\n' + getSessionNowPlayingStreamInfo(device)
|
||||
+ '\n\n' + globalize.translate('LabelReasonForTranscoding')
|
||||
+ '\n' + localizedTranscodeReasons.join('\n')
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setIsPlaybackInfoOpen(true);
|
||||
}, [ device ]);
|
||||
|
||||
const nowPlayingName = useMemo(() => (
|
||||
getNowPlayingName(device)
|
||||
), [ device ]);
|
||||
|
||||
const nowPlayingImage = useMemo(() => (
|
||||
device.NowPlayingItem && getNowPlayingImageUrl(device.NowPlayingItem)
|
||||
), [device]);
|
||||
|
||||
const runningTime = useMemo(() => (
|
||||
getSessionNowPlayingTime(device)
|
||||
), [ device ]);
|
||||
|
||||
const deviceIcon = useMemo(() => (
|
||||
getDeviceIcon(device)
|
||||
), [ device ]);
|
||||
|
||||
const canControl = device.ServerId && device.NowPlayingItem && device.SupportsRemoteControl;
|
||||
|
||||
return (
|
||||
<Card sx={{ width: { xs: '100%', sm: '360px' } }}>
|
||||
<InputDialog
|
||||
open={isMessageDialogOpen}
|
||||
onClose={onMessageDialogClose}
|
||||
title={globalize.translate('HeaderSendMessage')}
|
||||
label={globalize.translate('LabelMessageText')}
|
||||
confirmButtonText={globalize.translate('ButtonSend')}
|
||||
onConfirm={onMessageSend}
|
||||
/>
|
||||
<SimpleAlert
|
||||
open={isPlaybackInfoOpen}
|
||||
title={playbackInfoTitle}
|
||||
text={playbackInfoDesc}
|
||||
onClose={closePlaybackInfo}
|
||||
/>
|
||||
<CardMedia
|
||||
sx={{
|
||||
height: 200,
|
||||
display: 'flex'
|
||||
}}
|
||||
className={getDefaultBackgroundClass(device.Id)}
|
||||
image={nowPlayingImage || undefined}
|
||||
>
|
||||
<Stack
|
||||
justifyContent={'space-between'}
|
||||
flexGrow={1}
|
||||
sx={{
|
||||
backgroundColor: nowPlayingImage ? 'rgba(0, 0, 0, 0.7)' : null,
|
||||
padding: 2
|
||||
}}>
|
||||
<Stack direction='row' alignItems='center' spacing={1}>
|
||||
<img
|
||||
src={deviceIcon}
|
||||
style={{
|
||||
maxWidth: '2.5em',
|
||||
maxHeight: '2.5em'
|
||||
}}
|
||||
alt={device.DeviceName || ''}
|
||||
/>
|
||||
<Stack>
|
||||
<Typography>{device.DeviceName}</Typography>
|
||||
<Typography>{device.Client + ' ' + device.ApplicationVersion}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction='row' alignItems={'end'}>
|
||||
<Stack flexGrow={1}>
|
||||
{nowPlayingName.image ? (
|
||||
<img
|
||||
src={nowPlayingName.image}
|
||||
style={{
|
||||
maxHeight: '24px',
|
||||
maxWidth: '130px',
|
||||
alignSelf: 'flex-start'
|
||||
}}
|
||||
alt='Media Icon'
|
||||
/>
|
||||
) : (
|
||||
<Typography>{nowPlayingName.topText}</Typography>
|
||||
)}
|
||||
<Typography>{nowPlayingName.bottomText}</Typography>
|
||||
</Stack>
|
||||
{device.NowPlayingItem && (
|
||||
<Typography>{runningTime.start} / {runningTime.end}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardMedia>
|
||||
{(device.PlayState?.PositionTicks != null && device.NowPlayingItem?.RunTimeTicks != null) && (
|
||||
<LinearProgress
|
||||
variant='buffer'
|
||||
value={(device.PlayState.PositionTicks / device.NowPlayingItem.RunTimeTicks) * 100}
|
||||
valueBuffer={device.TranscodingInfo?.CompletionPercentage || 0}
|
||||
sx={{
|
||||
'& .MuiLinearProgress-dashed': {
|
||||
animation: 'none',
|
||||
backgroundImage: 'none',
|
||||
backgroundColor: 'background.paper'
|
||||
},
|
||||
'& .MuiLinearProgress-bar2': {
|
||||
backgroundColor: '#dd4919'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CardActions disableSpacing>
|
||||
<Stack direction='row' flexGrow={1} justifyContent='center'>
|
||||
{canControl && (
|
||||
<>
|
||||
<IconButton onClick={onPlayPauseSession}>
|
||||
{device.PlayState?.IsPaused ? <PlayArrow /> : <Pause />}
|
||||
</IconButton>
|
||||
<IconButton onClick={onStopSession}>
|
||||
<Stop />
|
||||
</IconButton>
|
||||
<IconButton onClick={showPlaybackInfo}>
|
||||
<Info />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton onClick={showMessageDialog}>
|
||||
<Comment />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</CardActions>
|
||||
{device.UserName && (
|
||||
<Stack
|
||||
direction='row'
|
||||
flexGrow={1}
|
||||
justifyContent='center'
|
||||
sx={{ paddingBottom: 2 }}
|
||||
>
|
||||
<Typography>{device.UserName}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceCard;
|
||||
@@ -22,7 +22,8 @@ const getItemCountsQuery = (
|
||||
) => queryOptions({
|
||||
queryKey: [ 'ItemCounts', params ],
|
||||
queryFn: ({ signal }) => fetchItemCounts(api!, params, { signal }),
|
||||
enabled: !!api
|
||||
enabled: !!api,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
export const useItemCounts = (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SessionApiSendPlaystateCommandRequest } from '@jellyfin/sdk/lib/generated-client/api/session-api';
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const useSendPlayStateCommand = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: SessionApiSendPlaystateCommandRequest) => (
|
||||
getSessionApi(api!)
|
||||
.sendPlaystateCommand(params)
|
||||
)
|
||||
});
|
||||
};
|
||||
15
src/apps/dashboard/features/sessions/api/useSendMessage.ts
Normal file
15
src/apps/dashboard/features/sessions/api/useSendMessage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SessionApiSendMessageCommandRequest } from '@jellyfin/sdk/lib/generated-client/api/session-api';
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const useSendMessage = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (params: SessionApiSendMessageCommandRequest) => (
|
||||
getSessionApi(api!)
|
||||
.sendMessageCommand(params)
|
||||
)
|
||||
});
|
||||
};
|
||||
34
src/apps/dashboard/features/sessions/api/useSessions.ts
Normal file
34
src/apps/dashboard/features/sessions/api/useSessions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { SessionApiGetSessionsRequest } from '@jellyfin/sdk/lib/generated-client/api/session-api';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import type { Api } from '@jellyfin/sdk';
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
export const QUERY_KEY = 'Sessions';
|
||||
|
||||
const fetchSessions = async (
|
||||
api: Api,
|
||||
requestParams?: SessionApiGetSessionsRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) => {
|
||||
const response = await getSessionApi(api).getSessions(requestParams, {
|
||||
signal: options?.signal
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useSessions = (
|
||||
requestParams: SessionApiGetSessionsRequest
|
||||
) => {
|
||||
const { api } = useApi();
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY, requestParams],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchSessions(api!, requestParams, { signal }),
|
||||
enabled: !!api,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SessionInfoDto } from '@jellyfin/sdk/lib/generated-client/models/session-info-dto';
|
||||
import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { ApiClient } from 'jellyfin-apiclient';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import serverNotifications from 'scripts/serverNotifications';
|
||||
import Events, { Event } from 'utils/events';
|
||||
import { QUERY_KEY, useSessions } from '../api/useSessions';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import filterSessions from '../utils/filterSessions';
|
||||
|
||||
const QUERY_PARAMS = {
|
||||
activeWithinSeconds: 960
|
||||
};
|
||||
|
||||
const useLiveSessions = () => {
|
||||
const { __legacyApiClient__ } = useApi();
|
||||
|
||||
const sessionsQuery = useSessions(QUERY_PARAMS);
|
||||
|
||||
const updateSessions = useCallback((sessions: SessionInfoDto[]) => {
|
||||
const newSessions = filterSessions(sessions);
|
||||
const data = queryClient.getQueryData([ QUERY_KEY, QUERY_PARAMS ]) as SessionInfoDto[];
|
||||
if (data) {
|
||||
const currentSessions = [ ...data ];
|
||||
|
||||
for (const session of newSessions) {
|
||||
const sessionIndex = currentSessions.findIndex((value) => value.DeviceId === session.DeviceId);
|
||||
if (sessionIndex == -1) {
|
||||
currentSessions.push(session);
|
||||
} else {
|
||||
currentSessions[sessionIndex] = session;
|
||||
}
|
||||
}
|
||||
return currentSessions;
|
||||
} else {
|
||||
return newSessions;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onSessionsUpdate = (evt: Event, apiClient: ApiClient, info: SessionInfoDto[]) => {
|
||||
queryClient.setQueryData([ QUERY_KEY, QUERY_PARAMS ], updateSessions(info));
|
||||
};
|
||||
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.SessionsStart, '0,1500');
|
||||
Events.on(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate);
|
||||
|
||||
return () => {
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.SessionsStop, null);
|
||||
Events.off(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return sessionsQuery;
|
||||
};
|
||||
|
||||
export default useLiveSessions;
|
||||
18
src/apps/dashboard/features/sessions/utils/filterSessions.ts
Normal file
18
src/apps/dashboard/features/sessions/utils/filterSessions.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { SessionInfoDto } from '@jellyfin/sdk/lib/generated-client/models/session-info-dto';
|
||||
import { parseISO, subSeconds } from 'date-fns';
|
||||
|
||||
const MIN_SESSION_ACTIVE_TIME = 95;
|
||||
|
||||
const filterSessions = (sessions: SessionInfoDto[] = []) => {
|
||||
const minActiveDate = subSeconds(new Date(), MIN_SESSION_ACTIVE_TIME);
|
||||
|
||||
return sessions.filter(session => {
|
||||
if (!session.LastActivityDate) return false;
|
||||
|
||||
const lastActivityDate = parseISO(session.LastActivityDate);
|
||||
|
||||
return !!((lastActivityDate >= minActiveDate) && (session.NowPlayingItem || session.UserId));
|
||||
});
|
||||
};
|
||||
|
||||
export default filterSessions;
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto';
|
||||
import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import dom from 'scripts/dom';
|
||||
|
||||
const getNowPlayingImageUrl = (item: BaseItemDto) => {
|
||||
if (!item.ServerId) return null;
|
||||
|
||||
const apiClient = ServerConnections.getApiClient(item.ServerId);
|
||||
|
||||
/* Screen width is multiplied by 0.2, as the there is currently no way to get the width of
|
||||
elements that aren't created yet. */
|
||||
if (item?.BackdropImageTags?.length && item.Id) {
|
||||
return apiClient.getScaledImageUrl(item.Id, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: ImageType.Backdrop,
|
||||
tag: item.BackdropImageTags[0]
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.ParentBackdropImageTags?.length && item.ParentBackdropItemId) {
|
||||
return apiClient.getScaledImageUrl(item.ParentBackdropItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: ImageType.Backdrop,
|
||||
tag: item.ParentBackdropImageTags[0]
|
||||
});
|
||||
}
|
||||
|
||||
const imageTags = item?.ImageTags || {};
|
||||
|
||||
if (item?.Id && imageTags.Thumb) {
|
||||
return apiClient.getScaledImageUrl(item.Id, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: ImageType.Thumb,
|
||||
tag: imageTags.Thumb
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.ParentThumbImageTag && item.ParentThumbItemId) {
|
||||
return apiClient.getScaledImageUrl(item.ParentThumbItemId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: ImageType.Thumb,
|
||||
tag: item.ParentThumbImageTag
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.Id && imageTags.Primary) {
|
||||
return apiClient.getScaledImageUrl(item.Id, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: ImageType.Primary,
|
||||
tag: imageTags.Primary
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.AlbumPrimaryImageTag && item.AlbumId) {
|
||||
return apiClient.getScaledImageUrl(item.AlbumId, {
|
||||
maxWidth: Math.round(dom.getScreenWidth() * 0.20),
|
||||
type: ImageType.Primary,
|
||||
tag: item.AlbumPrimaryImageTag
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default getNowPlayingImageUrl;
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SessionInfo } from '@jellyfin/sdk/lib/generated-client/models/session-info';
|
||||
import itemHelper from 'components/itemHelper';
|
||||
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
|
||||
import globalize from 'lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import { getLocaleWithSuffix } from 'utils/dateFnsLocale';
|
||||
|
||||
type NowPlayingInfo = {
|
||||
topText?: string;
|
||||
bottomText: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
const getNowPlayingName = (session: SessionInfo): NowPlayingInfo => {
|
||||
let imgUrl = '';
|
||||
const nowPlayingItem = session.NowPlayingItem;
|
||||
// FIXME: It seems that, sometimes, server sends date in the future, so date-fns displays messages like 'in less than a minute'. We should fix
|
||||
// how dates are returned by the server when the session is active and show something like 'Active now', instead of past/future sentences
|
||||
if (!nowPlayingItem) {
|
||||
return {
|
||||
bottomText: globalize.translate('LastSeen', formatDistanceToNow(Date.parse(session.LastActivityDate!), getLocaleWithSuffix()))
|
||||
};
|
||||
}
|
||||
|
||||
let topText = itemHelper.getDisplayName(nowPlayingItem);
|
||||
let bottomText = '';
|
||||
|
||||
if (nowPlayingItem.Artists?.length) {
|
||||
bottomText = topText;
|
||||
topText = nowPlayingItem.Artists[0];
|
||||
} else if (nowPlayingItem.SeriesName || nowPlayingItem.Album) {
|
||||
bottomText = topText;
|
||||
topText = nowPlayingItem.SeriesName || nowPlayingItem.Album;
|
||||
} else if (nowPlayingItem.ProductionYear) {
|
||||
bottomText = nowPlayingItem.ProductionYear.toString();
|
||||
}
|
||||
|
||||
if (nowPlayingItem.ImageTags?.Logo) {
|
||||
imgUrl = ServerConnections.getApiClient(session.ServerId!).getScaledImageUrl(nowPlayingItem.Id!, {
|
||||
tag: nowPlayingItem.ImageTags.Logo,
|
||||
maxHeight: 24,
|
||||
maxWidth: 130,
|
||||
type: 'Logo'
|
||||
});
|
||||
} else if (nowPlayingItem.ParentLogoImageTag) {
|
||||
imgUrl = ServerConnections.getApiClient(session.ServerId!).getScaledImageUrl(nowPlayingItem.ParentLogoItemId!, {
|
||||
tag: nowPlayingItem.ParentLogoImageTag,
|
||||
maxHeight: 24,
|
||||
maxWidth: 130,
|
||||
type: 'Logo'
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
topText: topText,
|
||||
bottomText: bottomText,
|
||||
image: imgUrl
|
||||
};
|
||||
};
|
||||
|
||||
export default getNowPlayingName;
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { SessionInfo } from '@jellyfin/sdk/lib/generated-client/models/session-info';
|
||||
import playmethodhelper from 'components/playback/playmethodhelper';
|
||||
import globalize from 'lib/globalize';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
const getSessionNowPlayingStreamInfo = (session: SessionInfo): string => {
|
||||
let text = '';
|
||||
let showTranscodingInfo = false;
|
||||
const displayPlayMethod = playmethodhelper.getDisplayPlayMethod(session);
|
||||
|
||||
if (displayPlayMethod === 'DirectPlay') {
|
||||
text += globalize.translate('DirectPlaying');
|
||||
} else if (displayPlayMethod === 'Remux') {
|
||||
text += globalize.translate('Remuxing');
|
||||
} else if (displayPlayMethod === 'DirectStream') {
|
||||
text += globalize.translate('DirectStreaming');
|
||||
} else if (displayPlayMethod === 'Transcode') {
|
||||
if (session.TranscodingInfo?.Framerate) {
|
||||
text += `${globalize.translate('Framerate')}: ${session.TranscodingInfo.Framerate}fps`;
|
||||
}
|
||||
|
||||
showTranscodingInfo = true;
|
||||
}
|
||||
|
||||
if (showTranscodingInfo) {
|
||||
const line = [];
|
||||
|
||||
if (session.TranscodingInfo) {
|
||||
if (session.TranscodingInfo.Bitrate) {
|
||||
if (session.TranscodingInfo.Bitrate > 1e6) {
|
||||
line.push((session.TranscodingInfo.Bitrate / 1e6).toFixed(1) + ' Mbps');
|
||||
} else {
|
||||
line.push(Math.floor(session.TranscodingInfo.Bitrate / 1e3) + ' Kbps');
|
||||
}
|
||||
}
|
||||
|
||||
if (session.TranscodingInfo.Container) {
|
||||
line.push(session.TranscodingInfo.Container.toUpperCase());
|
||||
}
|
||||
|
||||
if (session.TranscodingInfo.VideoCodec) {
|
||||
line.push(session.TranscodingInfo.VideoCodec.toUpperCase());
|
||||
}
|
||||
|
||||
if (session.TranscodingInfo.AudioCodec && session.TranscodingInfo.AudioCodec !== session.TranscodingInfo.Container) {
|
||||
line.push(session.TranscodingInfo.AudioCodec.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
if (line.length) {
|
||||
text += '\n\n' + line.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
export default getSessionNowPlayingStreamInfo;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SessionInfo } from '@jellyfin/sdk/lib/generated-client/models/session-info';
|
||||
import datetime from 'scripts/datetime';
|
||||
|
||||
const getSessionNowPlayingTime = (session: SessionInfo) => {
|
||||
const nowPlayingItem = session.NowPlayingItem;
|
||||
|
||||
let start = '0:00';
|
||||
let end = '0:00';
|
||||
|
||||
if (nowPlayingItem) {
|
||||
if (session.PlayState?.PositionTicks) {
|
||||
start = datetime.getDisplayRunningTime(session.PlayState.PositionTicks);
|
||||
}
|
||||
|
||||
if (nowPlayingItem.RunTimeTicks) {
|
||||
end = datetime.getDisplayRunningTime(nowPlayingItem.RunTimeTicks);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
end
|
||||
};
|
||||
};
|
||||
|
||||
export default getSessionNowPlayingTime;
|
||||
@@ -19,7 +19,8 @@ const getSystemStorageQuery = (
|
||||
) => queryOptions({
|
||||
queryKey: [ 'SystemStorage' ],
|
||||
queryFn: ({ signal }) => fetchSystemStorage(api!, { signal }),
|
||||
enabled: !!api
|
||||
enabled: !!api,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
export const useSystemStorage = () => {
|
||||
|
||||
16
src/apps/dashboard/features/system/api/useRestartServer.ts
Normal file
16
src/apps/dashboard/features/system/api/useRestartServer.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
const useRestartServer = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => {
|
||||
return getSystemApi(api!)
|
||||
.restartApplication();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default useRestartServer;
|
||||
16
src/apps/dashboard/features/system/api/useShutdownServer.ts
Normal file
16
src/apps/dashboard/features/system/api/useShutdownServer.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
|
||||
const useShutdownServer = () => {
|
||||
const { api } = useApi();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => {
|
||||
return getSystemApi(api!)
|
||||
.shutdownApplication();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default useShutdownServer;
|
||||
45
src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts
Normal file
45
src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { QUERY_KEY, useTasks } from '../api/useTasks';
|
||||
import type { ScheduledTasksApiGetTasksRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import { ApiClient } from 'jellyfin-apiclient';
|
||||
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
|
||||
import Events, { Event } from 'utils/events';
|
||||
import serverNotifications from 'scripts/serverNotifications';
|
||||
import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type';
|
||||
|
||||
const FALLBACK_POLL_INTERVAL_MS = 10000;
|
||||
|
||||
const useLiveTasks = (params: ScheduledTasksApiGetTasksRequest) => {
|
||||
const { __legacyApiClient__ } = useApi();
|
||||
const tasksQuery = useTasks(params);
|
||||
|
||||
// TODO: Replace usage of the legacy apiclient when websocket support is added to the TS SDK.
|
||||
useEffect(() => {
|
||||
const onScheduledTasksUpdate = (_e: Event, _apiClient: ApiClient, info: TaskInfo[]) => {
|
||||
queryClient.setQueryData([ QUERY_KEY ], info);
|
||||
};
|
||||
|
||||
const fallbackInterval = setInterval(() => {
|
||||
if (!__legacyApiClient__?.isMessageChannelOpen()) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
}, FALLBACK_POLL_INTERVAL_MS);
|
||||
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStart, '1000,1000');
|
||||
Events.on(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
|
||||
|
||||
return () => {
|
||||
clearInterval(fallbackInterval);
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStop, null);
|
||||
Events.off(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
|
||||
};
|
||||
}, [ __legacyApiClient__ ]);
|
||||
|
||||
return tasksQuery;
|
||||
};
|
||||
|
||||
export default useLiveTasks;
|
||||
@@ -2,6 +2,7 @@ import type { AsyncRoute } from 'components/router/AsyncRoute';
|
||||
import { AppType } from 'constants/appType';
|
||||
|
||||
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
|
||||
{ path: '', type: AppType.Dashboard },
|
||||
{ path: 'activity', type: AppType.Dashboard },
|
||||
{ path: 'backups', type: AppType.Dashboard },
|
||||
{ path: 'branding', type: AppType.Dashboard },
|
||||
|
||||
@@ -3,13 +3,6 @@ import { AppType } from 'constants/appType';
|
||||
|
||||
export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
controller: 'dashboard',
|
||||
view: 'dashboard.html'
|
||||
}
|
||||
}, {
|
||||
path: 'networking',
|
||||
pageProps: {
|
||||
appType: AppType.Dashboard,
|
||||
|
||||
132
src/apps/dashboard/routes/index.tsx
Normal file
132
src/apps/dashboard/routes/index.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import ServerPathWidget from '../components/widgets/ServerPathWidget';
|
||||
import ServerInfoWidget from '../components/widgets/ServerInfoWidget';
|
||||
import ActivityLogWidget from '../components/widgets/ActivityLogWidget';
|
||||
import AlertsLogWidget from '../components/widgets/AlertsLogWidget';
|
||||
import useTheme from '@mui/material/styles/useTheme';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import useShutdownServer from '../features/system/api/useShutdownServer';
|
||||
import useRestartServer from '../features/system/api/useRestartServer';
|
||||
import ConfirmDialog from 'components/ConfirmDialog';
|
||||
import useLiveTasks from '../features/tasks/hooks/useLiveTasks';
|
||||
import RunningTasksWidget from '../components/widgets/RunningTasksWidget';
|
||||
import DevicesWidget from '../components/widgets/DevicesWidget';
|
||||
import { useStartTask } from '../features/tasks/api/useStartTask';
|
||||
import ItemCountsWidget from '../components/widgets/ItemCountsWidget';
|
||||
|
||||
export const Component = () => {
|
||||
const theme = useTheme();
|
||||
const isMedium = useMediaQuery(theme.breakpoints.only('md'));
|
||||
const isExtraLarge = useMediaQuery(theme.breakpoints.only('xl'));
|
||||
const [ isRestartConfirmDialogOpen, setIsRestartConfirmDialogOpen ] = useState(false);
|
||||
const [ isShutdownConfirmDialogOpen, setIsShutdownConfirmDialogOpen ] = useState(false);
|
||||
const startTask = useStartTask();
|
||||
const restartServer = useRestartServer();
|
||||
const shutdownServer = useShutdownServer();
|
||||
|
||||
const { data: tasks } = useLiveTasks({ isHidden: false });
|
||||
|
||||
const promptRestart = useCallback(() => {
|
||||
setIsRestartConfirmDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRestartDialog = useCallback(() => {
|
||||
setIsRestartConfirmDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const promptShutdown = useCallback(() => {
|
||||
setIsShutdownConfirmDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeShutdownDialog = useCallback(() => {
|
||||
setIsShutdownConfirmDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
const onScanLibraries = useCallback(() => {
|
||||
const scanLibrariesTask = tasks?.find((value) => value.Key === 'RefreshLibrary');
|
||||
|
||||
if (scanLibrariesTask?.Id) {
|
||||
startTask.mutate({
|
||||
taskId: scanLibrariesTask.Id
|
||||
});
|
||||
}
|
||||
}, [ startTask, tasks ]);
|
||||
|
||||
const onRestartConfirm = useCallback(() => {
|
||||
restartServer.mutate();
|
||||
setIsRestartConfirmDialogOpen(false);
|
||||
}, [ restartServer ]);
|
||||
|
||||
const onShutdownConfirm = useCallback(() => {
|
||||
shutdownServer.mutate();
|
||||
setIsShutdownConfirmDialogOpen(false);
|
||||
}, [ shutdownServer ]);
|
||||
|
||||
return (
|
||||
<Page
|
||||
id='dashboardPage'
|
||||
title={globalize.translate('TabDashboard')}
|
||||
className='mainAnimatedPage type-interior'
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={isRestartConfirmDialogOpen}
|
||||
title={globalize.translate('Restart')}
|
||||
text={globalize.translate('MessageConfirmRestart')}
|
||||
onConfirm={onRestartConfirm}
|
||||
onCancel={closeRestartDialog}
|
||||
confirmButtonText={globalize.translate('Restart')}
|
||||
confirmButtonColor='error'
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={isShutdownConfirmDialogOpen}
|
||||
title={globalize.translate('ButtonShutdown')}
|
||||
text={globalize.translate('MessageConfirmShutdown')}
|
||||
onConfirm={onShutdownConfirm}
|
||||
onCancel={closeShutdownDialog}
|
||||
confirmButtonText={globalize.translate('ButtonShutdown')}
|
||||
confirmButtonColor='error'
|
||||
/>
|
||||
<Box className='content-primary'>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 12, lg: 8, xl: 6 }}>
|
||||
<Stack spacing={3}>
|
||||
<ServerInfoWidget
|
||||
onScanLibrariesClick={onScanLibraries}
|
||||
onRestartClick={promptRestart}
|
||||
onShutdownClick={promptShutdown}
|
||||
/>
|
||||
<ItemCountsWidget />
|
||||
<RunningTasksWidget tasks={tasks} />
|
||||
<DevicesWidget />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6, lg: 4, xl: 3 }}>
|
||||
<ActivityLogWidget />
|
||||
</Grid>
|
||||
{isMedium || isExtraLarge ? (
|
||||
<Grid size={{ md: 6, xl: 3 }}>
|
||||
<Stack spacing={3}>
|
||||
<AlertsLogWidget />
|
||||
<ServerPathWidget />
|
||||
</Stack>
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid size={12}>
|
||||
<Stack spacing={3}>
|
||||
<AlertsLogWidget />
|
||||
<ServerPathWidget />
|
||||
</Stack>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
Component.displayName = 'DashboardPage';
|
||||
@@ -1,47 +1,15 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import Page from 'components/Page';
|
||||
import globalize from 'lib/globalize';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { QUERY_KEY, useTasks } from '../../features/tasks/api/useTasks';
|
||||
import { getCategories, getTasksByCategory } from '../../features/tasks/utils/tasks';
|
||||
import Loading from 'components/loading/LoadingComponent';
|
||||
import Tasks from '../../features/tasks/components/Tasks';
|
||||
import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info';
|
||||
import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type';
|
||||
import serverNotifications from 'scripts/serverNotifications';
|
||||
import Events, { Event } from 'utils/events';
|
||||
import { ApiClient } from 'jellyfin-apiclient';
|
||||
import { useApi } from 'hooks/useApi';
|
||||
import { queryClient } from 'utils/query/queryClient';
|
||||
import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks';
|
||||
|
||||
export const Component = () => {
|
||||
const { __legacyApiClient__ } = useApi();
|
||||
const { data: tasks, isPending } = useTasks({ isHidden: false });
|
||||
|
||||
// TODO: Replace usage of the legacy apiclient when websocket support is added to the TS SDK.
|
||||
useEffect(() => {
|
||||
const onScheduledTasksUpdate = (_e: Event, _apiClient: ApiClient, info: TaskInfo[]) => {
|
||||
queryClient.setQueryData([ QUERY_KEY ], info);
|
||||
};
|
||||
|
||||
const fallbackInterval = setInterval(() => {
|
||||
if (!__legacyApiClient__?.isMessageChannelOpen()) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [ QUERY_KEY ]
|
||||
});
|
||||
}
|
||||
}, 1e4);
|
||||
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStart, '1000,1000');
|
||||
Events.on(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
|
||||
|
||||
return () => {
|
||||
clearInterval(fallbackInterval);
|
||||
__legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStop, null);
|
||||
Events.off(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate);
|
||||
};
|
||||
}, [__legacyApiClient__]);
|
||||
const { data: tasks, isPending } = useLiveTasks({ isHidden: false });
|
||||
|
||||
if (isPending || !tasks) {
|
||||
return <Loading />;
|
||||
|
||||
62
src/components/InputDialog.tsx
Normal file
62
src/components/InputDialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog, { type DialogProps } from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import globalize from 'lib/globalize';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
interface InputDialogProps extends DialogProps {
|
||||
title: string;
|
||||
label: string;
|
||||
confirmButtonText?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (text: string) => void;
|
||||
};
|
||||
|
||||
const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm }: InputDialogProps) => {
|
||||
const [ text, setText ] = useState('');
|
||||
|
||||
const onTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setText(e.target.value);
|
||||
}, []);
|
||||
|
||||
const onConfirmClick = useCallback(() => {
|
||||
onConfirm(text);
|
||||
setText('');
|
||||
}, [ text, onConfirm ]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth='xs'
|
||||
fullWidth
|
||||
>
|
||||
{title && (
|
||||
<DialogTitle>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
)}
|
||||
<DialogContent>
|
||||
<Stack>
|
||||
<TextField
|
||||
label={label}
|
||||
value={text}
|
||||
onChange={onTextChange}
|
||||
variant='standard'
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onConfirmClick}>
|
||||
{confirmButtonText || globalize.translate('ButtonOk')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputDialog;
|
||||
@@ -22,7 +22,7 @@ const SimpleAlert = ({ open, title, text, onClose }: SimpleAlertDialog) => {
|
||||
</DialogTitle>
|
||||
)}
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<DialogContentText sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{text}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import escapeHtml from 'escape-html';
|
||||
import Events from '../utils/events.ts';
|
||||
import globalize from '../lib/globalize';
|
||||
import { ServerConnections } from 'lib/jellyfin-apiclient';
|
||||
import dom from '../scripts/dom';
|
||||
import { formatRelative } from 'date-fns';
|
||||
import serverNotifications from '../scripts/serverNotifications';
|
||||
import '../elements/emby-button/emby-button';
|
||||
import './listview/listview.scss';
|
||||
import alert from './alert';
|
||||
import { getLocale } from '../utils/dateFnsLocale.ts';
|
||||
import { toBoolean } from '../utils/string.ts';
|
||||
|
||||
function getEntryHtml(entry, apiClient) {
|
||||
let html = '';
|
||||
html += '<div class="listItem listItem-border">';
|
||||
let color = '#00a4dc';
|
||||
let icon = 'notifications';
|
||||
|
||||
if (entry.Severity == 'Error' || entry.Severity == 'Fatal' || entry.Severity == 'Warn') {
|
||||
color = '#cc0000';
|
||||
icon = 'notification_important';
|
||||
}
|
||||
|
||||
if (entry.UserId && entry.UserPrimaryImageTag) {
|
||||
html += '<span class="listItemIcon material-icons dvr" aria-hidden="true" style="width:2em!important;height:2em!important;padding:0;color:transparent;background-color:' + color + ";background-image:url('" + apiClient.getUserImageUrl(entry.UserId, {
|
||||
type: 'Primary',
|
||||
tag: entry.UserPrimaryImageTag
|
||||
}) + "');background-repeat:no-repeat;background-position:center center;background-size: cover;\"></span>";
|
||||
} else {
|
||||
html += '<span class="listItemIcon material-icons ' + icon + '" aria-hidden="true" style="background-color:' + color + '"></span>';
|
||||
}
|
||||
|
||||
html += '<div class="listItemBody three-line">';
|
||||
html += '<div class="listItemBodyText">';
|
||||
html += escapeHtml(entry.Name);
|
||||
html += '</div>';
|
||||
html += '<div class="listItemBodyText secondary">';
|
||||
html += formatRelative(Date.parse(entry.Date), Date.now(), { locale: getLocale() });
|
||||
html += '</div>';
|
||||
html += '<div class="listItemBodyText secondary listItemBodyText-nowrap">';
|
||||
html += escapeHtml(entry.ShortOverview || '');
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
if (entry.Overview) {
|
||||
html += `<button type="button" is="paper-icon-button-light" class="btnEntryInfo" data-id="${entry.Id}" title="${globalize.translate('Info')}">
|
||||
<span class="material-icons info" aria-hidden="true"></span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderList(elem, apiClient, result) {
|
||||
elem.innerHTML = result.Items.map(function (i) {
|
||||
return getEntryHtml(i, apiClient);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function reloadData(instance, elem, apiClient, startIndex, limit) {
|
||||
if (startIndex == null) {
|
||||
startIndex = parseInt(elem.getAttribute('data-activitystartindex') || '0', 10);
|
||||
}
|
||||
|
||||
limit = limit || parseInt(elem.getAttribute('data-activitylimit') || '7', 10);
|
||||
const minDate = new Date();
|
||||
const hasUserId = toBoolean(elem.getAttribute('data-useractivity'), true);
|
||||
|
||||
// TODO: Use date-fns
|
||||
if (hasUserId) {
|
||||
minDate.setTime(minDate.getTime() - 24 * 60 * 60 * 1000); // one day back
|
||||
} else {
|
||||
minDate.setTime(minDate.getTime() - 7 * 24 * 60 * 60 * 1000); // one week back
|
||||
}
|
||||
|
||||
ApiClient.getJSON(ApiClient.getUrl('System/ActivityLog/Entries', {
|
||||
startIndex: startIndex,
|
||||
limit: limit,
|
||||
minDate: minDate.toISOString(),
|
||||
hasUserId: hasUserId
|
||||
})).then(function (result) {
|
||||
elem.setAttribute('data-activitystartindex', startIndex);
|
||||
elem.setAttribute('data-activitylimit', limit);
|
||||
if (!startIndex) {
|
||||
const activityContainer = dom.parentWithClass(elem, 'activityContainer');
|
||||
|
||||
if (activityContainer) {
|
||||
if (result.Items.length) {
|
||||
activityContainer.classList.remove('hide');
|
||||
} else {
|
||||
activityContainer.classList.add('hide');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
instance.items = result.Items;
|
||||
renderList(elem, apiClient, result);
|
||||
});
|
||||
}
|
||||
|
||||
function onActivityLogUpdate(e, apiClient) {
|
||||
const options = this.options;
|
||||
|
||||
if (options && options.serverId === apiClient.serverId()) {
|
||||
reloadData(this, options.element, apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
function onListClick(e) {
|
||||
const btnEntryInfo = dom.parentWithClass(e.target, 'btnEntryInfo');
|
||||
|
||||
if (btnEntryInfo) {
|
||||
const id = btnEntryInfo.getAttribute('data-id');
|
||||
const items = this.items;
|
||||
|
||||
if (items) {
|
||||
const item = items.filter(function (i) {
|
||||
return i.Id.toString() === id;
|
||||
})[0];
|
||||
|
||||
if (item) {
|
||||
showItemOverview(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showItemOverview(item) {
|
||||
alert({
|
||||
text: item.Overview
|
||||
});
|
||||
}
|
||||
|
||||
class ActivityLog {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
const element = options.element;
|
||||
element.classList.add('activityLogListWidget');
|
||||
element.addEventListener('click', onListClick.bind(this));
|
||||
const apiClient = ServerConnections.getApiClient(options.serverId);
|
||||
reloadData(this, element, apiClient);
|
||||
const onUpdate = onActivityLogUpdate.bind(this);
|
||||
this.updateFn = onUpdate;
|
||||
Events.on(serverNotifications, 'ActivityLogEntry', onUpdate);
|
||||
apiClient.sendMessage('ActivityLogEntryStart', '0,1500');
|
||||
}
|
||||
destroy() {
|
||||
const options = this.options;
|
||||
|
||||
if (options) {
|
||||
options.element.classList.remove('activityLogListWidget');
|
||||
ServerConnections.getApiClient(options.serverId).sendMessage('ActivityLogEntryStop', '0,1500');
|
||||
}
|
||||
|
||||
const onUpdate = this.updateFn;
|
||||
|
||||
if (onUpdate) {
|
||||
Events.off(serverNotifications, 'ActivityLogEntry', onUpdate);
|
||||
}
|
||||
|
||||
this.items = null;
|
||||
this.options = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default ActivityLog;
|
||||
@@ -23,7 +23,7 @@ export const DEFAULT_COLOR_SCHEME: ColorSystemOptions = {
|
||||
main: '#f2b01e' // Yellow color
|
||||
},
|
||||
error: {
|
||||
main: '#cb272a' // Red color
|
||||
main: '#c62828' // Red color
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user