2020-12-29 15:53:04 +00:00
|
|
|
import * as React from "react";
|
2021-01-10 16:36:57 +00:00
|
|
|
import {useContext, useRef, useState} from "react";
|
2020-12-29 15:53:04 +00:00
|
|
|
import {Registry} from "tc-shared/events";
|
|
|
|
import {
|
|
|
|
MusicPlaylistEntryInfo,
|
|
|
|
MusicPlaylistStatus,
|
|
|
|
MusicPlaylistUiEvents
|
|
|
|
} from "tc-shared/ui/frames/side/MusicPlaylistDefinitions";
|
|
|
|
import {Button} from "tc-shared/ui/react-elements/Button";
|
|
|
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
|
|
|
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
2021-03-17 20:20:03 +00:00
|
|
|
import {showImagePreview} from "tc-shared/ui/frames/ImagePreview";
|
2020-12-29 15:53:04 +00:00
|
|
|
import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper";
|
|
|
|
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
|
2021-01-10 15:13:15 +00:00
|
|
|
import {copyToClipboard} from "tc-shared/utils/helpers";
|
2021-03-17 18:41:51 +00:00
|
|
|
import ImagePlaylistNoThumbnail from "./MusicPlaylistNoThumbnail.png";
|
2020-12-29 15:53:04 +00:00
|
|
|
|
|
|
|
const cssStyle = require("./MusicPlaylistRenderer.scss");
|
|
|
|
|
|
|
|
const EventContext = React.createContext<Registry<MusicPlaylistUiEvents>>(undefined);
|
|
|
|
const kPlaylistDragPrefixIds = "x-teaspeak-playlist-drag-ids-";
|
|
|
|
const kPlaylistDragSongUrl = "x-teaspeak-playlist-drag-url";
|
|
|
|
|
|
|
|
function parseDragIds(transfer: DataTransfer) : { serverUniqueId: string, entryId: number, playlistId: number } | undefined {
|
|
|
|
for(const item of transfer.items) {
|
|
|
|
if(!item.type.startsWith(kPlaylistDragPrefixIds)) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
const [ handlerId, playlistIdStr, entryIdStr ] = item.type.substring(kPlaylistDragPrefixIds.length).split("-");
|
|
|
|
return { serverUniqueId: handlerId, entryId: parseInt(entryIdStr), playlistId: parseInt(playlistIdStr) };
|
|
|
|
}
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
export function formatPlaytime(value: number) {
|
|
|
|
if(value == 0) {
|
|
|
|
return "--:--:--";
|
|
|
|
}
|
|
|
|
|
|
|
|
value /= 1000;
|
|
|
|
|
|
|
|
let hours = 0, minutes = 0;
|
|
|
|
while(value >= 60 * 60) {
|
|
|
|
hours++;
|
|
|
|
value -= 60 * 60;
|
|
|
|
}
|
|
|
|
|
|
|
|
while(value >= 60) {
|
|
|
|
minutes++;
|
|
|
|
value -= 60;
|
|
|
|
}
|
|
|
|
|
|
|
|
return ("0" + hours).substr(-2) + ":" + ("0" + minutes).substr(-2) + ":" + ("0" + value.toFixed(0)).substr(-2);
|
|
|
|
}
|
|
|
|
|
|
|
|
export const DefaultThumbnail = (_props: { type: "loading" | "none-present" }) => {
|
|
|
|
return (
|
|
|
|
<img
|
|
|
|
draggable={false}
|
2021-03-17 18:41:51 +00:00
|
|
|
src={ImagePlaylistNoThumbnail}
|
2020-12-29 15:53:04 +00:00
|
|
|
alt={useTr("loading")}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const PlaylistEntry = React.memo((props: { serverUniqueId: string, playlistId: number, entryId: number, active: boolean }) => {
|
|
|
|
const events = useContext(EventContext);
|
|
|
|
const refContainer = useRef<HTMLDivElement>();
|
|
|
|
const refDragLeaveTimer = useRef<number>();
|
|
|
|
|
|
|
|
const [ insertMarker, setInsertMarker ] = useState<"above" | "bellow" | "none">("none");
|
|
|
|
|
|
|
|
const [ status, setStatus ] = useState<MusicPlaylistEntryInfo>(() => {
|
|
|
|
events.fire("query_entry_status", { entryId: props.entryId });
|
|
|
|
return { type: "loading", url: undefined };
|
|
|
|
});
|
|
|
|
events.reactUse("notify_entry_status", event => event.entryId === props.entryId && setStatus(event.status));
|
|
|
|
|
|
|
|
let thumbnail, firstRow: React.ReactElement | string = "", secondRow: React.ReactElement | string = "", secondRowTitle, length;
|
|
|
|
switch (status.type) {
|
|
|
|
case "song":
|
|
|
|
if(status.thumbnailImage) {
|
|
|
|
thumbnail = (
|
|
|
|
<img
|
|
|
|
draggable={false}
|
|
|
|
key={"song-thumbnail"}
|
|
|
|
src={status.thumbnailImage}
|
2021-03-17 20:20:03 +00:00
|
|
|
onClick={() => showImagePreview(status.thumbnailImage, status.thumbnailImage)}
|
2020-12-29 15:53:04 +00:00
|
|
|
alt={useTr("Thumbnail")}
|
|
|
|
/>
|
|
|
|
)
|
|
|
|
} else {
|
|
|
|
thumbnail = <DefaultThumbnail key={"default-none"} type={"none-present"} />;
|
|
|
|
}
|
|
|
|
firstRow = status.title;
|
|
|
|
|
|
|
|
const description = status.description || tr("No song description given.");
|
|
|
|
secondRow = description.substr(0, 100);
|
|
|
|
secondRowTitle = description;
|
|
|
|
|
|
|
|
length = formatPlaytime(status.length);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "loading":
|
|
|
|
if(status.url) {
|
|
|
|
secondRow = status.url;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* fall through expected */
|
|
|
|
default:
|
|
|
|
thumbnail = <DefaultThumbnail key={"default"} type={"loading"} />;
|
|
|
|
firstRow = <React.Fragment key={"loading"}><Translatable>Loading</Translatable> <LoadingDots /></React.Fragment>;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
let insertClass;
|
|
|
|
switch (insertMarker) {
|
|
|
|
case "above":
|
|
|
|
insertClass = cssStyle.insertMarkerAbove;
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "bellow":
|
|
|
|
insertClass = cssStyle.insertMarkerBellow;
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "none":
|
|
|
|
default:
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
//cssStyle.playlistEntry + " " + cssStyle.shown + " " + (props.active ? cssStyle.currentSong : "")
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
ref={refContainer}
|
|
|
|
className={joinClassList(cssStyle.playlistEntry, cssStyle.shown, props.active && cssStyle.currentSong, insertClass)}
|
|
|
|
onContextMenu={event => {
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
spawnContextMenu({ pageY: event.pageY, pageX: event.pageX }, [
|
|
|
|
{
|
|
|
|
type: "normal",
|
|
|
|
label: tr("Copy URL"),
|
2021-01-10 15:13:15 +00:00
|
|
|
click: () => { status.type === "song" ? copyToClipboard(status.url) : undefined; },
|
2020-12-29 15:53:04 +00:00
|
|
|
visible: status.type === "song"
|
|
|
|
},
|
|
|
|
{
|
|
|
|
type: "normal",
|
|
|
|
label: tr("Copy description"),
|
2021-01-10 15:13:15 +00:00
|
|
|
click: () => { status.type === "song" ? copyToClipboard(status.description) : undefined; },
|
2020-12-29 15:53:04 +00:00
|
|
|
visible: status.type === "song" && !!status.description
|
|
|
|
},
|
|
|
|
{
|
|
|
|
type: "normal",
|
|
|
|
label: tr("Remove song"),
|
|
|
|
click: () => events.fire("action_entry_delete", { entryId: props.entryId })
|
|
|
|
}
|
|
|
|
]);
|
|
|
|
}}
|
|
|
|
draggable={true}
|
|
|
|
onDragStart={event => {
|
|
|
|
event.dataTransfer.setData(kPlaylistDragPrefixIds + props.serverUniqueId + "-" + props.playlistId + "-" + props.entryId, "");
|
|
|
|
if(status.type === "song") {
|
|
|
|
event.dataTransfer.setData(kPlaylistDragSongUrl, status.url);
|
|
|
|
}
|
|
|
|
event.dataTransfer.effectAllowed = "all";
|
|
|
|
}}
|
|
|
|
onDragOver={event => {
|
|
|
|
const info = parseDragIds(event.dataTransfer);
|
|
|
|
if(!info || !refContainer.current) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
if(info.playlistId === props.playlistId && info.serverUniqueId === props.serverUniqueId) {
|
|
|
|
if(info.entryId === props.entryId) {
|
|
|
|
event.dataTransfer.dropEffect = "none";
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
event.dataTransfer.dropEffect = "move";
|
|
|
|
} else if([...event.dataTransfer.items].findIndex(item => item.type === kPlaylistDragSongUrl) !== -1) {
|
|
|
|
event.dataTransfer.dropEffect = "copy";
|
|
|
|
} else {
|
|
|
|
event.dataTransfer.dropEffect = "none";
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if(refDragLeaveTimer.current) {
|
|
|
|
clearTimeout(refDragLeaveTimer.current);
|
|
|
|
refDragLeaveTimer.current = undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
const containerRect = refContainer.current.getBoundingClientRect();
|
|
|
|
switch (insertMarker) {
|
|
|
|
case "bellow": {
|
|
|
|
const yThreshold = containerRect.y + containerRect.height * .4;
|
|
|
|
if(event.pageY < yThreshold) {
|
|
|
|
setInsertMarker("above");
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
case "above": {
|
|
|
|
const yThreshold = containerRect.y + containerRect.height * .6;
|
|
|
|
if(event.pageY > yThreshold) {
|
|
|
|
setInsertMarker("bellow");
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
case "none": {
|
|
|
|
const yThreshold = containerRect.y + containerRect.height / 2;
|
|
|
|
if(event.pageY > yThreshold) {
|
|
|
|
setInsertMarker("bellow");
|
|
|
|
} else {
|
|
|
|
setInsertMarker("above");
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}}
|
|
|
|
onDragLeave={() => {
|
|
|
|
if(refDragLeaveTimer.current) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* The drag leave event might also gets fired when the component itself updates. If set set the insert marker to none it might cause flickering */
|
|
|
|
refDragLeaveTimer.current = setTimeout(() => {
|
|
|
|
setInsertMarker("none");
|
|
|
|
refDragLeaveTimer.current = undefined;
|
|
|
|
}, 50);
|
|
|
|
}}
|
|
|
|
onDragExit={() => setInsertMarker("none")}
|
|
|
|
onDragEnd={() => setInsertMarker("none")}
|
|
|
|
onDrop={event => {
|
|
|
|
const info = parseDragIds(event.dataTransfer);
|
|
|
|
if(!info) {
|
|
|
|
setInsertMarker("none");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if(info.playlistId === props.playlistId && info.serverUniqueId === props.serverUniqueId) {
|
|
|
|
switch (insertMarker) {
|
|
|
|
case "above":
|
|
|
|
events.fire("action_reorder_song", { entryId: info.entryId, targetEntryId: props.entryId, mode: "before" });
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "bellow":
|
|
|
|
events.fire("action_reorder_song", { entryId: info.entryId, targetEntryId: props.entryId, mode: "after" });
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "none":
|
|
|
|
default:
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const songUrl = event.dataTransfer.getData(kPlaylistDragSongUrl);
|
|
|
|
if(!songUrl) { return; }
|
|
|
|
|
|
|
|
switch (insertMarker) {
|
|
|
|
case "above":
|
|
|
|
events.fire("action_add_song", { targetEntryId: props.entryId, mode: "before", url: songUrl });
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "bellow":
|
|
|
|
events.fire("action_add_song", { targetEntryId: props.entryId, mode: "after", url: songUrl });
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "none":
|
|
|
|
default:
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
setInsertMarker("none");
|
|
|
|
}}
|
|
|
|
onDoubleClick={() => events.fire("action_select_entry", { entryId: props.entryId })}
|
|
|
|
>
|
|
|
|
<div className={cssStyle.thumbnail}>
|
|
|
|
{thumbnail}
|
|
|
|
</div>
|
|
|
|
<div className={cssStyle.data}>
|
|
|
|
<div className={cssStyle.row}>
|
|
|
|
<div className={cssStyle.name}>{firstRow}</div>
|
|
|
|
<div className={cssStyle.delete} onClick={() => events.fire("action_entry_delete", { entryId: props.entryId })}>
|
|
|
|
<img src="img/icon_conversation_message_delete.svg" alt="X" />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div className={cssStyle.row + " " + cssStyle.second}>
|
|
|
|
<div className={cssStyle.description} title={secondRowTitle}>{secondRow}</div>
|
|
|
|
<div className={cssStyle.length}>{length}</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
export const MusicPlaylistList = (props: { events: Registry<MusicPlaylistUiEvents>, className?: string }) => {
|
|
|
|
const [ state, setState ] = useState<MusicPlaylistStatus>(() => {
|
|
|
|
props.events.fire("query_playlist_status");
|
|
|
|
|
|
|
|
return {
|
|
|
|
status: "loading"
|
|
|
|
};
|
|
|
|
});
|
|
|
|
props.events.reactUse("notify_playlist_status", event => setState(event.status));
|
|
|
|
|
|
|
|
let content;
|
|
|
|
switch (state.status) {
|
|
|
|
case "error":
|
|
|
|
content = (
|
|
|
|
<div className={cssStyle.overlay + " " + cssStyle.error} key={"error"}>
|
|
|
|
<a><Translatable>An error occurred while fetching the playlist:</Translatable></a>
|
|
|
|
<a>{state.reason}</a>
|
|
|
|
|
|
|
|
<Button color={"blue"} type={"small"} className={cssStyle.button} onClick={() => props.events.fire("action_load_playlist", { forced: false })}>
|
|
|
|
<Translatable>Reload</Translatable>
|
|
|
|
</Button>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "no-permissions":
|
|
|
|
content = (
|
|
|
|
<div className={cssStyle.overlay} key={"no-permissions"}>
|
|
|
|
<a><Translatable>You don't have permissions to see this playlist:</Translatable></a>
|
|
|
|
<a>
|
|
|
|
<Translatable>Failed on permission</Translatable>
|
|
|
|
<code>{state.failedPermission}</code>
|
|
|
|
</a>
|
|
|
|
|
|
|
|
<Button color={"blue"} type={"small"} className={cssStyle.button} onClick={() => props.events.fire("action_load_playlist", { forced: false })}>
|
|
|
|
<Translatable>Reload</Translatable>
|
|
|
|
</Button>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "unloaded":
|
|
|
|
content = (
|
|
|
|
<div className={cssStyle.overlay} key={"unloaded"}>
|
|
|
|
<a><Translatable>Playlist hasn't been loaded</Translatable></a>
|
|
|
|
<Button color={"blue"} type={"small"} className={cssStyle.button} onClick={() => props.events.fire("action_load_playlist", { forced: false })}>
|
|
|
|
<Translatable>Load playlist</Translatable>
|
|
|
|
</Button>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "loading":
|
|
|
|
content = (
|
|
|
|
<div className={cssStyle.overlay} key={"loading"}>
|
|
|
|
<a><Translatable>Fetching playlist</Translatable> <LoadingDots /></a>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "unselected":
|
|
|
|
content = (
|
|
|
|
<div className={cssStyle.overlay} key={"unselected"}>
|
|
|
|
<a><Translatable>Please select a playlist</Translatable></a>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
|
|
|
|
case "loaded":
|
|
|
|
content = (
|
|
|
|
<div className={cssStyle.playlist} key={"playlist"}>
|
|
|
|
{state.songs.map(songId => <PlaylistEntry entryId={songId} key={"song-" + songId} active={songId === state.activeSong} playlistId={state.playlistId} serverUniqueId={state.serverUniqueId} />)}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<EventContext.Provider value={props.events}>
|
|
|
|
<div className={cssStyle.containerPlaylist + " " + props.className}>
|
|
|
|
{content}
|
|
|
|
</div>
|
|
|
|
</EventContext.Provider>
|
|
|
|
);
|
|
|
|
}
|