diff --git a/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx b/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx
new file mode 100644
index 0000000000..663053b6fd
--- /dev/null
+++ b/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx
@@ -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 (
+
+ {isPending ? (
+
+
+
+
+
+
+ ) : (
+
+ {logs?.Items?.map(entry => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default ActivityLogWidget;
diff --git a/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx b/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx
new file mode 100644
index 0000000000..6fca74c23e
--- /dev/null
+++ b/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx
@@ -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 (
+
+
+ {alerts?.Items?.map(entry => (
+
+ ))}
+
+
+ );
+};
+
+export default AlertsLogWidget;
diff --git a/src/apps/dashboard/components/widgets/DevicesWidget.tsx b/src/apps/dashboard/components/widgets/DevicesWidget.tsx
new file mode 100644
index 0000000000..e4f5c34ebe
--- /dev/null
+++ b/src/apps/dashboard/components/widgets/DevicesWidget.tsx
@@ -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 (
+
+
+ {devices?.map(device => (
+
+ ))}
+
+
+ );
+};
+
+export default DevicesWidget;
diff --git a/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx b/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx
new file mode 100644
index 0000000000..1095a9bba5
--- /dev/null
+++ b/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx
@@ -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 (
+
+
+
+ {runningTasks.map((task => (
+
+ {task.Name}
+
+
+ )))}
+
+
+
+ );
+};
+
+export default RunningTasksWidget;
diff --git a/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx b/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx
new file mode 100644
index 0000000000..04977ba162
--- /dev/null
+++ b/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx
@@ -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 (
+
+
+
+
+
+ {globalize.translate('LabelServerName')}
+ {globalize.translate('LabelServerVersion')}
+ {globalize.translate('LabelWebVersion')}
+ {globalize.translate('LabelBuildVersion')}
+
+
+ {isPending ? (
+ <>
+
+
+
+
+ >
+ ) : (
+ <>
+ {systemInfo?.ServerName}
+ {systemInfo?.Version}
+ {__PACKAGE_JSON_VERSION__}
+ {__JF_BUILD_VERSION__}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ServerInfoWidget;
diff --git a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx
index 98fe4be579..36f673a321 100644
--- a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx
+++ b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx
@@ -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 (
- <>
- }
- 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'
- >
-
- {globalize.translate('HeaderPaths')}
-
-
-
+
{
folder={systemStorage?.WebFolder}
/>
- >
+
);
};
diff --git a/src/apps/dashboard/components/widgets/Widget.tsx b/src/apps/dashboard/components/widgets/Widget.tsx
new file mode 100644
index 0000000000..d0d86de95a
--- /dev/null
+++ b/src/apps/dashboard/components/widgets/Widget.tsx
@@ -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 (
+
+ }
+ sx={{
+ marginTop: 1,
+ marginBottom: 1
+ }}
+ >
+
+ {title}
+
+
+
+ {children}
+
+ );
+};
+
+export default Widget;
diff --git a/src/apps/dashboard/controllers/dashboard.html b/src/apps/dashboard/controllers/dashboard.html
deleted file mode 100644
index e0656d1cc8..0000000000
--- a/src/apps/dashboard/controllers/dashboard.html
+++ /dev/null
@@ -1,91 +0,0 @@
-
-
-
-
-
-
- ${TabServer}
-
-
-
-
-
${LabelServerName}
-
-
${LabelServerVersion}
-
-
${LabelWebVersion}
-
-
${LabelBuildVersion}
-
-
-
-
-
-
-
-
-
-
-
${HeaderRunningTasks}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
${HeaderActiveRecordings}
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/apps/dashboard/controllers/dashboard.js b/src/apps/dashboard/controllers/dashboard.js
deleted file mode 100644
index c9dd75b97d..0000000000
--- a/src/apps/dashboard/controllers/dashboard.js
+++ /dev/null
@@ -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('
');
- text.push(globalize.translate('RemuxHelp2'));
- } else if (displayPlayMethod === 'DirectStream') {
- title = globalize.translate('DirectStreaming');
- text.push(globalize.translate('DirectStreamHelp1'));
- text.push('
');
- 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('
');
- text.push(globalize.translate('LabelReasonForTranscoding'));
- session.TranscodingInfo.TranscodeReasons.forEach(function (transcodeReason) {
- text.push(globalize.translate(transcodeReason));
- });
- }
- }
-
- alert({
- text: text.join('
'),
- 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 += '';
- html += '
';
- html += '
';
- html += '
';
- html += `
`;
-
- if (imgUrl) {
- html += '
";
- } else {
- html += '
';
- }
-
- html += `
`;
- html += '
';
- const clientImage = DashboardPage.getClientImage(session);
-
- if (clientImage) {
- html += clientImage;
- }
-
- html += '
';
- html += '
' + escapeHtml(session.DeviceName) + '
';
- html += '
' + escapeHtml(DashboardPage.getAppSecondaryText(session)) + '
';
- html += '
';
- html += '
';
-
- html += '
';
- const nowPlayingName = DashboardPage.getNowPlayingName(session);
- html += '
';
- html += '' + nowPlayingName.html + '';
- html += '
';
- html += '
' + escapeHtml(DashboardPage.getSessionNowPlayingTime(session)) + '
';
- html += '
';
-
- 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 += '
';
- html += '
';
- html += '
';
- html += '';
- html += '
';
- html += '
';
- }
- }
-
- 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 += '';
- html += task.Name + '
';
-
- if (task.State === 'Running') {
- const progress = (task.CurrentProgressPercentage || 0).toFixed(1);
- html += '';
- html += "" + progress + '%';
- html += '';
- } else if (task.State === 'Cancelling') {
- html += '' + globalize.translate('LabelStopping') + '';
- }
-
- html += '
';
- }
-
- 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 += '
' + 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 = '
';
- }
-
- return {
- html: bottomText ? topText + '
' + 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 "
";
- },
- 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();
- }
- });
-}
-
diff --git a/src/apps/dashboard/controllers/dashboard.scss b/src/apps/dashboard/controllers/dashboard.scss
deleted file mode 100644
index 3771524760..0000000000
--- a/src/apps/dashboard/controllers/dashboard.scss
+++ /dev/null
@@ -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%;
- }
-}
diff --git a/src/apps/dashboard/features/activity/api/useLogEntries.ts b/src/apps/dashboard/features/activity/api/useLogEntries.ts
index 4f8b433da8..4259f32be6 100644
--- a/src/apps/dashboard/features/activity/api/useLogEntries.ts
+++ b/src/apps/dashboard/features/activity/api/useLogEntries.ts
@@ -26,6 +26,7 @@ export const useLogEntries = (
queryKey: ['ActivityLogEntries', requestParams],
queryFn: ({ signal }) =>
fetchLogEntries(api!, requestParams, { signal }),
- enabled: !!api
+ enabled: !!api,
+ refetchOnMount: false
});
};
diff --git a/src/apps/dashboard/features/activity/components/ActivityListItem.tsx b/src/apps/dashboard/features/activity/components/ActivityListItem.tsx
new file mode 100644
index 0000000000..b27b42735a
--- /dev/null
+++ b/src/apps/dashboard/features/activity/components/ActivityListItem.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {item.Name}}
+ secondary={(
+
+
+ {relativeDate}
+
+ {displayShortOverview && (
+
+ {item.ShortOverview}
+
+ )}
+
+ )}
+ disableTypography
+ />
+
+
+ );
+};
+
+export default ActivityListItem;
diff --git a/src/apps/dashboard/features/activity/components/LogLevelChip.tsx b/src/apps/dashboard/features/activity/components/LogLevelChip.tsx
index 7f1f68f750..5eb6ab233b 100644
--- a/src/apps/dashboard/features/activity/components/LogLevelChip.tsx
+++ b/src/apps/dashboard/features/activity/components/LogLevelChip.tsx
@@ -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 (
diff --git a/src/apps/dashboard/features/activity/utils/getLogLevelColor.ts b/src/apps/dashboard/features/activity/utils/getLogLevelColor.ts
new file mode 100644
index 0000000000..42f08799ca
--- /dev/null
+++ b/src/apps/dashboard/features/activity/utils/getLogLevelColor.ts
@@ -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;
diff --git a/src/apps/dashboard/features/devices/components/DeviceCard.tsx b/src/apps/dashboard/features/devices/components/DeviceCard.tsx
new file mode 100644
index 0000000000..1ee65f8d77
--- /dev/null
+++ b/src/apps/dashboard/features/devices/components/DeviceCard.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {device.DeviceName}
+ {device.Client + ' ' + device.ApplicationVersion}
+
+
+
+
+ {nowPlayingName.image ? (
+
+ ) : (
+ {nowPlayingName.topText}
+ )}
+ {nowPlayingName.bottomText}
+
+ {device.NowPlayingItem && (
+ {runningTime.start} / {runningTime.end}
+ )}
+
+
+
+ {(device.PlayState?.PositionTicks != null && device.NowPlayingItem?.RunTimeTicks != null) && (
+
+ )}
+
+
+ {canControl && (
+ <>
+
+ {device.PlayState?.IsPaused ? : }
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+ {device.UserName && (
+
+ {device.UserName}
+
+ )}
+
+ );
+};
+
+export default DeviceCard;
diff --git a/src/apps/dashboard/features/metrics/api/useItemCounts.ts b/src/apps/dashboard/features/metrics/api/useItemCounts.ts
index ee2ef772db..01f39c3fec 100644
--- a/src/apps/dashboard/features/metrics/api/useItemCounts.ts
+++ b/src/apps/dashboard/features/metrics/api/useItemCounts.ts
@@ -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 = (
diff --git a/src/apps/dashboard/features/sessions/api/usePlayPauseSession.ts b/src/apps/dashboard/features/sessions/api/usePlayPauseSession.ts
new file mode 100644
index 0000000000..680b1c0c77
--- /dev/null
+++ b/src/apps/dashboard/features/sessions/api/usePlayPauseSession.ts
@@ -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)
+ )
+ });
+};
diff --git a/src/apps/dashboard/features/sessions/api/useSendMessage.ts b/src/apps/dashboard/features/sessions/api/useSendMessage.ts
new file mode 100644
index 0000000000..764a039e8b
--- /dev/null
+++ b/src/apps/dashboard/features/sessions/api/useSendMessage.ts
@@ -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)
+ )
+ });
+};
diff --git a/src/apps/dashboard/features/sessions/api/useSessions.ts b/src/apps/dashboard/features/sessions/api/useSessions.ts
new file mode 100644
index 0000000000..279e2491d1
--- /dev/null
+++ b/src/apps/dashboard/features/sessions/api/useSessions.ts
@@ -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
+ });
+};
diff --git a/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts b/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts
new file mode 100644
index 0000000000..f6074da938
--- /dev/null
+++ b/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts
@@ -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;
diff --git a/src/apps/dashboard/features/sessions/utils/filterSessions.ts b/src/apps/dashboard/features/sessions/utils/filterSessions.ts
new file mode 100644
index 0000000000..06a87001f4
--- /dev/null
+++ b/src/apps/dashboard/features/sessions/utils/filterSessions.ts
@@ -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;
diff --git a/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts b/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts
new file mode 100644
index 0000000000..6efbea32bc
--- /dev/null
+++ b/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts
@@ -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;
diff --git a/src/apps/dashboard/features/sessions/utils/getNowPlayingName.ts b/src/apps/dashboard/features/sessions/utils/getNowPlayingName.ts
new file mode 100644
index 0000000000..f853db6f12
--- /dev/null
+++ b/src/apps/dashboard/features/sessions/utils/getNowPlayingName.ts
@@ -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;
diff --git a/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts
new file mode 100644
index 0000000000..16f86d8ac7
--- /dev/null
+++ b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts
@@ -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;
diff --git a/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingTime.ts b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingTime.ts
new file mode 100644
index 0000000000..07f55f8712
--- /dev/null
+++ b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingTime.ts
@@ -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;
diff --git a/src/apps/dashboard/features/storage/api/useSystemStorage.ts b/src/apps/dashboard/features/storage/api/useSystemStorage.ts
index 12db4a1713..8017f767b7 100644
--- a/src/apps/dashboard/features/storage/api/useSystemStorage.ts
+++ b/src/apps/dashboard/features/storage/api/useSystemStorage.ts
@@ -19,7 +19,8 @@ const getSystemStorageQuery = (
) => queryOptions({
queryKey: [ 'SystemStorage' ],
queryFn: ({ signal }) => fetchSystemStorage(api!, { signal }),
- enabled: !!api
+ enabled: !!api,
+ refetchOnWindowFocus: false
});
export const useSystemStorage = () => {
diff --git a/src/apps/dashboard/features/system/api/useRestartServer.ts b/src/apps/dashboard/features/system/api/useRestartServer.ts
new file mode 100644
index 0000000000..449e965930
--- /dev/null
+++ b/src/apps/dashboard/features/system/api/useRestartServer.ts
@@ -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;
diff --git a/src/apps/dashboard/features/system/api/useShutdownServer.ts b/src/apps/dashboard/features/system/api/useShutdownServer.ts
new file mode 100644
index 0000000000..90d2f6cc6a
--- /dev/null
+++ b/src/apps/dashboard/features/system/api/useShutdownServer.ts
@@ -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;
diff --git a/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts b/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts
new file mode 100644
index 0000000000..6cd875952b
--- /dev/null
+++ b/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts
@@ -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;
diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts
index 7e52f99aa7..e5822957d6 100644
--- a/src/apps/dashboard/routes/_asyncRoutes.ts
+++ b/src/apps/dashboard/routes/_asyncRoutes.ts
@@ -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 },
diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts
index bf292f8185..e3911d3b4d 100644
--- a/src/apps/dashboard/routes/_legacyRoutes.ts
+++ b/src/apps/dashboard/routes/_legacyRoutes.ts
@@ -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,
diff --git a/src/apps/dashboard/routes/index.tsx b/src/apps/dashboard/routes/index.tsx
new file mode 100644
index 0000000000..76b1d076b1
--- /dev/null
+++ b/src/apps/dashboard/routes/index.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isMedium || isExtraLarge ? (
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+
+ );
+};
+
+Component.displayName = 'DashboardPage';
diff --git a/src/apps/dashboard/routes/tasks/index.tsx b/src/apps/dashboard/routes/tasks/index.tsx
index 232fb34950..880f654a15 100644
--- a/src/apps/dashboard/routes/tasks/index.tsx
+++ b/src/apps/dashboard/routes/tasks/index.tsx
@@ -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 ;
diff --git a/src/components/InputDialog.tsx b/src/components/InputDialog.tsx
new file mode 100644
index 0000000000..ccf52aed29
--- /dev/null
+++ b/src/components/InputDialog.tsx
@@ -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) => {
+ setText(e.target.value);
+ }, []);
+
+ const onConfirmClick = useCallback(() => {
+ onConfirm(text);
+ setText('');
+ }, [ text, onConfirm ]);
+
+ return (
+
+ );
+};
+
+export default InputDialog;
diff --git a/src/components/SimpleAlert.tsx b/src/components/SimpleAlert.tsx
index 5322662d7d..efac0177cd 100644
--- a/src/components/SimpleAlert.tsx
+++ b/src/components/SimpleAlert.tsx
@@ -22,7 +22,7 @@ const SimpleAlert = ({ open, title, text, onClose }: SimpleAlertDialog) => {
)}
-
+
{text}
diff --git a/src/components/activitylog.js b/src/components/activitylog.js
deleted file mode 100644
index cde6c81da8..0000000000
--- a/src/components/activitylog.js
+++ /dev/null
@@ -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 += '';
- 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 += '
";
- } else {
- html += '
';
- }
-
- html += '
';
- html += '
';
- html += escapeHtml(entry.Name);
- html += '
';
- html += '
';
- html += formatRelative(Date.parse(entry.Date), Date.now(), { locale: getLocale() });
- html += '
';
- html += '
';
- html += escapeHtml(entry.ShortOverview || '');
- html += '
';
- html += '
';
-
- if (entry.Overview) {
- html += `
`;
- }
-
- html += '
';
-
- 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;
diff --git a/src/themes/defaults.ts b/src/themes/defaults.ts
index 5150ec003c..db7fcbd009 100644
--- a/src/themes/defaults.ts
+++ b/src/themes/defaults.ts
@@ -23,7 +23,7 @@ export const DEFAULT_COLOR_SCHEME: ColorSystemOptions = {
main: '#f2b01e' // Yellow color
},
error: {
- main: '#cb272a' // Red color
+ main: '#c62828' // Red color
}
}
};