Skip to content

Commit

Permalink
Merge pull request #6184 from thornbill/playlist-editor
Browse files Browse the repository at this point in the history
Add playlist editing
  • Loading branch information
thornbill authored Oct 13, 2024
2 parents 941f0e7 + 3ad0fb0 commit 9c405e9
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 29 deletions.
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -113,6 +113,7 @@ const MoreCommandsButton: FC<MoreCommandsButtonProps> = ({
itemId: selectedItemId || itemId || ''
});
const parentId = item?.SeasonId || item?.SeriesId || item?.ParentId;
const [ hasCommands, setHasCommands ] = useState(false);

const playlistItem = useMemo(() => {
let PlaylistItemId: string | null = null;
Expand Down Expand Up @@ -198,10 +199,15 @@ const MoreCommandsButton: FC<MoreCommandsButtonProps> = ({
[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 (
<IconButton
className='button-flat btnMoreCommands'
Expand Down
36 changes: 28 additions & 8 deletions src/components/itemContextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import globalize from '../lib/globalize';
import actionsheet from './actionSheet/actionSheet';
import { appHost } from './apphost';
import { appRouter } from './router/appRouter';
import itemHelper from './itemHelper';
import itemHelper, { canEditPlaylist } from './itemHelper';
import { playbackManager } from './playback/playbackmanager';
import ServerConnections from './ServerConnections';
import toast from './toast/toast';
Expand All @@ -29,7 +29,7 @@ function getDeleteLabel(type) {
}
}

export function getCommands(options) {
export async function getCommands(options) {
const item = options.item;
const user = options.user;

Expand Down Expand Up @@ -209,6 +209,17 @@ export function getCommands(options) {
});
}

if (item.Type === BaseItemKind.Playlist) {
const _canEditPlaylist = await canEditPlaylist(user, item);
if (_canEditPlaylist) {
commands.push({
name: globalize.translate('Edit'),
id: 'editplaylist',
icon: 'edit'
});
}
}

const canEdit = itemHelper.canEdit(user, item);
if (canEdit && options.edit !== false && item.Type !== 'SeriesTimer') {
const text = (item.Type === 'Timer' || item.Type === 'SeriesTimer') ? globalize.translate('Edit') : globalize.translate('EditMetadata');
Expand Down Expand Up @@ -466,6 +477,15 @@ function executeCommand(item, id, options) {
case 'edit':
editItem(apiClient, item).then(getResolveFunction(resolve, id, true), getResolveFunction(resolve, id));
break;
case 'editplaylist':
import('./playlisteditor/playlisteditor').then(({ default: PlaylistEditor }) => {
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({
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 25 additions & 2 deletions src/components/itemHelper.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
85 changes: 77 additions & 8 deletions src/components/playlisteditor/playlisteditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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
Expand All @@ -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 => {
Expand All @@ -73,6 +84,9 @@ function onSubmit(this: HTMLElement, e: Event) {
}

function createPlaylist(dlg: DialogElement) {
const name = dlg.querySelector<HTMLInputElement>('#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);

Expand All @@ -81,7 +95,7 @@ function createPlaylist(dlg: DialogElement) {
return getPlaylistsApi(api)
.createPlaylist({
createPlaylistDto: {
Name: dlg.querySelector<HTMLInputElement>('#txtNewPlaylistName')?.value,
Name: name,
IsPublic: dlg.querySelector<HTMLInputElement>('#chkPlaylistPublic')?.checked,
Ids: itemIds?.split(','),
UserId: apiClient.getCurrentUserId()
Expand All @@ -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<HTMLInputElement>('#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<HTMLInputElement>('#chkPlaylistPublic')?.checked
}
})
.then(() => {
dlg.submitted = true;
dialogHelper.close(dlg);
});
}

function addToPlaylist(dlg: DialogElement, id: string) {
const apiClient = ServerConnections.getApiClient(currentServerId);
const api = toApi(apiClient);
Expand Down Expand Up @@ -210,7 +247,7 @@ function populatePlaylists(editorOptions: PlaylistEditorOptions, panel: DialogEl
});
}

function getEditorHtml(items: string[]) {
function getEditorHtml(items: string[], options: PlaylistEditorOptions) {
let html = '';

html += '<div class="formDialogContent smoothScrollY" style="padding-top:2em;">';
Expand All @@ -232,7 +269,7 @@ function getEditorHtml(items: string[]) {
html += `
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input type="checkbox" is="emby-checkbox" id="chkPlaylistPublic" checked />
<input type="checkbox" is="emby-checkbox" id="chkPlaylistPublic" />
<span>${globalize.translate('PlaylistPublic')}</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Expand All @@ -244,7 +281,7 @@ function getEditorHtml(items: string[]) {
html += '</div>';

html += '<div class="formDialogFooter">';
html += `<button is="emby-button" type="submit" class="raised btnSubmit block formDialogFooterItem button-submit">${globalize.translate('Add')}</button>`;
html += `<button is="emby-button" type="submit" class="raised btnSubmit block formDialogFooterItem button-submit">${options.id ? globalize.translate('Save') : globalize.translate('Add')}</button>`;
html += '</div>';

html += '<input type="hidden" class="fldSelectedItemIds" />';
Expand Down Expand Up @@ -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<HTMLInputElement>('#txtNewPlaylistName');
if (nameField) nameField.value = playlistItem.Name || '';

const publicField = panel.querySelector<HTMLInputElement>('#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');

Expand Down Expand Up @@ -325,17 +390,21 @@ export class PlaylistEditor {
dlg.classList.add('formDialog');

let html = '';
const title = globalize.translate('HeaderAddToPlaylist');

html += '<div class="formDialogHeader">';
html += `<button is="paper-icon-button-light" class="btnCancel autoSize" tabindex="-1" title="${globalize.translate('ButtonBack')}"><span class="material-icons arrow_back" aria-hidden="true"></span></button>`;
html += '<h3 class="formDialogHeaderTitle">';
html += title;
if (items.length) {
html += globalize.translate('HeaderAddToPlaylist');
} else if (options.id) {
html += globalize.translate('HeaderEditPlaylist');
} else {
html += globalize.translate('HeaderNewPlaylist');
}
html += '</h3>';

html += '</div>';

html += getEditorHtml(items);
html += getEditorHtml(items, options);

dlg.innerHTML = html;

Expand Down
12 changes: 7 additions & 5 deletions src/controllers/itemDetails/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
3 changes: 3 additions & 0 deletions src/strings/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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",
Expand Down
Loading

0 comments on commit 9c405e9

Please sign in to comment.