[enhancement]: allow downloading individual tracks for external use
This commit is contained in:
parent
10fca2dc12
commit
c4677a63f6
10 changed files with 91 additions and 6 deletions
|
@ -318,6 +318,7 @@
|
||||||
"createPlaylist": "$t(action.createPlaylist)",
|
"createPlaylist": "$t(action.createPlaylist)",
|
||||||
"deletePlaylist": "$t(action.deletePlaylist)",
|
"deletePlaylist": "$t(action.deletePlaylist)",
|
||||||
"deselectAll": "$t(action.deselectAll)",
|
"deselectAll": "$t(action.deselectAll)",
|
||||||
|
"download": "download",
|
||||||
"moveToBottom": "$t(action.moveToBottom)",
|
"moveToBottom": "$t(action.moveToBottom)",
|
||||||
"moveToTop": "$t(action.moveToTop)",
|
"moveToTop": "$t(action.moveToTop)",
|
||||||
"numberSelected": "{{count}} selected",
|
"numberSelected": "{{count}} selected",
|
||||||
|
|
|
@ -364,6 +364,10 @@ const createWindow = async (first = true) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on('download-url', (_event, url: string) => {
|
||||||
|
mainWindow?.webContents.downloadURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
|
const globalMediaKeysEnabled = store.get('global_media_hotkeys', true) as boolean;
|
||||||
|
|
||||||
if (globalMediaKeysEnabled) {
|
if (globalMediaKeysEnabled) {
|
||||||
|
|
|
@ -47,7 +47,12 @@ const logger = (
|
||||||
ipcRenderer.send('logger', cb);
|
ipcRenderer.send('logger', cb);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const download = (url: string) => {
|
||||||
|
ipcRenderer.send('download-url', url);
|
||||||
|
};
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
|
download,
|
||||||
isLinux,
|
isLinux,
|
||||||
isMacOS,
|
isMacOS,
|
||||||
isWindows,
|
isWindows,
|
||||||
|
|
|
@ -58,6 +58,7 @@ import type {
|
||||||
ServerType,
|
ServerType,
|
||||||
ShareItemResponse,
|
ShareItemResponse,
|
||||||
MoveItemArgs,
|
MoveItemArgs,
|
||||||
|
DownloadArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
import { DeletePlaylistResponse, RandomSongListArgs } from './types';
|
||||||
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||||
|
@ -83,6 +84,7 @@ export type ControllerEndpoint = Partial<{
|
||||||
getArtistDetail: () => void;
|
getArtistDetail: () => void;
|
||||||
getArtistInfo: (args: any) => void;
|
getArtistInfo: (args: any) => void;
|
||||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||||
|
getDownloadUrl: (args: DownloadArgs) => string;
|
||||||
getFavoritesList: () => void;
|
getFavoritesList: () => void;
|
||||||
getFolderItemList: () => void;
|
getFolderItemList: () => void;
|
||||||
getFolderList: () => void;
|
getFolderList: () => void;
|
||||||
|
@ -132,6 +134,7 @@ const endpoints: ApiController = {
|
||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
getArtistInfo: undefined,
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
|
getDownloadUrl: jfController.getDownloadUrl,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
|
@ -173,6 +176,7 @@ const endpoints: ApiController = {
|
||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
getArtistInfo: undefined,
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
|
getDownloadUrl: ssController.getDownloadUrl,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
|
@ -213,6 +217,7 @@ const endpoints: ApiController = {
|
||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
getArtistInfo: undefined,
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
|
getDownloadUrl: ssController.getDownloadUrl,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
|
@ -554,6 +559,15 @@ const movePlaylistItem = async (args: MoveItemArgs) => {
|
||||||
)?.(args);
|
)?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDownloadUrl = (args: DownloadArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'getDownloadUrl',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getDownloadUrl']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
export const controller = {
|
export const controller = {
|
||||||
addToPlaylist,
|
addToPlaylist,
|
||||||
authenticate,
|
authenticate,
|
||||||
|
@ -566,6 +580,7 @@ export const controller = {
|
||||||
getAlbumDetail,
|
getAlbumDetail,
|
||||||
getAlbumList,
|
getAlbumList,
|
||||||
getArtistList,
|
getArtistList,
|
||||||
|
getDownloadUrl,
|
||||||
getGenreList,
|
getGenreList,
|
||||||
getLyrics,
|
getLyrics,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
|
|
|
@ -54,6 +54,7 @@ import {
|
||||||
SimilarSongsArgs,
|
SimilarSongsArgs,
|
||||||
Song,
|
Song,
|
||||||
MoveItemArgs,
|
MoveItemArgs,
|
||||||
|
DownloadArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||||
import { jfNormalize } from './jellyfin-normalize';
|
import { jfNormalize } from './jellyfin-normalize';
|
||||||
|
@ -1043,6 +1044,12 @@ const movePlaylistItem = async (args: MoveItemArgs): Promise<void> => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDownloadUrl = (args: DownloadArgs) => {
|
||||||
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const jfController = {
|
export const jfController = {
|
||||||
addToPlaylist,
|
addToPlaylist,
|
||||||
authenticate,
|
authenticate,
|
||||||
|
@ -1055,6 +1062,7 @@ export const jfController = {
|
||||||
getAlbumDetail,
|
getAlbumDetail,
|
||||||
getAlbumList,
|
getAlbumList,
|
||||||
getArtistList,
|
getArtistList,
|
||||||
|
getDownloadUrl,
|
||||||
getGenreList,
|
getGenreList,
|
||||||
getLyrics,
|
getLyrics,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
|
|
|
@ -27,6 +27,7 @@ import {
|
||||||
StructuredLyric,
|
StructuredLyric,
|
||||||
SimilarSongsArgs,
|
SimilarSongsArgs,
|
||||||
Song,
|
Song,
|
||||||
|
DownloadArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { randomString } from '/@/renderer/utils';
|
import { randomString } from '/@/renderer/utils';
|
||||||
import { ServerFeatures } from '/@/renderer/api/features-types';
|
import { ServerFeatures } from '/@/renderer/api/features-types';
|
||||||
|
@ -482,10 +483,23 @@ const getSimilarSongs = async (args: SimilarSongsArgs): Promise<Song[]> => {
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDownloadUrl = (args: DownloadArgs) => {
|
||||||
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${apiClientProps.server?.url}/rest/download.view` +
|
||||||
|
`?id=${query.id}` +
|
||||||
|
`&${apiClientProps.server?.credential}` +
|
||||||
|
'&v=1.13.0' +
|
||||||
|
'&c=feishin'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ssController = {
|
export const ssController = {
|
||||||
authenticate,
|
authenticate,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
getArtistInfo,
|
getArtistInfo,
|
||||||
|
getDownloadUrl,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
getRandomSongList,
|
getRandomSongList,
|
||||||
getServerInfo,
|
getServerInfo,
|
||||||
|
|
|
@ -1202,3 +1202,11 @@ export type MoveItemQuery = {
|
||||||
export type MoveItemArgs = {
|
export type MoveItemArgs = {
|
||||||
query: MoveItemQuery;
|
query: MoveItemQuery;
|
||||||
} & BaseEndpointArgs;
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type DownloadQuery = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownloadArgs = {
|
||||||
|
query: DownloadQuery;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
|
@ -8,7 +8,8 @@ export const QUEUE_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ divider: true, id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ children: true, disabled: false, id: 'setRating' },
|
{ children: true, disabled: false, id: 'setRating' },
|
||||||
{ disabled: false, id: 'deselectAll' },
|
{ disabled: false, divider: true, id: 'deselectAll' },
|
||||||
|
{ id: 'download' },
|
||||||
{ divider: true, id: 'shareItem' },
|
{ divider: true, id: 'shareItem' },
|
||||||
{ divider: true, id: 'showDetails' },
|
{ divider: true, id: 'showDetails' },
|
||||||
];
|
];
|
||||||
|
@ -22,6 +23,7 @@ export const SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ divider: true, id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ children: true, disabled: false, divider: true, id: 'setRating' },
|
{ children: true, disabled: false, divider: true, id: 'setRating' },
|
||||||
|
{ id: 'download' },
|
||||||
{ divider: true, id: 'shareItem' },
|
{ divider: true, id: 'shareItem' },
|
||||||
{ divider: true, id: 'showDetails' },
|
{ divider: true, id: 'showDetails' },
|
||||||
];
|
];
|
||||||
|
@ -43,6 +45,7 @@ export const PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ divider: true, id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ children: true, disabled: false, id: 'setRating' },
|
{ children: true, disabled: false, id: 'setRating' },
|
||||||
|
{ id: 'download' },
|
||||||
{ divider: true, id: 'shareItem' },
|
{ divider: true, id: 'shareItem' },
|
||||||
{ divider: true, id: 'showDetails' },
|
{ divider: true, id: 'showDetails' },
|
||||||
];
|
];
|
||||||
|
@ -56,6 +59,7 @@ export const SMART_PLAYLIST_SONG_CONTEXT_MENU_ITEMS: SetContextMenuItems = [
|
||||||
{ id: 'addToFavorites' },
|
{ id: 'addToFavorites' },
|
||||||
{ divider: true, id: 'removeFromFavorites' },
|
{ divider: true, id: 'removeFromFavorites' },
|
||||||
{ children: true, disabled: false, id: 'setRating' },
|
{ children: true, disabled: false, id: 'setRating' },
|
||||||
|
{ id: 'download' },
|
||||||
{ divider: true, id: 'shareItem' },
|
{ divider: true, id: 'shareItem' },
|
||||||
{ divider: true, id: 'showDetails' },
|
{ divider: true, id: 'showDetails' },
|
||||||
];
|
];
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
RiShareForwardFill,
|
RiShareForwardFill,
|
||||||
RiInformationFill,
|
RiInformationFill,
|
||||||
RiRadio2Fill,
|
RiRadio2Fill,
|
||||||
|
RiDownload2Line,
|
||||||
} from 'react-icons/ri';
|
} from 'react-icons/ri';
|
||||||
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
|
import { AnyLibraryItems, LibraryItem, ServerType, AnyLibraryItem } from '/@/renderer/api/types';
|
||||||
import {
|
import {
|
||||||
|
@ -62,6 +63,7 @@ import { Play, PlaybackType } from '/@/renderer/types';
|
||||||
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
|
import { ItemDetailsModal } from '/@/renderer/features/item-details/components/item-details-modal';
|
||||||
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||||
import { controller } from '/@/renderer/api/controller';
|
import { controller } from '/@/renderer/api/controller';
|
||||||
|
import { api } from '/@/renderer/api';
|
||||||
|
|
||||||
type ContextMenuContextProps = {
|
type ContextMenuContextProps = {
|
||||||
closeContextMenu: () => void;
|
closeContextMenu: () => void;
|
||||||
|
@ -90,6 +92,7 @@ const JELLYFIN_IGNORED_MENU_ITEMS: ContextMenuItemType[] = ['setRating', 'shareI
|
||||||
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
// const SUBSONIC_IGNORED_MENU_ITEMS: ContextMenuItemType[] = [];
|
||||||
|
|
||||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
|
const utils = isElectron() ? window.electron.utils : null;
|
||||||
|
|
||||||
export interface ContextMenuProviderProps {
|
export interface ContextMenuProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
@ -685,6 +688,20 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW });
|
handlePlayQueueAdd?.({ byData: [ctx.data[0], ...songs], playType: Play.NOW });
|
||||||
}, [ctx, handlePlayQueueAdd]);
|
}, [ctx, handlePlayQueueAdd]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
const item = ctx.data[0];
|
||||||
|
const url = api.controller.getDownloadUrl({
|
||||||
|
apiClientProps: { server },
|
||||||
|
query: { id: item.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (utils) {
|
||||||
|
utils.download(url!);
|
||||||
|
} else {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
}, [ctx.data, server]);
|
||||||
|
|
||||||
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
|
const contextMenuItems: Record<ContextMenuItemType, ContextMenuItem> = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
addToFavorites: {
|
addToFavorites: {
|
||||||
|
@ -716,6 +733,13 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
leftIcon: <RiCloseCircleLine size="1.1rem" />,
|
leftIcon: <RiCloseCircleLine size="1.1rem" />,
|
||||||
onClick: handleDeselectAll,
|
onClick: handleDeselectAll,
|
||||||
},
|
},
|
||||||
|
download: {
|
||||||
|
disabled: ctx.data?.length !== 1,
|
||||||
|
id: 'download',
|
||||||
|
label: t('page.contextMenu.download', { postProcess: 'sentenceCase' }),
|
||||||
|
leftIcon: <RiDownload2Line size="1.1rem" />,
|
||||||
|
onClick: handleDownload,
|
||||||
|
},
|
||||||
moveToBottomOfQueue: {
|
moveToBottomOfQueue: {
|
||||||
id: 'moveToBottomOfQueue',
|
id: 'moveToBottomOfQueue',
|
||||||
label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }),
|
label: t('page.contextMenu.moveToBottom', { postProcess: 'sentenceCase' }),
|
||||||
|
@ -860,18 +884,19 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||||
handleAddToPlaylist,
|
handleAddToPlaylist,
|
||||||
openDeletePlaylistModal,
|
openDeletePlaylistModal,
|
||||||
handleDeselectAll,
|
handleDeselectAll,
|
||||||
|
ctx.data,
|
||||||
|
handleDownload,
|
||||||
handleMoveToBottom,
|
handleMoveToBottom,
|
||||||
handleMoveToTop,
|
handleMoveToTop,
|
||||||
|
handleSimilar,
|
||||||
handleRemoveFromFavorites,
|
handleRemoveFromFavorites,
|
||||||
handleRemoveFromPlaylist,
|
handleRemoveFromPlaylist,
|
||||||
handleRemoveSelected,
|
handleRemoveSelected,
|
||||||
ctx.data,
|
server,
|
||||||
|
handleShareItem,
|
||||||
handleOpenItemDetails,
|
handleOpenItemDetails,
|
||||||
handlePlay,
|
handlePlay,
|
||||||
handleUpdateRating,
|
handleUpdateRating,
|
||||||
handleShareItem,
|
|
||||||
server,
|
|
||||||
handleSimilar,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mergedRef = useMergedRef(ref, clickOutsideRef);
|
const mergedRef = useMergedRef(ref, clickOutsideRef);
|
||||||
|
|
|
@ -36,7 +36,8 @@ export type ContextMenuItemType =
|
||||||
| 'removeFromQueue'
|
| 'removeFromQueue'
|
||||||
| 'deselectAll'
|
| 'deselectAll'
|
||||||
| 'showDetails'
|
| 'showDetails'
|
||||||
| 'playSimilarSongs';
|
| 'playSimilarSongs'
|
||||||
|
| 'download';
|
||||||
|
|
||||||
export type SetContextMenuItems = {
|
export type SetContextMenuItems = {
|
||||||
children?: boolean;
|
children?: boolean;
|
||||||
|
|
Reference in a new issue