From df0e6d93eb92e277f3cb6048672472ee50406fcd Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Sat, 7 Jun 2025 10:23:28 +0300 Subject: [PATCH 01/17] Create separate widget component --- .../components/widgets/ServerPathWidget.tsx | 30 +++++----------- .../dashboard/components/widgets/Widget.tsx | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 22 deletions(-) create mode 100644 src/apps/dashboard/components/widgets/Widget.tsx diff --git a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx index 98fe4be579..b7c960e489 100644 --- a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx +++ b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx @@ -1,35 +1,21 @@ -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'; const ServerPathWidget = () => { const { data: systemStorage } = useSystemStorage(); return ( - <> - - + { 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..a2a4bc8b14 --- /dev/null +++ b/src/apps/dashboard/components/widgets/Widget.tsx @@ -0,0 +1,36 @@ +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'; + +type IProps = { + title: string; + href?: string; + children: React.ReactNode; +}; + +const Widget = ({ title, href, children }: IProps) => { + return ( + + + + {children} + + ); +}; + +export default Widget; From 0934889cc819d163348a132757caa6040b323d61 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Sat, 7 Jun 2025 17:49:55 +0300 Subject: [PATCH 02/17] Migrate dashboard to React --- .../components/widgets/ActivityLogWidget.tsx | 31 + .../components/widgets/AlertsLogWidget.tsx | 31 + .../components/widgets/DevicesWidget.tsx | 30 + .../components/widgets/RunningTasksWidget.tsx | 42 + .../components/widgets/ServerInfoWidget.tsx | 77 ++ .../components/widgets/ServerPathWidget.tsx | 13 +- .../dashboard/components/widgets/Widget.tsx | 34 +- src/apps/dashboard/controllers/dashboard.html | 91 -- src/apps/dashboard/controllers/dashboard.js | 880 ------------------ src/apps/dashboard/controllers/dashboard.scss | 17 - .../features/activity/api/useLogEntries.ts | 3 +- .../activity/components/ActivityListItem.tsx | 58 ++ .../devices/components/DeviceCard.tsx | 253 +++++ .../sessions/api/usePlayPauseSession.ts | 15 + .../features/sessions/api/useSendMessage.ts | 15 + .../features/sessions/api/useSessions.ts | 34 + .../sessions/hooks/useLiveSessions.ts | 38 + .../features/sessions/utils/filterSessions.ts | 18 + .../sessions/utils/getNowPlayingImageUrl.ts | 95 ++ .../sessions/utils/getNowPlayingName.ts | 61 ++ .../utils/getSessionNowPlayingStreamInfo.ts | 58 ++ .../utils/getSessionNowPlayingTime.ts | 26 + .../features/system/api/useRestartServer.ts | 16 + .../features/system/api/useShutdownServer.ts | 16 + .../features/tasks/hooks/useLiveTasks.ts | 43 + src/apps/dashboard/routes/_asyncRoutes.ts | 1 + src/apps/dashboard/routes/_legacyRoutes.ts | 7 - src/apps/dashboard/routes/index.tsx | 192 ++++ src/apps/dashboard/routes/tasks/index.tsx | 38 +- src/components/InputDialog.tsx | 61 ++ src/components/SimpleAlert.tsx | 2 +- src/components/activitylog.js | 169 ---- src/themes/defaults.ts | 2 +- 33 files changed, 1245 insertions(+), 1222 deletions(-) create mode 100644 src/apps/dashboard/components/widgets/ActivityLogWidget.tsx create mode 100644 src/apps/dashboard/components/widgets/AlertsLogWidget.tsx create mode 100644 src/apps/dashboard/components/widgets/DevicesWidget.tsx create mode 100644 src/apps/dashboard/components/widgets/RunningTasksWidget.tsx create mode 100644 src/apps/dashboard/components/widgets/ServerInfoWidget.tsx delete mode 100644 src/apps/dashboard/controllers/dashboard.html delete mode 100644 src/apps/dashboard/controllers/dashboard.js delete mode 100644 src/apps/dashboard/controllers/dashboard.scss create mode 100644 src/apps/dashboard/features/activity/components/ActivityListItem.tsx create mode 100644 src/apps/dashboard/features/devices/components/DeviceCard.tsx create mode 100644 src/apps/dashboard/features/sessions/api/usePlayPauseSession.ts create mode 100644 src/apps/dashboard/features/sessions/api/useSendMessage.ts create mode 100644 src/apps/dashboard/features/sessions/api/useSessions.ts create mode 100644 src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts create mode 100644 src/apps/dashboard/features/sessions/utils/filterSessions.ts create mode 100644 src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts create mode 100644 src/apps/dashboard/features/sessions/utils/getNowPlayingName.ts create mode 100644 src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts create mode 100644 src/apps/dashboard/features/sessions/utils/getSessionNowPlayingTime.ts create mode 100644 src/apps/dashboard/features/system/api/useRestartServer.ts create mode 100644 src/apps/dashboard/features/system/api/useShutdownServer.ts create mode 100644 src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts create mode 100644 src/apps/dashboard/routes/index.tsx create mode 100644 src/components/InputDialog.tsx delete mode 100644 src/components/activitylog.js diff --git a/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx b/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx new file mode 100644 index 0000000000..847af0edb1 --- /dev/null +++ b/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx @@ -0,0 +1,31 @@ +import React 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 type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry'; + +type IProps = { + logs?: ActivityLogEntry[]; +}; + +const ActivityLogWidget = ({ logs }: IProps) => { + return ( + + + {logs?.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..015d1c853c --- /dev/null +++ b/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx @@ -0,0 +1,31 @@ +import React 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 type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models'; + +type IProps = { + alerts?: ActivityLogEntry[]; +}; + +const AlertsLogWidget = ({ alerts }: IProps) => { + return ( + + + {alerts?.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..874bb3eb12 --- /dev/null +++ b/src/apps/dashboard/components/widgets/DevicesWidget.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import globalize from 'lib/globalize'; +import Widget from './Widget'; +import type { SessionInfo } from '@jellyfin/sdk/lib/generated-client/models/session-info'; +import DeviceCard from 'apps/dashboard/features/devices/components/DeviceCard'; +import Stack from '@mui/material/Stack'; + +type IProps = { + devices?: SessionInfo[]; +}; + +const DevicesWidget = ({ devices }: IProps) => { + 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..8f4f5eb4d1 --- /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 IProps = { + tasks?: TaskInfo[]; +}; + +const RunningTasksWidget = ({ tasks }: IProps) => { + 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..b612df3110 --- /dev/null +++ b/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx @@ -0,0 +1,77 @@ +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 type { SystemInfo } from '@jellyfin/sdk/lib/generated-client/models'; + +type IProps = { + systemInfo?: SystemInfo; + onScanLibrariesClick?: () => void; + onRestartClick?: () => void; + onShutdownClick?: () => void; +}; + +const ServerInfoWidget = ({ systemInfo, onScanLibrariesClick, onRestartClick, onShutdownClick }: IProps) => { + return ( + + + + + + {globalize.translate('LabelServerName')} + {globalize.translate('LabelServerVersion')} + {globalize.translate('LabelWebVersion')} + {globalize.translate('LabelBuildVersion')} + + + {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 b7c960e489..93d900cd32 100644 --- a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx +++ b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx @@ -1,20 +1,19 @@ import List from '@mui/material/List'; 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 type { SystemStorageDto } from '@jellyfin/sdk/lib/generated-client/models/system-storage-dto'; -const ServerPathWidget = () => { - const { data: systemStorage } = useSystemStorage(); +type IProps = { + systemStorage?: SystemStorageDto; +}; +const ServerPathWidget = ({ systemStorage }: IProps) => { return ( { return ( - + + {children} 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}

-
-
-
- - - -
-
-
- -
-
-
- Jellyfin -
-
-
-
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 += '
'; - - let btnCssClass = session.ServerId && session.NowPlayingItem && session.SupportsRemoteControl ? '' : ' hide'; - const playIcon = session.PlayState.IsPaused ? 'play_arrow' : 'pause'; - - html += ''; - html += ''; - html += ''; - - btnCssClass = session.ServerId && session.SupportedCommands.indexOf('DisplayMessage') !== -1 && session.DeviceId !== ServerConnections.deviceId() ? '' : ' hide'; - html += ''; - html += '
'; - - html += '
'; - const userImage = DashboardPage.getUserImage(session); - html += userImage ? '
" : '
'; - html += '
'; - html += DashboardPage.getUsersHtml(session); - - 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 += ''; - 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..9bedc3abf1 --- /dev/null +++ b/src/apps/dashboard/features/activity/components/ActivityListItem.tsx @@ -0,0 +1,58 @@ +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'; + +type IProps = { + item: ActivityLogEntry; + displayShortOverview: boolean; +}; + +const ActivityListItem = ({ item, displayShortOverview }: IProps) => { + 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/devices/components/DeviceCard.tsx b/src/apps/dashboard/features/devices/components/DeviceCard.tsx new file mode 100644 index 0000000000..a9fac0d720 --- /dev/null +++ b/src/apps/dashboard/features/devices/components/DeviceCard.tsx @@ -0,0 +1,253 @@ +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 IProps = { + device: SessionInfo; +}; + +const DeviceCard = ({ device }: IProps) => { + 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.DeviceName} + {device.Client + ' ' + device.ApplicationVersion} + + + + + {nowPlayingName.image ? ( + Media Icon + ) : ( + {nowPlayingName.topText} + )} + {nowPlayingName.bottomText} + + {device.NowPlayingItem && ( + {runningTime.start} / {runningTime.end} + )} + + + + {(device.PlayState?.PositionTicks && device.NowPlayingItem?.RunTimeTicks) && ( + + )} + + + {canControl && ( + <> + + {device.PlayState?.IsPaused ? : } + + + + + + + + + )} + + + + + + {device.UserName && ( + + {device.UserName} + + )} + + ); +}; + +export default DeviceCard; 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..9eb79f54fe --- /dev/null +++ b/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts @@ -0,0 +1,38 @@ +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 { 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 useLiveSessions = () => { + const { __legacyApiClient__ } = useApi(); + + const params = { + activeWithinSeconds: 960 + }; + + const sessionsQuery = useSessions(params); + + useEffect(() => { + const onSessionsUpdate = (evt: Event, apiClient: ApiClient, info: SessionInfoDto[]) => { + queryClient.setQueryData([ QUERY_KEY, params ], filterSessions(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..01fe75bbc5 --- /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..6eb0b2e710 --- /dev/null +++ b/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts @@ -0,0 +1,95 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'; +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: 'Backdrop', + tag: item.BackdropImageTags[0] + }); + } + + if (item?.ParentBackdropImageTags?.length && item.ParentBackdropItemId) { + 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 && item.Id && imageTags.Thumb) { + return apiClient.getScaledImageUrl(item.Id, { + maxWidth: Math.round(dom.getScreenWidth() * 0.20), + type: 'Thumb', + tag: imageTags.Thumb + }); + } + + if (item?.ParentThumbImageTag && item.ParentThumbItemId) { + 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 && item.Id && 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 && item.AlbumId) { + return apiClient.getScaledImageUrl(item.AlbumId, { + maxWidth: Math.round(dom.getScreenWidth() * 0.20), + type: '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..c00ed51594 --- /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/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..fd1d0d097a --- /dev/null +++ b/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts @@ -0,0 +1,43 @@ +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 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 ] + }); + } + }, 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__ ]); + + return tasksQuery; +}; + +export default useLiveTasks; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 4617c19822..e9a5ddaca4 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 8d0a956792..e182ba4717 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..61eed6ed6c --- /dev/null +++ b/src/apps/dashboard/routes/index.tsx @@ -0,0 +1,192 @@ +import React, { useCallback, useEffect, useMemo, 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 { useSystemStorage } from '../features/storage/api/useSystemStorage'; +import subSeconds from 'date-fns/subSeconds'; +import { useLogEntries } from '../features/activity/api/useLogEntries'; +import { useSystemInfo } from 'hooks/useSystemInfo'; +import Loading from 'components/loading/LoadingComponent'; +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 useLiveSessions from '../features/sessions/hooks/useLiveSessions'; +import { useStartTask } from '../features/tasks/api/useStartTask'; +import Link from '@mui/material/Link'; + +export const Component = () => { + const theme = useTheme(); + const isMedium = useMediaQuery(theme.breakpoints.only('md')); + const [ isRestartConfirmDialogOpen, setIsRestartConfirmDialogOpen ] = useState(false); + const [ isShutdownConfirmDialogOpen, setIsShutdownConfirmDialogOpen ] = useState(false); + const startTask = useStartTask(); + const restartServer = useRestartServer(); + const shutdownServer = useShutdownServer(); + + const { data: tasks, isPending: isTasksPending } = useLiveTasks({ isHidden: false }); + const { data: devices } = useLiveSessions(); + + useEffect(() => { + console.log('[session]', devices); + }, [ devices ] ); + + const dayBefore = useMemo(() => ( + subSeconds(new Date(), 24 * 60 * 60 * 1000).toISOString() + ), []); + + const weekBefore = useMemo(() => ( + subSeconds(new Date(), 7 * 24 * 60 * 60 * 1000).toISOString() + ), []); + + const { data: logs, isPending: isLogsPending } = useLogEntries({ + startIndex: 0, + limit: 7, + minDate: dayBefore, + hasUserId: true + }); + + const { data: alerts, isPending: isAlertsPending } = useLogEntries({ + startIndex: 0, + limit: 4, + minDate: weekBefore, + hasUserId: false + }); + + const { data: systemStorage, isPending: isSystemStoragePending } = useSystemStorage(); + const { data: systemInfo, isPending: isSystemInfoPending } = useSystemInfo(); + + 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 && 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 ]); + + const isPending = isLogsPending || isAlertsPending || isSystemStoragePending + || isSystemInfoPending || isTasksPending; + + if (isPending) { + return ; + } + + return ( + + + + + + + + + + + + + + + + {isMedium ? ( + + + + + + + ) : ( + <> + + + + + + + + )} + + + + + Jellyfin + + + + + ); +}; + +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..8dfab3e3be --- /dev/null +++ b/src/components/InputDialog.tsx @@ -0,0 +1,61 @@ +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 IProps extends DialogProps { + title: string; + label: string; + confirmButtonText?: string; + onClose: () => void; + onConfirm: (text: string) => void; +}; + +const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm }: IProps) => { + const [ text, setText ] = useState(''); + + const onTextChange = useCallback((e: React.ChangeEvent) => { + setText(e.target.value); + }, []); + + const onConfirmClick = useCallback(() => { + onConfirm(text); + setText(''); + }, [ text, onConfirm ]); + + return ( + + {title && ( + + {title} + + )} + + + + + + + + + + ); +}; + +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 } } }; From d01089a5b22dd23508e792d88a117d7d135fa2ce Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:46:49 +0300 Subject: [PATCH 03/17] Update grid layout --- .../dashboard/components/widgets/Widget.tsx | 28 ++++++++----------- src/apps/dashboard/routes/index.tsx | 15 +++++----- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/apps/dashboard/components/widgets/Widget.tsx b/src/apps/dashboard/components/widgets/Widget.tsx index a413f1081b..eac1e56a5d 100644 --- a/src/apps/dashboard/components/widgets/Widget.tsx +++ b/src/apps/dashboard/components/widgets/Widget.tsx @@ -4,7 +4,6 @@ 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'; -import Link from '@mui/material/Link'; type IProps = { title: string; @@ -15,26 +14,21 @@ type IProps = { const Widget = ({ title, href, children }: IProps) => { return ( - } + sx={{ + marginTop: 1, + marginBottom: 1 + }} > - - + + {title} + + {children} diff --git a/src/apps/dashboard/routes/index.tsx b/src/apps/dashboard/routes/index.tsx index 61eed6ed6c..85c9e532c7 100644 --- a/src/apps/dashboard/routes/index.tsx +++ b/src/apps/dashboard/routes/index.tsx @@ -28,6 +28,7 @@ import Link from '@mui/material/Link'; 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(); @@ -134,8 +135,8 @@ export const Component = () => { confirmButtonColor='error' /> - - + + { - + - {isMedium ? ( - + {isMedium || isExtraLarge ? ( + @@ -159,10 +160,10 @@ export const Component = () => { ) : ( <> - + - + From d9ee6251ed913c5f1570b07cd8f4326984459233 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Wed, 11 Jun 2025 00:09:35 +0300 Subject: [PATCH 04/17] Move item counts widget --- src/apps/dashboard/routes/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apps/dashboard/routes/index.tsx b/src/apps/dashboard/routes/index.tsx index 85c9e532c7..a9e96e63d9 100644 --- a/src/apps/dashboard/routes/index.tsx +++ b/src/apps/dashboard/routes/index.tsx @@ -24,6 +24,7 @@ import DevicesWidget from '../components/widgets/DevicesWidget'; import useLiveSessions from '../features/sessions/hooks/useLiveSessions'; import { useStartTask } from '../features/tasks/api/useStartTask'; import Link from '@mui/material/Link'; +import ItemCountsWidget from '../components/widgets/ItemCountsWidget'; export const Component = () => { const theme = useTheme(); @@ -144,6 +145,7 @@ export const Component = () => { onRestartClick={promptRestart} onShutdownClick={promptShutdown} /> + From c475b43ebd7976138baf4df4a27118ffc4b7fbe8 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Wed, 11 Jun 2025 00:49:25 +0300 Subject: [PATCH 05/17] Maintain order of session updates --- .../sessions/hooks/useLiveSessions.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts b/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts index 9eb79f54fe..e4801a0949 100644 --- a/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts +++ b/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts @@ -2,7 +2,7 @@ import type { SessionInfoDto } from '@jellyfin/sdk/lib/generated-client/models/s import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type'; import { useApi } from 'hooks/useApi'; import { ApiClient } from 'jellyfin-apiclient'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import serverNotifications from 'scripts/serverNotifications'; import Events, { Event } from 'utils/events'; import { QUERY_KEY, useSessions } from '../api/useSessions'; @@ -18,9 +18,29 @@ const useLiveSessions = () => { const sessionsQuery = useSessions(params); + const updateSessions = useCallback((sessions: SessionInfoDto[]) => { + const newSessions = filterSessions(sessions); + const data = queryClient.getQueryData([ QUERY_KEY, 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) { + return [ ...currentSessions, session ]; + }; + currentSessions[sessionIndex] = session; + return currentSessions; + } + return currentSessions; + } else { + return newSessions; + } + }, []); + useEffect(() => { const onSessionsUpdate = (evt: Event, apiClient: ApiClient, info: SessionInfoDto[]) => { - queryClient.setQueryData([ QUERY_KEY, params ], filterSessions(info)); + queryClient.setQueryData([ QUERY_KEY, params ], updateSessions(info)); }; __legacyApiClient__?.sendMessage(SessionMessageType.SessionsStart, '0,1500'); From e1c9c8efd86587734d526e6909c66c4bd0183fec Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Wed, 11 Jun 2025 00:56:54 +0300 Subject: [PATCH 06/17] Fix scaling of logos --- .../dashboard/components/widgets/ItemCountsWidget.tsx | 8 +++++--- .../dashboard/features/devices/components/DeviceCard.tsx | 3 ++- src/apps/dashboard/features/metrics/api/useItemCounts.ts | 3 ++- .../dashboard/features/storage/api/useSystemStorage.ts | 3 ++- src/apps/dashboard/routes/index.tsx | 6 ++++-- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/apps/dashboard/components/widgets/ItemCountsWidget.tsx b/src/apps/dashboard/components/widgets/ItemCountsWidget.tsx index 12a58a60ef..641968ac24 100644 --- a/src/apps/dashboard/components/widgets/ItemCountsWidget.tsx +++ b/src/apps/dashboard/components/widgets/ItemCountsWidget.tsx @@ -7,13 +7,15 @@ import VideoLibrary from '@mui/icons-material/VideoLibrary'; import Grid from '@mui/material/Grid2'; import React from 'react'; -import { useItemCounts } from 'apps/dashboard/features/metrics/api/useItemCounts'; import MetricCard from 'apps/dashboard/features/metrics/components/MetricCard'; import globalize from 'lib/globalize'; +import type { ItemCounts } from '@jellyfin/sdk/lib/generated-client/models/item-counts'; -const ItemCountsWidget = () => { - const { data: counts } = useItemCounts(); +type IProps = { + counts?: ItemCounts; +}; +const ItemCountsWidget = ({ counts }: IProps) => { return ( { src={nowPlayingName.image} style={{ maxHeight: '24px', - maxWidth: '130px' + maxWidth: '130px', + alignSelf: 'flex-start' }} alt='Media Icon' /> 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/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/routes/index.tsx b/src/apps/dashboard/routes/index.tsx index a9e96e63d9..c8aac18768 100644 --- a/src/apps/dashboard/routes/index.tsx +++ b/src/apps/dashboard/routes/index.tsx @@ -25,6 +25,7 @@ import useLiveSessions from '../features/sessions/hooks/useLiveSessions'; import { useStartTask } from '../features/tasks/api/useStartTask'; import Link from '@mui/material/Link'; import ItemCountsWidget from '../components/widgets/ItemCountsWidget'; +import { useItemCounts } from '../features/metrics/api/useItemCounts'; export const Component = () => { const theme = useTheme(); @@ -67,6 +68,7 @@ export const Component = () => { const { data: systemStorage, isPending: isSystemStoragePending } = useSystemStorage(); const { data: systemInfo, isPending: isSystemInfoPending } = useSystemInfo(); + const { data: itemCounts, isPending: isItemCountsPending } = useItemCounts(); const promptRestart = useCallback(() => { setIsRestartConfirmDialogOpen(true); @@ -105,7 +107,7 @@ export const Component = () => { }, [ shutdownServer ]); const isPending = isLogsPending || isAlertsPending || isSystemStoragePending - || isSystemInfoPending || isTasksPending; + || isSystemInfoPending || isTasksPending || isItemCountsPending; if (isPending) { return ; @@ -145,7 +147,7 @@ export const Component = () => { onRestartClick={promptRestart} onShutdownClick={promptShutdown} /> - + From 6a82bda2f8c749d4066d4dfec103f3e8fc41408b Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Wed, 11 Jun 2025 01:00:21 +0300 Subject: [PATCH 07/17] Fix progress bar on device card --- src/apps/dashboard/features/devices/components/DeviceCard.tsx | 2 +- src/apps/dashboard/routes/index.tsx | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/apps/dashboard/features/devices/components/DeviceCard.tsx b/src/apps/dashboard/features/devices/components/DeviceCard.tsx index 13a849b8d8..1038747079 100644 --- a/src/apps/dashboard/features/devices/components/DeviceCard.tsx +++ b/src/apps/dashboard/features/devices/components/DeviceCard.tsx @@ -200,7 +200,7 @@ const DeviceCard = ({ device }: IProps) => { - {(device.PlayState?.PositionTicks && device.NowPlayingItem?.RunTimeTicks) && ( + {(device.PlayState?.PositionTicks != null && device.NowPlayingItem?.RunTimeTicks != null) && ( { const { data: tasks, isPending: isTasksPending } = useLiveTasks({ isHidden: false }); const { data: devices } = useLiveSessions(); - useEffect(() => { - console.log('[session]', devices); - }, [ devices ] ); - const dayBefore = useMemo(() => ( subSeconds(new Date(), 24 * 60 * 60 * 1000).toISOString() ), []); From 1e96289f73afaee7e83940eee537c7fa0bcb747a Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:05:58 +0300 Subject: [PATCH 08/17] cleanup --- .../components/widgets/ServerInfoWidget.tsx | 3 +- .../sessions/utils/getNowPlayingImageUrl.ts | 30 ------------------- src/apps/dashboard/routes/index.tsx | 2 +- 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx b/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx index b612df3110..8fba9eef8e 100644 --- a/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx +++ b/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx @@ -64,7 +64,8 @@ const ServerInfoWidget = ({ systemInfo, onScanLibrariesClick, onRestartClick, on onClick={onShutdownClick} color='error' sx={{ - fontWeight: 'bold' }} + fontWeight: 'bold' + }} > {globalize.translate('ButtonShutdown')} diff --git a/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts b/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts index 6eb0b2e710..465adfa763 100644 --- a/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts +++ b/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts @@ -25,16 +25,6 @@ const getNowPlayingImageUrl = (item: BaseItemDto) => { }); } - /* - 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 && item.Id && imageTags.Thumb) { @@ -53,16 +43,6 @@ const getNowPlayingImageUrl = (item: BaseItemDto) => { }); } - /* - if (item?.ThumbImageTag) { - return apiClient.getScaledImageUrl(item.ThumbItemId, { - maxWidth: Math.round(dom.getScreenWidth() * 0.20), - type: 'Thumb', - tag: item.ThumbImageTag - }); - } - */ - if (item && item.Id && imageTags.Primary) { return apiClient.getScaledImageUrl(item.Id, { maxWidth: Math.round(dom.getScreenWidth() * 0.20), @@ -71,16 +51,6 @@ const getNowPlayingImageUrl = (item: BaseItemDto) => { }); } - /* - if (item?.PrimaryImageTag) { - return apiClient.getScaledImageUrl(item.PrimaryImageItemId, { - maxWidth: Math.round(dom.getScreenWidth() * 0.20), - type: 'Primary', - tag: item.PrimaryImageTag - }); - } - */ - if (item?.AlbumPrimaryImageTag && item.AlbumId) { return apiClient.getScaledImageUrl(item.AlbumId, { maxWidth: Math.round(dom.getScreenWidth() * 0.20), diff --git a/src/apps/dashboard/routes/index.tsx b/src/apps/dashboard/routes/index.tsx index 89db221253..b27ac45c4d 100644 --- a/src/apps/dashboard/routes/index.tsx +++ b/src/apps/dashboard/routes/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import Page from 'components/Page'; import globalize from 'lib/globalize'; import Box from '@mui/material/Box'; From d227ec86ed9994773a2620215f6579369dc850d6 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Fri, 27 Jun 2025 02:13:41 +0300 Subject: [PATCH 09/17] Apply review feedback --- .../components/widgets/AlertsLogWidget.tsx | 2 ++ .../activity/components/ActivityListItem.tsx | 2 +- .../sessions/hooks/useLiveSessions.ts | 8 ++--- .../utils/getSessionNowPlayingStreamInfo.ts | 2 +- .../features/tasks/hooks/useLiveTasks.ts | 4 ++- src/apps/dashboard/routes/index.tsx | 29 +++++-------------- 6 files changed, 18 insertions(+), 29 deletions(-) diff --git a/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx b/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx index 015d1c853c..f1147b49d8 100644 --- a/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx +++ b/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx @@ -10,6 +10,8 @@ type IProps = { }; const AlertsLogWidget = ({ alerts }: IProps) => { + if (alerts?.length == 0) return null; + return ( { }, [ item ]); return ( - + diff --git a/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts b/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts index e4801a0949..55a907941e 100644 --- a/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts +++ b/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts @@ -27,10 +27,10 @@ const useLiveSessions = () => { for (const session of newSessions) { const sessionIndex = currentSessions.findIndex((value) => value.DeviceId === session.DeviceId); if (sessionIndex == -1) { - return [ ...currentSessions, session ]; - }; - currentSessions[sessionIndex] = session; - return currentSessions; + currentSessions.push(session); + } else { + currentSessions[sessionIndex] = session; + } } return currentSessions; } else { diff --git a/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts index c00ed51594..16f86d8ac7 100644 --- a/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts +++ b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts @@ -42,7 +42,7 @@ const getSessionNowPlayingStreamInfo = (session: SessionInfo): string => { line.push(session.TranscodingInfo.VideoCodec.toUpperCase()); } - if (session.TranscodingInfo.AudioCodec && session.TranscodingInfo.AudioCodec != session.TranscodingInfo.Container) { + if (session.TranscodingInfo.AudioCodec && session.TranscodingInfo.AudioCodec !== session.TranscodingInfo.Container) { line.push(session.TranscodingInfo.AudioCodec.toUpperCase()); } } diff --git a/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts b/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts index fd1d0d097a..6cd875952b 100644 --- a/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts +++ b/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts @@ -9,6 +9,8 @@ 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); @@ -25,7 +27,7 @@ const useLiveTasks = (params: ScheduledTasksApiGetTasksRequest) => { queryKey: [ QUERY_KEY ] }); } - }, 1e4); + }, FALLBACK_POLL_INTERVAL_MS); __legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStart, '1000,1000'); Events.on(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate); diff --git a/src/apps/dashboard/routes/index.tsx b/src/apps/dashboard/routes/index.tsx index b27ac45c4d..45741cfbd9 100644 --- a/src/apps/dashboard/routes/index.tsx +++ b/src/apps/dashboard/routes/index.tsx @@ -23,7 +23,6 @@ import RunningTasksWidget from '../components/widgets/RunningTasksWidget'; import DevicesWidget from '../components/widgets/DevicesWidget'; import useLiveSessions from '../features/sessions/hooks/useLiveSessions'; import { useStartTask } from '../features/tasks/api/useStartTask'; -import Link from '@mui/material/Link'; import ItemCountsWidget from '../components/widgets/ItemCountsWidget'; import { useItemCounts } from '../features/metrics/api/useItemCounts'; @@ -41,11 +40,11 @@ export const Component = () => { const { data: devices } = useLiveSessions(); const dayBefore = useMemo(() => ( - subSeconds(new Date(), 24 * 60 * 60 * 1000).toISOString() + subSeconds(new Date(), 24 * 60 * 60).toISOString() ), []); const weekBefore = useMemo(() => ( - subSeconds(new Date(), 7 * 24 * 60 * 60 * 1000).toISOString() + subSeconds(new Date(), 7 * 24 * 60 * 60).toISOString() ), []); const { data: logs, isPending: isLogsPending } = useLogEntries({ @@ -160,31 +159,17 @@ export const Component = () => { ) : ( <> - - - + {(alerts?.Items && alerts.Items.length > 0) && ( + + + + )} )} - - - - Jellyfin - - ); From e929a21e371b9ef17289484eac90bf691401733c Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Fri, 27 Jun 2025 05:53:39 +0300 Subject: [PATCH 10/17] Use skeleton loading where possible --- .../components/widgets/ActivityLogWidget.tsx | 49 +++++++++----- .../components/widgets/AlertsLogWidget.tsx | 24 ++++--- .../components/widgets/DevicesWidget.tsx | 8 +-- .../components/widgets/ItemCountsWidget.tsx | 8 +-- .../components/widgets/ServerInfoWidget.tsx | 25 +++++-- .../components/widgets/ServerPathWidget.tsx | 8 +-- src/apps/dashboard/routes/index.tsx | 66 ++++--------------- 7 files changed, 90 insertions(+), 98 deletions(-) diff --git a/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx b/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx index 847af0edb1..663053b6fd 100644 --- a/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx +++ b/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx @@ -1,29 +1,48 @@ -import React from 'react'; +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 type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry'; +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'; -type IProps = { - logs?: ActivityLogEntry[]; -}; +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 + }); -const ActivityLogWidget = ({ logs }: IProps) => { return ( - - {logs?.map(entry => ( - - ))} - + {isPending ? ( + + + + + + + ) : ( + + {logs?.Items?.map(entry => ( + + ))} + + )} ); }; diff --git a/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx b/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx index f1147b49d8..c665e7293c 100644 --- a/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx +++ b/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx @@ -1,16 +1,24 @@ -import React from 'react'; +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 type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models'; +import subSeconds from 'date-fns/subSeconds'; +import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries'; -type IProps = { - alerts?: ActivityLogEntry[]; -}; +const AlertsLogWidget = () => { + const weekBefore = useMemo(() => ( + subSeconds(new Date(), 7 * 24 * 60 * 60).toISOString() + ), []); -const AlertsLogWidget = ({ alerts }: IProps) => { - if (alerts?.length == 0) return null; + const { data: alerts, isPending } = useLogEntries({ + startIndex: 0, + limit: 4, + minDate: weekBefore, + hasUserId: false + }); + + if (isPending || alerts?.Items?.length == 0) return null; return ( { href='/dashboard/activity?useractivity=false' > - {alerts?.map(entry => ( + {alerts?.Items?.map(entry => ( { + const { data: devices } = useLiveSessions(); -const DevicesWidget = ({ devices }: IProps) => { return ( { + const { data: counts } = useItemCounts(); -const ItemCountsWidget = ({ counts }: IProps) => { return ( void; }; -const ServerInfoWidget = ({ systemInfo, onScanLibrariesClick, onRestartClick, onShutdownClick }: IProps) => { +const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: IProps) => { + const { data: systemInfo, isPending } = useSystemInfo(); + return ( {globalize.translate('LabelBuildVersion')} - {systemInfo?.ServerName} - {systemInfo?.Version} - {__PACKAGE_JSON_VERSION__} - {__JF_BUILD_VERSION__} + {isPending ? ( + <> + + + + + + ) : ( + <> + {systemInfo?.ServerName} + {systemInfo?.Version} + {__PACKAGE_JSON_VERSION__} + {__JF_BUILD_VERSION__} + + )} diff --git a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx index 93d900cd32..36f673a321 100644 --- a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx +++ b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx @@ -3,13 +3,11 @@ import React from 'react'; import StorageListItem from 'apps/dashboard/features/storage/components/StorageListItem'; import globalize from 'lib/globalize'; import Widget from './Widget'; -import type { SystemStorageDto } from '@jellyfin/sdk/lib/generated-client/models/system-storage-dto'; +import { useSystemStorage } from 'apps/dashboard/features/storage/api/useSystemStorage'; -type IProps = { - systemStorage?: SystemStorageDto; -}; +const ServerPathWidget = () => { + const { data: systemStorage } = useSystemStorage(); -const ServerPathWidget = ({ systemStorage }: IProps) => { return ( { const theme = useTheme(); @@ -36,34 +29,7 @@ export const Component = () => { const restartServer = useRestartServer(); const shutdownServer = useShutdownServer(); - const { data: tasks, isPending: isTasksPending } = useLiveTasks({ isHidden: false }); - const { data: devices } = useLiveSessions(); - - const dayBefore = useMemo(() => ( - subSeconds(new Date(), 24 * 60 * 60).toISOString() - ), []); - - const weekBefore = useMemo(() => ( - subSeconds(new Date(), 7 * 24 * 60 * 60).toISOString() - ), []); - - const { data: logs, isPending: isLogsPending } = useLogEntries({ - startIndex: 0, - limit: 7, - minDate: dayBefore, - hasUserId: true - }); - - const { data: alerts, isPending: isAlertsPending } = useLogEntries({ - startIndex: 0, - limit: 4, - minDate: weekBefore, - hasUserId: false - }); - - const { data: systemStorage, isPending: isSystemStoragePending } = useSystemStorage(); - const { data: systemInfo, isPending: isSystemInfoPending } = useSystemInfo(); - const { data: itemCounts, isPending: isItemCountsPending } = useItemCounts(); + const { data: tasks } = useLiveTasks({ isHidden: false }); const promptRestart = useCallback(() => { setIsRestartConfirmDialogOpen(true); @@ -101,13 +67,6 @@ export const Component = () => { setIsShutdownConfirmDialogOpen(false); }, [ shutdownServer ]); - const isPending = isLogsPending || isAlertsPending || isSystemStoragePending - || isSystemInfoPending || isTasksPending || isItemCountsPending; - - if (isPending) { - return ; - } - return ( { - + - + - + {isMedium || isExtraLarge ? ( - - + + ) : ( <> - {(alerts?.Items && alerts.Items.length > 0) && ( - - - - )} - + + + + )} From aed6bebc554d30e180fec2bc6654a2a8d4e0bdde Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Sat, 28 Jun 2025 00:55:18 +0300 Subject: [PATCH 11/17] Use severity colors --- .../activity/components/ActivityListItem.tsx | 4 +++- .../activity/components/LogLevelChip.tsx | 21 ++++--------------- .../activity/utils/getLogLevelColor.ts | 15 +++++++++++++ 3 files changed, 22 insertions(+), 18 deletions(-) create mode 100644 src/apps/dashboard/features/activity/utils/getLogLevelColor.ts diff --git a/src/apps/dashboard/features/activity/components/ActivityListItem.tsx b/src/apps/dashboard/features/activity/components/ActivityListItem.tsx index 333487edbe..36ef3ddb4f 100644 --- a/src/apps/dashboard/features/activity/components/ActivityListItem.tsx +++ b/src/apps/dashboard/features/activity/components/ActivityListItem.tsx @@ -10,6 +10,8 @@ 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 IProps = { item: ActivityLogEntry; @@ -29,7 +31,7 @@ const ActivityListItem = ({ item, displayShortOverview }: IProps) => { - + 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; From e74b77bfee09cedd40fb1c63a0f4f6160496e4f3 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Sat, 28 Jun 2025 03:18:45 +0300 Subject: [PATCH 12/17] Revert ItemCountsWidget.tsx changes --- src/apps/dashboard/components/widgets/ItemCountsWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/dashboard/components/widgets/ItemCountsWidget.tsx b/src/apps/dashboard/components/widgets/ItemCountsWidget.tsx index adc203ed52..12a58a60ef 100644 --- a/src/apps/dashboard/components/widgets/ItemCountsWidget.tsx +++ b/src/apps/dashboard/components/widgets/ItemCountsWidget.tsx @@ -7,9 +7,9 @@ import VideoLibrary from '@mui/icons-material/VideoLibrary'; import Grid from '@mui/material/Grid2'; import React from 'react'; +import { useItemCounts } from 'apps/dashboard/features/metrics/api/useItemCounts'; import MetricCard from 'apps/dashboard/features/metrics/components/MetricCard'; import globalize from 'lib/globalize'; -import { useItemCounts } from 'apps/dashboard/features/metrics/api/useItemCounts'; const ItemCountsWidget = () => { const { data: counts } = useItemCounts(); From 532f5a750e453225c06150f2100c137b2aca3d65 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Sat, 28 Jun 2025 18:05:02 +0300 Subject: [PATCH 13/17] Improve layout on large screens --- src/apps/dashboard/routes/index.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/apps/dashboard/routes/index.tsx b/src/apps/dashboard/routes/index.tsx index e71bca7a59..2c59d735d3 100644 --- a/src/apps/dashboard/routes/index.tsx +++ b/src/apps/dashboard/routes/index.tsx @@ -93,7 +93,7 @@ export const Component = () => { /> - + { - + {isMedium || isExtraLarge ? ( @@ -116,14 +116,12 @@ export const Component = () => { ) : ( - <> - + + - - - - + + )} From 124f9c6755a5cfebe3ebb521fd4cde44d376b173 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Sat, 28 Jun 2025 18:28:31 +0300 Subject: [PATCH 14/17] Use standard variant for input dialog --- src/components/InputDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/InputDialog.tsx b/src/components/InputDialog.tsx index 8dfab3e3be..ea233cfad4 100644 --- a/src/components/InputDialog.tsx +++ b/src/components/InputDialog.tsx @@ -46,12 +46,13 @@ const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm label={label} value={text} onChange={onTextChange} + variant='standard' /> From 1c48ede63e0ba1f18c21a40ed61416d9535dbc75 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Tue, 1 Jul 2025 03:37:49 +0300 Subject: [PATCH 15/17] Apply review feedback --- .../components/widgets/RunningTasksWidget.tsx | 4 ++-- .../components/widgets/ServerInfoWidget.tsx | 6 ++---- src/apps/dashboard/components/widgets/Widget.tsx | 4 ++-- .../activity/components/ActivityListItem.tsx | 4 ++-- .../features/devices/components/DeviceCard.tsx | 4 ++-- .../features/sessions/hooks/useLiveSessions.ts | 14 +++++++------- .../sessions/utils/getNowPlayingImageUrl.ts | 13 +++++++------ src/components/InputDialog.tsx | 4 ++-- 8 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx b/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx index 8f4f5eb4d1..1095a9bba5 100644 --- a/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx +++ b/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx @@ -9,11 +9,11 @@ import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress' import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; -type IProps = { +type RunningTasksWidgetProps = { tasks?: TaskInfo[]; }; -const RunningTasksWidget = ({ tasks }: IProps) => { +const RunningTasksWidget = ({ tasks }: RunningTasksWidgetProps) => { const runningTasks = useMemo(() => { return tasks?.filter(v => v.State == TaskState.Running) || []; }, [ tasks ]); diff --git a/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx b/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx index b00c68f1e1..04977ba162 100644 --- a/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx +++ b/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx @@ -5,18 +5,16 @@ 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 type { SystemInfo } from '@jellyfin/sdk/lib/generated-client/models'; import Skeleton from '@mui/material/Skeleton'; import { useSystemInfo } from 'hooks/useSystemInfo'; -type IProps = { - systemInfo?: SystemInfo; +type ServerInfoWidgetProps = { onScanLibrariesClick?: () => void; onRestartClick?: () => void; onShutdownClick?: () => void; }; -const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: IProps) => { +const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: ServerInfoWidgetProps) => { const { data: systemInfo, isPending } = useSystemInfo(); return ( diff --git a/src/apps/dashboard/components/widgets/Widget.tsx b/src/apps/dashboard/components/widgets/Widget.tsx index eac1e56a5d..d0d86de95a 100644 --- a/src/apps/dashboard/components/widgets/Widget.tsx +++ b/src/apps/dashboard/components/widgets/Widget.tsx @@ -5,13 +5,13 @@ import ChevronRight from '@mui/icons-material/ChevronRight'; import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; -type IProps = { +type WidgetProps = { title: string; href: string; children: React.ReactNode; }; -const Widget = ({ title, href, children }: IProps) => { +const Widget = ({ title, href, children }: WidgetProps) => { return (