Merge pull request #6939 from viown/react-dashboard

This commit is contained in:
Bill Thornton
2025-07-11 17:14:26 -04:00
committed by GitHub
37 changed files with 1264 additions and 1245 deletions

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

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

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

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

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

View File

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

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

View File

@@ -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>

View File

@@ -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();
}
});
}

View File

@@ -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%;
}
}

View File

@@ -26,6 +26,7 @@ export const useLogEntries = (
queryKey: ['ActivityLogEntries', requestParams],
queryFn: ({ signal }) =>
fetchLogEntries(api!, requestParams, { signal }),
enabled: !!api
enabled: !!api,
refetchOnMount: false
});
};

View File

@@ -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;

View File

@@ -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}
/>

View File

@@ -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;

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

View File

@@ -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 = (

View File

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

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

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

View File

@@ -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;

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -19,7 +19,8 @@ const getSystemStorageQuery = (
) => queryOptions({
queryKey: [ 'SystemStorage' ],
queryFn: ({ signal }) => fetchSystemStorage(api!, { signal }),
enabled: !!api
enabled: !!api,
refetchOnWindowFocus: false
});
export const useSystemStorage = () => {

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

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

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

View File

@@ -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 },

View File

@@ -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,

View 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';

View File

@@ -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 />;

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

View File

@@ -22,7 +22,7 @@ const SimpleAlert = ({ open, title, text, onClose }: SimpleAlertDialog) => {
</DialogTitle>
)}
<DialogContent>
<DialogContentText>
<DialogContentText sx={{ whiteSpace: 'pre-wrap' }}>
{text}
</DialogContentText>
</DialogContent>

View File

@@ -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;

View File

@@ -23,7 +23,7 @@ export const DEFAULT_COLOR_SCHEME: ColorSystemOptions = {
main: '#f2b01e' // Yellow color
},
error: {
main: '#cb272a' // Red color
main: '#c62828' // Red color
}
}
};