diff --git a/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx b/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx new file mode 100644 index 0000000000..663053b6fd --- /dev/null +++ b/src/apps/dashboard/components/widgets/ActivityLogWidget.tsx @@ -0,0 +1,50 @@ +import React, { useMemo } from 'react'; +import globalize from 'lib/globalize'; +import Widget from './Widget'; +import List from '@mui/material/List'; +import ActivityListItem from 'apps/dashboard/features/activity/components/ActivityListItem'; +import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries'; +import subSeconds from 'date-fns/subSeconds'; +import Skeleton from '@mui/material/Skeleton'; +import Stack from '@mui/material/Stack'; + +const ActivityLogWidget = () => { + const dayBefore = useMemo(() => ( + subSeconds(new Date(), 24 * 60 * 60).toISOString() + ), []); + + const { data: logs, isPending } = useLogEntries({ + startIndex: 0, + limit: 7, + minDate: dayBefore, + hasUserId: true + }); + + return ( + + {isPending ? ( + + + + + + + ) : ( + + {logs?.Items?.map(entry => ( + + ))} + + )} + + ); +}; + +export default ActivityLogWidget; diff --git a/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx b/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx new file mode 100644 index 0000000000..6fca74c23e --- /dev/null +++ b/src/apps/dashboard/components/widgets/AlertsLogWidget.tsx @@ -0,0 +1,41 @@ +import React, { useMemo } from 'react'; +import globalize from 'lib/globalize'; +import Widget from './Widget'; +import List from '@mui/material/List'; +import ActivityListItem from 'apps/dashboard/features/activity/components/ActivityListItem'; +import subSeconds from 'date-fns/subSeconds'; +import { useLogEntries } from 'apps/dashboard/features/activity/api/useLogEntries'; + +const AlertsLogWidget = () => { + const weekBefore = useMemo(() => ( + subSeconds(new Date(), 7 * 24 * 60 * 60).toISOString() + ), []); + + const { data: alerts, isPending } = useLogEntries({ + startIndex: 0, + limit: 4, + minDate: weekBefore, + hasUserId: false + }); + + if (isPending || alerts?.Items?.length === 0) return null; + + return ( + + + {alerts?.Items?.map(entry => ( + + ))} + + + ); +}; + +export default AlertsLogWidget; diff --git a/src/apps/dashboard/components/widgets/DevicesWidget.tsx b/src/apps/dashboard/components/widgets/DevicesWidget.tsx new file mode 100644 index 0000000000..e4f5c34ebe --- /dev/null +++ b/src/apps/dashboard/components/widgets/DevicesWidget.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import globalize from 'lib/globalize'; +import Widget from './Widget'; +import DeviceCard from 'apps/dashboard/features/devices/components/DeviceCard'; +import Stack from '@mui/material/Stack'; +import useLiveSessions from 'apps/dashboard/features/sessions/hooks/useLiveSessions'; + +const DevicesWidget = () => { + const { data: devices } = useLiveSessions(); + + return ( + + + {devices?.map(device => ( + + ))} + + + ); +}; + +export default DevicesWidget; diff --git a/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx b/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx new file mode 100644 index 0000000000..1095a9bba5 --- /dev/null +++ b/src/apps/dashboard/components/widgets/RunningTasksWidget.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from 'react'; +import globalize from 'lib/globalize'; +import Widget from './Widget'; +import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info'; +import Paper from '@mui/material/Paper'; +import { TaskState } from '@jellyfin/sdk/lib/generated-client/models/task-state'; +import Typography from '@mui/material/Typography'; +import TaskProgress from 'apps/dashboard/features/tasks/components/TaskProgress'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; + +type RunningTasksWidgetProps = { + tasks?: TaskInfo[]; +}; + +const RunningTasksWidget = ({ tasks }: RunningTasksWidgetProps) => { + const runningTasks = useMemo(() => { + return tasks?.filter(v => v.State == TaskState.Running) || []; + }, [ tasks ]); + + if (runningTasks.length == 0) return null; + + return ( + + + + {runningTasks.map((task => ( + + {task.Name} + + + )))} + + + + ); +}; + +export default RunningTasksWidget; diff --git a/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx b/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx new file mode 100644 index 0000000000..04977ba162 --- /dev/null +++ b/src/apps/dashboard/components/widgets/ServerInfoWidget.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import globalize from 'lib/globalize'; +import Widget from './Widget'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import Skeleton from '@mui/material/Skeleton'; +import { useSystemInfo } from 'hooks/useSystemInfo'; + +type ServerInfoWidgetProps = { + onScanLibrariesClick?: () => void; + onRestartClick?: () => void; + onShutdownClick?: () => void; +}; + +const ServerInfoWidget = ({ onScanLibrariesClick, onRestartClick, onShutdownClick }: ServerInfoWidgetProps) => { + const { data: systemInfo, isPending } = useSystemInfo(); + + return ( + + + + + + {globalize.translate('LabelServerName')} + {globalize.translate('LabelServerVersion')} + {globalize.translate('LabelWebVersion')} + {globalize.translate('LabelBuildVersion')} + + + {isPending ? ( + <> + + + + + + ) : ( + <> + {systemInfo?.ServerName} + {systemInfo?.Version} + {__PACKAGE_JSON_VERSION__} + {__JF_BUILD_VERSION__} + + )} + + + + + + + + + + + + + + ); +}; + +export default ServerInfoWidget; diff --git a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx index 98fe4be579..36f673a321 100644 --- a/src/apps/dashboard/components/widgets/ServerPathWidget.tsx +++ b/src/apps/dashboard/components/widgets/ServerPathWidget.tsx @@ -1,35 +1,18 @@ -import ChevronRight from '@mui/icons-material/ChevronRight'; -import Button from '@mui/material/Button'; import List from '@mui/material/List'; -import Typography from '@mui/material/Typography'; import React from 'react'; - -import { useSystemStorage } from 'apps/dashboard/features/storage/api/useSystemStorage'; import StorageListItem from 'apps/dashboard/features/storage/components/StorageListItem'; import globalize from 'lib/globalize'; +import Widget from './Widget'; +import { useSystemStorage } from 'apps/dashboard/features/storage/api/useSystemStorage'; const ServerPathWidget = () => { const { data: systemStorage } = useSystemStorage(); return ( - <> - - + { folder={systemStorage?.WebFolder} /> - + ); }; diff --git a/src/apps/dashboard/components/widgets/Widget.tsx b/src/apps/dashboard/components/widgets/Widget.tsx new file mode 100644 index 0000000000..d0d86de95a --- /dev/null +++ b/src/apps/dashboard/components/widgets/Widget.tsx @@ -0,0 +1,38 @@ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import ChevronRight from '@mui/icons-material/ChevronRight'; +import React from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +type WidgetProps = { + title: string; + href: string; + children: React.ReactNode; +}; + +const Widget = ({ title, href, children }: WidgetProps) => { + return ( + + + + {children} + + ); +}; + +export default Widget; diff --git a/src/apps/dashboard/controllers/dashboard.html b/src/apps/dashboard/controllers/dashboard.html deleted file mode 100644 index e0656d1cc8..0000000000 --- a/src/apps/dashboard/controllers/dashboard.html +++ /dev/null @@ -1,91 +0,0 @@ -
-
-
-
-
- -

${TabServer}

- -
- -
-
${LabelServerName}
-
-
${LabelServerVersion}
-
-
${LabelWebVersion}
-
-
${LabelBuildVersion}
-
-
- -
- - - -
- -
-

${HeaderRunningTasks}

-
-
-
- -
-
- - -
- - -
- -
-

${HeaderActiveRecordings}

-
-
-
- - - -
-
-
- -
-
-
- 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..b27b42735a --- /dev/null +++ b/src/apps/dashboard/features/activity/components/ActivityListItem.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +import type { ActivityLogEntry } from '@jellyfin/sdk/lib/generated-client/models/activity-log-entry'; +import Notifications from '@mui/icons-material/Notifications'; +import Avatar from '@mui/material/Avatar'; +import ListItem from '@mui/material/ListItem'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import formatRelative from 'date-fns/formatRelative'; +import { getLocale } from 'utils/dateFnsLocale'; +import Stack from '@mui/material/Stack'; +import getLogLevelColor from '../utils/getLogLevelColor'; +import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level'; + +type ActivityListItemProps = { + item: ActivityLogEntry; + displayShortOverview: boolean; +}; + +const ActivityListItem = ({ item, displayShortOverview }: ActivityListItemProps) => { + const relativeDate = useMemo(() => { + if (item.Date) { + return formatRelative(Date.parse(item.Date), Date.now(), { locale: getLocale() }); + } else { + return 'N/A'; + } + }, [ item ]); + + return ( + + + + + + + + + {item.Name}} + secondary={( + + + {relativeDate} + + {displayShortOverview && ( + + {item.ShortOverview} + + )} + + )} + disableTypography + /> + + + ); +}; + +export default ActivityListItem; diff --git a/src/apps/dashboard/features/activity/components/LogLevelChip.tsx b/src/apps/dashboard/features/activity/components/LogLevelChip.tsx index 7f1f68f750..5eb6ab233b 100644 --- a/src/apps/dashboard/features/activity/components/LogLevelChip.tsx +++ b/src/apps/dashboard/features/activity/components/LogLevelChip.tsx @@ -1,30 +1,17 @@ import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level'; import Chip from '@mui/material/Chip'; -import React from 'react'; +import React, { useMemo } from 'react'; import globalize from 'lib/globalize'; +import getLogLevelColor from '../utils/getLogLevelColor'; const LogLevelChip = ({ level }: { level: LogLevel }) => { - let color: 'info' | 'warning' | 'error' | undefined; - switch (level) { - case LogLevel.Information: - color = 'info'; - break; - case LogLevel.Warning: - color = 'warning'; - break; - case LogLevel.Error: - case LogLevel.Critical: - color = 'error'; - break; - } - - const levelText = globalize.translate(`LogLevel.${level}`); + const levelText = useMemo(() => globalize.translate(`LogLevel.${level}`), [level]); return ( diff --git a/src/apps/dashboard/features/activity/utils/getLogLevelColor.ts b/src/apps/dashboard/features/activity/utils/getLogLevelColor.ts new file mode 100644 index 0000000000..42f08799ca --- /dev/null +++ b/src/apps/dashboard/features/activity/utils/getLogLevelColor.ts @@ -0,0 +1,15 @@ +import { LogLevel } from '@jellyfin/sdk/lib/generated-client/models/log-level'; + +const getLogLevelColor = (level: LogLevel) => { + switch (level) { + case LogLevel.Information: + return 'info'; + case LogLevel.Warning: + return 'warning'; + case LogLevel.Error: + case LogLevel.Critical: + return 'error'; + } +}; + +export default getLogLevelColor; diff --git a/src/apps/dashboard/features/devices/components/DeviceCard.tsx b/src/apps/dashboard/features/devices/components/DeviceCard.tsx new file mode 100644 index 0000000000..1ee65f8d77 --- /dev/null +++ b/src/apps/dashboard/features/devices/components/DeviceCard.tsx @@ -0,0 +1,254 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import type { SessionInfo } from '@jellyfin/sdk/lib/generated-client/models/session-info'; +import Typography from '@mui/material/Typography'; +import Card from '@mui/material/Card'; +import CardMedia from '@mui/material/CardMedia'; +import { getDeviceIcon } from 'utils/image'; +import Stack from '@mui/material/Stack'; +import getNowPlayingName from '../../sessions/utils/getNowPlayingName'; +import getSessionNowPlayingTime from '../../sessions/utils/getSessionNowPlayingTime'; +import getNowPlayingImageUrl from '../../sessions/utils/getNowPlayingImageUrl'; +import { getDefaultBackgroundClass } from 'components/cardbuilder/cardBuilderUtils'; +import Comment from '@mui/icons-material/Comment'; +import PlayArrow from '@mui/icons-material/PlayArrow'; +import Pause from '@mui/icons-material/Pause'; +import Stop from '@mui/icons-material/Stop'; +import Info from '@mui/icons-material/Info'; +import LinearProgress from '@mui/material/LinearProgress'; +import CardActions from '@mui/material/CardActions'; +import IconButton from '@mui/material/IconButton'; +import SimpleAlert from 'components/SimpleAlert'; +import playmethodhelper from 'components/playback/playmethodhelper'; +import globalize from 'lib/globalize'; +import getSessionNowPlayingStreamInfo from '../../sessions/utils/getSessionNowPlayingStreamInfo'; +import { useSendPlayStateCommand } from '../../sessions/api/usePlayPauseSession'; +import { PlaystateCommand } from '@jellyfin/sdk/lib/generated-client/models/playstate-command'; +import InputDialog from 'components/InputDialog'; +import { useSendMessage } from '../../sessions/api/useSendMessage'; + +type DeviceCardProps = { + device: SessionInfo; +}; + +const DeviceCard = ({ device }: DeviceCardProps) => { + const [ playbackInfoTitle, setPlaybackInfoTitle ] = useState(''); + const [ playbackInfoDesc, setPlaybackInfoDesc ] = useState(''); + const [ isPlaybackInfoOpen, setIsPlaybackInfoOpen ] = useState(false); + const [ isMessageDialogOpen, setIsMessageDialogOpen ] = useState(false); + const sendMessage = useSendMessage(); + const playStateCommand = useSendPlayStateCommand(); + + const onPlayPauseSession = useCallback(() => { + if (device.Id) { + playStateCommand.mutate({ + sessionId: device.Id, + command: PlaystateCommand.PlayPause + }); + } + }, [ device, playStateCommand ]); + + const onStopSession = useCallback(() => { + if (device.Id) { + playStateCommand.mutate({ + sessionId: device.Id, + command: PlaystateCommand.Stop + }); + } + }, [ device, playStateCommand ]); + + const onMessageSend = useCallback((message: string) => { + if (device.Id) { + sendMessage.mutate({ + sessionId: device.Id, + messageCommand: { + Text: message, + TimeoutMs: 5000 + } + }); + setIsMessageDialogOpen(false); + } + }, [ sendMessage, device ]); + + const showMessageDialog = useCallback(() => { + setIsMessageDialogOpen(true); + }, []); + + const onMessageDialogClose = useCallback(() => { + setIsMessageDialogOpen(false); + }, []); + + const closePlaybackInfo = useCallback(() => { + setIsPlaybackInfoOpen(false); + }, []); + + const showPlaybackInfo = useCallback(() => { + const displayPlayMethod = playmethodhelper.getDisplayPlayMethod(device); + + switch (displayPlayMethod) { + case 'Remux': + setPlaybackInfoTitle(globalize.translate('Remuxing')); + setPlaybackInfoDesc(globalize.translate('RemuxHelp1') + '\n' + globalize.translate('RemuxHelp2')); + break; + case 'DirectStream': + setPlaybackInfoTitle(globalize.translate('DirectStreaming')); + setPlaybackInfoDesc(globalize.translate('DirectStreamHelp1') + '\n' + globalize.translate('DirectStreamHelp2')); + break; + case 'DirectPlay': + setPlaybackInfoTitle(globalize.translate('DirectPlaying')); + setPlaybackInfoDesc(globalize.translate('DirectPlayHelp')); + break; + case 'Transcode': { + const transcodeReasons = device.TranscodingInfo?.TranscodeReasons as string[] | undefined; + const localizedTranscodeReasons = transcodeReasons?.map(transcodeReason => globalize.translate(transcodeReason)) || []; + setPlaybackInfoTitle(globalize.translate('Transcoding')); + setPlaybackInfoDesc( + globalize.translate('MediaIsBeingConverted') + + '\n\n' + getSessionNowPlayingStreamInfo(device) + + '\n\n' + globalize.translate('LabelReasonForTranscoding') + + '\n' + localizedTranscodeReasons.join('\n') + ); + break; + } + } + + setIsPlaybackInfoOpen(true); + }, [ device ]); + + const nowPlayingName = useMemo(() => ( + getNowPlayingName(device) + ), [ device ]); + + const nowPlayingImage = useMemo(() => ( + device.NowPlayingItem && getNowPlayingImageUrl(device.NowPlayingItem) + ), [device]); + + const runningTime = useMemo(() => ( + getSessionNowPlayingTime(device) + ), [ device ]); + + const deviceIcon = useMemo(() => ( + getDeviceIcon(device) + ), [ device ]); + + const canControl = device.ServerId && device.NowPlayingItem && device.SupportsRemoteControl; + + return ( + + + + + + + {device.DeviceName + + {device.DeviceName} + {device.Client + ' ' + device.ApplicationVersion} + + + + + {nowPlayingName.image ? ( + Media Icon + ) : ( + {nowPlayingName.topText} + )} + {nowPlayingName.bottomText} + + {device.NowPlayingItem && ( + {runningTime.start} / {runningTime.end} + )} + + + + {(device.PlayState?.PositionTicks != null && device.NowPlayingItem?.RunTimeTicks != null) && ( + + )} + + + {canControl && ( + <> + + {device.PlayState?.IsPaused ? : } + + + + + + + + + )} + + + + + + {device.UserName && ( + + {device.UserName} + + )} + + ); +}; + +export default DeviceCard; diff --git a/src/apps/dashboard/features/metrics/api/useItemCounts.ts b/src/apps/dashboard/features/metrics/api/useItemCounts.ts index ee2ef772db..01f39c3fec 100644 --- a/src/apps/dashboard/features/metrics/api/useItemCounts.ts +++ b/src/apps/dashboard/features/metrics/api/useItemCounts.ts @@ -22,7 +22,8 @@ const getItemCountsQuery = ( ) => queryOptions({ queryKey: [ 'ItemCounts', params ], queryFn: ({ signal }) => fetchItemCounts(api!, params, { signal }), - enabled: !!api + enabled: !!api, + refetchOnWindowFocus: false }); export const useItemCounts = ( diff --git a/src/apps/dashboard/features/sessions/api/usePlayPauseSession.ts b/src/apps/dashboard/features/sessions/api/usePlayPauseSession.ts new file mode 100644 index 0000000000..680b1c0c77 --- /dev/null +++ b/src/apps/dashboard/features/sessions/api/usePlayPauseSession.ts @@ -0,0 +1,15 @@ +import { SessionApiSendPlaystateCommandRequest } from '@jellyfin/sdk/lib/generated-client/api/session-api'; +import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api'; +import { useMutation } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; + +export const useSendPlayStateCommand = () => { + const { api } = useApi(); + + return useMutation({ + mutationFn: (params: SessionApiSendPlaystateCommandRequest) => ( + getSessionApi(api!) + .sendPlaystateCommand(params) + ) + }); +}; diff --git a/src/apps/dashboard/features/sessions/api/useSendMessage.ts b/src/apps/dashboard/features/sessions/api/useSendMessage.ts new file mode 100644 index 0000000000..764a039e8b --- /dev/null +++ b/src/apps/dashboard/features/sessions/api/useSendMessage.ts @@ -0,0 +1,15 @@ +import { SessionApiSendMessageCommandRequest } from '@jellyfin/sdk/lib/generated-client/api/session-api'; +import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api'; +import { useMutation } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; + +export const useSendMessage = () => { + const { api } = useApi(); + + return useMutation({ + mutationFn: (params: SessionApiSendMessageCommandRequest) => ( + getSessionApi(api!) + .sendMessageCommand(params) + ) + }); +}; diff --git a/src/apps/dashboard/features/sessions/api/useSessions.ts b/src/apps/dashboard/features/sessions/api/useSessions.ts new file mode 100644 index 0000000000..279e2491d1 --- /dev/null +++ b/src/apps/dashboard/features/sessions/api/useSessions.ts @@ -0,0 +1,34 @@ +import type { SessionApiGetSessionsRequest } from '@jellyfin/sdk/lib/generated-client/api/session-api'; +import type { AxiosRequestConfig } from 'axios'; +import type { Api } from '@jellyfin/sdk'; +import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api'; +import { useQuery } from '@tanstack/react-query'; + +import { useApi } from 'hooks/useApi'; + +export const QUERY_KEY = 'Sessions'; + +const fetchSessions = async ( + api: Api, + requestParams?: SessionApiGetSessionsRequest, + options?: AxiosRequestConfig +) => { + const response = await getSessionApi(api).getSessions(requestParams, { + signal: options?.signal + }); + + return response.data; +}; + +export const useSessions = ( + requestParams: SessionApiGetSessionsRequest +) => { + const { api } = useApi(); + return useQuery({ + queryKey: [QUERY_KEY, requestParams], + queryFn: ({ signal }) => + fetchSessions(api!, requestParams, { signal }), + enabled: !!api, + refetchOnWindowFocus: false + }); +}; diff --git a/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts b/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts new file mode 100644 index 0000000000..f6074da938 --- /dev/null +++ b/src/apps/dashboard/features/sessions/hooks/useLiveSessions.ts @@ -0,0 +1,58 @@ +import type { SessionInfoDto } from '@jellyfin/sdk/lib/generated-client/models/session-info-dto'; +import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type'; +import { useApi } from 'hooks/useApi'; +import { ApiClient } from 'jellyfin-apiclient'; +import { useCallback, useEffect } from 'react'; +import serverNotifications from 'scripts/serverNotifications'; +import Events, { Event } from 'utils/events'; +import { QUERY_KEY, useSessions } from '../api/useSessions'; +import { queryClient } from 'utils/query/queryClient'; +import filterSessions from '../utils/filterSessions'; + +const QUERY_PARAMS = { + activeWithinSeconds: 960 +}; + +const useLiveSessions = () => { + const { __legacyApiClient__ } = useApi(); + + const sessionsQuery = useSessions(QUERY_PARAMS); + + const updateSessions = useCallback((sessions: SessionInfoDto[]) => { + const newSessions = filterSessions(sessions); + const data = queryClient.getQueryData([ QUERY_KEY, QUERY_PARAMS ]) as SessionInfoDto[]; + if (data) { + const currentSessions = [ ...data ]; + + for (const session of newSessions) { + const sessionIndex = currentSessions.findIndex((value) => value.DeviceId === session.DeviceId); + if (sessionIndex == -1) { + currentSessions.push(session); + } else { + currentSessions[sessionIndex] = session; + } + } + return currentSessions; + } else { + return newSessions; + } + }, []); + + useEffect(() => { + const onSessionsUpdate = (evt: Event, apiClient: ApiClient, info: SessionInfoDto[]) => { + queryClient.setQueryData([ QUERY_KEY, QUERY_PARAMS ], updateSessions(info)); + }; + + __legacyApiClient__?.sendMessage(SessionMessageType.SessionsStart, '0,1500'); + Events.on(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate); + + return () => { + __legacyApiClient__?.sendMessage(SessionMessageType.SessionsStop, null); + Events.off(serverNotifications, SessionMessageType.Sessions, onSessionsUpdate); + }; + }, []); + + return sessionsQuery; +}; + +export default useLiveSessions; diff --git a/src/apps/dashboard/features/sessions/utils/filterSessions.ts b/src/apps/dashboard/features/sessions/utils/filterSessions.ts new file mode 100644 index 0000000000..06a87001f4 --- /dev/null +++ b/src/apps/dashboard/features/sessions/utils/filterSessions.ts @@ -0,0 +1,18 @@ +import type { SessionInfoDto } from '@jellyfin/sdk/lib/generated-client/models/session-info-dto'; +import { parseISO, subSeconds } from 'date-fns'; + +const MIN_SESSION_ACTIVE_TIME = 95; + +const filterSessions = (sessions: SessionInfoDto[] = []) => { + const minActiveDate = subSeconds(new Date(), MIN_SESSION_ACTIVE_TIME); + + return sessions.filter(session => { + if (!session.LastActivityDate) return false; + + const lastActivityDate = parseISO(session.LastActivityDate); + + return !!((lastActivityDate >= minActiveDate) && (session.NowPlayingItem || session.UserId)); + }); +}; + +export default filterSessions; diff --git a/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts b/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts new file mode 100644 index 0000000000..6efbea32bc --- /dev/null +++ b/src/apps/dashboard/features/sessions/utils/getNowPlayingImageUrl.ts @@ -0,0 +1,66 @@ +import type { BaseItemDto } from '@jellyfin/sdk/lib/generated-client/models/base-item-dto'; +import { ImageType } from '@jellyfin/sdk/lib/generated-client/models/image-type'; +import { ServerConnections } from 'lib/jellyfin-apiclient'; +import dom from 'scripts/dom'; + +const getNowPlayingImageUrl = (item: BaseItemDto) => { + if (!item.ServerId) return null; + + const apiClient = ServerConnections.getApiClient(item.ServerId); + + /* Screen width is multiplied by 0.2, as the there is currently no way to get the width of + elements that aren't created yet. */ + if (item?.BackdropImageTags?.length && item.Id) { + return apiClient.getScaledImageUrl(item.Id, { + maxWidth: Math.round(dom.getScreenWidth() * 0.20), + type: ImageType.Backdrop, + tag: item.BackdropImageTags[0] + }); + } + + if (item?.ParentBackdropImageTags?.length && item.ParentBackdropItemId) { + return apiClient.getScaledImageUrl(item.ParentBackdropItemId, { + maxWidth: Math.round(dom.getScreenWidth() * 0.20), + type: ImageType.Backdrop, + tag: item.ParentBackdropImageTags[0] + }); + } + + const imageTags = item?.ImageTags || {}; + + if (item?.Id && imageTags.Thumb) { + return apiClient.getScaledImageUrl(item.Id, { + maxWidth: Math.round(dom.getScreenWidth() * 0.20), + type: ImageType.Thumb, + tag: imageTags.Thumb + }); + } + + if (item?.ParentThumbImageTag && item.ParentThumbItemId) { + return apiClient.getScaledImageUrl(item.ParentThumbItemId, { + maxWidth: Math.round(dom.getScreenWidth() * 0.20), + type: ImageType.Thumb, + tag: item.ParentThumbImageTag + }); + } + + if (item?.Id && imageTags.Primary) { + return apiClient.getScaledImageUrl(item.Id, { + maxWidth: Math.round(dom.getScreenWidth() * 0.20), + type: ImageType.Primary, + tag: imageTags.Primary + }); + } + + if (item?.AlbumPrimaryImageTag && item.AlbumId) { + return apiClient.getScaledImageUrl(item.AlbumId, { + maxWidth: Math.round(dom.getScreenWidth() * 0.20), + type: ImageType.Primary, + tag: item.AlbumPrimaryImageTag + }); + } + + return null; +}; + +export default getNowPlayingImageUrl; diff --git a/src/apps/dashboard/features/sessions/utils/getNowPlayingName.ts b/src/apps/dashboard/features/sessions/utils/getNowPlayingName.ts new file mode 100644 index 0000000000..f853db6f12 --- /dev/null +++ b/src/apps/dashboard/features/sessions/utils/getNowPlayingName.ts @@ -0,0 +1,61 @@ +import type { SessionInfo } from '@jellyfin/sdk/lib/generated-client/models/session-info'; +import itemHelper from 'components/itemHelper'; +import formatDistanceToNow from 'date-fns/formatDistanceToNow'; +import globalize from 'lib/globalize'; +import { ServerConnections } from 'lib/jellyfin-apiclient'; +import { getLocaleWithSuffix } from 'utils/dateFnsLocale'; + +type NowPlayingInfo = { + topText?: string; + bottomText: string; + image?: string; +}; + +const getNowPlayingName = (session: SessionInfo): NowPlayingInfo => { + let imgUrl = ''; + const nowPlayingItem = session.NowPlayingItem; + // FIXME: It seems that, sometimes, server sends date in the future, so date-fns displays messages like 'in less than a minute'. We should fix + // how dates are returned by the server when the session is active and show something like 'Active now', instead of past/future sentences + if (!nowPlayingItem) { + return { + bottomText: globalize.translate('LastSeen', formatDistanceToNow(Date.parse(session.LastActivityDate!), getLocaleWithSuffix())) + }; + } + + let topText = itemHelper.getDisplayName(nowPlayingItem); + let bottomText = ''; + + if (nowPlayingItem.Artists?.length) { + bottomText = topText; + topText = nowPlayingItem.Artists[0]; + } else if (nowPlayingItem.SeriesName || nowPlayingItem.Album) { + bottomText = topText; + topText = nowPlayingItem.SeriesName || nowPlayingItem.Album; + } else if (nowPlayingItem.ProductionYear) { + bottomText = nowPlayingItem.ProductionYear.toString(); + } + + if (nowPlayingItem.ImageTags?.Logo) { + imgUrl = ServerConnections.getApiClient(session.ServerId!).getScaledImageUrl(nowPlayingItem.Id!, { + tag: nowPlayingItem.ImageTags.Logo, + maxHeight: 24, + maxWidth: 130, + type: 'Logo' + }); + } else if (nowPlayingItem.ParentLogoImageTag) { + imgUrl = ServerConnections.getApiClient(session.ServerId!).getScaledImageUrl(nowPlayingItem.ParentLogoItemId!, { + tag: nowPlayingItem.ParentLogoImageTag, + maxHeight: 24, + maxWidth: 130, + type: 'Logo' + }); + } + + return { + topText: topText, + bottomText: bottomText, + image: imgUrl + }; +}; + +export default getNowPlayingName; diff --git a/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts new file mode 100644 index 0000000000..16f86d8ac7 --- /dev/null +++ b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingStreamInfo.ts @@ -0,0 +1,58 @@ +import type { SessionInfo } from '@jellyfin/sdk/lib/generated-client/models/session-info'; +import playmethodhelper from 'components/playback/playmethodhelper'; +import globalize from 'lib/globalize'; + +// eslint-disable-next-line sonarjs/cognitive-complexity +const getSessionNowPlayingStreamInfo = (session: SessionInfo): string => { + let text = ''; + let showTranscodingInfo = false; + const displayPlayMethod = playmethodhelper.getDisplayPlayMethod(session); + + if (displayPlayMethod === 'DirectPlay') { + text += globalize.translate('DirectPlaying'); + } else if (displayPlayMethod === 'Remux') { + text += globalize.translate('Remuxing'); + } else if (displayPlayMethod === 'DirectStream') { + text += globalize.translate('DirectStreaming'); + } else if (displayPlayMethod === 'Transcode') { + if (session.TranscodingInfo?.Framerate) { + text += `${globalize.translate('Framerate')}: ${session.TranscodingInfo.Framerate}fps`; + } + + showTranscodingInfo = true; + } + + if (showTranscodingInfo) { + const line = []; + + if (session.TranscodingInfo) { + if (session.TranscodingInfo.Bitrate) { + if (session.TranscodingInfo.Bitrate > 1e6) { + line.push((session.TranscodingInfo.Bitrate / 1e6).toFixed(1) + ' Mbps'); + } else { + line.push(Math.floor(session.TranscodingInfo.Bitrate / 1e3) + ' Kbps'); + } + } + + if (session.TranscodingInfo.Container) { + line.push(session.TranscodingInfo.Container.toUpperCase()); + } + + if (session.TranscodingInfo.VideoCodec) { + line.push(session.TranscodingInfo.VideoCodec.toUpperCase()); + } + + if (session.TranscodingInfo.AudioCodec && session.TranscodingInfo.AudioCodec !== session.TranscodingInfo.Container) { + line.push(session.TranscodingInfo.AudioCodec.toUpperCase()); + } + } + + if (line.length) { + text += '\n\n' + line.join(' '); + } + } + + return text; +}; + +export default getSessionNowPlayingStreamInfo; diff --git a/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingTime.ts b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingTime.ts new file mode 100644 index 0000000000..07f55f8712 --- /dev/null +++ b/src/apps/dashboard/features/sessions/utils/getSessionNowPlayingTime.ts @@ -0,0 +1,26 @@ +import type { SessionInfo } from '@jellyfin/sdk/lib/generated-client/models/session-info'; +import datetime from 'scripts/datetime'; + +const getSessionNowPlayingTime = (session: SessionInfo) => { + const nowPlayingItem = session.NowPlayingItem; + + let start = '0:00'; + let end = '0:00'; + + if (nowPlayingItem) { + if (session.PlayState?.PositionTicks) { + start = datetime.getDisplayRunningTime(session.PlayState.PositionTicks); + } + + if (nowPlayingItem.RunTimeTicks) { + end = datetime.getDisplayRunningTime(nowPlayingItem.RunTimeTicks); + } + } + + return { + start, + end + }; +}; + +export default getSessionNowPlayingTime; diff --git a/src/apps/dashboard/features/storage/api/useSystemStorage.ts b/src/apps/dashboard/features/storage/api/useSystemStorage.ts index 12db4a1713..8017f767b7 100644 --- a/src/apps/dashboard/features/storage/api/useSystemStorage.ts +++ b/src/apps/dashboard/features/storage/api/useSystemStorage.ts @@ -19,7 +19,8 @@ const getSystemStorageQuery = ( ) => queryOptions({ queryKey: [ 'SystemStorage' ], queryFn: ({ signal }) => fetchSystemStorage(api!, { signal }), - enabled: !!api + enabled: !!api, + refetchOnWindowFocus: false }); export const useSystemStorage = () => { diff --git a/src/apps/dashboard/features/system/api/useRestartServer.ts b/src/apps/dashboard/features/system/api/useRestartServer.ts new file mode 100644 index 0000000000..449e965930 --- /dev/null +++ b/src/apps/dashboard/features/system/api/useRestartServer.ts @@ -0,0 +1,16 @@ +import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'; +import { useMutation } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; + +const useRestartServer = () => { + const { api } = useApi(); + + return useMutation({ + mutationFn: () => { + return getSystemApi(api!) + .restartApplication(); + } + }); +}; + +export default useRestartServer; diff --git a/src/apps/dashboard/features/system/api/useShutdownServer.ts b/src/apps/dashboard/features/system/api/useShutdownServer.ts new file mode 100644 index 0000000000..90d2f6cc6a --- /dev/null +++ b/src/apps/dashboard/features/system/api/useShutdownServer.ts @@ -0,0 +1,16 @@ +import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'; +import { useMutation } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; + +const useShutdownServer = () => { + const { api } = useApi(); + + return useMutation({ + mutationFn: () => { + return getSystemApi(api!) + .shutdownApplication(); + } + }); +}; + +export default useShutdownServer; diff --git a/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts b/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts new file mode 100644 index 0000000000..6cd875952b --- /dev/null +++ b/src/apps/dashboard/features/tasks/hooks/useLiveTasks.ts @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { useApi } from 'hooks/useApi'; +import { QUERY_KEY, useTasks } from '../api/useTasks'; +import type { ScheduledTasksApiGetTasksRequest } from '@jellyfin/sdk/lib/generated-client/api/scheduled-tasks-api'; +import { queryClient } from 'utils/query/queryClient'; +import { ApiClient } from 'jellyfin-apiclient'; +import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info'; +import Events, { Event } from 'utils/events'; +import serverNotifications from 'scripts/serverNotifications'; +import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type'; + +const FALLBACK_POLL_INTERVAL_MS = 10000; + +const useLiveTasks = (params: ScheduledTasksApiGetTasksRequest) => { + const { __legacyApiClient__ } = useApi(); + const tasksQuery = useTasks(params); + + // TODO: Replace usage of the legacy apiclient when websocket support is added to the TS SDK. + useEffect(() => { + const onScheduledTasksUpdate = (_e: Event, _apiClient: ApiClient, info: TaskInfo[]) => { + queryClient.setQueryData([ QUERY_KEY ], info); + }; + + const fallbackInterval = setInterval(() => { + if (!__legacyApiClient__?.isMessageChannelOpen()) { + void queryClient.invalidateQueries({ + queryKey: [ QUERY_KEY ] + }); + } + }, FALLBACK_POLL_INTERVAL_MS); + + __legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStart, '1000,1000'); + Events.on(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate); + + return () => { + clearInterval(fallbackInterval); + __legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStop, null); + Events.off(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate); + }; + }, [ __legacyApiClient__ ]); + + return tasksQuery; +}; + +export default useLiveTasks; diff --git a/src/apps/dashboard/routes/_asyncRoutes.ts b/src/apps/dashboard/routes/_asyncRoutes.ts index 7e52f99aa7..e5822957d6 100644 --- a/src/apps/dashboard/routes/_asyncRoutes.ts +++ b/src/apps/dashboard/routes/_asyncRoutes.ts @@ -2,6 +2,7 @@ import type { AsyncRoute } from 'components/router/AsyncRoute'; import { AppType } from 'constants/appType'; export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [ + { path: '', type: AppType.Dashboard }, { path: 'activity', type: AppType.Dashboard }, { path: 'backups', type: AppType.Dashboard }, { path: 'branding', type: AppType.Dashboard }, diff --git a/src/apps/dashboard/routes/_legacyRoutes.ts b/src/apps/dashboard/routes/_legacyRoutes.ts index bf292f8185..e3911d3b4d 100644 --- a/src/apps/dashboard/routes/_legacyRoutes.ts +++ b/src/apps/dashboard/routes/_legacyRoutes.ts @@ -3,13 +3,6 @@ import { AppType } from 'constants/appType'; export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [ { - path: '/dashboard', - pageProps: { - appType: AppType.Dashboard, - controller: 'dashboard', - view: 'dashboard.html' - } - }, { path: 'networking', pageProps: { appType: AppType.Dashboard, diff --git a/src/apps/dashboard/routes/index.tsx b/src/apps/dashboard/routes/index.tsx new file mode 100644 index 0000000000..76b1d076b1 --- /dev/null +++ b/src/apps/dashboard/routes/index.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useState } from 'react'; +import Page from 'components/Page'; +import globalize from 'lib/globalize'; +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid2'; +import ServerPathWidget from '../components/widgets/ServerPathWidget'; +import ServerInfoWidget from '../components/widgets/ServerInfoWidget'; +import ActivityLogWidget from '../components/widgets/ActivityLogWidget'; +import AlertsLogWidget from '../components/widgets/AlertsLogWidget'; +import useTheme from '@mui/material/styles/useTheme'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import Stack from '@mui/material/Stack'; +import useShutdownServer from '../features/system/api/useShutdownServer'; +import useRestartServer from '../features/system/api/useRestartServer'; +import ConfirmDialog from 'components/ConfirmDialog'; +import useLiveTasks from '../features/tasks/hooks/useLiveTasks'; +import RunningTasksWidget from '../components/widgets/RunningTasksWidget'; +import DevicesWidget from '../components/widgets/DevicesWidget'; +import { useStartTask } from '../features/tasks/api/useStartTask'; +import ItemCountsWidget from '../components/widgets/ItemCountsWidget'; + +export const Component = () => { + const theme = useTheme(); + const isMedium = useMediaQuery(theme.breakpoints.only('md')); + const isExtraLarge = useMediaQuery(theme.breakpoints.only('xl')); + const [ isRestartConfirmDialogOpen, setIsRestartConfirmDialogOpen ] = useState(false); + const [ isShutdownConfirmDialogOpen, setIsShutdownConfirmDialogOpen ] = useState(false); + const startTask = useStartTask(); + const restartServer = useRestartServer(); + const shutdownServer = useShutdownServer(); + + const { data: tasks } = useLiveTasks({ isHidden: false }); + + const promptRestart = useCallback(() => { + setIsRestartConfirmDialogOpen(true); + }, []); + + const closeRestartDialog = useCallback(() => { + setIsRestartConfirmDialogOpen(false); + }, []); + + const promptShutdown = useCallback(() => { + setIsShutdownConfirmDialogOpen(true); + }, []); + + const closeShutdownDialog = useCallback(() => { + setIsShutdownConfirmDialogOpen(false); + }, []); + + const onScanLibraries = useCallback(() => { + const scanLibrariesTask = tasks?.find((value) => value.Key === 'RefreshLibrary'); + + if (scanLibrariesTask?.Id) { + startTask.mutate({ + taskId: scanLibrariesTask.Id + }); + } + }, [ startTask, tasks ]); + + const onRestartConfirm = useCallback(() => { + restartServer.mutate(); + setIsRestartConfirmDialogOpen(false); + }, [ restartServer ]); + + const onShutdownConfirm = useCallback(() => { + shutdownServer.mutate(); + setIsShutdownConfirmDialogOpen(false); + }, [ shutdownServer ]); + + return ( + + + + + + + + + + + + + + + + + {isMedium || isExtraLarge ? ( + + + + + + + ) : ( + + + + + + + )} + + + + ); +}; + +Component.displayName = 'DashboardPage'; diff --git a/src/apps/dashboard/routes/tasks/index.tsx b/src/apps/dashboard/routes/tasks/index.tsx index 232fb34950..880f654a15 100644 --- a/src/apps/dashboard/routes/tasks/index.tsx +++ b/src/apps/dashboard/routes/tasks/index.tsx @@ -1,47 +1,15 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import Page from 'components/Page'; import globalize from 'lib/globalize'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; -import { QUERY_KEY, useTasks } from '../../features/tasks/api/useTasks'; import { getCategories, getTasksByCategory } from '../../features/tasks/utils/tasks'; import Loading from 'components/loading/LoadingComponent'; import Tasks from '../../features/tasks/components/Tasks'; -import type { TaskInfo } from '@jellyfin/sdk/lib/generated-client/models/task-info'; -import { SessionMessageType } from '@jellyfin/sdk/lib/generated-client/models/session-message-type'; -import serverNotifications from 'scripts/serverNotifications'; -import Events, { Event } from 'utils/events'; -import { ApiClient } from 'jellyfin-apiclient'; -import { useApi } from 'hooks/useApi'; -import { queryClient } from 'utils/query/queryClient'; +import useLiveTasks from 'apps/dashboard/features/tasks/hooks/useLiveTasks'; export const Component = () => { - const { __legacyApiClient__ } = useApi(); - const { data: tasks, isPending } = useTasks({ isHidden: false }); - - // TODO: Replace usage of the legacy apiclient when websocket support is added to the TS SDK. - useEffect(() => { - const onScheduledTasksUpdate = (_e: Event, _apiClient: ApiClient, info: TaskInfo[]) => { - queryClient.setQueryData([ QUERY_KEY ], info); - }; - - const fallbackInterval = setInterval(() => { - if (!__legacyApiClient__?.isMessageChannelOpen()) { - void queryClient.invalidateQueries({ - queryKey: [ QUERY_KEY ] - }); - } - }, 1e4); - - __legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStart, '1000,1000'); - Events.on(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate); - - return () => { - clearInterval(fallbackInterval); - __legacyApiClient__?.sendMessage(SessionMessageType.ScheduledTasksInfoStop, null); - Events.off(serverNotifications, SessionMessageType.ScheduledTasksInfo, onScheduledTasksUpdate); - }; - }, [__legacyApiClient__]); + const { data: tasks, isPending } = useLiveTasks({ isHidden: false }); if (isPending || !tasks) { return ; diff --git a/src/components/InputDialog.tsx b/src/components/InputDialog.tsx new file mode 100644 index 0000000000..ccf52aed29 --- /dev/null +++ b/src/components/InputDialog.tsx @@ -0,0 +1,62 @@ +import React, { useCallback, useState } from 'react'; +import Button from '@mui/material/Button'; +import Dialog, { type DialogProps } from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import TextField from '@mui/material/TextField'; +import globalize from 'lib/globalize'; +import Stack from '@mui/material/Stack'; + +interface InputDialogProps extends DialogProps { + title: string; + label: string; + confirmButtonText?: string; + onClose: () => void; + onConfirm: (text: string) => void; +}; + +const InputDialog = ({ open, title, label, onClose, confirmButtonText, onConfirm }: InputDialogProps) => { + const [ text, setText ] = useState(''); + + const onTextChange = useCallback((e: React.ChangeEvent) => { + setText(e.target.value); + }, []); + + const onConfirmClick = useCallback(() => { + onConfirm(text); + setText(''); + }, [ text, onConfirm ]); + + return ( + + {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 } } };