mirror of
https://github.com/jellyfin/jellyfin-web
synced 2025-03-30 19:56:21 +00:00
Merge pull request #6039 from grafixeyehero/Add-details-react-view
Add detail view buttons
This commit is contained in:
commit
f405602bd0
22 changed files with 921 additions and 4 deletions
|
@ -0,0 +1,68 @@
|
||||||
|
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<CancelSeriesTimerButtonProps> = ({
|
||||||
|
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) => {
|
||||||
|
loading.hide();
|
||||||
|
toast(globalize.translate('MessageCancelSeriesTimerError'));
|
||||||
|
console.error(
|
||||||
|
'[cancelSeriesTimer] failed to cancel series timer',
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// confirm dialog closed
|
||||||
|
});
|
||||||
|
}, [cancelSeriesTimer, navigate, itemId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className='button-flat btnCancelSeriesTimer'
|
||||||
|
title={globalize.translate('CancelSeries')}
|
||||||
|
onClick={onCancelSeriesTimerClick}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CancelSeriesTimerButton;
|
|
@ -0,0 +1,60 @@
|
||||||
|
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<CancelTimerButtonProps> = ({
|
||||||
|
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) => {
|
||||||
|
loading.hide();
|
||||||
|
toast(globalize.translate('MessageCancelTimerError'));
|
||||||
|
console.error(
|
||||||
|
'[cancelTimer] failed to cancel timer',
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [cancelTimer, queryClient, queryKey, timerId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className='button-flat btnCancelTimer'
|
||||||
|
title={globalize.translate('StopRecording')}
|
||||||
|
onClick={onCancelTimerClick}
|
||||||
|
>
|
||||||
|
<StopIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CancelTimerButton;
|
|
@ -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<DownloadButtonProps> = ({ 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 (
|
||||||
|
<IconButton
|
||||||
|
className='button-flat btnDownload'
|
||||||
|
title={globalize.translate('Download')}
|
||||||
|
onClick={onDownloadClick}
|
||||||
|
>
|
||||||
|
<FileDownloadIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadButton;
|
|
@ -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<InstantMixButtonProps> = ({ item }) => {
|
||||||
|
const onInstantMixClick = useCallback(() => {
|
||||||
|
playbackManager.instantMix(item);
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className='button-flat btnInstantMix'
|
||||||
|
title={globalize.translate('HeaderInstantMix')}
|
||||||
|
onClick={onInstantMixClick}
|
||||||
|
>
|
||||||
|
<ExploreIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InstantMixButton;
|
|
@ -0,0 +1,219 @@
|
||||||
|
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 {
|
||||||
|
itemType: ItemKind;
|
||||||
|
selectedItemId?: string;
|
||||||
|
itemId?: string;
|
||||||
|
items?: ItemDto[] | null;
|
||||||
|
collectionId?: NullableString;
|
||||||
|
playlistId?: NullableString;
|
||||||
|
canEditPlaylist?: boolean;
|
||||||
|
itemPlaylistItemId?: NullableString;
|
||||||
|
contextMenuOpts?: ContextMenuOpts;
|
||||||
|
queryKey?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MoreCommandsButton: FC<MoreCommandsButtonProps> = ({
|
||||||
|
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<HTMLElement>) => {
|
||||||
|
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 (
|
||||||
|
<IconButton
|
||||||
|
className='button-flat btnMoreCommands'
|
||||||
|
title={globalize.translate('ButtonMore')}
|
||||||
|
onClick={onMoreCommandsClick}
|
||||||
|
>
|
||||||
|
<MoreVertIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MoreCommandsButton;
|
|
@ -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<PlayOrResumeButtonProps> = ({
|
||||||
|
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 (
|
||||||
|
<IconButton
|
||||||
|
className='button-flat btnPlayOrResume'
|
||||||
|
data-action={isResumable ? 'resume' : 'play'}
|
||||||
|
title={
|
||||||
|
isResumable ?
|
||||||
|
globalize.translate('ButtonResume') :
|
||||||
|
globalize.translate('Play')
|
||||||
|
}
|
||||||
|
onClick={onPlayClick}
|
||||||
|
>
|
||||||
|
{isResumable ? <ReplayIcon /> : <PlayArrowIcon />}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlayOrResumeButton;
|
|
@ -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<PlayTrailerButtonProps> = ({ item }) => {
|
||||||
|
const onPlayTrailerClick = useCallback(async () => {
|
||||||
|
await playbackManager.playTrailers(item);
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className='button-flat btnPlayTrailer'
|
||||||
|
title={globalize.translate('ButtonTrailer')}
|
||||||
|
onClick={onPlayTrailerClick}
|
||||||
|
>
|
||||||
|
<TheatersIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlayTrailerButton;
|
|
@ -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<ShuffleButtonProps> = ({ item }) => {
|
||||||
|
const shuffle = useCallback(() => {
|
||||||
|
playbackManager.shuffle(item);
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
title={globalize.translate('Shuffle')}
|
||||||
|
className='button-flat btnShuffle'
|
||||||
|
onClick={shuffle}
|
||||||
|
>
|
||||||
|
<ShuffleIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShuffleButton;
|
|
@ -0,0 +1,68 @@
|
||||||
|
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';
|
||||||
|
import toast from 'components/toast/toast';
|
||||||
|
|
||||||
|
interface SplitVersionsButtonProps {
|
||||||
|
paramId: string;
|
||||||
|
queryKey?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SplitVersionsButton: FC<SplitVersionsButtonProps> = ({
|
||||||
|
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) => {
|
||||||
|
loading.hide();
|
||||||
|
toast(globalize.translate('MessageSplitVersionsError'));
|
||||||
|
console.error(
|
||||||
|
'[splitVersions] failed to split versions',
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// confirm dialog closed
|
||||||
|
});
|
||||||
|
}, [deleteAlternateSources, paramId, queryClient, queryKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
className='button-flat btnSplitVersions'
|
||||||
|
title={globalize.translate('ButtonSplit')}
|
||||||
|
onClick={splitVersions}
|
||||||
|
>
|
||||||
|
<CallSplitIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SplitVersionsButton;
|
|
@ -0,0 +1,62 @@
|
||||||
|
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 { ItemKind } from 'types/base/models/item-kind';
|
||||||
|
|
||||||
|
const getItemByType = async (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
itemType: ItemKind,
|
||||||
|
itemId: string,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
const { api, user } = apiContext;
|
||||||
|
|
||||||
|
if (!api) throw new Error('[getItemByType] No API instance available');
|
||||||
|
if (!user?.Id) throw new Error('[getItemByType] No User 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: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
};
|
|
@ -351,12 +351,13 @@ export function getCommands(options) {
|
||||||
return commands;
|
return commands;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResolveFunction(resolve, id, changed, deleted) {
|
function getResolveFunction(resolve, commandId, changed, deleted, itemId) {
|
||||||
return function () {
|
return function () {
|
||||||
resolve({
|
resolve({
|
||||||
command: id,
|
command: commandId,
|
||||||
updated: changed,
|
updated: changed,
|
||||||
deleted: deleted
|
deleted: deleted,
|
||||||
|
itemId: itemId
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -533,7 +534,7 @@ function executeCommand(item, id, options) {
|
||||||
getResolveFunction(resolve, id)();
|
getResolveFunction(resolve, id)();
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
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;
|
break;
|
||||||
case 'share':
|
case 'share':
|
||||||
navigator.share({
|
navigator.share({
|
||||||
|
|
1
src/hooks/api/libraryHooks/index.ts
Normal file
1
src/hooks/api/libraryHooks/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './useGetDownload';
|
34
src/hooks/api/libraryHooks/useGetDownload.ts
Normal file
34
src/hooks/api/libraryHooks/useGetDownload.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
import type { LibraryApiGetDownloadRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api';
|
||||||
|
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
import { type JellyfinApiContext, useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
const getDownload = async (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: LibraryApiGetDownloadRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
const { api, user } = apiContext;
|
||||||
|
|
||||||
|
if (!api) throw new Error('[getDownload] No API instance available');
|
||||||
|
if (!user?.Id) throw new Error('[getDownload] No User ID provided');
|
||||||
|
|
||||||
|
const response = await getLibraryApi(api).getDownload(params, options);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDownloadQuery = (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: LibraryApiGetDownloadRequest
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['Download', params.itemId],
|
||||||
|
queryFn: ({ signal }) => getDownload(apiContext, params, { signal }),
|
||||||
|
enabled: !!apiContext.api && !!apiContext.user?.Id && !!params.itemId
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useGetDownload = (params: LibraryApiGetDownloadRequest) => {
|
||||||
|
const apiContext = useApi();
|
||||||
|
return useQuery(getDownloadQuery(apiContext, params));
|
||||||
|
};
|
5
src/hooks/api/liveTvHooks/index.ts
Normal file
5
src/hooks/api/liveTvHooks/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export * from './useCancelSeriesTimer';
|
||||||
|
export * from './useCancelTimer';
|
||||||
|
export * from './useGetChannel';
|
||||||
|
export * from './useGetSeriesTimer';
|
||||||
|
export * from './useGetTimer';
|
24
src/hooks/api/liveTvHooks/useCancelSeriesTimer.ts
Normal file
24
src/hooks/api/liveTvHooks/useCancelSeriesTimer.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import type { LiveTvApiCancelSeriesTimerRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { type JellyfinApiContext, useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
const cancelSeriesTimer = async (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: LiveTvApiCancelSeriesTimerRequest
|
||||||
|
) => {
|
||||||
|
const { api } = apiContext;
|
||||||
|
|
||||||
|
if (!api) throw new Error('[cancelSeriesTimer] No API instance available');
|
||||||
|
|
||||||
|
const response = await getLiveTvApi(api).cancelSeriesTimer(params);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCancelSeriesTimer = () => {
|
||||||
|
const apiContext = useApi();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: LiveTvApiCancelSeriesTimerRequest) =>
|
||||||
|
cancelSeriesTimer(apiContext, params)
|
||||||
|
});
|
||||||
|
};
|
24
src/hooks/api/liveTvHooks/useCancelTimer.ts
Normal file
24
src/hooks/api/liveTvHooks/useCancelTimer.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import type { LiveTvApiCancelTimerRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { type JellyfinApiContext, useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
const cancelTimer = async (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: LiveTvApiCancelTimerRequest
|
||||||
|
) => {
|
||||||
|
const { api } = apiContext;
|
||||||
|
|
||||||
|
if (!api) throw new Error('[cancelTimer] No API instance available');
|
||||||
|
|
||||||
|
const response = await getLiveTvApi(api).cancelTimer(params);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCancelTimer = () => {
|
||||||
|
const apiContext = useApi();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: LiveTvApiCancelTimerRequest) =>
|
||||||
|
cancelTimer(apiContext, params)
|
||||||
|
});
|
||||||
|
};
|
41
src/hooks/api/liveTvHooks/useGetChannel.ts
Normal file
41
src/hooks/api/liveTvHooks/useGetChannel.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
import type { LiveTvApiGetChannelRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
|
||||||
|
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
import { type JellyfinApiContext, useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
const getChannel = async (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: LiveTvApiGetChannelRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
const { api, user } = apiContext;
|
||||||
|
|
||||||
|
if (!api) throw new Error('[getChannel] No API instance available');
|
||||||
|
if (!user?.Id) throw new Error('[getChannel] No User ID provided');
|
||||||
|
|
||||||
|
const response = await getLiveTvApi(api).getChannel(
|
||||||
|
{
|
||||||
|
userId: user.Id,
|
||||||
|
...params
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getChannelQuery = (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: LiveTvApiGetChannelRequest
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['Channel', params.channelId],
|
||||||
|
queryFn: ({ signal }) => getChannel(apiContext, params, { signal }),
|
||||||
|
enabled:
|
||||||
|
!!apiContext.api && !!apiContext.user?.Id && !!params.channelId
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useGetChannel = (params: LiveTvApiGetChannelRequest) => {
|
||||||
|
const apiContext = useApi();
|
||||||
|
return useQuery(getChannelQuery(apiContext, params));
|
||||||
|
};
|
35
src/hooks/api/liveTvHooks/useGetSeriesTimer.ts
Normal file
35
src/hooks/api/liveTvHooks/useGetSeriesTimer.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
import type { LiveTvApiGetSeriesTimerRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
|
||||||
|
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
import { type JellyfinApiContext, useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
const getSeriesTimer = async (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: LiveTvApiGetSeriesTimerRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
const { api } = apiContext;
|
||||||
|
|
||||||
|
if (!api) throw new Error('[getSeriesTimer] No API instance available');
|
||||||
|
|
||||||
|
const response = await getLiveTvApi(api).getSeriesTimer(params, options);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSeriesTimerQuery = (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: LiveTvApiGetSeriesTimerRequest
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['SeriesTimer', params.timerId],
|
||||||
|
queryFn: ({ signal }) => getSeriesTimer(apiContext, params, { signal }),
|
||||||
|
enabled: !!apiContext.api && !!apiContext.user?.Id && !!params.timerId
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useGetSeriesTimer = (
|
||||||
|
requestParameters: LiveTvApiGetSeriesTimerRequest
|
||||||
|
) => {
|
||||||
|
const apiContext = useApi();
|
||||||
|
return useQuery(getSeriesTimerQuery(apiContext, requestParameters));
|
||||||
|
};
|
33
src/hooks/api/liveTvHooks/useGetTimer.ts
Normal file
33
src/hooks/api/liveTvHooks/useGetTimer.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
import type { LiveTvApiGetTimerRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getLiveTvApi } from '@jellyfin/sdk/lib/utils/api/live-tv-api';
|
||||||
|
import { queryOptions, useQuery } from '@tanstack/react-query';
|
||||||
|
import { type JellyfinApiContext, useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
const getTimer = async (
|
||||||
|
currentApi: JellyfinApiContext,
|
||||||
|
params: LiveTvApiGetTimerRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) => {
|
||||||
|
const { api } = currentApi;
|
||||||
|
|
||||||
|
if (!api) throw new Error('[getTimer] No API instance available');
|
||||||
|
|
||||||
|
const response = await getLiveTvApi(api).getTimer(params, options);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTimerQuery = (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: LiveTvApiGetTimerRequest
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['Timer', params.timerId],
|
||||||
|
queryFn: ({ signal }) => getTimer(apiContext, params, { signal }),
|
||||||
|
enabled: !!apiContext.api && !!apiContext.user?.Id && !!params.timerId
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useGetTimer = (requestParameters: LiveTvApiGetTimerRequest) => {
|
||||||
|
const apiContext = useApi();
|
||||||
|
return useQuery(getTimerQuery(apiContext, requestParameters));
|
||||||
|
};
|
1
src/hooks/api/videosHooks/index.ts
Normal file
1
src/hooks/api/videosHooks/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './useDeleteAlternateSources';
|
24
src/hooks/api/videosHooks/useDeleteAlternateSources.ts
Normal file
24
src/hooks/api/videosHooks/useDeleteAlternateSources.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import type { VideosApiDeleteAlternateSourcesRequest } from '@jellyfin/sdk/lib/generated-client';
|
||||||
|
import { getVideosApi } from '@jellyfin/sdk/lib/utils/api/videos-api';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { type JellyfinApiContext, useApi } from 'hooks/useApi';
|
||||||
|
|
||||||
|
const deleteAlternateSources = async (
|
||||||
|
apiContext: JellyfinApiContext,
|
||||||
|
params: VideosApiDeleteAlternateSourcesRequest
|
||||||
|
) => {
|
||||||
|
const { api } = apiContext;
|
||||||
|
|
||||||
|
if (!api) throw new Error('[deleteAlternateSources] No API instance available');
|
||||||
|
|
||||||
|
const response = await getVideosApi(api).deleteAlternateSources(params);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteAlternateSources = () => {
|
||||||
|
const apiContext = useApi();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (params: VideosApiDeleteAlternateSourcesRequest) =>
|
||||||
|
deleteAlternateSources(apiContext, params)
|
||||||
|
});
|
||||||
|
};
|
|
@ -1067,6 +1067,8 @@
|
||||||
"MessageAreYouSureDeleteSubtitles": "Are you sure you wish to delete this subtitle file?",
|
"MessageAreYouSureDeleteSubtitles": "Are you sure you wish to delete this subtitle file?",
|
||||||
"MessageAreYouSureYouWishToRemoveMediaFolder": "Are you sure you wish to remove this media folder?",
|
"MessageAreYouSureYouWishToRemoveMediaFolder": "Are you sure you wish to remove this media folder?",
|
||||||
"MessageBrowsePluginCatalog": "Browse our plugin catalog to view available plugins.",
|
"MessageBrowsePluginCatalog": "Browse our plugin catalog to view available plugins.",
|
||||||
|
"MessageCancelSeriesTimerError": "An error occurred while canceling the series timer",
|
||||||
|
"MessageCancelTimerError": "An error occurred while canceling the timer",
|
||||||
"MessageChangeRecordingPath": "Changing your recording folder will not migrate existing recordings from the old location to the new. You'll need to move them manually if desired.",
|
"MessageChangeRecordingPath": "Changing your recording folder will not migrate existing recordings from the old location to the new. You'll need to move them manually if desired.",
|
||||||
"MessageConfirmAppExit": "Do you want to exit?",
|
"MessageConfirmAppExit": "Do you want to exit?",
|
||||||
"MessageConfirmDeleteGuideProvider": "Are you sure you wish to delete this guide provider?",
|
"MessageConfirmDeleteGuideProvider": "Are you sure you wish to delete this guide provider?",
|
||||||
|
@ -1119,6 +1121,7 @@
|
||||||
"MessageRenameMediaFolder": "Renaming a media library will cause all metadata to be lost, proceed with caution.",
|
"MessageRenameMediaFolder": "Renaming a media library will cause all metadata to be lost, proceed with caution.",
|
||||||
"MessageRepositoryInstallDisclaimer": "WARNING: Installing a third party plugin repository carries risks. It may contain unstable or malicious code, and may change at any time. Only install repositories from authors that you trust.",
|
"MessageRepositoryInstallDisclaimer": "WARNING: Installing a third party plugin repository carries risks. It may contain unstable or malicious code, and may change at any time. Only install repositories from authors that you trust.",
|
||||||
"MessageSent": "Message sent.",
|
"MessageSent": "Message sent.",
|
||||||
|
"MessageSplitVersionsError": "An error occurred while splitting versions",
|
||||||
"MessageSyncPlayCreateGroupDenied": "Permission required to create a group.",
|
"MessageSyncPlayCreateGroupDenied": "Permission required to create a group.",
|
||||||
"MessageSyncPlayDisabled": "SyncPlay disabled.",
|
"MessageSyncPlayDisabled": "SyncPlay disabled.",
|
||||||
"MessageSyncPlayEnabled": "SyncPlay enabled.",
|
"MessageSyncPlayEnabled": "SyncPlay enabled.",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue