From 690b1fbed549d316d09b0cf124269fdd54fa4989 Mon Sep 17 00:00:00 2001 From: grafixeyehero Date: Sun, 8 Sep 2024 20:15:20 +0300 Subject: [PATCH] Add detail view buttons --- .../buttons/CancelSeriesTimerButton.tsx | 66 ++++++ .../components/buttons/CancelTimerButton.tsx | 58 +++++ .../components/buttons/DownloadButton.tsx | 42 ++++ .../components/buttons/InstantMixButton.tsx | 28 +++ .../components/buttons/MoreCommandsButton.tsx | 221 ++++++++++++++++++ .../components/buttons/PlayOrResumeButton.tsx | 87 +++++++ .../components/buttons/PlayTrailerButton.tsx | 28 +++ .../components/buttons/ShuffleButton.tsx | 29 +++ .../buttons/SplitVersionsButton.tsx | 65 ++++++ .../details/hooks/api/useGetItemByType.ts | 63 +++++ src/components/itemContextMenu.js | 9 +- 11 files changed, 692 insertions(+), 4 deletions(-) create mode 100644 src/apps/experimental/features/details/components/buttons/CancelSeriesTimerButton.tsx create mode 100644 src/apps/experimental/features/details/components/buttons/CancelTimerButton.tsx create mode 100644 src/apps/experimental/features/details/components/buttons/DownloadButton.tsx create mode 100644 src/apps/experimental/features/details/components/buttons/InstantMixButton.tsx create mode 100644 src/apps/experimental/features/details/components/buttons/MoreCommandsButton.tsx create mode 100644 src/apps/experimental/features/details/components/buttons/PlayOrResumeButton.tsx create mode 100644 src/apps/experimental/features/details/components/buttons/PlayTrailerButton.tsx create mode 100644 src/apps/experimental/features/details/components/buttons/ShuffleButton.tsx create mode 100644 src/apps/experimental/features/details/components/buttons/SplitVersionsButton.tsx create mode 100644 src/apps/experimental/features/details/hooks/api/useGetItemByType.ts diff --git a/src/apps/experimental/features/details/components/buttons/CancelSeriesTimerButton.tsx b/src/apps/experimental/features/details/components/buttons/CancelSeriesTimerButton.tsx new file mode 100644 index 00000000000..7f3acd1678f --- /dev/null +++ b/src/apps/experimental/features/details/components/buttons/CancelSeriesTimerButton.tsx @@ -0,0 +1,66 @@ +import React, { FC, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { IconButton } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; + +import { useCancelSeriesTimer } from 'hooks/api/liveTvHooks'; +import globalize from 'lib/globalize'; +import loading from 'components/loading/loading'; +import toast from 'components/toast/toast'; +import confirm from 'components/confirm/confirm'; + +interface CancelSeriesTimerButtonProps { + itemId: string; +} + +const CancelSeriesTimerButton: FC = ({ + itemId +}) => { + const navigate = useNavigate(); + const cancelSeriesTimer = useCancelSeriesTimer(); + + const onCancelSeriesTimerClick = useCallback(() => { + confirm({ + text: globalize.translate('MessageConfirmRecordingCancellation'), + primary: 'delete', + confirmText: globalize.translate('HeaderCancelSeries'), + cancelText: globalize.translate('HeaderKeepSeries') + }) + .then(function () { + loading.show(); + cancelSeriesTimer.mutate( + { + timerId: itemId + }, + { + onSuccess: async () => { + toast(globalize.translate('SeriesCancelled')); + loading.hide(); + navigate('/livetv.html'); + }, + onError: (err: unknown) => { + console.error( + '[cancelSeriesTimer] failed to cancel series timer', + err + ); + } + } + ); + }) + .catch(() => { + // confirm dialog closed + }); + }, [cancelSeriesTimer, navigate, itemId]); + + return ( + + + + ); +}; + +export default CancelSeriesTimerButton; diff --git a/src/apps/experimental/features/details/components/buttons/CancelTimerButton.tsx b/src/apps/experimental/features/details/components/buttons/CancelTimerButton.tsx new file mode 100644 index 00000000000..0687df7f5d8 --- /dev/null +++ b/src/apps/experimental/features/details/components/buttons/CancelTimerButton.tsx @@ -0,0 +1,58 @@ +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import StopIcon from '@mui/icons-material/Stop'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCancelTimer } from 'hooks/api/liveTvHooks'; +import globalize from 'lib/globalize'; +import loading from 'components/loading/loading'; +import toast from 'components/toast/toast'; + +interface CancelTimerButtonProps { + timerId: string; + queryKey?: string[]; +} + +const CancelTimerButton: FC = ({ + timerId, + queryKey +}) => { + const queryClient = useQueryClient(); + const cancelTimer = useCancelTimer(); + + const onCancelTimerClick = useCallback(() => { + loading.show(); + cancelTimer.mutate( + { + timerId: timerId + }, + { + onSuccess: async () => { + toast(globalize.translate('RecordingCancelled')); + loading.hide(); + await queryClient.invalidateQueries({ + queryKey + }); + }, + + onError: (err: unknown) => { + console.error( + '[cancelTimer] failed to cancel timer', + err + ); + } + } + ); + }, [cancelTimer, queryClient, queryKey, timerId]); + + return ( + + + + ); +}; + +export default CancelTimerButton; diff --git a/src/apps/experimental/features/details/components/buttons/DownloadButton.tsx b/src/apps/experimental/features/details/components/buttons/DownloadButton.tsx new file mode 100644 index 00000000000..284984239e3 --- /dev/null +++ b/src/apps/experimental/features/details/components/buttons/DownloadButton.tsx @@ -0,0 +1,42 @@ +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import FileDownloadIcon from '@mui/icons-material/FileDownload'; +import { useGetDownload } from 'hooks/api/libraryHooks'; +import globalize from 'lib/globalize'; +import { download } from 'scripts/fileDownloader'; +import type { NullableString } from 'types/base/common/shared/types'; + +interface DownloadButtonProps { + itemId: string; + itemServerId: NullableString, + itemName: NullableString, + itemPath: NullableString, +} + +const DownloadButton: FC = ({ itemId, itemServerId, itemName, itemPath }) => { + const { data: downloadHref } = useGetDownload({ itemId }); + + const onDownloadClick = useCallback(async () => { + download([ + { + url: downloadHref, + itemId: itemId, + serverId: itemServerId, + title: itemName, + filename: itemPath?.replace(/^.*[\\/]/, '') + } + ]); + }, [downloadHref, itemId, itemName, itemPath, itemServerId]); + + return ( + + + + ); +}; + +export default DownloadButton; diff --git a/src/apps/experimental/features/details/components/buttons/InstantMixButton.tsx b/src/apps/experimental/features/details/components/buttons/InstantMixButton.tsx new file mode 100644 index 00000000000..9cb223178ff --- /dev/null +++ b/src/apps/experimental/features/details/components/buttons/InstantMixButton.tsx @@ -0,0 +1,28 @@ +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import ExploreIcon from '@mui/icons-material/Explore'; +import { playbackManager } from 'components/playback/playbackmanager'; +import globalize from 'lib/globalize'; +import type { ItemDto } from 'types/base/models/item-dto'; + +interface InstantMixButtonProps { + item?: ItemDto; +} + +const InstantMixButton: FC = ({ item }) => { + const onInstantMixClick = useCallback(() => { + playbackManager.instantMix(item); + }, [item]); + + return ( + + + + ); +}; + +export default InstantMixButton; diff --git a/src/apps/experimental/features/details/components/buttons/MoreCommandsButton.tsx b/src/apps/experimental/features/details/components/buttons/MoreCommandsButton.tsx new file mode 100644 index 00000000000..e679f9e7729 --- /dev/null +++ b/src/apps/experimental/features/details/components/buttons/MoreCommandsButton.tsx @@ -0,0 +1,221 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { IconButton } from '@mui/material'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { useQueryClient } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; +import { useGetItemByType } from '../../hooks/api/useGetItemByType'; +import globalize from 'lib/globalize'; +import itemContextMenu from 'components/itemContextMenu'; +import { playbackManager } from 'components/playback/playbackmanager'; +import { appRouter } from 'components/router/appRouter'; + +import { ItemKind } from 'types/base/models/item-kind'; +import type { NullableString } from 'types/base/common/shared/types'; +import type { ItemDto } from 'types/base/models/item-dto'; + +interface PlayAllFromHereOptions { + item: ItemDto; + items: ItemDto[]; + serverId: NullableString; + queue?: boolean; +} + +function playAllFromHere(opts: PlayAllFromHereOptions) { + const { item, items, serverId, queue } = opts; + + const ids = []; + + let foundCard = false; + let startIndex; + + for (let i = 0, length = items?.length; i < length; i++) { + if (items[i] === item) { + foundCard = true; + startIndex = i; + } + if (foundCard || !queue) { + ids.push(items[i].Id); + } + } + + if (!ids.length) { + return; + } + + if (queue) { + return playbackManager.queue({ + ids, + serverId + }); + } else { + return playbackManager.play({ + ids, + serverId, + startIndex + }); + } +} + +export interface ContextMenuOpts { + open?: boolean; + play?: boolean; + playAllFromHere?: boolean; + queueAllFromHere?: boolean; + cancelTimer?: boolean; + record?: boolean; + deleteItem?: boolean; + shuffle?: boolean; + instantMix?: boolean; + share?: boolean; + stopPlayback?: boolean; + clearQueue?: boolean; + queue?: boolean; + playlist?: boolean; + edit?: boolean; + editImages?: boolean; + editSubtitles?: boolean; + identify?: boolean; + moremediainfo?: boolean; + openAlbum?: boolean; + openArtist?: boolean; + openLyrics?: boolean; +} + +interface MoreCommandsButtonProps { + className?: string; + itemType: ItemKind; + selectedItemId?: string; + itemId?: string; + items?: ItemDto[] | null; + collectionId?: NullableString; + playlistId?: NullableString; + canEditPlaylist?: boolean; + itemPlaylistItemId?: NullableString; + contextMenuOpts?: ContextMenuOpts; + queryKey?: string[]; +} + +const MoreCommandsButton: FC = ({ + className, + itemType, + selectedItemId, + itemId, + collectionId, + playlistId, + canEditPlaylist, + itemPlaylistItemId, + contextMenuOpts, + items, + queryKey +}) => { + const { user } = useApi(); + const queryClient = useQueryClient(); + const { data: item } = useGetItemByType({ + itemType, + itemId: selectedItemId || itemId + }); + const parentId = item?.SeasonId || item?.SeriesId || item?.ParentId; + + const playlistItem = useMemo(() => { + let PlaylistItemId: string | null = null; + let PlaylistIndex = -1; + let PlaylistItemCount = 0; + + if (playlistId) { + PlaylistItemId = itemPlaylistItemId || null; + + if (items?.length) { + PlaylistItemCount = items.length; + PlaylistIndex = items.findIndex(listItem => listItem.PlaylistItemId === PlaylistItemId); + } + } + return { PlaylistItemId, PlaylistIndex, PlaylistItemCount }; + }, [itemPlaylistItemId, items, playlistId]); + + const defaultMenuOptions = useMemo(() => { + return { + + item: { + ...item, + ...playlistItem + }, + user: user, + play: true, + queue: true, + playAllFromHere: item?.Type === ItemKind.Season || !item?.IsFolder, + queueAllFromHere: !item?.IsFolder, + canEditPlaylist: canEditPlaylist, + playlistId: playlistId, + collectionId: collectionId, + ...contextMenuOpts + }; + }, [canEditPlaylist, collectionId, contextMenuOpts, item, playlistId, playlistItem, user]); + + const onMoreCommandsClick = useCallback( + async (e: React.MouseEvent) => { + itemContextMenu + .show({ + ...defaultMenuOptions, + positionTo: e.currentTarget + }) + .then(async function (result) { + if (result.command === 'playallfromhere') { + console.log('handleItemClick', { + item, + items: items || [], + serverId: item?.ServerId + }); + playAllFromHere({ + item: item || {}, + items: items || [], + serverId: item?.ServerId + }); + } else if (result.command === 'queueallfromhere') { + playAllFromHere({ + item: item || {}, + items: items || [], + serverId: item?.ServerId, + queue: true + }); + } else if (result.deleted) { + if (result?.itemId !== itemId) { + await queryClient.invalidateQueries({ + queryKey + }); + } else if (parentId) { + appRouter.showItem(parentId, item?.ServerId); + } else { + await appRouter.goHome(); + } + } else if (result.updated) { + await queryClient.invalidateQueries({ + queryKey + }); + } + }) + .catch(() => { + /* no-op */ + }); + }, + [defaultMenuOptions, item, itemId, items, parentId, queryClient, queryKey] + ); + + if ( + item + && itemContextMenu.getCommands(defaultMenuOptions).length + ) { + return ( + + + + ); + } + + return null; +}; + +export default MoreCommandsButton; diff --git a/src/apps/experimental/features/details/components/buttons/PlayOrResumeButton.tsx b/src/apps/experimental/features/details/components/buttons/PlayOrResumeButton.tsx new file mode 100644 index 00000000000..faed1104cc8 --- /dev/null +++ b/src/apps/experimental/features/details/components/buttons/PlayOrResumeButton.tsx @@ -0,0 +1,87 @@ +import React, { FC, useCallback, useMemo } from 'react'; +import { IconButton } from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import ReplayIcon from '@mui/icons-material/Replay'; +import { useQueryClient } from '@tanstack/react-query'; +import { useApi } from 'hooks/useApi'; +import { getChannelQuery } from 'hooks/api/liveTvHooks/useGetChannel'; +import globalize from 'lib/globalize'; +import { playbackManager } from 'components/playback/playbackmanager'; +import type { ItemDto } from 'types/base/models/item-dto'; +import { ItemKind } from 'types/base/models/item-kind'; +import itemHelper from 'components/itemHelper'; + +interface PlayOrResumeButtonProps { + item: ItemDto; + isResumable?: boolean; + selectedMediaSourceId?: string | null; + selectedAudioTrack?: number; + selectedSubtitleTrack?: number; +} + +const PlayOrResumeButton: FC = ({ + item, + isResumable, + selectedMediaSourceId, + selectedAudioTrack, + selectedSubtitleTrack +}) => { + const apiContext = useApi(); + const queryClient = useQueryClient(); + + const playOptions = useMemo(() => { + if (itemHelper.supportsMediaSourceSelection(item)) { + return { + startPositionTicks: + item.UserData && isResumable ? + item.UserData.PlaybackPositionTicks : + 0, + mediaSourceId: selectedMediaSourceId, + audioStreamIndex: selectedAudioTrack || null, + subtitleStreamIndex: selectedSubtitleTrack + }; + } + }, [ + item, + isResumable, + selectedMediaSourceId, + selectedAudioTrack, + selectedSubtitleTrack + ]); + + const onPlayClick = useCallback(async () => { + if (item.Type === ItemKind.Program && item.ChannelId) { + const channel = await queryClient.fetchQuery( + getChannelQuery(apiContext, { + channelId: item.ChannelId + }) + ); + playbackManager.play({ + items: [channel] + }); + return; + } + + playbackManager.play({ + items: [item], + ...playOptions + }); + }, [apiContext, item, playOptions, queryClient]); + + return ( + + {isResumable ? : } + + ); +}; + +export default PlayOrResumeButton; diff --git a/src/apps/experimental/features/details/components/buttons/PlayTrailerButton.tsx b/src/apps/experimental/features/details/components/buttons/PlayTrailerButton.tsx new file mode 100644 index 00000000000..0c82c06690c --- /dev/null +++ b/src/apps/experimental/features/details/components/buttons/PlayTrailerButton.tsx @@ -0,0 +1,28 @@ +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import TheatersIcon from '@mui/icons-material/Theaters'; +import { playbackManager } from 'components/playback/playbackmanager'; +import globalize from 'lib/globalize'; +import type { ItemDto } from 'types/base/models/item-dto'; + +interface PlayTrailerButtonProps { + item?: ItemDto; +} + +const PlayTrailerButton: FC = ({ item }) => { + const onPlayTrailerClick = useCallback(async () => { + await playbackManager.playTrailers(item); + }, [item]); + + return ( + + + + ); +}; + +export default PlayTrailerButton; diff --git a/src/apps/experimental/features/details/components/buttons/ShuffleButton.tsx b/src/apps/experimental/features/details/components/buttons/ShuffleButton.tsx new file mode 100644 index 00000000000..258e26fc79a --- /dev/null +++ b/src/apps/experimental/features/details/components/buttons/ShuffleButton.tsx @@ -0,0 +1,29 @@ +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import ShuffleIcon from '@mui/icons-material/Shuffle'; + +import { playbackManager } from 'components/playback/playbackmanager'; +import globalize from 'lib/globalize'; +import type { ItemDto } from 'types/base/models/item-dto'; + +interface ShuffleButtonProps { + item: ItemDto; +} + +const ShuffleButton: FC = ({ item }) => { + const shuffle = useCallback(() => { + playbackManager.shuffle(item); + }, [item]); + + return ( + + + + ); +}; + +export default ShuffleButton; diff --git a/src/apps/experimental/features/details/components/buttons/SplitVersionsButton.tsx b/src/apps/experimental/features/details/components/buttons/SplitVersionsButton.tsx new file mode 100644 index 00000000000..b7bb1016934 --- /dev/null +++ b/src/apps/experimental/features/details/components/buttons/SplitVersionsButton.tsx @@ -0,0 +1,65 @@ +import React, { FC, useCallback } from 'react'; +import { IconButton } from '@mui/material'; +import CallSplitIcon from '@mui/icons-material/CallSplit'; +import { useQueryClient } from '@tanstack/react-query'; +import { useDeleteAlternateSources } from 'hooks/api/videosHooks'; +import globalize from 'lib/globalize'; +import confirm from 'components/confirm/confirm'; +import loading from 'components/loading/loading'; + +interface SplitVersionsButtonProps { + paramId: string; + queryKey?: string[]; +} + +const SplitVersionsButton: FC = ({ + paramId, + queryKey +}) => { + const queryClient = useQueryClient(); + const deleteAlternateSources = useDeleteAlternateSources(); + + const splitVersions = useCallback(() => { + confirm({ + title: globalize.translate('HeaderSplitMediaApart'), + text: globalize.translate('MessageConfirmSplitMediaSources') + }) + .then(function () { + loading.show(); + deleteAlternateSources.mutate( + { + itemId: paramId + }, + { + onSuccess: async () => { + loading.hide(); + await queryClient.invalidateQueries({ + queryKey + }); + }, + onError: (err: unknown) => { + console.error( + '[splitVersions] failed to delete Videos', + err + ); + } + } + ); + }) + .catch(() => { + // confirm dialog closed + }); + }, [deleteAlternateSources, paramId, queryClient, queryKey]); + + return ( + + + + ); +}; + +export default SplitVersionsButton; diff --git a/src/apps/experimental/features/details/hooks/api/useGetItemByType.ts b/src/apps/experimental/features/details/hooks/api/useGetItemByType.ts new file mode 100644 index 00000000000..dc72771b403 --- /dev/null +++ b/src/apps/experimental/features/details/hooks/api/useGetItemByType.ts @@ -0,0 +1,63 @@ +import type { AxiosRequestConfig } from 'axios'; +import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'; +import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api'; +import { useQuery } from '@tanstack/react-query'; +import { type JellyfinApiContext, useApi } from 'hooks/useApi'; +import type { ItemDto } from 'types/base/models/item-dto'; +import type{ NullableString } from 'types/base/common/shared/types'; +import { ItemKind } from 'types/base/models/item-kind'; + +const getItemByType = async ( + apiContext: JellyfinApiContext, + itemType: ItemKind, + itemId: NullableString, + options?: AxiosRequestConfig +) => { + const { api, user } = apiContext; + if (!api) throw new Error('No API instance available'); + if (!user?.Id) throw new Error('No User ID provided'); + if (!itemId) throw new Error('No item ID provided'); + + let response; + switch (itemType) { + case ItemKind.Timer: { + response = await getLiveTvApi(api).getTimer( + { timerId: itemId }, + options + ); + break; + } + case ItemKind.SeriesTimer: + response = await getLiveTvApi(api).getSeriesTimer( + { timerId: itemId }, + options + ); + break; + default: { + response = await getUserLibraryApi(api).getItem( + { userId: user.Id, itemId }, + options + ); + break; + } + } + return response.data as ItemDto; +}; + +interface UseGetItemByTypeProps { + itemType: ItemKind; + itemId: NullableString; +} + +export const useGetItemByType = ({ + itemType, + itemId +}: UseGetItemByTypeProps) => { + const apiContext = useApi(); + return useQuery({ + queryKey: ['ItemByType', { itemType, itemId }], + queryFn: ({ signal }) => + getItemByType(apiContext, itemType, itemId, { signal }), + enabled: !!apiContext.api && !!apiContext.user?.Id && !!itemId + }); +}; diff --git a/src/components/itemContextMenu.js b/src/components/itemContextMenu.js index ee70af15cf7..42b6885fab4 100644 --- a/src/components/itemContextMenu.js +++ b/src/components/itemContextMenu.js @@ -351,12 +351,13 @@ export function getCommands(options) { return commands; } -function getResolveFunction(resolve, id, changed, deleted) { +function getResolveFunction(resolve, commandId, changed, deleted, itemId) { return function () { resolve({ - command: id, + command: commandId, updated: changed, - deleted: deleted + deleted: deleted, + itemId: itemId }); }; } @@ -533,7 +534,7 @@ function executeCommand(item, id, options) { getResolveFunction(resolve, id)(); break; case 'delete': - deleteItem(apiClient, item).then(getResolveFunction(resolve, id, true, true), getResolveFunction(resolve, id)); + deleteItem(apiClient, item).then(getResolveFunction(resolve, id, true, true, itemId), getResolveFunction(resolve, id)); break; case 'share': navigator.share({