diff --git a/src/apps/experimental/features/details/components/buttons/MoreCommandsButton.tsx b/src/apps/experimental/features/details/components/buttons/MoreCommandsButton.tsx index e767712364a..b9c979f9f88 100644 --- a/src/apps/experimental/features/details/components/buttons/MoreCommandsButton.tsx +++ b/src/apps/experimental/features/details/components/buttons/MoreCommandsButton.tsx @@ -1,4 +1,4 @@ -import React, { FC, useCallback, useMemo } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { IconButton } from '@mui/material'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import { useQueryClient } from '@tanstack/react-query'; @@ -113,6 +113,7 @@ const MoreCommandsButton: FC = ({ itemId: selectedItemId || itemId || '' }); const parentId = item?.SeasonId || item?.SeriesId || item?.ParentId; + const [ hasCommands, setHasCommands ] = useState(false); const playlistItem = useMemo(() => { let PlaylistItemId: string | null = null; @@ -198,10 +199,15 @@ const MoreCommandsButton: FC = ({ [defaultMenuOptions, item, itemId, items, parentId, queryClient, queryKey] ); - if ( - item - && itemContextMenu.getCommands(defaultMenuOptions).length - ) { + useEffect(() => { + const getCommands = async () => { + const commands = await itemContextMenu.getCommands(defaultMenuOptions); + setHasCommands(commands.length > 0); + }; + void getCommands(); + }, [ defaultMenuOptions ]); + + if (item && hasCommands) { return ( { + const playlistEditor = new PlaylistEditor(); + playlistEditor.show({ + id: itemId, + serverId + }).then(getResolveFunction(resolve, id, true), getResolveFunction(resolve, id)); + }); + break; case 'editimages': import('./imageeditor/imageeditor').then((imageEditor) => { imageEditor.show({ @@ -712,19 +732,19 @@ function refresh(apiClient, item) { }); } -export function show(options) { - const commands = getCommands(options); +export async function show(options) { + const commands = await getCommands(options); if (!commands.length) { - return Promise.reject(); + throw new Error('No item commands present'); } - return actionsheet.show({ + const id = await actionsheet.show({ items: commands, positionTo: options.positionTo, resolveOnClick: ['share'] - }).then(function (id) { - return executeCommand(options.item, id, options); }); + + return executeCommand(options.item, id, options); } export default { diff --git a/src/components/itemHelper.js b/src/components/itemHelper.js index 260c80b32c5..2a93c36e755 100644 --- a/src/components/itemHelper.js +++ b/src/components/itemHelper.js @@ -1,10 +1,14 @@ -import { appHost } from './apphost'; -import globalize from 'lib/globalize'; import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-item-kind'; import { LocationType } from '@jellyfin/sdk/lib/generated-client/models/location-type'; import { RecordingStatus } from '@jellyfin/sdk/lib/generated-client/models/recording-status'; import { MediaType } from '@jellyfin/sdk/lib/generated-client/models/media-type'; +import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api'; + +import { appHost } from './apphost'; +import globalize from 'lib/globalize'; +import ServerConnections from './ServerConnections'; +import { toApi } from 'utils/jellyfin-apiclient/compat'; export function getDisplayName(item, options = {}) { if (!item) { @@ -159,6 +163,25 @@ export function canEditImages (user, item) { return itemType !== 'Timer' && itemType !== 'SeriesTimer' && canEdit(user, item) && !isLocalItem(item); } +export async function canEditPlaylist(user, item) { + const apiClient = ServerConnections.getApiClient(item.ServerId); + const api = toApi(apiClient); + + try { + const { data: permissions } = await getPlaylistsApi(api) + .getPlaylistUser({ + userId: user.Id, + playlistId: item.Id + }); + + return !!permissions.CanEdit; + } catch (err) { + console.error('Failed to get playlist permissions', err); + } + + return false; +} + export function canEditSubtitles (user, item) { if (item.MediaType !== MediaType.Video) { return false; diff --git a/src/components/playlisteditor/playlisteditor.ts b/src/components/playlisteditor/playlisteditor.ts index 620a8a7c859..3db83914e16 100644 --- a/src/components/playlisteditor/playlisteditor.ts +++ b/src/components/playlisteditor/playlisteditor.ts @@ -2,6 +2,7 @@ import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models/base-ite import { ItemSortBy } from '@jellyfin/sdk/lib/generated-client/models/item-sort-by'; import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'; import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api'; +import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'; import escapeHtml from 'escape-html'; import toast from 'components/toast/toast'; @@ -10,6 +11,7 @@ import globalize from 'lib/globalize'; import { currentSettings as userSettings } from 'scripts/settings/userSettings'; import { PluginType } from 'types/plugin'; import { toApi } from 'utils/jellyfin-apiclient/compat'; +import { isBlank } from 'utils/string'; import dialogHelper from '../dialogHelper/dialogHelper'; import loading from '../loading/loading'; @@ -28,11 +30,13 @@ import 'material-design-icons-iconfont'; import '../formdialog.scss'; interface DialogElement extends HTMLDivElement { + playlistId?: string submitted?: boolean } interface PlaylistEditorOptions { items: string[], + id?: string, serverId: string, enableAddToPlayQueue?: boolean, defaultValue?: string @@ -56,6 +60,13 @@ function onSubmit(this: HTMLElement, e: Event) { toast(globalize.translate('PlaylistError.AddFailed')); }) .finally(loading.hide); + } else if (panel.playlistId) { + updatePlaylist(panel) + .catch(err => { + console.error('[PlaylistEditor] Failed to update to playlist %s', panel.playlistId, err); + toast(globalize.translate('PlaylistError.UpdateFailed')); + }) + .finally(loading.hide); } else { createPlaylist(panel) .catch(err => { @@ -73,6 +84,9 @@ function onSubmit(this: HTMLElement, e: Event) { } function createPlaylist(dlg: DialogElement) { + const name = dlg.querySelector('#txtNewPlaylistName')?.value; + if (isBlank(name)) return Promise.reject(new Error('Playlist name should not be blank')); + const apiClient = ServerConnections.getApiClient(currentServerId); const api = toApi(apiClient); @@ -81,7 +95,7 @@ function createPlaylist(dlg: DialogElement) { return getPlaylistsApi(api) .createPlaylist({ createPlaylistDto: { - Name: dlg.querySelector('#txtNewPlaylistName')?.value, + Name: name, IsPublic: dlg.querySelector('#chkPlaylistPublic')?.checked, Ids: itemIds?.split(','), UserId: apiClient.getCurrentUserId() @@ -99,6 +113,29 @@ function redirectToPlaylist(id: string | undefined) { appRouter.showItem(id, currentServerId); } +function updatePlaylist(dlg: DialogElement) { + if (!dlg.playlistId) return Promise.reject(new Error('Missing playlist ID')); + + const name = dlg.querySelector('#txtNewPlaylistName')?.value; + if (isBlank(name)) return Promise.reject(new Error('Playlist name should not be blank')); + + const apiClient = ServerConnections.getApiClient(currentServerId); + const api = toApi(apiClient); + + return getPlaylistsApi(api) + .updatePlaylist({ + playlistId: dlg.playlistId, + updatePlaylistDto: { + Name: name, + IsPublic: dlg.querySelector('#chkPlaylistPublic')?.checked + } + }) + .then(() => { + dlg.submitted = true; + dialogHelper.close(dlg); + }); +} + function addToPlaylist(dlg: DialogElement, id: string) { const apiClient = ServerConnections.getApiClient(currentServerId); const api = toApi(apiClient); @@ -210,7 +247,7 @@ function populatePlaylists(editorOptions: PlaylistEditorOptions, panel: DialogEl }); } -function getEditorHtml(items: string[]) { +function getEditorHtml(items: string[], options: PlaylistEditorOptions) { let html = ''; html += '
'; @@ -232,7 +269,7 @@ function getEditorHtml(items: string[]) { html += `
@@ -244,7 +281,7 @@ function getEditorHtml(items: string[]) { html += '
'; html += '
'; - html += ``; + html += ``; html += '
'; html += ''; @@ -281,6 +318,34 @@ function initEditor(content: DialogElement, options: PlaylistEditorOptions, item console.error('[PlaylistEditor] failed to populate playlists', err); }) .finally(loading.hide); + } else if (options.id) { + content.querySelector('.fldSelectPlaylist')?.classList.add('hide'); + const panel = dom.parentWithClass(content, 'dialog') as DialogElement | null; + if (!panel) { + console.error('[PlaylistEditor] could not find dialog element'); + return; + } + + const apiClient = ServerConnections.getApiClient(currentServerId); + const api = toApi(apiClient); + Promise.all([ + getUserLibraryApi(api) + .getItem({ itemId: options.id }), + getPlaylistsApi(api) + .getPlaylist({ playlistId: options.id }) + ]) + .then(([ { data: playlistItem }, { data: playlist } ]) => { + panel.playlistId = options.id; + + const nameField = panel.querySelector('#txtNewPlaylistName'); + if (nameField) nameField.value = playlistItem.Name || ''; + + const publicField = panel.querySelector('#chkPlaylistPublic'); + if (publicField) publicField.checked = !!playlist.OpenAccess; + }) + .catch(err => { + console.error('[playlistEditor] failed to get playlist details', err); + }); } else { content.querySelector('.fldSelectPlaylist')?.classList.add('hide'); @@ -325,17 +390,21 @@ export class PlaylistEditor { dlg.classList.add('formDialog'); let html = ''; - const title = globalize.translate('HeaderAddToPlaylist'); - html += '
'; html += ``; html += '

'; - html += title; + if (items.length) { + html += globalize.translate('HeaderAddToPlaylist'); + } else if (options.id) { + html += globalize.translate('HeaderEditPlaylist'); + } else { + html += globalize.translate('HeaderNewPlaylist'); + } html += '

'; html += '
'; - html += getEditorHtml(items); + html += getEditorHtml(items, options); dlg.innerHTML = html; diff --git a/src/controllers/itemDetails/index.js b/src/controllers/itemDetails/index.js index 937c6a61ad9..ef2ce9279e4 100644 --- a/src/controllers/itemDetails/index.js +++ b/src/controllers/itemDetails/index.js @@ -582,11 +582,13 @@ function reloadFromItem(instance, page, params, item, user) { page.querySelector('.btnSplitVersions').classList.add('hide'); } - if (itemContextMenu.getCommands(getContextMenuOptions(item, user)).length) { - hideAll(page, 'btnMoreCommands', true); - } else { - hideAll(page, 'btnMoreCommands'); - } + itemContextMenu.getCommands(getContextMenuOptions(item, user)).then(commands => { + if (commands.length) { + hideAll(page, 'btnMoreCommands', true); + } else { + hideAll(page, 'btnMoreCommands'); + } + }); const itemBirthday = page.querySelector('#itemBirthday'); diff --git a/src/strings/en-us.json b/src/strings/en-us.json index 8546b6d77c1..1adc9794c87 100644 --- a/src/strings/en-us.json +++ b/src/strings/en-us.json @@ -411,6 +411,7 @@ "HeaderDummyChapter": "Chapter Images", "HeaderDVR": "DVR", "HeaderEditImages": "Edit Images", + "HeaderEditPlaylist": "Edit Playlist", "HeaderEnabledFields": "Enabled Fields", "HeaderEnabledFieldsHelp": "Uncheck a field to lock it and prevent its data from being changed.", "HeaderEpisodesStatus": "Episodes Status", @@ -457,6 +458,7 @@ "HeaderNetworking": "IP Protocols", "HeaderNewApiKey": "New API Key", "HeaderNewDevices": "New Devices", + "HeaderNewPlaylist": "New Playlist", "HeaderNewRepository": "New Repository", "HeaderNextItem": "Next {0}", "HeaderNextItemPlayingInValue": "Next {0} Playing in {1}", @@ -1335,6 +1337,7 @@ "PlayFromBeginning": "Play from beginning", "PlaylistError.AddFailed": "Error adding to playlist", "PlaylistError.CreateFailed": "Error creating playlist", + "PlaylistError.UpdateFailed": "Error updating playlist", "PlaylistPublic": "Allow public access", "PlaylistPublicDescription": "Allow this playlist to be viewed by any logged in user.", "Playlists": "Playlists", diff --git a/src/utils/string.test.ts b/src/utils/string.test.ts index ba1c686ede4..8274e292731 100644 --- a/src/utils/string.test.ts +++ b/src/utils/string.test.ts @@ -1,6 +1,24 @@ import { describe, expect, it } from 'vitest'; -import { toBoolean, toFloat } from './string'; +import { isBlank, toBoolean, toFloat } from './string'; + +describe('isBlank', () => { + it('Should return true if the string is blank', () => { + let check = isBlank(undefined); + expect(check).toBe(true); + check = isBlank(null); + expect(check).toBe(true); + check = isBlank(''); + expect(check).toBe(true); + check = isBlank(' \t\t '); + expect(check).toBe(true); + }); + + it('Should return false if the string is not blank', () => { + const check = isBlank('not an empty string'); + expect(check).toBe(false); + }); +}); describe('toBoolean', () => { it('Should return the boolean represented by the string', () => { diff --git a/src/utils/string.ts b/src/utils/string.ts index fff42b95a6c..b3a858e6f51 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -1,3 +1,12 @@ +/** + * Checks if a string is empty or contains only whitespace. + * @param {string} value The string to test. + * @returns {boolean} True if the string is blank. + */ +export function isBlank(value: string | undefined | null) { + return !value?.trim().length; +} + /** * Gets the value of a string as boolean. * @param {string} name The value as a string.