Reimplemented the music bot manage UI

master
WolverinDEV 2020-12-29 16:53:04 +01:00
parent 6efc4ad075
commit 12a7b6eb50
28 changed files with 2923 additions and 1889 deletions

View File

@ -1,4 +1,9 @@
# Changelog:
* **29.12.20**
- Reimplemented the music bot control UI
- Fixing some bugs from earlier versions
- Added a volume change button to easily change the bots volume
* **22.12.20**
- Fixed missing channel status icon update on channel type edit
- Improved channel edit UI experience and fixed some bugs
@ -33,7 +38,7 @@
- Enabled context menus for all clickable client tags
- Allowing to drag client tags
- Fixed the context menu within popout windows for the web client
- Reworked the whole sidebar (Hightly decreased memory footprint)
- Reworked the whole sidebar (Highly decreased memory footprint)
* **08.12.20**
- Fixed the permission editor not resolving unique ids

View File

@ -1,12 +1,7 @@
@import "mixin";
@import "properties";
//$color_client_normal: #bebebe;
$color_client_normal: #cccccc;
$client_info_avatar_size: 10em;
$bot_thumbnail_width: 16em;
$bot_thumbnail_height: 9em;
/* FIXME: Resolve variable usage! */
html:root {
--side-info-background: #2e2e2e;
--side-info-shadow: rgba(0, 0, 0, 0.25);
@ -26,520 +21,4 @@ html:root {
--side-info-ping-very-poor: #7f2222;
--side-info-bot-add-song: #3f7538;
}
.container-chat-frame {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
justify-content: stretch;
min-height: 200px;
.container-chat {
width: 100%;
flex-grow: 1;
flex-shrink: 1;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
min-width: 350px;
min-height: 16em;
display: flex;
flex-direction: column;
.container-music-info {
position: relative;
height: 100%;
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
justify-content: stretch;
padding-right: 5px;
padding-left: 5px;
.player {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: column;
justify-content: stretch;
.container-thumbnail {
flex-grow: 0;
flex-shrink: 0;
position: relative;
display: inline-block;
margin: calc(#{$bot_thumbnail_height} / -2) .75em .5em .5em;
align-self: center;
border-radius: .5em;
overflow: hidden;
.thumbnail {
overflow: hidden;
width: $bot_thumbnail_width;
height: $bot_thumbnail_height;
@include transition(opacity $button_hover_animation_time ease-in-out);
img {
position: absolute;
width: 100%;
height: 100%;
}
}
}
.container-song-info {
display: flex;
flex-direction: column;
justify-content: flex-start;
flex-shrink: 1;
flex-grow: 1;
margin-left: .5em;
margin-right: .5em;
min-width: 1em;
.song-name {
font-size: 1.5em;
min-width: 1em;
max-width: 100%;
flex-shrink: 1;
flex-grow: 0;
align-self: center;
color: #999999;
@include text-dotdotdot();
}
.song-description {
display: none;
}
}
.container-timeline {
margin-left: .5em;
margin-right: .5em;
margin-bottom: .5em;
.timestamps {
display: flex;
flex-direction: row;
justify-content: space-between;
color: #999;
font-size: .75em;
}
$timeline_height: .6em;
.timeline {
width: 100%;
position: relative;
font-size: 0.8em;
margin-top: 0.1em;
height: $timeline_height;
cursor: pointer;
background-color: #242527;
border-radius: 0.2em;
overflow: visible;
.indicator {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: .2em;
}
.indicator-buffered {
background-color: #2f3133;
width: 30%;
}
.indicator-playtime {
background-color: #4370a2;
width: 25%;
}
$thumb_width: 1.2em;
$thumb_inner_width: 0.4em;
.thumb {
position: absolute;
height: $thumb_width;
width: $thumb_width;
left: -($thumb_width / 2);
bottom: -$thumb_width / 2 + $timeline_height / 2;
background-color: #a5a5a5;
box-shadow: 0 0 0.5em 1px #a5a5a53d;
display: flex;
flex-direction: column;
justify-content: center;
.dot {
align-self: center;
height: $thumb_inner_width;
width: $thumb_inner_width;
background-color: #4370a2;
box-shadow: 0 0 0.1em 1px hsla(212, 41%, 60%, 1);
border-radius: 50%;
}
border-radius: 50%;
//@include transition(.4s);
margin-left: 25%;
}
}
}
.control-buttons {
display: flex;
flex-direction: row;
justify-content: center;
margin-top: 1em;
.button {
width: 2em;
height: 2em;
margin-right: .5em;
margin-left: .5em;
cursor: pointer;
svg {
width: 2em;
height: 2em;
fill: #242527;
@include transition($button_hover_animation_time ease-in-out);
}
&:hover {
svg {
fill: #0a0a0a;
}
}
}
.button-play, .button-pause {
&.hidden {
display: none;
}
}
}
}
.container-playlist {
flex-grow: 1;
flex-shrink: 1;
min-height: calc(3em + 4px);
position: relative;
display: flex;
flex-direction: column;
justify-content: stretch;
margin-bottom: 5px;
margin-top: 1em;
@include user-select(none);
.overlay {
position: absolute;
z-index: 1;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #2b2b28;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 0.2em;
border: 1px #161616 solid;
a {
text-align: center;
font-size: 1.5em;
color: hsla(0, 1%, 40%, 1);
}
button {
width: 8em;
font-size: .8em;
align-self: center;
margin-top: .5em;
}
&.hidden {
display: none;
}
}
.playlist {
flex-grow: 1;
flex-shrink: 1;
min-height: 3em;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow-x: hidden;
overflow-y: auto;
border: 1px #161616 solid;
border-radius: 0.2em;
background-color: rgba(43, 43, 40, 1);
@include chat-scrollbar-vertical();
.entry {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
width: 100%;
overflow: hidden;
padding: .5em;
color: #999;
border-bottom: 1px solid #242527;
opacity: 0;
height: 0;
@include transition(background-color $button_hover_animation_time ease-in-out);
&:hover {
background-color: hsla(220, 0%, 20%, 1);
}
&.shown {
opacity: 1;
height: 3.7em;
}
&.animation {
@include transition(opacity 0.5s ease-in-out, height 0.5s ease-in);
}
&.deleted {
@include transition(opacity 0.5s ease-in-out, height 0.5s ease-in, padding 0.5s ease-in);
padding: 0;
opacity: 0;
height: 0;
}
&.reordering {
z-index: 10000;
position: fixed;
cursor: move;
border: 1px #161616 solid;
border-radius: 0.2em;
background-color: #2b2b28;
}
.container-thumbnail {
flex-shrink: 0;
flex-grow: 0;
align-self: center;
height: .9em;
width: 1.6em;
font-size: 3em;
position: relative;
border-radius: 0.05em;
overflow: hidden;
img {
position: absolute;
width: 100%;
height: 100%;
}
}
.container-data {
margin-left: .5em;
display: flex;
flex-direction: column;
justify-content: center;
flex-shrink: 1;
flex-grow: 1;
min-width: 2em;
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
&.second {
font-size: .8em;
}
.name {
flex-shrink: 1;
min-width: 1em;
@include text-dotdotdot();
}
.container-delete {
flex-grow: 0;
flex-shrink: 0;
width: 1.5em;
height: 1em;
margin-left: .5em;
opacity: .4;
@include transition($button_hover_animation_time ease-in-out);
&:hover {
opacity: 1;
}
}
.description {
flex-shrink: 1;
min-width: 1em;
@include text-dotdotdot();
}
.length {
flex-grow: 0;
flex-shrink: 0;
margin-left: .5em;
}
}
}
&.current-song {
background-color: hsla(130, 50%, 30%, .25);
&:hover {
background-color: hsla(130, 50%, 50%, .25);
}
.container-delete {
display: none;
}
}
}
.reorder-indicator {
$indicator_thickness: .2em;
height: 0;
border: none;
border-top: $indicator_thickness solid hsla(0, 0%, 30%, 1);
margin-top: $indicator_thickness / -2;
margin-bottom: $indicator_thickness / -2;
}
}
}
.button-close {
font-size: 4em;
cursor: pointer;
position: absolute;
right: 0;
top: 0;
bottom: 0;
opacity: 0.3;
width: .5em;
height: .5em;
margin-right: .1em;
margin-top: .1em;
&:hover {
opacity: 1;
}
@include transition(opacity $button_hover_animation_time ease-in-out);
&:before, &:after {
position: absolute;
left: .25em;
content: ' ';
height: .5em;
width: .05em;
background-color: #5a5a5a;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
}
}
}
}
}

View File

@ -67,168 +67,6 @@
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_info" type="text/html">
<div class="lane">
<div class="block left">
<div class="title">{{tr "You're talking in Channel" /}}</div>
<div class="value value-voice-channel"></div>
<div class="small-value value-voice-limit"></div>
</div>
<div class="block right">
<div class="title">{{tr "Your ping" /}}</div>
<div class="value value-ping">11 ms</div>
</div>
</div>
<div class="lane">
<div class="block left mode-based mode-channel_chat channel">
<div class="title"></div>
<div class="value value-text-channel"></div>
<div class="small-value value-text-limit"></div>
</div>
<div class="block left mode-based mode-private_chat">
<div class="button button-switch-chat-channel">{{tr "Switch to channel chat" /}}</div>
</div>
<div class="block left mode-based mode-music_bot">
<div class="title">&nbsp;</div>
<div class="value button bot-manage">{{tr "Manage Bot" /}}</div>
</div>
<div class="block left mode-based mode-client_info">
<div class="spacer-client-info"></div>
</div>
<div class="block right mode-based mode-private_chat">
<div class="title">{{tr "Private chats" /}}
<div class="container-indicator">
<div class="chat-unread-counter">-1</div>
</div>
</div>
<div class="value button chat-counter">Hmm... Something seems to be wrong!</div>
</div>
<div class="block right mode-basedt mode-channel_chat">
<div class="title">{{tr "Private chats" /}}
<div class="container-indicator">
<div class="chat-unread-counter">-1</div>
</div>
</div>
<div class="value button chat-counter">Hmm... Something seems to be wrong!</div>
</div>
<div class="block right mode-based mode-client_info">
<div class="title">&nbsp;</div>
<div class="value button open-conversation">error: open conversation</div>
</div>
<div class="block right mode-based mode-music_bot">
<div class="title">&nbsp;</div>
<div class="value button bot-add-song">{{tr "Add song" /}}</div>
</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_client_info" type="text/html">
<div class="container-client-info">
<div class="heading">
<div class="container-avatar">
<div class="avatar">
<img src="img/style/avatar.png" style="height: 100%; width: 100%">
</div>
<div class="container-avatar-edit" title="{{tr 'Upload Avatar' /}}">
<img src="img/photo-camera.svg">
</div>
</div>
<a class="client-name"></a>
<div class="container-description">
<a class="client-description">error: description</a>
</div>
</div>
<div class="general-info">
<div class="block block-left">
<div class="container-property">
<div class="container-icon">
<img src="img/icon_client_online_time.svg" />
</div>
<div class="property">
<div class="title">{{tr "Online since" /}}</div>
<div class="value client-online-time">error: online-time</div>
</div>
</div>
<div class="container-property">
<div class="container-icon">
<img src="img/icon_client_country.svg" />
</div>
<div class="property">
<div class="title">{{tr "Country" /}}</div>
<div class="value client-country"><a>error: country</a></div>
</div>
</div>
<div class="container-property container-teaforo">
<div class="container-icon">
<img src="img/icon_client_forum_account.svg" />
</div>
<div class="property">
<div class="title">{{tr "TeaSpeak Forum Account" /}}</div>
<div class="value client-teaforo-account"><a>error: online-time</a></div>
</div>
</div>
<div class="container-property">
<div class="container-icon">
<img src="img/icon_client_version.svg" />
</div>
<div class="property">
<div class="title">{{tr "Version" /}}</div>
<div class="value client-version"><a>error: version</a></div>
</div>
</div>
<div class="container-property">
<!-- Using inline style because the icon itself is a bit off. A better way would be to edit the icon itself, but I'm unable to do so.... -->
<div class="container-icon" style="margin-right: .16em; margin-left: .04em;">
<img src="img/icon_client_volume.svg" />
</div>
<div class="property">
<div class="title">{{tr "Volume" /}}</div>
<div class="value client-local-volume">error: local volume</div>
</div>
</div>
<div class="container-property list container-client-status">
<div class="container-icon">
<img src="img/icon_client_status.svg" />
</div>
<div class="property">
<div class="title">{{tr "Status" /}}</div>
<div class="value client-status">
<div>error: client status</div>
</div>
</div>
</div>
</div>
<div class="block block-right">
<div class="container-property">
<div class="icon_em client-permission_server_groups"></div>
<div class="property">
<div class="title">{{tr "Channel Group" /}}</div>
<div class="value client-group-channel"><a>error: channel group</a></div>
</div>
</div>
<div class="container-property list">
<div class="icon_em client-permission_channel"></div>
<div class="property">
<div class="title">{{tr "Server Groups" /}}</div>
<div class="value client-group-server">
<div>error: client server groups</div>
</div>
</div>
</div>
</div>
</div>
<div class="button-close"></div>
<div class="button-more">{{tr "Full Info" /}}</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_chat_music_info" type="text/html">
<div class="container-music-info">
<div class="player">
@ -370,25 +208,6 @@
</div>
</script>
<script class="jsrender-template" id="tmpl_frame_music_playlist_entry" type="text/html">
<div class="entry shown">
<div class="container-thumbnail">
<img src="img/music/no-thumbnail.png">
</div>
<div class="container-data">
<div class="row">
<div class="name">error: name</div>
<div class="container-delete button-delete"><img src="img/icon_conversation_message_delete.svg" alt="X"></div>
</div>
<div class="row second">
<div class="description">error: description</div>
<div class="length">--:--:--</div>
</div>
</div>
</div>
</script>
<div class="template-group-modals">
<script class="jsrender-template" id="tmpl_modal" type="text/html">
<div class="modal fade" tabindex="-1" role="dialog" aria-hidden="true">
@ -2249,113 +2068,6 @@
</div>
</script>
<!-- Music interface -->
<script class="jsrender-template" id="tmpl_music_frame" type="text/html">
<!-- First we want to define some variables if not defined yet. -->
{{if !thumbnail}}
{{*
data.thumbnail = "img/music/no_thumbnail.svg";
}}
{{/if}}
{{*
if(!data.song_max_width) data.song_max_width = 300;
let width = calculate_width(data.song_name);
if(width > data.song_max_width)
data.song_use_scroller = true;
}}
<div class="music-wrapper">
<div class="container">
<div class="left">
<div class="static-card">
<img src="{{:thumbnail}}" alt="Thumbnail" class="thumbnail">
</div>
</div>
<div class="right">
<div class="controls hover">
<label class="btn-forward"><span></span></label>
<label class="btn-rewind"><span></span></label>
<label class="btn-settings"><span></span></label>
</div>
<div class="flip-card">
<img src="{{:thumbnail}}" alt="Thumbnail" class="thumbnail">
</div>
</div>
</div>
<div class="controls-overlay">
<div class="timer">
<div class="button-container">
<div class="container-play-pause">
<div class="button button_play">
<svg x="0px" y="0px" viewBox="0 0 4.5 6.9"
style="enable-background:new 0 0 4.5 6.9;">
<defs>
<filter id="shadow_play" title="Start replaying">
<feDropShadow dx="4" dy="8" stdDeviation="4"/>
</filter>
</defs>
<polyline style="filter:url(#shadow_play);" class="button"
points="0.6,0.3 3.9,3.4 0.6,6.6 "></polyline>
</svg>
</div>
<div class="button button_pause" title="Pause the current song">
<svg x="0px" y="0px" viewBox="0 0 4.5 6.9"
style="enable-background:new 0 0 4.5 6.9;">
<defs>
<filter id="shadow_pause">
<feDropShadow dx="4" dy="8" stdDeviation="4"/>
</filter>
</defs>
<g style="filter:url(#shadow_pause);">
<line x1="0.4" y1="0.1" x2="0.4" y2="6.8"></line>
<line x1="4.1" y1="0.1" x2="4.1" y2="6.8"></line>
</g>
</svg>
</div>
</div>
<div>
<div class="button button_stop" title="Stop the music bot">
<svg x="0px" y="0px" viewBox="0 0 4.5 6.9"
style="enable-background:new 0 0 4.5 6.9;">
<defs>
<filter id="shadow_stop">
<feDropShadow dx="4" dy="8" stdDeviation="4"/>
</filter>
</defs>
<g style="filter:url(#shadow_stop);">
<rect x="0.25" y="1.45" width="4" height="4" fill="black"></rect>
</g>
</svg>
</div>
</div>
</div>
<div class="timeline">
<div class="buffered"></div>
<div class="played"></div>
<div class="slider"></div>
</div>
<div class="time player_time">--:--</div>
</div>
<div class="song">
{{if song_use_scroller}}
<div class="scroll-left">
<p class="name">{{>song_name}}</p>
</div>
{{else}}
<div class="name" style="">{{>song_name}}</div>
{{/if}}
</div>
</div>
</div>
</script>
<script class="jsrender-template" id="tmpl_music_frame_empty" type="text/html">
<div class="music-wrapper empty">
<img src="img/music/empty_disk.svg">
<a>{{tr "Not playing any music"/}}</a>
</div>
</script>
<script class="jsrender-template" id="tmpl_poke_popup" type="text/html">
<div class="container-poke">
<div class="container-servers"></div>

View File

@ -40,7 +40,7 @@ import {PrivateConversationManager} from "tc-shared/conversations/PrivateConvers
import {SelectedClientInfo} from "./SelectedClientInfo";
import {SideBarManager} from "tc-shared/SideBarManager";
import {ServerEventLog} from "tc-shared/connectionlog/ServerEventLog";
import {EventType} from "tc-shared/connectionlog/Definitions";
import {PlaylistManager} from "tc-shared/music/PlaylistManager";
export enum InputHardwareState {
MISSING,
@ -157,6 +157,7 @@ export class ConnectionHandler {
serverFeatures: ServerFeatures;
private sideBar: SideBarManager;
private playlistManager: PlaylistManager;
private channelConversations: ChannelConversationManager;
private privateConversations: PrivateConversationManager;
@ -213,6 +214,7 @@ export class ConnectionHandler {
this.channelTree = new ChannelTree(this);
this.fileManager = new FileManager(this);
this.permissions = new PermissionManager(this);
this.playlistManager = new PlaylistManager(this);
this.sideBar = new SideBarManager(this);
this.privateConversations = new PrivateConversationManager(this);
@ -370,6 +372,10 @@ export class ConnectionHandler {
return this.sideBar;
}
getPlaylistManager() : PlaylistManager {
return this.playlistManager;
}
initializeLocalClient(clientId: number, acceptedName: string) {
this._clientId = clientId;
this.localClient["_clientId"] = clientId;
@ -1056,6 +1062,9 @@ export class ConnectionHandler {
this.sideBar?.destroy();
this.sideBar = undefined;
this.playlistManager?.destroy();
this.playlistManager = undefined;
this.clientInfoManager?.destroy();
this.clientInfoManager = undefined;

View File

@ -35,7 +35,6 @@ export class ConnectionManager {
private _container_channel_tree: JQuery;
private _container_hostbanner: JQuery;
private containerChannelVideo: ReplaceableContainer;
private containerSideBar: HTMLDivElement;
private containerFooter: HTMLDivElement;
private containerServerLog: HTMLDivElement;
@ -157,6 +156,10 @@ export class ConnectionManager {
all_connections() : ConnectionHandler[] {
return this.connection_handlers;
}
getSidebarController() : SideBarController {
return this.sideBarController;
}
}
export interface ConnectionManagerEvents {

View File

@ -41,7 +41,7 @@ export abstract class AbstractCommandHandlerBoss {
this.single_command_handler = undefined;
}
register_explicit_handler(command: string, callback: ExplicitCommandHandler) {
register_explicit_handler(command: string, callback: ExplicitCommandHandler) : () => void {
this.explicitHandlers[command] = this.explicitHandlers[command] || [];
this.explicitHandlers[command].push(callback);

View File

@ -77,11 +77,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
this["notifymusicstatusupdate"] = this.handleNotifyMusicStatusUpdate;
this["notifymusicplayersongchange"] = this.handleMusicPlayerSongChange;
this["notifyplaylistsongadd"] = this.handleNotifyPlaylistSongAdd;
this["notifyplaylistsongremove"] = this.handleNotifyPlaylistSongRemove;
this["notifyplaylistsongreorder"] = this.handleNotifyPlaylistSongReorder;
this["notifyplaylistsongloaded"] = this.handleNotifyPlaylistSongLoaded;
}
private loggable_invoker(uniqueId, clientId, clientName) : EventClient | undefined {
@ -446,11 +441,9 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
handleCommandChannelHide(json) {
let tree = this.connection.client.channelTree;
const conversations = this.connection.client.getChannelConversations();
log.info(LogCategory.NETWORKING, tr("Got %d channel hides"), json.length);
for(let index = 0; index < json.length; index++) {
conversations.destroyConversation(parseInt(json[index]["cid"]));
let channel = tree.findChannel(json[index]["cid"]);
if(!channel) {
logError(LogCategory.NETWORKING, tr("Invalid channel on hide (Unknown channel)"));
@ -471,26 +464,26 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
let client: ClientEntry;
let channel = undefined;
let old_channel = undefined;
let reason_id, reason_msg;
let reasonId, reasonMsg;
let invokerid, invokername, invokeruid;
let invokerId, invokerName, invokerUniqueId;
for(const entry of json) {
/* attempt to update properties if given */
channel = typeof(entry["ctid"]) !== "undefined" ? tree.findChannel(parseInt(entry["ctid"])) : channel;
old_channel = typeof(entry["cfid"]) !== "undefined" ? tree.findChannel(parseInt(entry["cfid"])) : old_channel;
reason_id = typeof(entry["reasonid"]) !== "undefined" ? entry["reasonid"] : reason_id;
reason_msg = typeof(entry["reason_msg"]) !== "undefined" ? entry["reason_msg"] : reason_msg;
reasonId = typeof(entry["reasonid"]) !== "undefined" ? entry["reasonid"] : reasonId;
reasonMsg = typeof(entry["reason_msg"]) !== "undefined" ? entry["reason_msg"] : reasonMsg;
invokerid = typeof(entry["invokerid"]) !== "undefined" ? parseInt(entry["invokerid"]) : invokerid;
invokername = typeof(entry["invokername"]) !== "undefined" ? entry["invokername"] : invokername;
invokeruid = typeof(entry["invokeruid"]) !== "undefined" ? entry["invokeruid"] : invokeruid;
invokerId = typeof(entry["invokerid"]) !== "undefined" ? parseInt(entry["invokerid"]) : invokerId;
invokerName = typeof(entry["invokername"]) !== "undefined" ? entry["invokername"] : invokerName;
invokerUniqueId = typeof(entry["invokeruid"]) !== "undefined" ? entry["invokeruid"] : invokerUniqueId;
client = tree.findClient(parseInt(entry["clid"]));
if(!client) {
if(parseInt(entry["client_type_exact"]) == ClientType.CLIENT_MUSIC) {
client = new MusicClientEntry(parseInt(entry["clid"]), entry["client_nickname"]);
client = new MusicClientEntry(parseInt(entry["clid"]), entry["client_nickname"]) as any;
} else {
client = new ClientEntry(parseInt(entry["clid"]), entry["client_nickname"]);
}
@ -498,7 +491,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
/* TODO: Apply all other properties here as well and than register him */
client.properties.client_unique_identifier = entry["client_unique_identifier"];
client.properties.client_type = parseInt(entry["client_type"]);
client = tree.insertClient(client, channel, { reason: reason_id, isServerJoin: parseInt(entry["cfid"]) === 0 });
client = tree.insertClient(client, channel, { reason: reasonId, isServerJoin: parseInt(entry["cfid"]) === 0 });
} else {
tree.moveClient(client, channel);
}
@ -509,27 +502,27 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
channel_from: old_channel ? old_channel.log_data() : undefined,
channel_to: channel ? channel.log_data() : undefined,
client: client.log_data(),
invoker: this.loggable_invoker(invokeruid, invokerid, invokername),
message:reason_msg,
reason: parseInt(reason_id),
invoker: this.loggable_invoker(invokerUniqueId, invokerId, invokerName),
message:reasonMsg,
reason: parseInt(reasonId),
});
if(reason_id == ViewReasonId.VREASON_USER_ACTION) {
if(reasonId == ViewReasonId.VREASON_USER_ACTION) {
if(own_channel == channel)
if(old_channel)
this.connection_handler.sound.play(Sound.USER_ENTERED);
else
this.connection_handler.sound.play(Sound.USER_ENTERED_CONNECT);
} else if(reason_id == ViewReasonId.VREASON_MOVED) {
} else if(reasonId == ViewReasonId.VREASON_MOVED) {
if(own_channel == channel)
this.connection_handler.sound.play(Sound.USER_ENTERED_MOVED);
} else if(reason_id == ViewReasonId.VREASON_CHANNEL_KICK) {
} else if(reasonId == ViewReasonId.VREASON_CHANNEL_KICK) {
if(own_channel == channel)
this.connection_handler.sound.play(Sound.USER_ENTERED_KICKED);
} else if(reason_id == ViewReasonId.VREASON_SYSTEM) {
} else if(reasonId == ViewReasonId.VREASON_SYSTEM) {
} else {
console.warn(tr("Unknown reasonid for %o"), reason_id);
console.warn(tr("Unknown reasonid for %o"), reasonId);
}
}
@ -552,7 +545,6 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
client.updateVariables(...updates);
if(client instanceof LocalClientEntry) {
client.initializeListener();
this.connection_handler.update_voice_status();
const conversations = this.connection.client.getChannelConversations();
conversations.setSelectedConversation(conversations.findOrCreateConversation(client.currentChannel().channelId));
@ -941,6 +933,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
key: string,
value: string
}[] = [];
for(let key in json) {
if(key === "invokerid") continue;
if(key === "invokername") continue;
@ -1064,14 +1057,14 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
const bot_id = parseInt(json["bot_id"]);
const client = this.connection.client.channelTree.find_client_by_dbid(bot_id);
if(!client) {
if(!client || !(client instanceof MusicClientEntry)) {
log.warn(LogCategory.CLIENT, tr("Received music bot status update for unknown bot (%d)"), bot_id);
return;
}
client.events.fire("music_status_update", {
player_replay_index: parseInt(json["player_replay_index"]),
player_buffered_index: parseInt(json["player_buffered_index"])
client.events.fire("notify_music_player_timestamp", {
replayIndex: parseInt(json["player_replay_index"]),
bufferedIndex: parseInt(json["player_buffered_index"])
});
}
@ -1080,7 +1073,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
const bot_id = parseInt(json["bot_id"]);
const client = this.connection.client.channelTree.find_client_by_dbid(bot_id);
if(!client) {
if(!client || !(client instanceof MusicClientEntry)) {
log.warn(LogCategory.CLIENT, tr("Received music bot status update for unknown bot (%d)"), bot_id);
return;
}
@ -1092,80 +1085,8 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
JSON.map_to(song, json);
}
client.events.fire("music_song_change", {
song: song
});
}
handleNotifyPlaylistSongAdd(json: any[]) {
json = json[0];
const playlist_id = parseInt(json["playlist_id"]);
const client = this.connection.client.channelTree.clients.find(e => e instanceof MusicClientEntry && e.properties.client_playlist_id === playlist_id);
if(!client) {
log.warn(LogCategory.CLIENT, tr("Received playlist song add event, but we've no music bot for the playlist (%d)"), playlist_id);
return;
}
client.events.fire("playlist_song_add", {
song: {
song_id: parseInt(json["song_id"]),
song_invoker: json["song_invoker"],
song_previous_song_id: parseInt(json["song_previous_song_id"]),
song_url: json["song_url"],
song_url_loader: json["song_url_loader"],
song_loaded: json["song_loaded"] == true || json["song_loaded"] == "1",
song_metadata: json["song_metadata"]
}
});
}
handleNotifyPlaylistSongRemove(json: any[]) {
json = json[0];
const playlist_id = parseInt(json["playlist_id"]);
const client = this.connection.client.channelTree.clients.find(e => e instanceof MusicClientEntry && e.properties.client_playlist_id === playlist_id);
if(!client) {
log.warn(LogCategory.CLIENT, tr("Received playlist song remove event, but we've no music bot for the playlist (%d)"), playlist_id);
return;
}
const song_id = parseInt(json["song_id"]);
client.events.fire("playlist_song_remove", { song_id: song_id });
}
handleNotifyPlaylistSongReorder(json: any[]) {
json = json[0];
const playlist_id = parseInt(json["playlist_id"]);
const client = this.connection.client.channelTree.clients.find(e => e instanceof MusicClientEntry && e.properties.client_playlist_id === playlist_id);
if(!client) {
log.warn(LogCategory.CLIENT, tr("Received playlist song reorder event, but we've no music bot for the playlist (%d)"), playlist_id);
return;
}
const song_id = parseInt(json["song_id"]);
const previous_song_id = parseInt(json["song_previous_song_id"]);
client.events.fire("playlist_song_reorder", { song_id: song_id, previous_song_id: previous_song_id });
}
handleNotifyPlaylistSongLoaded(json: any[]) {
json = json[0];
const playlist_id = parseInt(json["playlist_id"]);
const client = this.connection.client.channelTree.clients.find(e => e instanceof MusicClientEntry && e.properties.client_playlist_id === playlist_id);
if(!client) {
log.warn(LogCategory.CLIENT, tr("Received playlist song loaded event, but we've no music bot for the playlist (%d)"), playlist_id);
return;
}
const song_id = parseInt(json["song_id"]);
client.events.fire("playlist_song_loaded", {
song_id: song_id,
success: json["success"] == 1,
error_msg: json["load_error_msg"],
metadata: json["song_metadata"]
client.events.fire("notify_music_player_song_change", {
newSong: song
});
}
}

View File

@ -174,5 +174,6 @@ export enum ErrorCode {
/** @deprecated Use SERVER_INSUFFICIENT_PERMISSIONS */
PERMISSION_ERROR = ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS,
/** @deprecated Use DATABASE_EMPTY_RESULT */
EMPTY_RESULT = ErrorCode.DATABASE_EMPTY_RESULT
}

View File

@ -1,4 +1,5 @@
import {LaterPromise} from "../utils/LaterPromise";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
export class CommandResult {
success: boolean;
@ -26,6 +27,9 @@ export class CommandResult {
}
formattedMessage() {
if(this.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
return "failed on permission " + this.json["failed_permid"]
}
return this.extra_message ? this.message + " (" + this.extra_message + ")" : this.message;
}
}

View File

@ -382,6 +382,10 @@ export class ChannelConversationManager extends AbstractChatManager<ChannelConve
}
}));
this.listenerConnection.push(connection.channelTree.events.on("notify_channel_deleted", event => {
this.destroyConversation(event.channel.getChannelId());
}));
this.listenerConnection.push(connection.channelTree.events.on("notify_client_enter_view", event => {
if(event.client instanceof LocalClientEntry) {
const targetConversation = this.findConversation(event.targetChannel.channelId);

View File

@ -73,8 +73,20 @@ export function tra(message: string, ...args: any[]) : JQuery[];
export function tra(message: string, ...args: any[]) : any {
message = /* @tr-ignore */ tr(message);
for(const element of args) {
if(typeof element !== "string" && typeof element !== "number" && typeof element !== "boolean")
return formatMessage(message, ...args);
if(element === null) {
continue;
}
switch (typeof element) {
case "boolean":
case "number":
case "string":
case "undefined":
continue;
default:
return formatMessage(message, ...args);
}
}
if(message.indexOf("{:") !== -1)
return formatMessage(message, ...args);
@ -329,7 +341,7 @@ export async function initialize() {
declare global {
interface Window {
tr(message: string) : string;
tra(message: string, ...args: (string | number | boolean)[]) : string;
tra(message: string, ...args: (string | number | boolean | null | undefined)[]) : string;
tra(message: string, ...args: any[]) : JQuery[];
log: any;

View File

@ -0,0 +1,555 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {Registry} from "tc-shared/events";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
import _ = require("lodash");
export type PlaylistEntry = {
type: "song",
id: number,
previousId: number,
url: string,
urlLoader: string,
invokerDatabaseId: number,
metadata: PlaylistSongMetadata
}
export type PlaylistSongMetadata = {
status: "loading"
} | {
status: "unparsed",
metadata: string
} | {
status: "loaded",
metadata: string,
title: string,
description: string,
thumbnailUrl?: string,
length: number,
};
function parseUnparsedSongMetadata(metadata: string) : PlaylistSongMetadata {
const meta = JSON.parse(metadata);
return {
status: "loaded",
metadata: metadata,
title: meta["title"],
description: meta["description"],
length: parseInt(meta["length"]),
thumbnailUrl: !meta["thumbnail"] || meta["thumbnail"] === "none" ? undefined : meta["thumbnail"]
};
}
function parsePlayListSongEntry(data: any) {
const result: PlaylistEntry = {
type: "song",
id: parseInt(data["song_id"]),
previousId: parseInt(data["song_previous_song_id"]),
url: data["song_url"],
urlLoader: data["song_url_loader"],
invokerDatabaseId: parseInt(data["song_invoker"]),
metadata: { status: "loading" }
};
if(isNaN(result.id)) {
throw tra("failed to parse song_id as an integer ({})", data["song_id"]);
}
if(isNaN(result.previousId)) {
throw tra("failed to parse song_previous_song_id as an integer ({})", data["song_previous_song_id"]);
}
if(isNaN(result.invokerDatabaseId)) {
throw tra("failed to parse song_invoker as an integer ({})", data["song_invoker"]);
}
if(parseInt(data["song_loaded"]) === 1) {
if(typeof data["song_metadata_title"] !== "undefined") {
result.metadata = {
status: "loaded",
metadata: data["song_metadata"],
title: data["song_metadata_title"],
description: data["song_metadata_description"],
length: parseInt(data["song_metadata_length"]),
thumbnailUrl: !data["song_metadata_thumbnail_url"] || data["song_metadata_thumbnail_url"] === "none" ? undefined : data["song_metadata_thumbnail_url"]
};
if(isNaN(result.metadata.length)) {
throw tra("failed to parse song_metadata_length as an integer ({})", data["song_metadata_length"]);
}
} else if(typeof data["song_metadata"] === "string") {
try {
result.metadata = parseUnparsedSongMetadata(data["song_metadata"]);
} catch (_error) {
result.metadata = {
status: "unparsed",
metadata: data["song_metadata"]
};
}
} else {
throw tr("Missing song metadata");
}
}
return result;
}
export interface SubscribedPlaylistEvents {
notify_status_changed: {},
notify_entry_added: { entry: PlaylistEntry },
notify_entry_deleted: { entry: PlaylistEntry },
notify_entry_reordered: { entry: PlaylistEntry, oldPreviousId: number },
notify_entry_updated: { entry: PlaylistEntry },
}
export type SubscribedPlaylistStatus = {
status: "loaded",
songs: PlaylistEntry[]
} | {
status: "loading",
} | {
status: "error",
error: string
} | {
status: "no-permissions",
failedPermission: string
} | {
status: "unloaded"
}
export abstract class SubscribedPlaylist {
readonly events: Registry<SubscribedPlaylistEvents>;
readonly playlistId: number;
readonly serverUniqueId: string;
protected status: SubscribedPlaylistStatus;
protected refCount: number;
protected constructor(serverUniqueId: string, playlistId: number) {
this.refCount = 1;
this.events = new Registry<SubscribedPlaylistEvents>();
this.playlistId = playlistId;
this.serverUniqueId = serverUniqueId;
this.status = { status: "unloaded" };
}
ref() : SubscribedPlaylist {
this.refCount++;
return this;
}
unref() {
if(--this.refCount === 0) {
this.destroy();
}
}
destroy() {
this.events.destroy();
}
/**
* Query the playlist songs from the remote server.
* The playlist status will change on a successfully or failed query.
*
* @param forceQuery Forcibly query even we're subscribed and already aware of all songs.
*/
abstract async querySongs(forceQuery: boolean) : Promise<void>;
abstract async addSong(url: string, urlLoader: "any" | "youtube" | "ffmpeg" | "channel", targetSongId: number | 0, mode?: "before" | "after" | "last") : Promise<void>;
abstract async deleteEntry(entryId: number) : Promise<void>;
abstract async reorderEntry(entryId: number, targetEntryId: number, mode: "before" | "after") : Promise<void>;
getStatus() : Readonly<SubscribedPlaylistStatus> {
return this.status;
}
protected setStatus(status: SubscribedPlaylistStatus) {
if(_.isEqual(this.status, status)) {
return;
}
this.status = status;
this.events.fire("notify_status_changed");
}
}
class InternalSubscribedPlaylist extends SubscribedPlaylist {
private readonly handle: PlaylistManager;
private readonly listenerConnection: (() => void)[];
private playlistSubscribed = false;
constructor(handle: PlaylistManager, playlistId: number) {
super(handle.connection.getCurrentServerUniqueId(), playlistId);
this.handle = handle;
this.listenerConnection = [];
const serverConnection = this.handle.connection.serverConnection;
this.listenerConnection.push(serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsongadd", command => {
const playlistId = parseInt(command.arguments[0]["playlist_id"]);
if(isNaN(playlistId)) {
logWarn(LogCategory.NETWORKING, tr("Received a playlist song add notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]);
return;
}
if(playlistId !== this.playlistId || this.status.status !== "loaded") {
return;
}
const song = parsePlayListSongEntry(command.arguments[0]);
InternalSubscribedPlaylist.insertEntry(this.status.songs, song);
this.events.fire("notify_entry_added", {
entry: song
});
}));
this.listenerConnection.push(serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsongremove", command => {
const playlistId = parseInt(command.arguments[0]["playlist_id"]);
if(isNaN(playlistId)) {
logWarn(LogCategory.NETWORKING, tr("Received a playlist song remove notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]);
return;
}
if(playlistId !== this.playlistId || this.status.status !== "loaded") {
return;
}
const songId = parseInt(command.arguments[0]["song_id"]);
const song = this.removeEntry(this.status.songs, songId);
if(!song) {
return;
}
this.events.fire("notify_entry_deleted", {
entry: song
});
}));
this.listenerConnection.push(serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsongreorder", command => {
const playlistId = parseInt(command.arguments[0]["playlist_id"]);
if(isNaN(playlistId)) {
logWarn(LogCategory.NETWORKING, tr("Received a playlist song reorder notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]);
return;
}
if(playlistId !== this.playlistId || this.status.status !== "loaded") {
return;
}
const entryId = parseInt(command.arguments[0]["song_id"]);
const previousEntryId = parseInt(command.arguments[0]["song_previous_song_id"]);
if(isNaN(entryId)) {
logError(LogCategory.NETWORKING, tr("Failed to parse song_id of playlist song reorder notify: %o"), command.arguments[0]["song_id"]);
return;
}
if(isNaN(entryId)) {
logError(LogCategory.NETWORKING, tr("Failed to parse song_previous_song_id of playlist song reorder notify: %o"), command.arguments[0]["song_previous_song_id"]);
return;
}
const entry = this.removeEntry(this.status.songs, entryId);
if(!entry) {
return;
}
const oldOrderId = entry.previousId;
entry.previousId = previousEntryId;
InternalSubscribedPlaylist.insertEntry(this.status.songs, entry);
this.events.fire("notify_entry_reordered", {
entry: entry,
oldPreviousId: oldOrderId
});
}));
this.listenerConnection.push(serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsongloaded", command => {
const playlistId = parseInt(command.arguments[0]["playlist_id"]);
if(isNaN(playlistId)) {
logWarn(LogCategory.NETWORKING, tr("Received a playlist song loaded notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]);
return;
}
if(playlistId !== this.playlistId || this.status.status !== "loaded") {
return;
}
const entryId = parseInt(command.arguments[0]["song_id"]);
const entry = this.status.songs.find(entry => entry.id === entryId);
const success = parseInt(command.arguments[0]["success"]) === 1;
if(!success) {
/* TODO: Change the entry type to failed and respect: load_error_msg */
this.removeEntry(this.status.songs, entryId);
this.events.fire("notify_entry_deleted", {
entry: entry,
});
} else if(entry.metadata.status !== "loaded") {
try {
entry.metadata = parseUnparsedSongMetadata(command.arguments[0]["song_metadata"]);
} catch (error) {
entry.metadata = {
status: "unparsed",
metadata: command.arguments[0]["song_metadata"]
};
}
this.events.fire("notify_entry_updated", {
entry: entry
});
}
}));
}
destroy() {
super.destroy();
this.listenerConnection.forEach(callback => callback());
this.listenerConnection.splice(0, this.listenerConnection.length);
if(this.handle["subscribedPlaylist"] === this) {
this.handle["subscribedPlaylist"] = undefined;
}
}
handleUnsubscribed() {
this.playlistSubscribed = false;
this.setStatus({ status: "unloaded" });
if(this.handle["subscribedPlaylist"] === this) {
this.handle["subscribedPlaylist"] = undefined;
}
}
async querySongs(forceQuery: boolean) : Promise<void> {
if(this.status.status === "loading") {
return;
} else if(this.status.status === "loaded" && !forceQuery) {
return;
}
this.setStatus({ status: "loading" });
try {
/* firstly subscribe to the playlist */
if(!this.playlistSubscribed) {
await this.handle.connection.serverConnection.send_command("playlistsetsubscription", { playlist_id: this.playlistId });
if(this.handle["subscribedPlaylist"] !== this) {
this.handle["subscribedPlaylist"]?.handleUnsubscribed();
}
this.handle["subscribedPlaylist"] = this;
this.playlistSubscribed = true;
}
/* now we can query the entries */
const entries = await this.handle.queryPlaylistEntries(this.playlistId);
/* TODO: Sort these entries! */
this.setStatus({ status: "loaded", songs: entries });
} catch (error) {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
this.setStatus({ status: "no-permissions", failedPermission: this.handle.connection.permissions.getFailedPermission(error) });
return;
} else if(error.id === ErrorCode.DATABASE_EMPTY_RESULT) {
this.setStatus({ status: "loaded", songs: [] });
return;
}
error = error.formattedMessage();
} else if(typeof error !== "string") {
logError(LogCategory.GENERAL, tr("Failed to query subscribed playlist entries: %o"), error);
error = tr("Lookup the console for details");
}
this.setStatus({ status: "error", error: error });
}
}
async deleteEntry(entryId: number): Promise<void> {
await this.handle.removeSong(this.playlistId, entryId);
}
private calculatePreviousSong(targetEntryId: number | undefined, mode: "before" | "after" | "last") : number {
if(targetEntryId === 0) {
return 0;
} else if(mode === "before") {
if(this.status.status !== "loaded") {
throw tr("Invalid playlist state");
}
const songIndex = this.status.songs.findIndex(song => song.id === targetEntryId);
if(songIndex === -1) {
throw tr("Invalid target id");
}
return songIndex === 0 ? 0 : this.status.songs[songIndex - 1].id;
} else if(mode === "last") {
if(this.status.status !== "loaded") {
throw tr("Invalid playlist state");
}
return this.status.songs.last()?.id || 0;
} else {
return targetEntryId;
}
}
async reorderEntry(entryId: number, targetEntryId: number, mode: "before" | "after"): Promise<void> {
await this.handle.reorderSong(this.playlistId, entryId, this.calculatePreviousSong(targetEntryId, mode));
}
async addSong(url: string, urlLoader: "any" | "youtube" | "ffmpeg" | "channel", targetSongId: number | 0, mode?: "before" | "after" | "last"): Promise<void> {
await this.handle.addSong(this.playlistId, url, urlLoader, this.calculatePreviousSong(targetSongId, mode || "after"));
}
private static insertEntry(playlist: PlaylistEntry[], entry: PlaylistEntry) {
const index = playlist.findIndex(e => e.id === entry.previousId);
const previousEntry = playlist[index];
const nextEntry = playlist[index + 1];
playlist.splice(index + 1, 0, entry);
entry.previousId = previousEntry ? previousEntry.id : 0;
if(nextEntry) {
nextEntry.previousId = entry.id;
}
}
private removeEntry(playlist: PlaylistEntry[], entryId: number) : PlaylistEntry | undefined {
const index = playlist.findIndex(entry => entry.id === entryId);
if(index === -1) {
return undefined;
}
const [ song ] = playlist.splice(index, 1);
const previousEntry = playlist[index - 1];
const nextEntry = playlist[index];
if(nextEntry) {
nextEntry.previousId = previousEntry ? previousEntry.id : 0;
}
return song;
}
}
export class PlaylistManager {
readonly connection: ConnectionHandler;
private listenerConnection: (() => void)[];
private playlistEntryListCache: {
[key: number]: {
result: PlaylistEntry[],
promise: Promise<void>
}
} = {};
/* Use internally by InternalSubscribedPlaylist. Do not remove! */
private subscribedPlaylist: InternalSubscribedPlaylist;
constructor(connection: ConnectionHandler) {
this.connection = connection;
this.listenerConnection = [];
this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsonglist", command => {
const playlistId = parseInt(command.arguments[0]["playlist_id"]);
if(isNaN(playlistId)) {
logWarn(LogCategory.NETWORKING, tr("Received playlist song list notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]);
return;
}
const cache = this.playlistEntryListCache[playlistId];
if(cache) {
for(const entry of command.arguments) {
if(!("song_id" in entry)) {
/* Some TeaSpeak versions are sending empty bulks... */
continue;
}
try {
cache.result.push(parsePlayListSongEntry(entry));
} catch (error) {
logWarn(LogCategory.NETWORKING, tr("Failed to parse playlist entry: %o"), error);
}
}
}
}));
}
destroy() {
this.listenerConnection.forEach(callback => callback());
this.listenerConnection.splice(0, this.listenerConnection.length);
this.playlistEntryListCache = {};
}
async queryPlaylistEntries(playlistId: number) : Promise<PlaylistEntry[]> {
let cache = this.playlistEntryListCache[playlistId];
if(!cache) {
cache = this.playlistEntryListCache[playlistId] = {
result: [],
promise: (async () => {
try {
await this.connection.serverConnection.send_command("playlistsonglist", { "playlist_id": playlistId });
} finally {
delete this.playlistEntryListCache[playlistId];
}
})()
};
}
await cache.promise;
return cache.result;
}
async reorderSong(playlistId: number, songId: number, previousSongId: number) {
await this.connection.serverConnection.send_command("playlistsongreorder", {
"playlist_id": playlistId,
"song_id": songId,
"song_previous_song_id": previousSongId
});
}
async addSong(playlistId: number, url: string, urlLoader: "any" | "youtube" | "ffmpeg" | "channel", previousSongId: number | 0) {
await this.connection.serverConnection.send_command("playlistsongadd", {
"playlist_id": playlistId,
"previous": previousSongId,
"url": url,
"type": urlLoader
});
}
async removeSong(playlistId: number, entryId: number) {
await this.connection.serverConnection.send_command("playlistsongremove", {
"playlist_id": playlistId,
"song_id": entryId,
});
}
/**
* @param playlistId
* @return Returns a subscribed playlist.
* Attention: You have to manually destroy the object!
*/
createSubscribedPlaylist(playlistId: number) : SubscribedPlaylist {
return new InternalSubscribedPlaylist(this, playlistId);
}
}

View File

@ -156,20 +156,6 @@ export interface ClientEvents extends ChannelTreeEntryEvents {
notify_status_icon_changed: { newIcon: ClientIcon },
notify_video_handle_changed: { oldHandle: VideoClient | undefined, newHandle: VideoClient | undefined },
music_status_update: {
player_buffered_index: number,
player_replay_index: number
},
music_song_change: {
"song": SongInfo
},
/* TODO: Move this out of the music bots interface? */
playlist_song_add: { song: PlaylistSong },
playlist_song_remove: { song_id: number },
playlist_song_reorder: { song_id: number, previous_song_id: number },
playlist_song_loaded: { song_id: number, success: boolean, error_msg?: string, metadata?: string },
}
const StatusIconUpdateKeys: (keyof ClientProperties)[] = [
@ -182,8 +168,8 @@ const StatusIconUpdateKeys: (keyof ClientProperties)[] = [
"client_talk_power"
];
export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
readonly events: Registry<ClientEvents>;
export class ClientEntry<Events extends ClientEvents = ClientEvents> extends ChannelTreeEntry<Events> {
readonly events: Registry<Events>;
channelTree: ChannelTree;
protected _clientId: number;
@ -192,7 +178,6 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
protected _properties: ClientProperties;
protected lastVariableUpdate: number = 0;
protected _speaking: boolean;
protected _listener_initialized: boolean;
protected voiceHandle: VoiceClient;
protected voiceVolume: number;
@ -211,7 +196,7 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
constructor(clientId: number, clientName, properties: ClientProperties = new ClientProperties()) {
super();
this.events = new Registry<ClientEvents>();
this.events = new Registry<Events>();
this._properties = properties;
this._properties.client_nickname = clientName;
@ -365,24 +350,20 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
this.events.fire("notify_mute_state_change", { muted: flagMuted });
for(const client of this.channelTree.clients) {
if(client === this || client.properties.client_unique_identifier !== this.properties.client_unique_identifier)
if(client === this as any || client.properties.client_unique_identifier !== this.properties.client_unique_identifier) {
continue;
}
client.setMuted(flagMuted, false);
}
}
protected initializeListener() {
if(this._listener_initialized) return;
this._listener_initialized = true;
}
protected contextmenu_info() : contextmenu.MenuEntry[] {
return [
{
type: contextmenu.MenuEntryType.ENTRY,
name: this.properties.client_type_exact === ClientType.CLIENT_MUSIC ? tr("Show bot info") : tr("Show client info"),
callback: () => {
this.channelTree.client.getSideBar().showClientInfo(this);
this.channelTree.client.getSideBar().showClientInfo(this as any);
},
icon_class: "client-about",
visible: !settings.static_global(Settings.KEY_SWITCH_INSTANT_CLIENT)
@ -491,7 +472,7 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
}
open_assignment_modal() {
createServerGroupAssignmentModal(this, (groups, flag) => {
createServerGroupAssignmentModal(this as any, (groups, flag) => {
if(groups.length == 0) return Promise.resolve(true);
if(groups.length == 1) {
@ -519,8 +500,8 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
open_text_chat() {
const privateConversations = this.channelTree.client.getPrivateConversations();
const conversation = privateConversations.findOrCreateConversation(this);
conversation.setActiveClientEntry(this);
const conversation = privateConversations.findOrCreateConversation(this as any);
conversation.setActiveClientEntry(this as any);
privateConversations.setSelectedConversation(conversation);
this.channelTree.client.getSideBar().showPrivateConversations();
@ -559,7 +540,7 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
type: contextmenu.MenuEntryType.ENTRY,
icon_class: ClientIcon.About,
name: tr("Show client info"),
callback: () => openClientInfo(this)
callback: () => openClientInfo(this as any)
},
contextmenu.Entry.HR(),
{
@ -687,13 +668,13 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
type: contextmenu.MenuEntryType.ENTRY,
icon_class: ClientIcon.Volume,
name: tr("Change Volume"),
callback: () => spawnClientVolumeChange(this)
callback: () => spawnClientVolumeChange(this as any)
},
{
type: contextmenu.MenuEntryType.ENTRY,
name: tr("Change playback latency"),
callback: () => {
spawnChangeLatency(this, this.voiceHandle.getLatencySettings(), () => {
spawnChangeLatency(this as any, this.voiceHandle.getLatencySettings(), () => {
this.voiceHandle.resetLatencySettings();
return this.voiceHandle.getLatencySettings();
}, settings => this.voiceHandle.setLatencySettings(settings), () => this.voiceHandle.flushBuffer());
@ -987,10 +968,6 @@ export class LocalClientEntry extends ClientEntry {
);
}
initializeListener(): void {
super.initializeListener();
}
renameSelf(new_name: string) : Promise<boolean> {
const old_name = this.properties.client_nickname;
this.updateVariables({ key: "client_nickname", value: new_name }); /* change it locally */
@ -1038,10 +1015,10 @@ export enum MusicClientPlayerState {
}
export class MusicClientProperties extends ClientProperties {
player_state: number = 0; /* MusicClientPlayerState */
player_state: number = 0;
player_volume: number = 0;
client_playlist_id: number = 0;
client_playlist_id: number = -1;
client_disabled: boolean = false;
client_flag_notify_song_change: boolean = false;
@ -1075,7 +1052,19 @@ export class MusicClientPlayerInfo extends SongInfo {
player_description: string = "";
}
export class MusicClientEntry extends ClientEntry {
export interface MusicClientEvents extends ClientEvents {
notify_music_player_song_change: { newSong: SongInfo | undefined },
notify_music_player_timestamp: {
bufferedIndex: number,
replayIndex: number
},
notify_subscribe_state_changed: { subscribed: boolean },
}
export class MusicClientEntry extends ClientEntry<MusicClientEvents> {
private subscribed: boolean;
private _info_promise: Promise<MusicClientPlayerInfo>;
private _info_promise_age: number = 0;
private _info_promise_resolve: any;
@ -1083,6 +1072,7 @@ export class MusicClientEntry extends ClientEntry {
constructor(clientId, clientName) {
super(clientId, clientName, new MusicClientProperties());
this.subscribed = false;
}
destroy() {
@ -1096,6 +1086,30 @@ export class MusicClientEntry extends ClientEntry {
return this._properties as MusicClientProperties;
}
isSubscribed() : boolean {
return this.subscribed;
}
async subscribe() : Promise<void> {
if(this.subscribed) {
return;
}
await this.channelTree.client.serverConnection.send_command("musicbotsetsubscription", { bot_id: this.properties.client_database_id });
this.channelTree.clients.forEach(client => {
if(client instanceof MusicClientEntry) {
if(client.subscribed) {
client.subscribed = false;
client.events.fire("notify_subscribe_state_changed", { subscribed: false });
}
}
})
this.subscribed = true;
this.events.fire("notify_subscribe_state_changed", { subscribed: this.subscribed });
}
showContextMenu(x: number, y: number, on_close: () => void = undefined): void {
let trigger_close = true;
contextmenu.spawn_context_menu(x, y,
@ -1198,7 +1212,7 @@ export class MusicClientEntry extends ClientEntry {
type: contextmenu.MenuEntryType.ENTRY,
icon_class: "client-volume",
name: tr("Change local volume"),
callback: () => spawnClientVolumeChange(this)
callback: () => spawnClientVolumeChange(this as any)
},
{
type: contextmenu.MenuEntryType.ENTRY,
@ -1217,7 +1231,7 @@ export class MusicClientEntry extends ClientEntry {
type: contextmenu.MenuEntryType.ENTRY,
name: tr("Change playback latency"),
callback: () => {
spawnChangeLatency(this, this.voiceHandle.getLatencySettings(), () => {
spawnChangeLatency(this as any, this.voiceHandle.getLatencySettings(), () => {
this.voiceHandle.resetLatencySettings();
return this.voiceHandle.getLatencySettings();
}, settings => this.voiceHandle.setLatencySettings(settings), () => this.voiceHandle.flushBuffer());
@ -1245,10 +1259,6 @@ export class MusicClientEntry extends ClientEntry {
);
}
initializeListener(): void {
super.initializeListener();
}
handlePlayerInfo(json) {
if(json) {
const info = new MusicClientPlayerInfo();

View File

@ -1,5 +1,4 @@
import {ConnectionHandler} from "../../ConnectionHandler";
import {ChannelConversationController} from "./side/ChannelConversationController";
import {PrivateConversationController} from "./side/PrivateConversationController";
import {ClientInfoController} from "tc-shared/ui/frames/side/ClientInfoController";
import {SideHeaderController} from "tc-shared/ui/frames/side/HeaderController";
@ -10,6 +9,7 @@ import {SideBarEvents, SideBarType} from "tc-shared/ui/frames/SideBarDefinitions
import {Registry} from "tc-shared/events";
import {LogCategory, logWarn} from "tc-shared/log";
import {ChannelBarController} from "tc-shared/ui/frames/side/ChannelBarController";
import {MusicBotController} from "tc-shared/ui/frames/side/MusicBotController";
export class SideBarController {
private readonly uiEvents: Registry<SideBarEvents>;
@ -21,6 +21,7 @@ export class SideBarController {
private clientInfo: ClientInfoController;
private privateConversations: PrivateConversationController;
private channelBar: ChannelBarController;
private musicPanel: MusicBotController;
constructor() {
this.listenerConnection = [];
@ -29,6 +30,7 @@ export class SideBarController {
this.uiEvents.on("query_content", () => this.sendContent());
this.uiEvents.on("query_content_data", event => this.sendContentData(event.content));
this.musicPanel = new MusicBotController();
this.channelBar = new ChannelBarController();
this.privateConversations = new PrivateConversationController();
this.clientInfo = new ClientInfoController();
@ -48,6 +50,7 @@ export class SideBarController {
this.clientInfo.setConnectionHandler(connection);
this.privateConversations.setConnectionHandler(connection);
this.channelBar.setConnectionHandler(connection);
this.musicPanel.setConnection(connection);
if(connection) {
this.listenerConnection.push(connection.getSideBar().events.on("notify_content_type_changed", () => this.sendContent()));
@ -60,6 +63,9 @@ export class SideBarController {
this.header?.destroy();
this.header = undefined;
this.musicPanel?.destroy();
this.musicPanel = undefined;
this.channelBar?.destroy();
this.channelBar = undefined;
@ -77,6 +83,10 @@ export class SideBarController {
}), container);
}
getMusicController() : MusicBotController {
return this.musicPanel;
}
private sendContent() {
if(this.currentConnection) {
this.uiEvents.fire("notify_content", { content: this.currentConnection.getSideBar().getSideBarContent() });
@ -145,7 +155,10 @@ export class SideBarController {
this.uiEvents.fire_react("notify_content_data", {
content: "music-manage",
data: { }
data: {
botEvents: this.musicPanel.getBotUiEvents(),
playlistEvents: this.musicPanel.getPlaylistUiEvents()
}
});
break;
}

View File

@ -3,6 +3,8 @@ import {PrivateConversationUIEvents} from "tc-shared/ui/frames/side/PrivateConve
import {ClientInfoEvents} from "tc-shared/ui/frames/side/ClientInfoDefinitions";
import {SideHeaderEvents} from "tc-shared/ui/frames/side/HeaderDefinitions";
import {ChannelBarUiEvents} from "tc-shared/ui/frames/side/ChannelBarDefinitions";
import {MusicBotUiEvents} from "tc-shared/ui/frames/side/MusicBotDefinitions";
import {MusicPlaylistUiEvents} from "tc-shared/ui/frames/side/MusicPlaylistDefinitions";
/* TODO: Somehow outsource the event registries to IPC? */
@ -20,7 +22,8 @@ export interface SideBarTypeData {
events: Registry<ClientInfoEvents>,
},
"music-manage": {
botEvents: Registry<MusicBotUiEvents>,
playlistEvents: Registry<MusicPlaylistUiEvents>
}
}

View File

@ -9,6 +9,7 @@ import {ChannelBarRenderer} from "tc-shared/ui/frames/side/ChannelBarRenderer";
import {LogCategory, logWarn} from "tc-shared/log";
import React = require("react");
import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary";
import {MusicBotRenderer} from "tc-shared/ui/frames/side/MusicBotRenderer";
const cssStyle = require("./SideBarRenderer.scss");
@ -60,6 +61,18 @@ const ContentRendererClientInfo = () => {
);
};
const ContentRendererMusicManage = () => {
const contentData = useContentData("music-manage");
if(!contentData) { return null; }
return (
<MusicBotRenderer
botEvents={contentData.botEvents}
playlistEvents={contentData.playlistEvents}
/>
);
};
const SideBarFrame = (props: { type: SideBarType }) => {
switch (props.type) {
case "channel":
@ -84,7 +97,11 @@ const SideBarFrame = (props: { type: SideBarType }) => {
);
case "music-manage":
/* TODO! */
return (
<ErrorBoundary key={props.type}>
<ContentRendererMusicManage />
</ErrorBoundary>
)
case "none":
default:

View File

@ -2,7 +2,10 @@ import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {SideHeaderEvents} from "tc-shared/ui/frames/side/HeaderDefinitions";
import {Registry} from "tc-shared/events";
import {ChannelEntry, ChannelProperties} from "tc-shared/tree/Channel";
import {LocalClientEntry} from "tc-shared/tree/Client";
import {LocalClientEntry, MusicClientEntry} from "tc-shared/tree/Client";
import {openMusicManage} from "tc-shared/ui/modal/ModalMusicManage";
import {createInputModal} from "tc-shared/ui/elements/Modal";
import {server_connections} from "tc-shared/ConnectionManager";
const ChannelInfoUpdateProperties: (keyof ChannelProperties)[] = [
"channel_name",
@ -55,18 +58,31 @@ export class SideHeaderController {
});
this.uiEvents.on("action_bot_manage", () => {
/* FIXME: TODO! */
/*
const bot = this.connection.getSideBar().music_info().current_bot();
if(!bot) return;
const client = this.connection.channelTree.getSelectedEntry();
if(!(client instanceof MusicClientEntry)) {
return;
}
openMusicManage(this.connection, bot);
*/
openMusicManage(this.connection, client);
});
this.uiEvents.on("action_bot_add_song", () => {
/* FIXME: TODO! */
//this.connection.side_bar.music_info().events.fire("action_song_add")
createInputModal(tr("Enter song URL"), tr("Please enter the target song URL"), text => {
try {
new URL(text);
return true;
} catch(error) {
return false;
}
}, result => {
if(!result) return;
server_connections.getSidebarController().getMusicController().getPlaylistUiEvents()
.fire("action_add_song", {
url: result as string,
mode: "last"
});
}).open();
});
this.uiEvents.on("query_client_info_own_client", () => this.sendClientInfoOwnClient());

View File

@ -0,0 +1,386 @@
import {Registry} from "tc-shared/events";
import {
MusicBotPlayerState,
MusicBotPlayerTimestamp,
MusicBotUiEvents
} from "tc-shared/ui/frames/side/MusicBotDefinitions";
import {MusicPlaylistController} from "tc-shared/ui/frames/side/MusicPlaylistController";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {MusicClientEntry, MusicClientPlayerState, SongInfo} from "tc-shared/tree/Client";
import {SubscribedPlaylist} from "tc-shared/music/PlaylistManager";
import {MusicPlaylistUiEvents} from "tc-shared/ui/frames/side/MusicPlaylistDefinitions";
import {LogCategory, logError} from "tc-shared/log";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
export class MusicBotController {
private readonly uiEvents: Registry<MusicBotUiEvents>;
private readonly playlistController: MusicPlaylistController;
private listenerConnection: (() => void)[];
private listenerBot: (() => void)[];
private currentConnection: ConnectionHandler;
private currentBot: MusicClientEntry;
private playerTimestamp: MusicBotPlayerTimestamp;
private currentSongInfo: SongInfo;
constructor() {
this.uiEvents = new Registry<MusicBotUiEvents>();
this.playlistController = new MusicPlaylistController();
this.uiEvents.on("query_player_state", () => this.reportPlayerState());
this.uiEvents.on("query_song_info", () => this.reportSongInfo());
this.uiEvents.on("query_volume", event => this.reportVolume(event.mode));
this.uiEvents.on("action_player_action", event => {
if(!this.currentConnection) { return; }
let playerAction: number;
switch (event.action) {
case "play":
playerAction = 1;
break;
case "pause":
playerAction = 2;
break;
case "forward":
playerAction = 3;
break;
case "rewind":
playerAction = 4;
break;
default:
return;
}
this.currentConnection.serverConnection.send_command("musicbotplayeraction", {
bot_id: this.currentBot.properties.client_database_id,
action: playerAction
}).catch(error => {
if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
return;
}
logError(LogCategory.CLIENT, tr("Failed to perform action %s on bot: %o"), event.type, error);
//TODO: Better error dialog
createErrorModal(tr("Failed to perform action."), tr("Failed to perform action for music bot.")).open();
});
});
this.uiEvents.on("action_seek_to", event => {
if(!this.playerTimestamp || !this.playerTimestamp.base || !this.playerTimestamp.seekable) {
return;
}
const timePassed = Date.now() - this.playerTimestamp.base;
const offset = event.target - timePassed - this.playerTimestamp.playOffset;
this.currentConnection.serverConnection.send_command("musicbotplayeraction", {
bot_id: this.currentBot.properties.client_database_id,
action: offset > 0 ? 5 : 6,
units: Math.floor(Math.abs(offset))
}).catch(error => {
this.reportPlayerTimestamp();
if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
return;
}
logError(LogCategory.CLIENT, tr("Failed to perform action %s on bot: %o"), event.type, error);
createErrorModal(tr("Failed to change replay offset."), tr("Failed to change replay offset for music bot.")).open();
});
});
this.uiEvents.on("action_change_volume", event => {
if(!this.currentBot) {
return;
}
if(event.mode === "local") {
this.currentBot.setAudioVolume(event.volume);
} else {
this.currentConnection.serverConnection.send_command("clientedit", {
clid: this.currentBot.clientId(),
player_volume: event.volume
}).catch(() => {
this.reportVolume("remote");
});
}
})
this.playlistController.uiEvents.on("action_select_entry", event => {
if(event.entryId === (this.currentSongInfo?.song_id || 0)) {
return;
}
if(!this.currentConnection || !this.currentBot) {
return;
}
this.currentConnection.serverConnection.send_command("playlistsongsetcurrent", {
playlist_id: this.currentBot.properties.client_playlist_id,
song_id: event.entryId
}).catch(error => {
if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return;
logError(LogCategory.CLIENT, tr("Failed to set current song on bot: %o"), event.type, error);
//TODO: Better error dialog
createErrorModal(tr("Failed to set song."), tr("Failed to set current replaying song.")).open();
})
});
}
destroy() {
this.playlistController.destroy();
this.uiEvents.destroy();
this.listenerBot?.forEach(callback => callback());
this.listenerBot = [];
this.currentBot = undefined;
this.listenerConnection?.forEach(callback => callback());
this.listenerConnection = [];
this.currentConnection = undefined;
this.currentSongInfo = undefined;
this.playerTimestamp = undefined;
}
getBotUiEvents() : Registry<MusicBotUiEvents> {
return this.uiEvents;
}
getPlaylistUiEvents() : Registry<MusicPlaylistUiEvents> {
return this.playlistController.uiEvents;
}
setConnection(connection: ConnectionHandler) {
if(this.currentConnection === connection) {
return;
}
this.listenerConnection?.forEach(callback => callback());
this.listenerConnection = [];
this.currentConnection = connection;
if(this.currentConnection) {
this.initializeConnectionListener(connection);
const entry = connection.channelTree.getSelectedEntry();
if(entry instanceof MusicClientEntry) {
this.setBot(entry);
} else {
this.setBot(undefined);
}
} else {
this.setBot(undefined);
}
}
setBot(bot: MusicClientEntry) {
if(this.currentBot === bot) {
return;
}
this.listenerBot?.forEach(callback => callback());
this.listenerBot = [];
this.currentBot = bot;
if(this.currentBot) {
this.initializeBotListener(bot);
/* client_playlist_id is a non in view variable */
bot.updateClientVariables().then(undefined);
bot.subscribe().then(undefined); /* TODO: Error handling */
}
this.uiEvents.fire_react("notify_bot_changed");
this.currentSongInfo = undefined;
this.reportSongInfo();
this.playerTimestamp = undefined;
this.reportPlayerTimestamp();
this.reportVolume("local");
this.reportVolume("remote");
this.updatePlaylist();
this.updatePlayerInfo().then(undefined);
}
private initializeConnectionListener(connection: ConnectionHandler) {
this.listenerConnection.push(connection.channelTree.events.on("notify_selected_entry_changed", event => {
if(event.newEntry instanceof MusicClientEntry) {
this.setBot(event.newEntry);
} else {
this.setBot(undefined);
}
}));
}
private initializeBotListener(bot: MusicClientEntry) {
this.listenerBot.push(bot.events.on("notify_properties_updated", event => {
if("client_playlist_id" in event.updated_properties) {
this.updatePlaylist();
}
if("player_state" in event.updated_properties) {
this.reportPlayerState();
if(bot.properties.player_state === MusicClientPlayerState.PLAYING && !this.currentSongInfo?.song_loaded) {
/* We don't receive song loaded updates... */
this.updatePlayerInfo().then(undefined);
}
}
if("player_volume" in event.updated_properties) {
this.reportVolume("remote");
}
}));
this.listenerBot.push(bot.events.on("notify_music_player_song_change", event => {
this.playerTimestamp = undefined;
this.reportPlayerTimestamp();
this.currentSongInfo = event.newSong;
this.reportSongInfo();
this.playlistController.setCurrentSongId(this.currentSongInfo?.song_id || 0);
}));
this.listenerBot.push(bot.events.on("notify_music_player_timestamp", event => {
if(!this.playerTimestamp) {
return;
}
this.playerTimestamp = Object.assign({}, this.playerTimestamp);
this.playerTimestamp.base = Date.now();
this.playerTimestamp.playOffset = event.replayIndex;
this.playerTimestamp.bufferOffset = event.bufferedIndex;
this.reportPlayerTimestamp();
}));
this.listenerBot.push(bot.events.on("notify_audio_level_changed", () => {
this.reportVolume("local");
}));
/* TODO: Handle bot unsubscribed event */
}
private updatePlaylist() {
let playlistId: number = 0;
if(this.currentConnection && this.currentBot) {
playlistId = this.currentBot.properties.client_playlist_id;
console.error("Client playlist id: %o", playlistId);
}
let playlist: SubscribedPlaylist;
if(playlistId > 0) {
const currentPlaylist = this.playlistController.getCurrentPlaylist();
if(typeof currentPlaylist === "object" && currentPlaylist.playlistId === playlistId) {
return;
}
playlist = this.currentConnection.getPlaylistManager().createSubscribedPlaylist(playlistId);
}
this.playlistController.setCurrentPlaylist(playlistId === -1 ? "loading" : playlist);
playlist?.unref();
}
private async updatePlayerInfo() {
if(this.currentBot) {
try {
const playerInfo = await this.currentBot.requestPlayerInfo();
if(playerInfo.song_id > 0) {
this.currentSongInfo = playerInfo;
if(playerInfo.song_loaded) {
this.playerTimestamp = {
base: Date.now(),
total: playerInfo.player_max_index,
playOffset: playerInfo.player_replay_index,
bufferOffset: playerInfo.player_buffered_index,
seekable: playerInfo.player_seekable
}
} else {
this.playerTimestamp = undefined;
}
} else {
this.currentSongInfo = undefined;
this.playerTimestamp = undefined;
}
this.playlistController.setCurrentSongId(this.currentSongInfo?.song_id || 0);
} catch (error) {
logError(LogCategory.NETWORKING, tr("Failed to request music bot player info: %o"), error);
this.currentSongInfo = undefined;
}
} else {
this.currentSongInfo = undefined;
}
this.reportSongInfo();
this.reportPlayerTimestamp();
/* TODO: Report timestamp etc */
}
private reportPlayerState() {
let state: MusicBotPlayerState = "paused";
if(this.currentBot) {
state = this.currentBot.isCurrentlyPlaying() ? "playing" : "paused";
}
this.uiEvents.fire_react("notify_player_state", { state: state });
}
private reportSongInfo() {
if(this.currentSongInfo && this.currentBot) {
const playerState = this.currentBot.properties.player_state;
if(playerState === MusicClientPlayerState.SLEEPING || playerState === MusicClientPlayerState.STOPPED) {
this.uiEvents.fire_react("notify_song_info", { info: { type: "none" }});
} else {
if(this.currentSongInfo.song_loaded) {
this.uiEvents.fire_react("notify_song_info", {
info: {
type: "song",
url: this.currentSongInfo.song_url,
description: this.currentSongInfo.song_description,
title: this.currentSongInfo.song_title,
thumbnail: this.currentSongInfo.song_thumbnail
}
});
} else {
this.uiEvents.fire_react("notify_song_info", { info: { type: "loading", url: this.currentSongInfo.song_url }});
}
}
} else {
this.uiEvents.fire_react("notify_song_info", { info: { type: "none" }});
}
}
private reportPlayerTimestamp() {
this.uiEvents.fire_react("notify_player_timestamp", {
timestamp: this.playerTimestamp || {
base: 0,
bufferOffset: 0,
playOffset: 0,
total: 0,
seekable: false
}
});
}
private reportVolume(mode: "local" | "remote") {
this.uiEvents.fire_react("notify_volume", {
mode: mode,
volume: this.currentBot ? mode === "local" ? this.currentBot.getAudioVolume() : this.currentBot.properties.player_volume : 0
});
}
}

View File

@ -0,0 +1,63 @@
export type MusicBotSongInfo = {
type: "none"
} | {
type: "loading",
url: string
} | {
type: "song",
url: string,
thumbnail: string,
title: string,
description: string
};
export type MusicBotPlayerState = "paused" | "playing";
export type MusicBotPlayerTimestamp = {
base: number,
playOffset: number,
bufferOffset: number,
total: number,
seekable: boolean
};
export interface MusicBotUiEvents {
action_player_action: {
action: "play" | "pause" | "forward" | "rewind"
},
action_seek_to: {
target: number
},
action_change_volume: {
mode: "local" | "remote",
volume: number
}
query_player_state: {},
query_player_timestamp: {},
query_song_info: {},
query_volume: {
mode: "local" | "remote",
}
notify_player_state: {
state: MusicBotPlayerState
},
notify_player_timestamp: {
timestamp: MusicBotPlayerTimestamp
},
notify_player_seek_timestamp: {
offset: number | undefined,
applySeek: boolean
},
notify_song_info: {
info: MusicBotSongInfo
},
notify_volume: {
mode: "local" | "remote",
volume: number
},
notify_bot_changed: {}
}

View File

@ -0,0 +1,329 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
$color_client_normal: #cccccc;
$client_info_avatar_size: 10em;
$bot_thumbnail_width: 16em;
$bot_thumbnail_height: 9em;
.bodySeek {
* {
@include user-select(none!important);
}
}
.container {
display: flex;
flex-direction: column;
justify-content: stretch;
padding-left: .5em;
padding-right: .5em;
padding-bottom: .5em;
height: 100%;
.playlist {
margin-top: 1.5em;
flex-shrink: 1;
flex-grow: 1;
}
}
.player {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: column;
justify-content: stretch;
}
.containerThumbnail {
flex-grow: 0;
flex-shrink: 0;
position: relative;
display: inline-block;
margin: calc(#{$bot_thumbnail_height} / -2) .75em .5em .5em;
align-self: center;
border-radius: .5em;
overflow: hidden;
.thumbnail {
overflow: hidden;
width: $bot_thumbnail_width;
height: $bot_thumbnail_height;
@include transition(opacity $button_hover_animation_time ease-in-out);
img {
position: absolute;
width: 100%;
height: 100%;
}
}
}
.containerTimeline {
margin-left: .5em;
margin-right: .5em;
margin-bottom: .5em;
.timestamps {
display: flex;
flex-direction: row;
justify-content: space-between;
color: #999;
font-size: .75em;
}
$timeline_height: .6em;
.timeline {
width: 100%;
position: relative;
font-size: 0.8em;
margin-top: 0.1em;
height: $timeline_height;
cursor: pointer;
background-color: #242527;
border-radius: 0.2em;
overflow: visible;
.indicator {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: .2em;
}
.buffered {
background-color: #2f3133;
width: 30%;
}
.playtime {
background-color: #4370a2;
width: 25%;
}
$thumb_width: 1.2em;
$thumb_inner_width: 0.4em;
.thumb {
position: absolute;
height: $thumb_width;
width: $thumb_width;
left: -($thumb_width / 2);
bottom: -$thumb_width / 2 + $timeline_height / 2;
background-color: #a5a5a5;
box-shadow: 0 0 0.5em 1px #a5a5a53d;
display: flex;
flex-direction: column;
justify-content: center;
.dot {
align-self: center;
height: $thumb_inner_width;
width: $thumb_inner_width;
background-color: #4370a2;
box-shadow: 0 0 0.1em 1px hsla(212, 41%, 60%, 1);
border-radius: 50%;
}
border-radius: 50%;
//@include transition(.4s);
margin-left: 25%;
}
}
}
.controlButtons {
display: flex;
flex-direction: row;
justify-content: center;
}
.controlButton {
width: 2em;
height: 2em;
margin-right: .5em;
margin-left: .5em;
cursor: pointer;
svg {
width: 2em;
height: 2em;
fill: #242527;
@include transition($button_hover_animation_time ease-in-out);
}
&:hover {
svg {
fill: #0a0a0a;
}
}
}
.containerSongInfo {
display: flex;
flex-direction: column;
justify-content: flex-start;
flex-shrink: 1;
flex-grow: 1;
margin-left: .5em;
margin-right: .5em;
min-width: 1em;
.songName {
font-size: 1.5em;
min-width: 1em;
max-width: 100%;
flex-shrink: 1;
flex-grow: 0;
align-self: center;
color: #999999;
@include text-dotdotdot();
}
.songDescription {
display: none;
}
}
.containerControl {
position: relative;
margin-top: 1em;
}
.volumeOverlay {
position: absolute;
top: 0;
left: 0;
bottom: 0;
max-width: 100%;
width: 4em;
margin-top: 0;
padding-top: 0;
padding-bottom: 0;
padding-right: .5em;
display: flex;
flex-direction: row;
justify-content: stretch;
border-radius: .2em;
@include transition(all .2s ease-in-out);
&.expended {
width: 100%;
background-color: #2e2e2e;
margin-top: -3em;
margin-bottom: -1em;
padding-top: 1.25em;
padding-bottom: 1.25em;
box-shadow: inset 0 0 5px var(--side-info-shadow);
.content {
min-width: 4em;
width: 100%;
flex-shrink: 1;
}
svg {
fill: #6c6c60!important;
&:hover {
fill: #59594f !important;
}
}
}
.controlButton {
flex-grow: 0;
flex-shrink: 0;
margin-left: .5em;
}
.content {
width: 0;
overflow: hidden;
margin-left: .5em;
display: flex;
flex-direction: column;
justify-content: center;
.containerSlider {
display: flex;
flex-direction: row;
justify-content: stretch;
.name {
flex-shrink: 0;
flex-grow: 0;
width: 9em;
color: var(--text);
a {
width: 2.3em;
text-align: right;
display: inline-block;
}
}
.slider {
flex-shrink: 1;
flex-grow: 1;
min-width: 3em;
}
&:not(:last-child) {
margin-bottom: .5em;
}
}
}
}

View File

@ -0,0 +1,450 @@
import {Registry} from "tc-shared/events";
import {MusicPlaylistUiEvents} from "tc-shared/ui/frames/side/MusicPlaylistDefinitions";
import {DefaultThumbnail, formatPlaytime, MusicPlaylistList} from "tc-shared/ui/frames/side/MusicPlaylistRenderer";
import * as React from "react";
import {
MusicBotPlayerState,
MusicBotPlayerTimestamp,
MusicBotSongInfo,
MusicBotUiEvents
} from "tc-shared/ui/frames/side/MusicBotDefinitions";
import {useContext, useEffect, useRef, useState} from "react";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {preview_image} from "tc-shared/ui/frames/image_preview";
import {Slider} from "tc-shared/ui/react-elements/Slider";
const cssStyle = require("./MusicBotRenderer.scss");
const EventContext = React.createContext<Registry<MusicBotUiEvents>>(undefined);
const SongInfoContext = React.createContext<MusicBotSongInfo>(undefined);
const TimestampContext = React.createContext<MusicBotPlayerTimestamp & { seekOffset: number | undefined }>(undefined);
const ButtonRewind = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" xmlSpace="preserve">
<path transform="rotate(180, 256, 256)" d="M504.171,239.489l-234.667-192c-6.357-5.227-15.189-6.293-22.656-2.773c-7.424,3.541-12.181,11.051-12.181,19.285v146.987
L34.837,47.489c-6.379-5.227-15.189-6.293-22.656-2.773C4.757,48.257,0,55.767,0,64.001v384c0,8.235,4.757,15.744,12.181,19.285
c2.923,1.365,6.059,2.048,9.152,2.048c4.843,0,9.621-1.643,13.504-4.821l199.829-163.499v146.987
c0,8.235,4.757,15.744,12.181,19.285c2.923,1.365,6.059,2.048,9.152,2.048c4.843,0,9.621-1.643,13.504-4.821l234.667-192
c4.949-4.053,7.829-10.112,7.829-16.512S509.12,243.543,504.171,239.489z"/>
</svg>
);
const ButtonPlay = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" xmlSpace="preserve">
<path d="M500.203,236.907L30.869,2.24c-6.613-3.285-14.443-2.944-20.736,0.939C3.84,7.083,0,13.931,0,21.333v469.333
c0,7.403,3.84,14.251,10.133,18.155c3.413,2.112,7.296,3.179,11.2,3.179c3.264,0,6.528-0.747,9.536-2.24l469.333-234.667
C507.435,271.467,512,264.085,512,256S507.435,240.533,500.203,236.907z"/>
</svg>
);
const ButtonPause = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" xmlSpace="preserve">
<path transform='rotate(90, 256, 256)' d="M85.333,213.333h341.333C473.728,213.333,512,175.061,512,128s-38.272-85.333-85.333-85.333H85.333
C38.272,42.667,0,80.939,0,128S38.272,213.333,85.333,213.333z"/>
<path transform='rotate(90, 256, 256)' d="M426.667,298.667H85.333C38.272,298.667,0,336.939,0,384s38.272,85.333,85.333,85.333h341.333
C473.728,469.333,512,431.061,512,384S473.728,298.667,426.667,298.667z"/>
</svg>
);
const ButtonForward = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" xmlSpace="preserve">
<path d="M504.171,239.489l-234.667-192c-6.357-5.227-15.189-6.293-22.656-2.773c-7.424,3.541-12.181,11.051-12.181,19.285v146.987
L34.837,47.489c-6.379-5.227-15.189-6.293-22.656-2.773C4.757,48.257,0,55.767,0,64.001v384c0,8.235,4.757,15.744,12.181,19.285
c2.923,1.365,6.059,2.048,9.152,2.048c4.843,0,9.621-1.643,13.504-4.821l199.829-163.499v146.987
c0,8.235,4.757,15.744,12.181,19.285c2.923,1.365,6.059,2.048,9.152,2.048c4.843,0,9.621-1.643,13.504-4.821l234.667-192
c4.949-4.053,7.829-10.112,7.829-16.512S509.12,243.543,504.171,239.489z"/>
</svg>
);
const ButtonVolume = () => (
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="93.038px" height="93.038px" viewBox="0 0 93.038 93.038"
xmlSpace="preserve">
<path d="M46.547,75.521c0,1.639-0.947,3.128-2.429,3.823c-0.573,0.271-1.187,0.402-1.797,0.402c-0.966,0-1.923-0.332-2.696-0.973
l-23.098-19.14H4.225C1.892,59.635,0,57.742,0,55.409V38.576c0-2.334,1.892-4.226,4.225-4.226h12.303l23.098-19.14
c1.262-1.046,3.012-1.269,4.493-0.569c1.481,0.695,2.429,2.185,2.429,3.823L46.547,75.521L46.547,75.521z M62.784,68.919
c-0.103,0.007-0.202,0.011-0.304,0.011c-1.116,0-2.192-0.441-2.987-1.237l-0.565-0.567c-1.482-1.479-1.656-3.822-0.408-5.504
c3.164-4.266,4.834-9.323,4.834-14.628c0-5.706-1.896-11.058-5.484-15.478c-1.366-1.68-1.24-4.12,0.291-5.65l0.564-0.565
c0.844-0.844,1.975-1.304,3.199-1.231c1.192,0.06,2.305,0.621,3.061,1.545c4.977,6.09,7.606,13.484,7.606,21.38
c0,7.354-2.325,14.354-6.725,20.24C65.131,68.216,64.007,68.832,62.784,68.919z M80.252,81.976
c-0.764,0.903-1.869,1.445-3.052,1.495c-0.058,0.002-0.117,0.004-0.177,0.004c-1.119,0-2.193-0.442-2.988-1.237l-0.555-0.555
c-1.551-1.55-1.656-4.029-0.246-5.707c6.814-8.104,10.568-18.396,10.568-28.982c0-11.011-4.019-21.611-11.314-29.847
c-1.479-1.672-1.404-4.203,0.17-5.783l0.554-0.555c0.822-0.826,1.89-1.281,3.115-1.242c1.163,0.033,2.263,0.547,3.036,1.417
c8.818,9.928,13.675,22.718,13.675,36.01C93.04,59.783,88.499,72.207,80.252,81.976z"/>
</svg>
);
const SongInfoProvider = (props) => {
const events = useContext(EventContext);
const [ info, setInfo ] = useState<MusicBotSongInfo>(() => {
events.fire("query_song_info");
return { type: "none" };
});
events.reactUse("notify_song_info", event => setInfo(event.info));
return (
<SongInfoContext.Provider value={info}>
{props.children}
</SongInfoContext.Provider>
);
};
const PlayerTimestampProvider = (props) => {
const events = useContext(EventContext);
const [ timestamp, setTimestamp ] = useState<MusicBotPlayerTimestamp>(() => {
events.fire("query_player_timestamp");
return {
seekable: false,
bufferOffset: 0,
playOffset: 0,
base: 0,
total: 0,
};
});
const [ seekOffset, setSeekOffset ] = useState<number | undefined>(undefined);
events.reactUse("notify_player_timestamp", event => setTimestamp(event.timestamp), undefined, []);
events.reactUse("notify_player_seek_timestamp", event => {
if(event.applySeek && timestamp.base > 0 && typeof seekOffset === "number") {
timestamp.playOffset = seekOffset;
timestamp.bufferOffset += Date.now() - timestamp.base;
timestamp.base = Date.now();
}
setSeekOffset(event.offset);
}, undefined, [ seekOffset, timestamp ]);
return (
<TimestampContext.Provider value={Object.assign({ seekOffset: seekOffset }, timestamp)}>
{props.children}
</TimestampContext.Provider>
)
}
const Thumbnail = React.memo(() => {
const info = useContext(SongInfoContext);
let thumbnail;
switch (info.type) {
case "none":
thumbnail = <DefaultThumbnail type={"none-present"} key={"none"} />;
break;
case "loading":
thumbnail = <DefaultThumbnail type={"loading"} key={"loading"} />;
break;
case "song":
if(info.thumbnail) {
thumbnail = (
<img
draggable={false}
key={"song-thumbnail"}
src={info.thumbnail}
onClick={() => preview_image(info.thumbnail, info.thumbnail)}
alt={tr("Thumbnail")}
style={{ cursor: "pointer" }}
/>
);
} else {
thumbnail = <DefaultThumbnail type={"none-present"} key={"none"} />;
}
break;
}
return (
<div className={cssStyle.thumbnail}>
{thumbnail}
</div>
);
});
const Timestamps = () => {
const info = useContext(TimestampContext);
const [ revision, setRevision ] = useState(0);
useEffect(() => {
const id = setTimeout(() => setRevision(revision + 1), 990);
return () => clearTimeout(id);
});
let current: number;
if(info.seekable && typeof info.seekOffset === "number") {
current = info.seekOffset;
} else {
const timePassed = info.base > 0 ? Date.now() - info.base : 0;
current = info.playOffset + timePassed;
}
return (
<div className={cssStyle.timestamps}>
<div>{formatPlaytime(current)}</div>
<div>{formatPlaytime(info.total)}</div>
</div>
);
}
const Timeline = () => {
const events = useContext(EventContext);
const info = useContext(TimestampContext);
const refContainer = useRef<HTMLDivElement>();
const [ moveActive, setMoveActive ] = useState(false);
useEffect(() => {
if(!moveActive) {
return;
}
document.body.classList.add(cssStyle.bodySeek);
let currentSeekOffset;
const mouseMoveListener = (event: MouseEvent) => {
if(!refContainer.current) {
return;
}
const { x, width } = refContainer.current.getBoundingClientRect();
if(event.pageX <= x) {
events.fire("notify_player_seek_timestamp", { offset: currentSeekOffset = 0, applySeek: false });
} else if(event.pageX >= x + width) {
events.fire("notify_player_seek_timestamp", { offset: currentSeekOffset = info.total, applySeek: false });
} else {
events.fire("notify_player_seek_timestamp", { offset: currentSeekOffset = Math.floor((event.pageX - x) / width * info.total), applySeek: false });
}
};
const mouseUpListener = () => {
if(typeof currentSeekOffset === "number") {
events.fire("action_seek_to", { target: currentSeekOffset });
}
events.fire("notify_player_seek_timestamp", { offset: undefined, applySeek: true });
setMoveActive(false);
}
document.addEventListener("mousemove", mouseMoveListener);
document.addEventListener("mouseleave", mouseUpListener);
document.addEventListener("mouseup", mouseUpListener);
document.addEventListener("focusout", mouseUpListener);
return () => {
document.body.classList.remove(cssStyle.bodySeek);
document.removeEventListener("mousemove", mouseMoveListener);
document.removeEventListener("mouseleave", mouseUpListener);
document.removeEventListener("mouseup", mouseUpListener);
document.removeEventListener("focusout", mouseUpListener);
};
}, [ moveActive ]);
let current: number, buffered: number;
const timePassed = info.base > 0 ? Date.now() - info.base : 0;
if(info.seekable && typeof info.seekOffset === "number") {
current = info.seekOffset;
} else {
current = info.playOffset + timePassed;
}
buffered = info.bufferOffset + timePassed;
let widthBuffered = info.total === 0 ? 100 : (buffered / info.total) * 100;
let widthCurrent = info.total === 0 ? 100 : (current / info.total) * 100;
return (
<div className={cssStyle.timeline} ref={refContainer}>
<div className={cssStyle.indicator + " " + cssStyle.buffered} style={{ width: widthBuffered.toFixed(2) + "%"}} />
<div className={cssStyle.indicator + " " + cssStyle.playtime} style={{ width: widthCurrent.toFixed(2) + "%"}} />
<div
className={cssStyle.thumb}
style={{ marginLeft: widthCurrent.toFixed(2) + "%"}}
onMouseDown={() => info.seekable && info.total > 0 && setMoveActive(true)}
>
<div className={cssStyle.dot} />
</div>
</div>
);
}
const SongInfo = () => {
const info = useContext(SongInfoContext);
let name, nameTitle/*, description */;
switch (info.type) {
case "none":
name = <Translatable key={"no-song"}>No song selected</Translatable>;
break;
case "song":
name = info.title || info.url;
nameTitle = name;
/* description = info.description; */
break;
case "loading":
name = info.url;
nameTitle = name;
break;
}
return (
<div className={cssStyle.containerSongInfo}>
<a className={cssStyle.songName} title={nameTitle}>{name}</a>
{/* <a className={cssStyle.songDescription}>{description}</a> */}
</div>
);
}
const ControlButtons = () => {
const events = useContext(EventContext);
const [ playerState, setPlayerState ] = useState<MusicBotPlayerState>(() => {
events.fire("query_player_state");
return "paused";
});
events.reactUse("notify_player_state", event => setPlayerState(event.state));
let playButton;
if(playerState === "paused") {
playButton = (
<div className={cssStyle.controlButton} key={"play"} onClick={() => events.fire("action_player_action", { action: "play" })}>
<ButtonPlay />
</div>
);
} else {
playButton = (
<div className={cssStyle.controlButton} key={"pause"} onClick={() => events.fire("action_player_action", { action: "pause" })}>
<ButtonPause />
</div>
);
}
return (
<div className={cssStyle.controlButtons}>
<div className={cssStyle.controlButton} onClick={() => events.fire("action_player_action", { action: "rewind" })}>
<ButtonRewind />
</div>
{playButton}
<div className={cssStyle.controlButton} onClick={() => events.fire("action_player_action", { action: "forward" })}>
<ButtonForward />
</div>
</div>
);
}
const VolumeSlider = (props: { mode: "local" | "remote", }) => {
const events = useContext(EventContext);
const refSlider = useRef<Slider>();
const [ value, setValue ] = useState(() => {
events.fire("query_volume", { mode: props.mode });
return 100;
});
events.reactUse("notify_volume", event => {
if(event.mode !== props.mode) {
return;
}
setValue(event.volume * 100);
if(!refSlider.current?.state.active) {
refSlider.current?.setState({ value: event.volume * 100 });
}
});
let name;
if(props.mode === "local") {
name = <Translatable key={"local"}>Local</Translatable>;
} else {
name = <Translatable key={"remote"}>Remote</Translatable>;
}
const valueString = (value: number) => {
if(value > 100) {
return "+" + (value - 100).toFixed(0);
} else if(value == 100) {
return "±0";
} else {
return "-" + (100 - value).toFixed(0);
}
}
return (
<div className={cssStyle.containerSlider}>
<div className={cssStyle.name}>{name} (<a>{valueString(value)}</a>%):</div>
<Slider
ref={refSlider}
minValue={0}
maxValue={200}
stepSize={1}
value={value}
className={cssStyle.slider}
onInput={value => {
setValue(value);
if(props.mode === "local") {
events.fire("action_change_volume", { mode: props.mode, volume: value / 100 });
}
}}
onChange={value => {
setValue(value);
events.fire("action_change_volume", { mode: props.mode, volume: value / 100 });
}}
tooltip={value => valueString(value) + "%"}
/>
</div>
);
}
const VolumeSetting = () => {
const events = useContext(EventContext);
const [ expended, setExpended ] = useState(false);
events.reactUse("notify_bot_changed", () => setExpended(false));
return (
<div className={cssStyle.volumeOverlay + " " + (expended ? cssStyle.expended : "")}>
<div className={cssStyle.controlButton} onClick={() => setExpended(!expended)}>
<ButtonVolume />
</div>
<div className={cssStyle.content}>
<VolumeSlider mode={"local"} />
<VolumeSlider mode={"remote"} />
</div>
</div>
);
}
const MusicBotPlayer = () => {
return (
<div className={cssStyle.player}>
<SongInfoProvider>
<div className={cssStyle.containerThumbnail}>
<Thumbnail />
</div>
<SongInfo />
</SongInfoProvider>
<PlayerTimestampProvider>
<div className={cssStyle.containerTimeline}>
<Timestamps />
<Timeline />
</div>
</PlayerTimestampProvider>
<div className={cssStyle.containerControl}>
<ControlButtons />
<VolumeSetting />
</div>
</div>
);
}
export const MusicBotRenderer = (props: {
botEvents: Registry<MusicBotUiEvents>,
playlistEvents: Registry<MusicPlaylistUiEvents>
}) => {
return (
<EventContext.Provider value={props.botEvents}>
<div className={cssStyle.container} draggable={false}>
<MusicBotPlayer />
<MusicPlaylistList events={props.playlistEvents} className={cssStyle.playlist} />
</div>
</EventContext.Provider>
);
}

View File

@ -0,0 +1,228 @@
import {SubscribedPlaylist} from "tc-shared/music/PlaylistManager";
import {Registry} from "tc-shared/events";
import {MusicPlaylistStatus, MusicPlaylistUiEvents} from "tc-shared/ui/frames/side/MusicPlaylistDefinitions";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {LogCategory, logError} from "tc-shared/log";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
export class MusicPlaylistController {
readonly uiEvents: Registry<MusicPlaylistUiEvents>;
private currentPlaylist: SubscribedPlaylist | "loading";
private listenerPlaylist: (() => void)[];
private currentSongId: number;
constructor() {
this.uiEvents = new Registry<MusicPlaylistUiEvents>();
this.uiEvents.on("query_playlist_status", () => this.reportPlaylistStatus());
this.uiEvents.on("query_entry_status", event => this.reportPlaylistEntry(event.entryId));
this.uiEvents.on("action_load_playlist", event => {
if(typeof this.currentPlaylist === "object") {
this.currentPlaylist.querySongs(event.forced).then(undefined);
}
});
this.uiEvents.on("action_entry_delete", async event => {
try {
if(typeof this.currentPlaylist === "object") {
await this.currentPlaylist.deleteEntry(event.entryId);
} else {
throw tr("No playlist selected");
}
} catch (error) {
if(error instanceof CommandResult) {
error = error.formattedMessage();
} else if(typeof error !== "string") {
logError(LogCategory.NETWORKING, tr("Failed to delete playlist song entry: %o"), error);
error = tr("Lookup the console for details");
}
createErrorModal(tr("Failed to delete song"), tra("Failed to delete song:\n", error)).open();
}
});
this.uiEvents.on("action_reorder_song", async event => {
try {
if(typeof this.currentPlaylist === "object") {
await this.currentPlaylist.reorderEntry(event.entryId, event.targetEntryId, event.mode);
} else {
throw tr("No playlist selected");
}
} catch (error) {
if(error instanceof CommandResult) {
error = error.formattedMessage();
} else if(typeof error !== "string") {
logError(LogCategory.NETWORKING, tr("Failed to reorder playlist song entry: %o"), error);
error = tr("Lookup the console for details");
}
createErrorModal(tr("Failed to reorder song"), tra("Failed to reorder song:\n", error)).open();
}
});
this.uiEvents.on("action_add_song", async event => {
try {
if(typeof this.currentPlaylist === "object") {
await this.currentPlaylist.addSong(event.url, "any", event.targetEntryId, event.mode);
} else {
throw tr("No playlist selected");
}
} catch (error) {
if(error instanceof CommandResult) {
error = error.formattedMessage();
} else if(typeof error !== "string") {
logError(LogCategory.NETWORKING, tr("Failed to add song to playlist entry: %o"), error);
error = tr("Lookup the console for details");
}
createErrorModal(tr("Failed to add song song"), tra("Failed to add song:\n", error)).open();
}
});
}
destroy() {
this.uiEvents.destroy();
}
setCurrentPlaylist(playlist: SubscribedPlaylist | "loading") {
if(this.currentPlaylist === playlist) {
return;
}
this.listenerPlaylist?.forEach(callback => callback());
this.listenerPlaylist = [];
if(typeof this.currentPlaylist === "object") {
this.currentPlaylist.unref();
}
this.currentPlaylist = playlist;
if(typeof this.currentPlaylist === "object") {
this.currentPlaylist.ref();
this.initializePlaylistListener(this.currentPlaylist);
}
this.reportPlaylistStatus();
}
getCurrentPlaylist() : SubscribedPlaylist | "loading" | undefined {
return this.currentPlaylist;
}
getCurrentSongId() : number {
return this.currentSongId;
}
setCurrentSongId(id: number | 0) {
if(this.currentSongId === id) {
return;
}
this.currentSongId = id;
this.reportPlaylistStatus();
}
private initializePlaylistListener(playlist: SubscribedPlaylist) {
this.listenerPlaylist.push(playlist.events.on("notify_status_changed", () => this.reportPlaylistStatus()));
this.listenerPlaylist.push(playlist.events.on("notify_entry_added", () => this.reportPlaylistStatus()));
this.listenerPlaylist.push(playlist.events.on("notify_entry_reordered", () => this.reportPlaylistStatus()));
this.listenerPlaylist.push(playlist.events.on("notify_entry_deleted", () => this.reportPlaylistStatus()));
this.listenerPlaylist.push(playlist.events.on("notify_entry_updated", event => this.reportPlaylistEntry(event.entry.id)));
}
private reportPlaylistStatus() {
let status: MusicPlaylistStatus = { status: "unselected" };
if(typeof this.currentPlaylist === "object") {
const playlistStatus = this.currentPlaylist.getStatus();
switch (playlistStatus.status) {
case "unloaded":
/* just query the playlist status instead of letting the user manually do this */
this.currentPlaylist.querySongs(false).then(undefined);
return;
case "loading":
status = { status: "loading" };
break;
case "loaded":
status = {
status: "loaded",
/* Drag and drop only supports lowercase characters! */
serverUniqueId: this.currentPlaylist.serverUniqueId.toLowerCase(),
playlistId: this.currentPlaylist.playlistId,
songs: playlistStatus.songs.map(song => song.id),
activeSong: this.currentSongId
};
break;
case "no-permissions":
status = {
status: "no-permissions",
failedPermission: playlistStatus.failedPermission
};
break;
case "error":
status = {
status: "error",
reason: playlistStatus.error
};
break;
}
} else if(this.currentPlaylist === "loading") {
status = { status: "loading" };
}
this.uiEvents.fire_react("notify_playlist_status", { status: status });
}
private reportPlaylistEntry(entryId: number) {
if(typeof this.currentPlaylist === "object") {
const playlistStatus = this.currentPlaylist.getStatus();
if(playlistStatus.status === "loaded") {
const song = playlistStatus.songs.find(song => song.id === entryId);
if(song) {
if(song.metadata.status === "loaded") {
this.uiEvents.fire_react("notify_entry_status", {
entryId: entryId,
status: {
type: "song",
url: song.url,
thumbnailImage: song.metadata.thumbnailUrl,
title: song.metadata.title,
description: song.metadata.description,
length: song.metadata.length
}
});
} else if(song.metadata.status === "unparsed") {
this.uiEvents.fire_react("notify_entry_status", {
entryId: entryId,
status: {
type: "song",
url: song.url,
thumbnailImage: undefined,
title: song.url,
description: "",
length: 0
}
});
} else {
this.uiEvents.fire_react("notify_entry_status", {
entryId: entryId,
status: { type: "loading", url: song.url }
});
}
return;
}
}
}
/* TODO: May fire an error? */
}
}

View File

@ -0,0 +1,51 @@
export type MusicPlaylistStatus = {
status: "loading" | "unselected" | "unloaded"
} | {
status: "error",
reason: string
} | {
status: "loaded",
serverUniqueId: string,
playlistId: number,
songs: number[],
activeSong: number
} | {
status: "no-permissions",
failedPermission: string
};
export type MusicPlaylistEntryInfo = {
type: "loading",
url: string | undefined
} | {
type: "song",
url: string,
title: string,
description: string,
length: number,
thumbnailImage: string
}
export interface MusicPlaylistUiEvents {
action_load_playlist: { forced: boolean },
action_entry_delete: { entryId: number },
action_reorder_song: { entryId: number, targetEntryId: number, mode: "before" | "after" },
action_add_song: { url: string, mode: "before" | "after" | "last", targetEntryId?: number },
action_select_entry: { entryId: number },
query_playlist_status: {},
query_entry_status: { entryId: number }
notify_playlist_status: {
status: MusicPlaylistStatus
},
notify_entry_status: {
entryId: number,
status: MusicPlaylistEntryInfo
},
}

View File

@ -0,0 +1,275 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
$color_client_normal: #cccccc;
$client_info_avatar_size: 10em;
$bot_thumbnail_width: 16em;
$bot_thumbnail_height: 9em;
.containerPlaylist {
flex-grow: 1;
flex-shrink: 1;
min-height: calc(3em + 4px);
position: relative;
display: flex;
flex-direction: column;
justify-content: stretch;
@include user-select(none);
.overlay {
position: absolute;
z-index: 1;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #2b2b28;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 0.2em;
border: 1px #161616 solid;
a {
text-align: center;
font-size: 1.5em;
color: hsla(0, 1%, 40%, 1);
}
code {
margin-left: .25em;
}
.button {
width: 8em;
font-size: .8em;
align-self: center;
margin-top: .5em;
}
&.hidden {
display: none;
}
&.error {
/* TODO: Text color */
}
}
.playlist {
flex-grow: 1;
flex-shrink: 1;
min-height: 3em;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow-x: hidden;
overflow-y: auto;
border: 1px #161616 solid;
border-radius: 0.2em;
background-color: rgba(43, 43, 40, 1);
@include chat-scrollbar-vertical();
.reorderIndicator {
$indicator_thickness: .2em;
height: 0;
border: none;
border-top: $indicator_thickness solid hsla(0, 0%, 30%, 1);
margin-top: $indicator_thickness / -2;
margin-bottom: $indicator_thickness / -2;
}
}
}
$playlist_entry_height: 3.7em;
.playlistEntry {
flex-shrink: 0;
flex-grow: 0;
position: relative;
display: flex;
flex-direction: row;
justify-content: stretch;
width: 100%;
padding: .5em;
color: #999;
border-bottom: 1px solid #242527;
opacity: 0;
height: 0;
@include transition(background-color $button_hover_animation_time ease-in-out);
&:hover {
background-color: hsla(220, 0%, 20%, 1);
}
&.shown {
opacity: 1;
height: $playlist_entry_height;
}
&.animation {
@include transition(opacity 0.5s ease-in-out, height 0.5s ease-in);
}
&.deleted {
@include transition(opacity 0.5s ease-in-out, height 0.5s ease-in, padding 0.5s ease-in);
padding: 0;
opacity: 0;
height: 0;
}
&.reordering {
z-index: 10000;
position: fixed;
cursor: move;
border: 1px #161616 solid;
border-radius: 0.2em;
background-color: #2b2b28;
}
.thumbnail {
flex-shrink: 0;
flex-grow: 0;
align-self: center;
height: .9em;
width: 1.6em;
font-size: 3em;
position: relative;
border-radius: 0.05em;
overflow: hidden;
img {
position: absolute;
width: 100%;
height: 100%;
}
}
.data {
margin-left: .5em;
display: flex;
flex-direction: column;
justify-content: center;
flex-shrink: 1;
flex-grow: 1;
min-width: 2em;
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
&.second {
font-size: .8em;
}
.name {
flex-shrink: 1;
min-width: 1em;
@include text-dotdotdot();
}
.delete {
flex-grow: 0;
flex-shrink: 0;
width: 1.5em;
height: 1em;
margin-left: .5em;
opacity: .4;
@include transition($button_hover_animation_time ease-in-out);
&:hover {
opacity: 1;
}
}
.description {
flex-shrink: 1;
min-width: 1em;
@include text-dotdotdot();
}
.length {
flex-grow: 0;
flex-shrink: 0;
margin-left: .5em;
}
}
}
&.currentSong {
background-color: hsla(130, 50%, 30%, .25);
&:hover {
background-color: hsla(130, 50%, 50%, .25);
}
.delete {
display: none;
}
}
&.insertMarkerAbove {
&::before {
content: "";
position: absolute;
width: 100%;
top: -1px;
left: 0;
border-top: 2px solid var(--channel-tree-move-border);
}
}
&.insertMarkerBellow {
z-index: 1;
&::after {
content: "";
position: absolute;
width: 100%;
bottom: -2px;
left: 0;
border-bottom: 2px solid var(--channel-tree-move-border);
}
}
}

View File

@ -0,0 +1,386 @@
import * as React from "react";
import {Registry} from "tc-shared/events";
import {
MusicPlaylistEntryInfo,
MusicPlaylistStatus,
MusicPlaylistUiEvents
} from "tc-shared/ui/frames/side/MusicPlaylistDefinitions";
import {useContext, useRef, useState} from "react";
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";
import {preview_image} from "tc-shared/ui/frames/image_preview";
import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper";
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
import {copy_to_clipboard} from "tc-shared/utils/helpers";
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}
src="img/music/no-thumbnail.png"
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}
onClick={() => preview_image(status.thumbnailImage, status.thumbnailImage)}
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"),
click: () => { status.type === "song" ? copy_to_clipboard(status.url) : undefined; },
visible: status.type === "song"
},
{
type: "normal",
label: tr("Copy description"),
click: () => { status.type === "song" ? copy_to_clipboard(status.description) : undefined; },
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);
console.error("Info: %o - %o - %o", info, insertMarker, { playlistId: props.playlistId, serverUniqueId: props.serverUniqueId });
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>
);
}

View File

@ -1,906 +0,0 @@
// import {SideBarController, FrameContent} from "../SideBarController";
// import {LogCategory} from "../../../log";
// import {CommandResult, PlaylistSong} from "../../../connection/ServerConnectionDeclaration";
// import {createErrorModal, createInputModal} from "../../../ui/elements/Modal";
// import * as log from "../../../log";
// import * as image_preview from "../image_preview";
// import {Registry} from "../../../events";
// import {ErrorCode} from "../../../connection/ErrorCode";
// import {ClientEvents, MusicClientEntry, SongInfo} from "../../../tree/Client";
// import { tr } from "tc-shared/i18n/localize";
//
// export interface MusicSidebarEvents {
// "open": {}, /* triggers when frame should be shown */
// "close": {}, /* triggers when frame will be closed */
//
// "bot_change": {
// old: MusicClientEntry | undefined,
// new: MusicClientEntry | undefined
// },
// "bot_property_update": {
// properties: string[]
// },
//
// "action_play": {},
// "action_pause": {},
// "action_song_set": { song_id: number },
// "action_forward": {},
// "action_rewind": {},
// "action_forward_ms": {
// units: number;
// },
// "action_rewind_ms": {
// units: number;
// },
// "action_song_add": {},
// "action_song_delete": { song_id: number },
// "action_playlist_reload": {},
//
// "playtime_move_begin": {},
// "playtime_move_end": {
// canceled: boolean,
// target_time?: number
// },
//
// "reorder_begin": { song_id: number; entry: JQuery },
// "reorder_end": { song_id: number; canceled: boolean; entry: JQuery; previous_entry?: number },
//
// "player_time_update": ClientEvents["music_status_update"],
// "player_song_change": ClientEvents["music_song_change"],
//
// "playlist_song_add": ClientEvents["playlist_song_add"] & { insert_effect?: boolean },
// "playlist_song_remove": ClientEvents["playlist_song_remove"],
// "playlist_song_reorder": ClientEvents["playlist_song_reorder"],
// "playlist_song_loaded": ClientEvents["playlist_song_loaded"] & { html_entry?: JQuery },
// }
//
// interface LoadedSongData {
// description: string;
// title: string;
// url: string;
//
// length: number;
// thumbnail?: string;
//
// metadata: {[key: string]: string};
// }
//
// export class MusicInfo {
// readonly events: Registry<MusicSidebarEvents>;
// readonly handle: SideBarController;
//
// private _html_tag: JQuery;
// private _container_playlist: JQuery;
//
// private currentMusicBot: MusicClientEntry | undefined;
// private update_song_info: number = 0; /* timestamp when we force update the info */
// private time_select: {
// active: boolean,
// max_time: number,
// current_select_time: number,
// current_player_time: number
// } = { active: false, current_select_time: 0, max_time: 0, current_player_time: 0};
// private song_reorder: {
// active: boolean,
// song_id: number,
// previous_entry: number,
// html: JQuery,
// mouse?: {x: number, y: number},
// indicator: JQuery
// } = { active: false, song_id: 0, previous_entry: 0, html: undefined, indicator: $.spawn("div").addClass("reorder-indicator") };
//
// previous_frame_content: FrameContent;
//
// constructor(handle: SideBarController) {
// this.events = new Registry<MusicSidebarEvents>();
// this.handle = handle;
//
// this.events.enableDebug("music-info");
// this.initialize_listener();
// this._build_html_tag();
//
// this.set_current_bot(undefined, true);
// }
//
// html_tag() : JQuery {
// return this._html_tag;
// }
//
// destroy() {
// this.set_current_bot(undefined);
// this.events.destroy();
//
// this._html_tag && this._html_tag.remove();
// this._html_tag = undefined;
//
// this.currentMusicBot = undefined;
// this.previous_frame_content = undefined;
// }
//
// private format_time(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);
// };
//
// private _build_html_tag() {
// this._html_tag = $("#tmpl_frame_chat_music_info").renderTag();
// this._container_playlist = this._html_tag.find(".container-playlist");
//
// this._html_tag.find(".button-close").on('click', () => {
// if(this.previous_frame_content === FrameContent.CLIENT_INFO) {
// this.previous_frame_content = FrameContent.NONE;
// }
//
// this.handle.set_content(this.previous_frame_content);
// });
//
// this._html_tag.find(".button-reload-playlist").on('click', () => this.events.fire("action_playlist_reload"));
// this._html_tag.find(".button-song-add").on('click', () => this.events.fire("action_song_add"));
// this._html_tag.find(".thumbnail").on('click', event => {
// const image = this._html_tag.find(".thumbnail img");
// const url = image.attr("x-thumbnail-url");
// if(!url) return;
//
// image_preview.preview_image(decodeURIComponent(url), decodeURIComponent(url));
// });
//
// {
// const button_play = this._html_tag.find(".control-buttons .button-play");
// const button_pause = this._html_tag.find(".control-buttons .button-pause");
//
// button_play.on('click', () => this.events.fire("action_play"));
// button_pause.on('click', () => this.events.fire("action_pause"));
//
// this.events.on(["bot_change", "bot_property_update"], event => {
// if(event.type === "bot_property_update" && event.as<"bot_property_update">().properties.indexOf("player_state") == -1) return;
//
// button_play.toggleClass("hidden", this.currentMusicBot === undefined || this.currentMusicBot.isCurrentlyPlaying());
// });
//
// this.events.on(["bot_change", "bot_property_update"], event => {
// if(event.type === "bot_property_update" && event.as<"bot_property_update">().properties.indexOf("player_state") == -1) return;
//
// button_pause.toggleClass("hidden", this.currentMusicBot !== undefined && !this.currentMusicBot.isCurrentlyPlaying());
// });
//
// this._html_tag.find(".control-buttons .button-rewind").on('click', () => this.events.fire("action_rewind"));
// this._html_tag.find(".control-buttons .button-forward").on('click', () => this.events.fire("action_forward"));
// }
//
// /* timeline updaters */
// {
// const container = this._html_tag.find(".container-timeline");
//
// const timeline = container.find(".timeline");
// const indicator_playtime = container.find(".indicator-playtime");
// const indicator_buffered = container.find(".indicator-buffered");
// const thumb = container.find(".thumb");
//
// const timestamp_current = container.find(".timestamps .current");
// const timestamp_max = container.find(".timestamps .max");
//
// thumb.on('mousedown', event => event.button === 0 && this.events.fire("playtime_move_begin"));
//
// this.events.on(["bot_change", "player_song_change", "player_time_update", "playtime_move_end"], event => {
// if(!this.currentMusicBot) {
// this.time_select.max_time = 0;
// indicator_buffered.each((_, e) => { e.style.width = "0%"; });
// indicator_playtime.each((_, e) => { e.style.width = "0%"; });
// thumb.each((_, e) => { e.style.marginLeft = "0%"; });
//
// timestamp_current.text("--:--:--");
// timestamp_max.text("--:--:--");
// return;
// }
// if(event.type === "playtime_move_end" && !event.as<"playtime_move_end">().canceled) return;
//
// const update_info = Date.now() > this.update_song_info;
// this.currentMusicBot.requestPlayerInfo(update_info ? 1000 : 60 * 1000).then(data => {
// if(update_info)
// this.display_song_info(data);
//
// let played, buffered;
// if(event.type !== "player_time_update") {
// played = data.player_replay_index;
// buffered = data.player_buffered_index;
// } else {
// played = event.as<"player_time_update">().player_replay_index;
// buffered = event.as<"player_time_update">().player_buffered_index;
// }
//
// this.time_select.current_player_time = played;
// this.time_select.max_time = data.player_max_index;
// timestamp_max.text(data.player_max_index ? this.format_time(data.player_max_index) : "--:--:--");
//
// if(this.time_select.active)
// return;
//
// let wplayed, wbuffered;
// if(data.player_max_index) {
// wplayed = (played * 100 / data.player_max_index).toFixed(2) + "%";
// wbuffered = (buffered * 100 / data.player_max_index).toFixed(2) + "%";
//
// timestamp_current.text(this.format_time(played));
// } else {
// wplayed = "100%";
// wbuffered = "100%";
//
// timestamp_current.text(this.format_time(played));
// }
//
// indicator_buffered.each((_, e) => { e.style.width = wbuffered; });
// indicator_playtime.each((_, e) => { e.style.width = wplayed; });
// thumb.each((_, e) => { e.style.marginLeft = wplayed; });
// });
// });
//
// const move_callback = (event: MouseEvent) => {
// const x_min = timeline.offset().left;
// const x_max = x_min + timeline.width();
//
// let current = event.pageX;
// if(current < x_min)
// current = x_min;
// else if(current > x_max)
// current = x_max;
//
// const percent = (current - x_min) / (x_max - x_min);
// this.time_select.current_select_time = percent * this.time_select.max_time;
// timestamp_current.text(this.format_time(this.time_select.current_select_time));
//
// const w = (percent * 100).toFixed(2) + "%";
// indicator_playtime.each((_, e) => { e.style.width = w; });
// thumb.each((_, e) => { e.style.marginLeft = w; });
// };
//
// const up_callback = (event: MouseEvent | FocusEvent) => {
// if(event.type === "mouseup")
// if((event as MouseEvent).button !== 0) return;
//
// this.events.fire("playtime_move_end", {
// canceled: event.type !== "mouseup",
// target_time: this.time_select.current_select_time
// });
// };
//
// this.events.on("playtime_move_begin", event => {
// if(this.time_select.max_time <= 0) return;
//
// this.time_select.active = true;
// indicator_buffered.each((_, e) => { e.style.width = "0"; });
// document.addEventListener("mousemove", move_callback);
// document.addEventListener("mouseleave", up_callback);
// document.addEventListener("blur", up_callback);
// document.addEventListener("mouseup", up_callback);
// document.body.style.userSelect = "none";
// });
//
// this.events.on(["bot_change", "player_song_change", "playtime_move_end"], event => {
// document.removeEventListener("mousemove", move_callback);
// document.removeEventListener("mouseleave", up_callback);
// document.removeEventListener("blur", up_callback);
// document.removeEventListener("mouseup", up_callback);
// document.body.style.userSelect = undefined;
// this.time_select.active = false;
//
// if(event.type === "playtime_move_end") {
// const data = event.as<"playtime_move_end">();
// if(data.canceled) return;
//
// const offset = data.target_time - this.time_select.current_player_time;
// this.events.fire(offset > 0 ? "action_forward_ms" : "action_rewind_ms", {units: Math.abs(offset) });
// }
// });
// }
//
// /* song info handlers */
// this.events.on(["bot_change", "player_song_change"], event => {
// let song: SongInfo;
//
// /* update the player info so we dont get old data */
// if(this.currentMusicBot) {
// this.update_song_info = 0;
// this.currentMusicBot.requestPlayerInfo(1000).then(data => {
// this.display_song_info(data);
// }).catch(error => {
// log.warn(LogCategory.CLIENT, tr("Failed to update current song for side bar: %o"), error);
// });
// }
//
// if(event.type === "bot_change") {
// song = undefined;
// } else {
// song = event.as<"player_song_change">().song;
// }
// this.display_song_info(song);
// });
// }
//
// private display_song_info(song: SongInfo) {
// if(song) {
// if(!song.song_loaded) {
// console.log("Awaiting a loaded song info.");
// this.update_song_info = 0;
// } else {
// console.log("Song info loaded.");
// this.update_song_info = Date.now() + 60 * 1000;
// }
// }
//
// if(!song) song = new SongInfo();
//
// const container_thumbnail = this._html_tag.find(".player .container-thumbnail");
// const container_info = this._html_tag.find(".player .container-song-info");
//
// container_thumbnail.find("img")
// .attr("src", song.song_thumbnail || "img/music/no-thumbnail.png")
// .attr("x-thumbnail-url", encodeURIComponent(song.song_thumbnail))
// .css("cursor", song.song_thumbnail ? "pointer" : null);
//
// if(song.song_id)
// container_info.find(".song-name").text(song.song_title || song.song_url).attr("title", song.song_title || song.song_url);
// else
// container_info.find(".song-name").text(tr("No song selected"));
// if(song.song_description) {
// container_info.find(".song-description").removeClass("hidden").text(song.song_description).attr("title", song.song_description);
// } else {
// container_info.find(".song-description").addClass("hidden").text(tr("Song has no description")).attr("title", tr("Song has no description"));
// }
// }
//
// private initialize_listener() {
// //Must come at first!
// this.events.on("player_song_change", event => {
// if(!this.currentMusicBot) return;
//
// this.currentMusicBot.requestPlayerInfo(0); /* enforce an info refresh */
// });
//
// /* bot property listener */
// const callback_property = (event: ClientEvents["notify_properties_updated"]) => this.events.fire("bot_property_update", { properties: Object.keys(event.updated_properties) });
// const callback_time_update = (event: ClientEvents["music_status_update"]) => this.events.fire("player_time_update", event, true);
// const callback_song_change = (event: ClientEvents["music_song_change"]) => this.events.fire("player_song_change", event, true);
// this.events.on("bot_change", event => {
// if(event.old) {
// event.old.events.off(callback_property);
// event.old.events.off(callback_time_update);
// event.old.events.off(callback_song_change);
// event.old.events.disconnectAll(this.events);
// }
// if(event.new) {
// event.new.events.on("notify_properties_updated", callback_property);
//
// event.new.events.on("music_status_update", callback_time_update);
// event.new.events.on("music_song_change", callback_song_change);
//
// // @ts-ignore
// event.new.events.connect("playlist_song_add", this.events);
//
// // @ts-ignore
// event.new.events.connect("playlist_song_remove", this.events);
//
// // @ts-ignore
// event.new.events.connect("playlist_song_reorder", this.events);
//
// // @ts-ignore
// event.new.events.connect("playlist_song_loaded", this.events);
// }
// });
//
// /* basic player actions */
// {
// const action_map = {
// "action_play": 1,
// "action_pause": 2,
// "action_forward": 3,
// "action_rewind": 4,
// "action_forward_ms": 5,
// "action_rewind_ms": 6
// };
//
// this.events.on(Object.keys(action_map) as any, event => {
// if(!this.currentMusicBot) return;
//
// const action_id = action_map[event.type];
// if(typeof action_id === "undefined") {
// log.warn(LogCategory.GENERAL, tr("Invalid music bot action event detected: %s. This should not happen!"), event.type);
// return;
// }
// const data = {
// bot_id: this.currentMusicBot.properties.client_database_id,
// action: action_id,
// units: event.units
// };
// this.handle.handle.serverConnection.send_command("musicbotplayeraction", data).catch(error => {
// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return;
//
// log.error(LogCategory.CLIENT, tr("Failed to perform action %s on bot: %o"), event.type, error);
// //TODO: Better error dialog
// createErrorModal(tr("Failed to perform action."), tr("Failed to perform action for music bot.")).open();
// });
// });
// }
//
// this.events.on("action_song_set", event => {
// if(!this.currentMusicBot) return;
//
// const connection = this.handle.handle.serverConnection;
// if(!connection || !connection.connected()) return;
//
// connection.send_command("playlistsongsetcurrent", {
// playlist_id: this.currentMusicBot.properties.client_playlist_id,
// song_id: event.song_id
// }).catch(error => {
// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return;
//
// log.error(LogCategory.CLIENT, tr("Failed to set current song on bot: %o"), event.type, error);
// //TODO: Better error dialog
// createErrorModal(tr("Failed to set song."), tr("Failed to set current replaying song.")).open();
// })
// });
//
// this.events.on("action_song_add", () => {
// if(!this.currentMusicBot) return;
//
// createInputModal(tr("Enter song URL"), tr("Please enter the target song URL"), text => {
// try {
// new URL(text);
// return true;
// } catch(error) {
// return false;
// }
// }, result => {
// if(!result || !this.currentMusicBot) return;
//
// const connection = this.handle.handle.serverConnection;
// connection.send_command("playlistsongadd", {
// playlist_id: this.currentMusicBot.properties.client_playlist_id,
// url: result
// }).catch(error => {
// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return;
//
// log.error(LogCategory.CLIENT, tr("Failed to add song to bot playlist: %o"), error);
//
// //TODO: Better error description
// createErrorModal(tr("Failed to insert song"), tr("Failed to append song to the playlist.")).open();
// });
// }).open();
// });
//
// this.events.on("action_song_delete", event => {
// if(!this.currentMusicBot) return;
//
// const connection = this.handle.handle.serverConnection;
// if(!connection || !connection.connected()) return;
//
// connection.send_command("playlistsongremove", {
// playlist_id: this.currentMusicBot.properties.client_playlist_id,
// song_id: event.song_id
// }).catch(error => {
// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return;
//
// log.error(LogCategory.CLIENT, tr("Failed to delete song from bot playlist: %o"), error);
//
// //TODO: Better error description
// createErrorModal(tr("Failed to delete song"), tr("Failed to remove song from the playlist.")).open();
// });
// });
//
// /* bot subscription */
// this.events.on("bot_change", () => {
// const connection = this.handle.handle.serverConnection;
// if(!connection || !connection.connected()) return;
//
// const bot_id = this.currentMusicBot ? this.currentMusicBot.properties.client_database_id : 0;
// this.handle.handle.serverConnection.send_command("musicbotsetsubscription", { bot_id: bot_id }).catch(error => {
// log.warn(LogCategory.CLIENT, tr("Failed to subscribe to displayed bot within the side bar: %o"), error);
// });
// });
//
// /* playlist stuff */
// this.events.on(["bot_change", "action_playlist_reload"], event => {
// this.playlist_subscribe(true);
// this.update_playlist();
// });
//
// this.events.on("playlist_song_add", event => {
// const animation = typeof event.insert_effect === "boolean" ? event.insert_effect : true;
// const html_entry = this.build_playlist_entry(event.song, animation);
// const playlist = this._container_playlist.find(".playlist");
// const previous = playlist.find(".entry[song-id=" + event.song.song_previous_song_id + "]");
//
// if(previous.length)
// html_entry.insertAfter(previous);
// else
// html_entry.appendTo(playlist);
// if(event.song.song_loaded)
// this.events.fire("playlist_song_loaded", {
// html_entry: html_entry,
// metadata: event.song.song_metadata,
// success: true,
// song_id: event.song.song_id
// });
// if(animation)
// setTimeout(() => html_entry.addClass("shown"), 50);
// });
//
// this.events.on("playlist_song_remove", event => {
// const playlist = this._container_playlist.find(".playlist");
// const song = playlist.find(".entry[song-id=" + event.song_id + "]");
// song.addClass("deleted");
// setTimeout(() => song.remove(), 5000); /* to play some animations */
// });
//
// this.events.on("playlist_song_reorder", event => {
// const playlist = this._container_playlist.find(".playlist");
// const entry = playlist.find(".entry[song-id=" + event.song_id + "]");
// if(!entry) return;
//
// console.log(event);
// const previous = playlist.find(".entry[song-id=" + event.previous_song_id + "]");
// if(previous.length) {
// entry.insertAfter(previous);
// } else {
// entry.insertBefore(playlist.find(".entry")[0]);
// }
// });
//
// this.events.on("playlist_song_loaded", event => {
// const entry = event.html_entry || this._container_playlist.find(".playlist .entry[song-id=" + event.song_id + "]");
//
// const thumbnail = entry.find(".container-thumbnail img");
// const name = entry.find(".name");
// const description = entry.find(".description");
// const length = entry.find(".length");
//
// if(event.success) {
// let meta: LoadedSongData;
// try {
// meta = JSON.parse(event.metadata);
// } catch(error) {
// log.warn(LogCategory.CLIENT, tr("Failed to decode song metadata"));
// meta = {
// description: "",
// title: "",
// metadata: {},
// length: 0,
// url: entry.attr("song-url")
// }
// }
//
// if(!meta.title && meta.description) {
// meta.title = meta.description.split("\n")[0];
// meta.description = meta.description.split("\n").slice(1).join("\n");
// }
// meta.title = meta.title || meta.url;
//
// name.text(meta.title);
// description.text(meta.description);
// length.text(this.format_time(meta.length || 0));
// if(meta.thumbnail) {
// thumbnail.attr("src", meta.thumbnail)
// .attr("x-thumbnail-url", encodeURIComponent(meta.thumbnail));
// }
// } else {
// name.text(tr("failed to load ") + entry.attr("song-url")).attr("title", tr("failed to load ") + entry.attr("song-url"));
// description.text(event.error_msg || tr("unknown error")).attr("title", event.error_msg || tr("unknown error"));
// }
// });
//
// /* song reorder */
// {
// const move_callback = (event: MouseEvent) => {
// if(!this.song_reorder.html) return;
//
// this.song_reorder.html.each((_, e) => {
// e.style.left = (event.pageX - this.song_reorder.mouse.x) + "px";
// e.style.top = (event.pageY - this.song_reorder.mouse.y) + "px";
// });
//
// const entries = this._container_playlist.find(".playlist .entry");
// let before: HTMLElement;
// for(const entry of entries) {
// const off = $(entry).offset().top;
// if(off > event.pageY) {
// this.song_reorder.indicator.insertBefore(entry);
// this.song_reorder.previous_entry = before ? parseInt(before.attributes.getNamedItem("song-id").value) : 0;
// return;
// }
//
// before = entry;
// }
// this.song_reorder.indicator.insertAfter(entries.last());
// this.song_reorder.previous_entry = before ? parseInt(before.attributes.getNamedItem("song-id").value) : 0;
// };
//
// const up_callback = (event: MouseEvent | FocusEvent) => {
// if(event.type === "mouseup")
// if((event as MouseEvent).button !== 0) return;
//
// this.events.fire("reorder_end", {
// canceled: event.type !== "mouseup",
// song_id: this.song_reorder.song_id,
// entry: this.song_reorder.html,
// previous_entry: this.song_reorder.previous_entry
// });
// };
//
// this.events.on("reorder_begin", event => {
// this.song_reorder.song_id = event.song_id;
// this.song_reorder.html = event.entry;
//
// const width = this.song_reorder.html.width() + "px";
// this.song_reorder.html.each((_, e) => { e.style.width = width; });
// this.song_reorder.active = true;
// this.song_reorder.html.addClass("reordering");
//
// document.addEventListener("mousemove", move_callback);
// document.addEventListener("mouseleave", up_callback);
// document.addEventListener("blur", up_callback);
// document.addEventListener("mouseup", up_callback);
// document.body.style.userSelect = "none";
// });
//
// this.events.on(["bot_change", "playlist_song_remove", "reorder_end"], event => {
// if(event.type === "playlist_song_remove" && event.as<"playlist_song_remove">().song_id !== this.song_reorder.song_id) return;
//
// document.removeEventListener("mousemove", move_callback);
// document.removeEventListener("mouseleave", up_callback);
// document.removeEventListener("blur", up_callback);
// document.removeEventListener("mouseup", up_callback);
// document.body.style.userSelect = undefined;
//
// this.song_reorder.active = false;
// this.song_reorder.indicator.remove();
// if(this.song_reorder.html) {
// this.song_reorder.html.each((_, e) => {
// e.style.width = null;
// e.style.left = null;
// e.style.top = null;
// });
// this.song_reorder.html.removeClass("reordering");
// }
//
// if(event.type === "reorder_end") {
// const data = event.as<"reorder_end">();
// if(data.canceled) return;
//
// const connection = this.handle.handle.serverConnection;
// if(!connection || !connection.connected()) return;
// if(!this.currentMusicBot) return;
//
// connection.send_command("playlistsongreorder", {
// playlist_id: this.currentMusicBot.properties.client_playlist_id,
// song_id: data.song_id,
// song_previous_song_id: data.previous_entry
// }).catch(error => {
// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) return;
//
// log.error(LogCategory.CLIENT, tr("Failed to add song to bot playlist: %o"), error);
//
// //TODO: Better error description
// createErrorModal(tr("Failed to reorder song"), tr("Failed to reorder song within the playlist.")).open();
// });
// console.log("Reorder to %d", data.previous_entry);
// }
// });
//
// this.events.on(["bot_change", "player_song_change"], event => {
// if(!this.currentMusicBot) {
// this._html_tag.find(".playlist .current-song").removeClass("current-song");
// return;
// }
//
// this.currentMusicBot.requestPlayerInfo(1000).then(data => {
// const song_id = data ? data.song_id : 0;
// this._html_tag.find(".playlist .current-song").removeClass("current-song");
// this._html_tag.find(".playlist .entry[song-id=" + song_id + "]").addClass("current-song");
// });
// });
// }
// }
//
// set_current_bot(client: MusicClientEntry | undefined, enforce?: boolean) {
// if(client) client.updateClientVariables(); /* just to ensure */
// if(client === this.currentMusicBot && (typeof(enforce) === "undefined" || !enforce))
// return;
//
// const old = this.currentMusicBot;
// this.currentMusicBot = client;
// this.events.fire("bot_change", {
// new: client,
// old: old
// });
// }
//
// current_bot() : MusicClientEntry | undefined {
// return this.currentMusicBot;
// }
//
// private sort_songs(data: PlaylistSong[]) {
// const result = [];
//
// let appendable: PlaylistSong[] = [];
// for(const song of data) {
// if(song.song_id == 0 || data.findIndex(e => e.song_id === song.song_previous_song_id) == -1)
// result.push(song);
// else
// appendable.push(song);
// }
//
// let iters;
// while (appendable.length) {
// do {
// iters = 0;
// const left: PlaylistSong[] = [];
// for(const song of appendable) {
// const index = data.findIndex(e => e.song_id === song.song_previous_song_id);
// if(index == -1) {
// left.push(song);
// continue;
// }
//
// result.splice(index + 1, 0, song);
// iters++;
// }
// appendable = left;
// } while(iters > 0);
//
// if(appendable.length)
// result.push(appendable.pop_front());
// }
//
// return result;
// }
//
// /* playlist stuff */
// update_playlist() {
// this.playlist_subscribe(true);
//
// this._container_playlist.find(".overlay").toggleClass("hidden", true);
// const playlist = this._container_playlist.find(".playlist");
// playlist.empty();
//
// if(!this.handle.handle.serverConnection || !this.handle.handle.serverConnection.connected() || !this.currentMusicBot) {
// this._container_playlist.find(".overlay-empty").removeClass("hidden");
// return;
// }
//
// const overlay_loading = this._container_playlist.find(".overlay-loading");
// overlay_loading.removeClass("hidden");
//
// this.currentMusicBot.updateClientVariables(true).catch(error => {
// log.warn(LogCategory.CLIENT, tr("Failed to update music bot variables: %o"), error);
// }).then(() => {
// this.handle.handle.serverConnection.command_helper.requestPlaylistSongs(this.currentMusicBot.properties.client_playlist_id, false).then(songs => {
// this.playlist_subscribe(false); /* we're allowed to see the playlist */
// if(!songs) {
// this._container_playlist.find(".overlay-empty").removeClass("hidden");
// return;
// }
//
// for(const song of this.sort_songs(songs))
// this.events.fire("playlist_song_add", { song: song, insert_effect: false });
// }).catch(error => {
// if(error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
// this._container_playlist.find(".overlay-no-permissions").removeClass("hidden");
// return;
// }
// log.error(LogCategory.CLIENT, tr("Failed to load bot playlist: %o"), error);
// this._container_playlist.find(".overlay.overlay-error").removeClass("hidden");
// }).then(() => {
// overlay_loading.addClass("hidden");
// });
// });
// }
//
// private _playlist_subscribed = false;
// private playlist_subscribe(unsubscribe: boolean) {
// if(!this.handle.handle.serverConnection) return;
//
// if(unsubscribe || !this.currentMusicBot) {
// if(!this._playlist_subscribed) return;
// this._playlist_subscribed = false;
//
// this.handle.handle.serverConnection.send_command("playlistsetsubscription", {playlist_id: 0}).catch(error => {
// log.warn(LogCategory.CLIENT, tr("Failed to unsubscribe from last playlist: %o"), error);
// });
// } else {
// this.handle.handle.serverConnection.send_command("playlistsetsubscription", {
// playlist_id: this.currentMusicBot.properties.client_playlist_id
// }).then(() => this._playlist_subscribed = true).catch(error => {
// log.warn(LogCategory.CLIENT, tr("Failed to subscribe to bots playlist: %o"), error);
// });
// }
// }
//
// private build_playlist_entry(data: PlaylistSong, insert_effect: boolean) : JQuery {
// const tag = $("#tmpl_frame_music_playlist_entry").renderTag();
// tag.attr({
// "song-id": data.song_id,
// "song-url": data.song_url
// });
//
// const thumbnail = tag.find(".container-thumbnail img");
// const name = tag.find(".name");
// const description = tag.find(".description");
// const length = tag.find(".length");
//
// tag.find(".button-delete").on('click', () => this.events.fire("action_song_delete", { song_id: data.song_id }));
// tag.find(".container-thumbnail").on('click', event => {
// const target = tag.find(".container-thumbnail img");
// const url = target.attr("x-thumbnail-url");
// if(!url) return;
//
// image_preview.preview_image(decodeURIComponent(url), decodeURIComponent(url));
// });
// tag.on('dblclick', event => this.events.fire("action_song_set", { song_id: data.song_id }));
// name.text(tr("loading..."));
// description.text(data.song_url);
//
// tag.on('mousedown', event => {
// if(event.button !== 0) return;
//
// this.song_reorder.mouse = {
// x: event.pageX,
// y: event.pageY
// };
//
// const baseOff = tag.offset();
// const off = { x: event.pageX - baseOff.left, y: event.pageY - baseOff.top };
// const move_listener = (event: MouseEvent) => {
// const distance = Math.pow(event.pageX - this.song_reorder.mouse.x, 2) + Math.pow(event.pageY - this.song_reorder.mouse.y, 2);
// if(distance < 50) return;
//
// document.removeEventListener("blur", up_listener);
// document.removeEventListener("mouseup", up_listener);
// document.removeEventListener("mousemove", move_listener);
//
// this.song_reorder.mouse = off;
// this.events.fire("reorder_begin", {
// entry: tag,
// song_id: data.song_id
// });
// };
//
// const up_listener = event => {
// if(event.type === "mouseup" && event.button !== 0) return;
//
// document.removeEventListener("blur", up_listener);
// document.removeEventListener("mouseup", up_listener);
// document.removeEventListener("mousemove", move_listener);
// };
//
// document.addEventListener("blur", up_listener);
// document.addEventListener("mouseup", up_listener);
// document.addEventListener("mousemove", move_listener);
// });
//
// if(this.currentMusicBot) {
// this.currentMusicBot.requestPlayerInfo(60 * 1000).then(pdata => {
// if(pdata.song_id === data.song_id)
// tag.addClass("current-song");
// });
// }
//
// if(insert_effect) {
// tag.removeClass("shown");
// tag.addClass("animation");
// }
// return tag;
// }
// }

View File

@ -308,7 +308,7 @@ export function spawnMusicBotVolumeChange(client: MusicClientEntry, maxValue: nu
});
});
const modal = spawnReactModal(VolumeChangeBot, client, events, maxValue);
const modal = spawnReactModal(VolumeChangeBot, client as any, events, maxValue);
events.on("close-modal", event => modal.destroy());
modal.show();

View File

@ -62,10 +62,11 @@ export class Slider extends React.Component<SliderProperties, SliderState> {
const range = this.props.maxValue - this.props.minValue;
let offset = Math.round(((current - min) * (range / this.props.stepSize)) / (max - min)) * this.props.stepSize;
if(offset < 0)
if(offset < 0) {
offset = 0;
else if(offset > range)
} else if(offset > range) {
offset = range;
}
this.refTooltip.current?.setState({
pageX: bounds.left + offset * bounds.width / range,
@ -120,7 +121,14 @@ export class Slider extends React.Component<SliderProperties, SliderState> {
render() {
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : this.props.disabled;
const offset = (this.state.value - this.props.minValue) * 100 / (this.props.maxValue - this.props.minValue);
let value = this.state.value;
if(value > this.props.maxValue) {
value = this.props.maxValue;
} else if(value < this.props.minValue) {
value = this.props.minValue;
}
const offset = (value - this.props.minValue) * 100 / (this.props.maxValue - this.props.minValue);
return (
<div
className={cssStyle.container + " " + (this.props.className || " ") + " " + (disabled ? cssStyle.disabled : "")}