Add dashboard

This commit is contained in:
Choco
2023-03-17 12:39:29 +08:00
parent c4197bdd86
commit 490e7da043
7 changed files with 1337 additions and 0 deletions

25
web/objects.py Normal file
View File

@@ -0,0 +1,25 @@
class Asset:
def __init__(self, userId: str, key: str):
self.key = key
self.url = f"https://cdn.discordapp.com/avatars/{userId}/{key}.png"
class User:
def __init__(self, data: dict):
self.id = int(data.get("id"))
self.username = data.get("username")
self.display_name = data.get("display_name")
self.avatar = Asset(self.id, data.get("avatar"))
self.avatar_decoration = data.get("avatar_decoration")
self.discriminator = data.get("discriminator")
self.public_flag = data.get("public_flag")
self.flags = data.get("flags")
self.banner = data.get("banner")
self.banner_color = data.get("banner_color")
self.locale = data.get("locale")
self.mfa_enabled = data.get("mfa_enabled")
self.premium_type = data.get("premium_type")
self.access_token = data.get("access_token")
self.refresh_token = data.get("refresh_token")
self.sid = None
self.guild_id = None

499
web/static/css/main.css Normal file
View File

@@ -0,0 +1,499 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;500;600;700&display=swap');
* {
padding: 0;
margin: 0;
box-sizing: border-box;
color: #ffffff;
font-family: 'Poppins', sans-serif;
}
html,
body {
background-color: #020202;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
white-space: nowrap;
max-height: 100%;
}
/* width */
::-webkit-scrollbar {
width: 10px;
}
/* Handle */
::-webkit-scrollbar-thumb {
background: #888;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #555;
}
.container {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100%;
}
.header {
display: flex;
padding: 1rem;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
}
.header .left {
display: flex;
}
.header .left p {
font-size: 20;
padding: 12px;
border-radius: 10px;
margin: 0rem 0.4rem;
transition: all 0.2s;
cursor: pointer;
}
.header .left p:hover {
background-color: rgba(142, 142, 142, 20%);
}
.header .center .search-contrainer {
display: flex;
justify-content: center;
position: relative;
}
.header .center .search-contrainer .search-bar {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 8px;
padding: .6rem 1rem;
width: 500px;
overflow: hidden;
background-color: rgba(33, 33, 33, 255);
}
.header .center .search-loader {
display: none;
border: 3px solid transparent;
border-radius: 50%;
border-top: 3px solid #c9cbcc;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.header .center input {
margin: 0 1rem;
background-color: transparent;
width: 100%;
height: 100%;
border: none;
outline: none;
font-size: 15px;
}
.header .center .search-result-list {
position: absolute;
display: none;
overflow-x: hidden;
overflow-y: auto;
top: 50px;
width: 100%;
height: 400px;
border-radius: 8px;
padding: 1rem .5rem;
background-color: rgba(33, 33, 33, 255);
}
.header .search-result {
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 8px;
padding: .5rem 1rem;
cursor: pointer;
margin-bottom: .5rem;
}
.header .search-result:hover {
background-color: rgba(142, 142, 142, 20%);
}
.header .search-result-left {
display: flex;
align-items: center;
overflow: hidden;
margin-right: .6rem;
max-width: 90%;
}
.header .search-result-info p.info {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.header .search-result-info p.desc {
font-size: 14px;
color: gray;
}
.header .search-result img {
width: auto;
height: 50px;
border-radius: 4px;
margin-right: .5rem;
}
.header .right .account {
display: flex;
align-items: center;
border-radius: 12px;
padding: 10px;
transition: all .2s;
cursor: pointer;
}
.header .right .account:hover {
background-color: rgba(142, 142, 142, 20%);
}
.header .right .account img {
border-radius: 2rem;
height: 40;
margin-right: .6rem;
}
.main {
display: flex;
flex-grow: 1;
padding: 0rem 4rem;
justify-content: space-around;
}
.main .toastContrainer {
position: absolute;
left: 2%;
}
.toastContrainer .toast {
display: flex;
position: relative;
padding: .8rem;
margin-top: 1rem;
background-color: rgba(142, 142, 142, 30%);
border-radius: 10px;
overflow: hidden;
transform: translateX(-100%);
animation: moveRight .5s linear forwards;
}
@keyframes moveRight {
100% {
transform: translateX(0);
}
}
.toastContrainer .toast::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background-color: azure;
animation: anim 6s linear forwards;
}
@keyframes anim {
100% {
width: 0;
}
}
.toastContrainer .toast img {
border-radius: 10px;
width: 40px;
height: 40px;
margin-right: .7rem;
}
.toastContrainer .toast .content p.username {
font-size: 15px;
}
.toastContrainer .toast .content p.message {
font-size: 10px;
width: 300px;
white-space: pre-wrap;
}
.main .thumbnail {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
}
.main .thumbnail img {
padding: 1rem;
width: 70%;
height: auto;
}
.player-controller {
position: sticky;
bottom: 0;
}
.progress-bar-container {
position: relative;
width: 100%;
}
.seek-bar {
position: absolute;
appearance: none;
width: 100%;
height: 4px;
cursor: pointer;
overflow: hidden;
background-color: rgb(73, 73, 73);
}
.seek-bar:hover {
height: 8px;
}
.seek-bar::-webkit-slider-thumb {
appearance: none;
width: 1px;
height: 20px;
background: rgb(171, 168, 168);
box-shadow: -1000px 0 0 1000px rgb(171, 168, 168);
}
.control-container {
display: flex;
padding: 1rem;
align-items: center;
background-color: #212121;
justify-content: space-between;
}
.player-controller .left {
display: flex;
flex: 1;
}
.player-controller .left .control {
display: flex;
align-items: center;
}
.player-controller .left .control i {
cursor: pointer;
padding: 5px;
margin-left: .8rem;
font-size: 20;
}
.player-controller .left .control #play-pause-button {
font-size: 30;
}
.player-controller .left .position {
display: flex;
padding: 10px;
margin-left: 1rem;
}
.player-controller .center {
display: flex;
padding: 0rem 1rem;
justify-content: center;
align-items: center;
flex-grow: 1;
flex: 2;
max-width: 40%;
}
.player-controller .center img {
border-radius: .2rem;
height: 40;
}
.player-controller .center .data {
margin-left: 1rem;
overflow: hidden;
}
.player-controller .center .desc {
color: grey;
font-size: 13;
}
.player-controller .right {
flex: 1;
display: flex;
justify-content: right;
align-items: center;
padding-right: 5px;
}
.player-controller .right i {
cursor: pointer;
padding: 5px;
margin-left: .8rem;
font-size: 20;
color: gray;
}
.main .queue-list {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
display: flex;
justify-content: center;
}
.track {
display: flex;
align-items: center;
cursor: pointer;
border-radius: .5rem;
padding: 5px 15px;
overflow: hidden;
margin-bottom: .5rem;
transition: background-color .2s, transform .2s;
}
.track:hover {
background-color: rgba(142, 142, 142, 20%);
/* transform: scale(1.02); */
}
.track.active {
background-color: rgba(142, 142, 142, 30%);
}
.track .left {
display: flex;
justify-content: left;
align-items: center;
margin-right: .5rem;
flex: 1;
}
.track .left i {
cursor: move;
margin-right: 1rem;
font-size: 20px;
}
.track .left img {
padding: 5px;
height: 50;
border-radius: .5rem;
}
.track .left .info {
display: block;
max-width: 300px;
margin-left: .5rem;
}
.track .left .info p {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.track .left .info p.desc {
color: grey;
font-size: 13;
}
.main .queue-list ul {
list-style: none;
width: 90%;
padding: 1rem;
max-height: 300px;
}
@media (max-width: 935px) {
.main {
flex-direction: column;
justify-content: center;
padding: 0;
}
.header .center {
display: none;
}
.player-controller .left .position {
display: none;
}
.player-controller .center img {
display: none;
}
ul {
width: 100%;
}
}
@media (max-width: 640px) {
.main {
padding: 0;
}
.main .thumbnail img {
padding: 0;
width: 80%;
height: auto;
}
.main .queue-list {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
display: flex;
justify-content: center;
min-width: 100%;
}
.track .left i {
margin-right: .5rem;
font-size: 15px;
}
.track .left img {
padding: 2px;
height: 30;
border-radius: .2rem;
}
.track .left .info {
max-width: 200px;
}
}

47
web/static/js/Socket.js Normal file
View File

@@ -0,0 +1,47 @@
class Socket {
constructor(url) {
this.url = url;
this.socket = null;
}
connect(player) {
this.socket = io.connect(this.url);
this.socket.on('connect', () => {
console.log("Connected to server!");
});
this.socket.on('disconnect', () => {
player.init();
console.log("Disconnected from server!");
});
this.socket.on('error', (e) => {
console.log(e);
})
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
}
}
send(msg) {
if (this.socket) {
this.socket.emit('message', msg);
}
}
addMessageListener(callback) {
if (this.socket) {
this.socket.on('message', callback);
}
}
removeMessageListener(callback) {
if (this.socket) {
this.socket.off('message', callback);
}
}
}

97
web/static/js/action.js Normal file
View File

@@ -0,0 +1,97 @@
$(document).ready(function () {
const player = new Player(userId);
var startPos = null;
var typingTimer;
var doneTypingInterval = 2000;
$('body').click(function(event) {
var $target = $(event.target);
var $resultList = $target.closest(".search-contrainer");
if (!$resultList.is('.search-contrainer') && $('#search-result-list').css("display") != 'none') {
$("#search-result-list").fadeOut(200);
}
});
$(function () {
$("#sortable").sortable({
handle: ".handle",
scroll: true,
axis: "y",
start: function (event, ui) {
startPos = ui.item.index();
},
stop: function (event, ui) {
if (startPos != null) {
var newPos = ui.item.index();
if (startPos != newPos) {
player.moveTrack(startPos, newPos)
}
}
}
});
});
$('#sortable').on('click', 'li', function () {
var index = $(this).index();
var position = player.current_queue_position;
if (index < position) {
player.backTo(position - index);
} else if (index > position) {
player.skipTo(index - position)
} else {
player.togglePause();
}
})
$('#search-result-list').on('click', 'li', function () {
var index = $(this).index();
var track = player.searchList[index];
if (track != undefined) {
player.send({ "op": "addTracks", "tracks": [track] })
}
$("#search-result-list").fadeOut(200);
})
$('#search-input').on('input', function () {
clearTimeout(typingTimer);
$("#search-loader").fadeIn(200);
typingTimer = setTimeout(function () {
var input = $('#search-input').val();
if (input.replace(/\s+/g, '') != "") {
player.send({ "op": "getTracks", "query": input })
} else {
$("#search-loader").fadeOut(200);
$("#search-result-list").fadeOut(200);
}
}, doneTypingInterval);
});
$('#search-input').focus(function () {
if ($(this).val() != "") {
$("#search-result-list").fadeIn(200);
}
})
$('#play-pause-button').on('click', function () {
player.togglePause();
});
$('#skip-button').on('click', function () {
player.skipTo();
});
$('#back-button').on('click', function () {
player.backTo();
});
$('#seek-bar').change(function () {
player.seekTo($(this).val());
})
$("#shuffle-button").on('click', function() {
player.shuffle();
})
});

370
web/static/js/objects.js Normal file
View File

@@ -0,0 +1,370 @@
class Timer {
constructor(callback, interval) {
this.callback = callback;
this.interval = interval;
this.timerId = null;
this.isRunning = false;
}
start() {
if (!this.isRunning) {
this.isRunning = true;
this.timerId = setInterval(() => {
this.callback();
}, this.interval);
}
}
stop() {
if (this.isRunning) {
clearInterval(this.timerId);
this.timerId = null;
this.isRunning = false;
}
}
getIsRunning() {
return this.isRunning;
}
}
const actions = {
initPlayer: function (player, data) {
player.init();
player.addTrack(data["tracks"]);
player.updateCurrentQueuePos(data['current_queue_position']);
player.isDJ = data['is_dj'];
player.is_paused = data['is_paused'];
player.current_position = data['current_position'];
data["users"].forEach(user => {
player.addUser(user);
})
},
playerUpdate: function (player, data) {
player.last_update = data["last_update"];
player.is_connected = data["is_connected"];
player.current_position = data["last_position"];
},
trackUpdate: function (player, data) {
var track = player.updateCurrentQueuePos(data['current_queue_position']);
player.is_paused = data['is_paused'];
if (track?.track_id != data["track_id"]) {
player.send({ "op": "initPlayer" })
}
},
addTrack: function (player, data) {
player.addTrack(data["tracks"]);
var tracks = data["tracks"];
if (tracks.length == 1) {
var msg = `Added ${tracks[0]['info']['title']} songs into the queue.`
} else {
var msg = `Added ${tracks.length} into the queue.`
}
player.showToast(data["requester_id"], msg)
},
getTracks: function (player, data) {
var tracks = data["tracks"];
if (tracks != undefined) {
const resultList = $("#search-result-list");
resultList.empty();
player.searchList = tracks;
for (var i in tracks) {
var track = new Track(tracks[i]);
resultList.append(`<li class="search-result"><div class="search-result-left"><img src=${track.imageUrl} /><div class="search-result-info"><p class="info">${track.title}</p><p class="desc">${track.author}</p></div></div><p>${player.msToReadableTime(track.length)}</p></li>`)
}
}
$("#search-result-list").fadeIn(200);
$("#search-loader").fadeOut(200);
},
playerClose: function (player, data) {
player.init();
},
updateGuild: function (player, data) {
const user = data["user"];
if (user["user_id"] == player.userId) {
if (data['is_joined']) {
player.send({ "op": "initPlayer" });
} else {
player.init();
}
}
if (data['is_joined']) {
player.addUser(user);
player.showToast(user["user_id"], "Joined your channel!");
} else {
if (player.users.hasOwnProperty(user["user_id"])) {
player.showToast(user["user_id"], "Left your channel!");
delete player.users[user['user_id']];
}
}
},
updatePause: function (player, data) {
player.is_paused = data['pause'];
var msg = "";
if (data["pause"]) {
msg = "Paused the player."
} else {
msg = "Resumed the player."
}
player.showToast(data["requester_id"], msg)
},
updatePosition: function (player, data) {
player.current_position = data["position"];
},
swapTrack: function (player, data) {
var index1 = player.current_queue_position + data['position2']["index"];
var index2 = player.current_queue_position + data['position1']["index"];
var track1 = player.queue[index1];
var track2 = player.queue[index2];
if (track1?.track_id != data['position1']["track_id"] || track2?.track_id != data['position2']["track_id"]) {
return player.send({ "op": "initPlayer" });
}
player.queue[index1] = player.queue.splice(index2, 1, player.queue[index1])[0];
player.initSortable();
player.showToast(data["requester_id"], `${track1.title} and ${track2.title} are swapped`)
},
moveTrack: function (player, data) {
let position = player.current_queue_position + data["position"]["index"];
let newPosition = player.current_queue_position + data["newPosition"]["index"];
let element = player.queue.splice(position, 1)[0];
if (element?.track_id != data["position"]["track_id"]) {
return player.send({ "op": "initPlayer" });
}
player.queue.splice(newPosition, 0, element);
const $ul = $('#sortable');
const $li = $ul.children().eq(position);
$li.detach();
$ul.children().eq(newPosition).before($li);
player.showToast(data["requester_id"], `Moved ${element.title} to ${newPosition}`)
},
shuffleTrack: function(player, data) {
var tracks = data["tracks"];
if (tracks != undefined) {
player.queue = [];
tracks.forEach(rawTrack => {
var track = new Track(rawTrack);
player.queue.push(track);
});
player.initSortable();
player.showToast(data["requester_id"], "The queue is shuffled.")
}
}
}
class Track {
constructor(object) {
this.title = object["info"]["title"];
this.author = object["info"]["author"];
this.imageUrl = object["thumbnail"];
this.length = object["info"]["length"];
this.track_id = object["track_id"];
}
}
class Player {
constructor(userId) {
this.socket = new Socket('http://127.0.0.1:5000');
this.socket.connect(this);
this.socket.addMessageListener((msg) => this.handleMessage(msg));
this.timer = new Timer(() => this.updateTime(), 1000);
this.userId = parseInt(userId);
this.isDJ = false;
this.date = new Date();
this.queue = [];
this.users = {};
this.searchList = []
this.currentTrack = null;
this.current_queue_position = 0;
this.current_position = 0;
this.is_paused = false;
this.volume = 100;
this.last_update = 0;
this.is_connected = true;
}
handleMessage(data) {
const op = data["op"];
const validActions = Object.keys(actions);
if (validActions.includes(op)) {
actions[op](this, data);
} else {
console.log(`Invalid action: ${op}`)
}
return this.updateInfo()
}
init() {
this.queue = []
this.currentTrack = null;
$('#sortable').empty();
this.updateInfo();
}
initSortable() {
$('#sortable').empty();
for (var i in this.queue) {
var track = this.queue[i];
$("#sortable").append(`<li><div class="track"><div class="left"><i class="fa-solid fa-bars handle"></i><img src=${track.imageUrl} /><div class="info"><p>${track.title}</p><p class="desc">${track.author}</p></div></div><p>${this.msToReadableTime(track.length)}</p></div></li>`)
}
}
updateCurrentQueuePos(pos) {
this.current_queue_position = pos - 1;
this.currentTrack = this.queue[this.current_queue_position];
$('#sortable li div').removeClass('active');
const li = $(`#sortable li:eq(${this.current_queue_position})`);
li.find('div').addClass('active');
$('.queue-list').animate({ scrollTop: li.position().top - $('.queue-list').position().top }, 'slow');
return this.currentTrack;
}
addUser(user) {
this.users[user['user_id']] = { "avatar_url": user["avatar_url"], "name": user["name"] };
}
addTrack(tracks) {
for (var i in tracks) {
var track = new Track(tracks[i]);
this.queue.push(track);
$("#sortable").append(`<li><div class="track"><div class="left"><i class="fa-solid fa-bars handle"></i><img src=${track.imageUrl} /><div class="info"><p>${track.title}</p><p class="desc">${track.author}</p></div></div><p>${this.msToReadableTime(track.length)}</p></div></li>`)
}
}
moveTrack(target, to) {
this.send({ "op": "moveTrack", "position": target, "newPosition": to })
}
togglePause() {
this.send({ "op": "updatePause", "pause": !this.is_paused });
}
skipTo(index = 1) {
this.send({ "op": "skipTo", "index": index });
}
backTo(index = 1) {
this.send({ "op": "backTo", "index": index });
}
seekTo(tempPosition) {
if (this.currentTrack == undefined) {
return;
}
var position = tempPosition / 500 * this.currentTrack.length;
this.send({ "op": "updatePosition", "position": position });
}
shuffle() {
if ((this.queue.length - this.current_queue_position) > 3) {
this.send({ "op": "shuffleTrack" });
} else {
this.showToast(this.userId, "Add more songs to the queue before shuffling.");
}
}
send(payload) {
var json = JSON.stringify(payload)
this.socket.send(json);
}
isPlaying() {
return (this.currentTrack != undefined && this.is_connected);
}
msToReadableTime(ms) {
let totalSeconds = Math.floor(ms / 1000);
let hours = Math.floor(totalSeconds / 3600);
let minutes = Math.floor((totalSeconds % 3600) / 60);
let seconds = totalSeconds % 60;
minutes = (minutes < 10) ? "0" + minutes : minutes;
seconds = (seconds < 10) ? "0" + seconds : seconds;
let timeString = "";
if (hours > 0) {
timeString += hours + ":" + minutes + ":" + seconds;
} else {
timeString += minutes + ":" + seconds;
}
return timeString;
}
showToast(userId, msg) {
var user = this.users[userId];
if (user != null) {
var $element = $(`<div class="toast"><img src=${user['avatar_url']} alt="user-icon"/><div class="content"><p class="username">${user['name']}</p><p class="message">${msg}</p></div></div>`)
$(".toastContrainer").append($element)
setTimeout(function () {
$element.fadeOut(500, function () {
$(this).remove();
});
}, 6000);
}
}
updateTime() {
if (this.currentTrack == undefined) {
return this.timer.stop();
}
if (this.current_position >= this.currentTrack?.length) {
return this.timer.stop();
}
this.current_position += 1000;
$("#position").text(this.msToReadableTime(this.current_position));
var time = (this.current_position / this.currentTrack?.length) * 500;
$("#seek-bar").val(time);
}
updateInfo() {
var currentTrack = this.currentTrack;
if (currentTrack == undefined) {
$("#title").text("");
$("#author").text("");
$("#position").text("00:00");
$("#length").text("00:00");
$("#image").removeAttr('src');
$("#largeImage").removeAttr('src');
} else {
$("#title").text(currentTrack.title);
$("#author").text(currentTrack.author);
$("#length").text(this.msToReadableTime(currentTrack.length));
$("#image").attr("src", currentTrack.imageUrl);
$("#largeImage").attr("src", currentTrack.imageUrl);
}
var play_pause_btn = $("#play-pause-button")
if (this.is_paused || currentTrack == undefined) {
play_pause_btn.removeClass('fa-pause').addClass('fa-play');
if (this.timer.getIsRunning()) {
this.timer.stop();
}
} else {
play_pause_btn.removeClass('fa-play').addClass('fa-pause');
this.timer.start();
}
}
}

97
web/templates/index.html Normal file
View File

@@ -0,0 +1,97 @@
<html>
<head>
<meta charset="utf-8">
<title>Vocard Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.3/jquery.min.js"
integrity="sha512-STof4xm1wgkfm7heWqFJVn58Hm3EtS31XFaagaa8VMReCXAkQnJZ+jEy8PCC/iT18dFy95WcExNHFTqLyp72eQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"
integrity="sha384-c79GN5VsunZvi+Q/WObgk2in0CbZsHnjEqvFxC5DxHn9lTfNce2WW6h2pH6u/kF+"
crossorigin="anonymous"></script>
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.js"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/socket.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/action.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/objects.js') }}"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css"
integrity="sha512-SzlrxWUlpfuzQ+pcUCosxcglQRNAq/DZjVsC0lE40xsADsfeQoEypE+enwcOiGjk/bSuGGKHEyjSoQ1zVisanQ=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<script> const userId = "{{ user.id }}"</script>
<div class="container">
<div class="header">
<div class="left">
<p>Home</p>
<p>Playlist</p>
</div>
<div class="center">
<div class="search-contrainer">
<div class="search-bar">
<i class="fa-solid fa-magnifying-glass"></i>
<input id="search-input" class="search-input" placeholder="Search your input and add it to the queue."/>
<div id="search-loader" class="search-loader"></div>
</div>
<ul id="search-result-list" class="search-result-list">
</ul>
</div>
</div>
<div class="right">
<div class="account">
<img src="{{ user.avatar.url }}" />
<p>{{ user.username }}</p>
</div>
</div>
</div>
<div class="main">
<div class="toastContrainer"></div>
<div class="thumbnail">
<img id="largeImage" />
</div>
<div class="queue-list">
<ul id="sortable">
</ul>
</div>
</div>
<div class="player-controller">
<div class="progress-bar-container">
<input type="range" value="0" min="0" max="500" class="seek-bar" id="seek-bar">
</div>
<div class="control-container">
<div class="left">
<div class="control">
<i id="back-button" class="fa-solid fa-backward-step"></i>
<i id="play-pause-button" class="fa-solid fa-play"></i>
<i id="skip-button" class="fa-solid fa-forward-step"></i>
</div>
<div class="position">
<p id="position">00:00 </p>/ <p id="length"> 00:00</p>
</div>
</div>
<div class="center">
<img class="image" id="image">
<div class="data">
<p id="title"></p>
<p class="desc" id="author"></p>
</div>
</div>
<div class="right">
<i id="shuffle-button" class="fa-solid fa-shuffle"></i>
</div>
</div>
</div>
</div>
</body>
</html>

202
web/webapp.py Normal file
View File

@@ -0,0 +1,202 @@
from flask import Flask, redirect, url_for, session, request, render_template, abort
from flask_socketio import SocketIO, emit, join_room, leave_room, rooms, disconnect
from ipc import IPCClient
from objects import User
from dotenv import load_dotenv
import requests
import json
import os
import asyncio
import functools
import threading
load_dotenv()
app = Flask(__name__)
app.secret_key = "aNOwneoiniowefn"
socketio = SocketIO(app)
# Discord OAuth2 credentials
CLIENT_ID = os.getenv("CLIENT_ID")
CLIENT_SECRET = os.getenv("CLIENT_SECRET_ID")
REDIRECT_URI = 'http://127.0.0.1:5000/callback'
DISCORD_API_BASE_URL = 'https://discord.com/api'
USERS = {}
def start_ipc_client(loop):
client = IPCClient(secret_key="Vocard")
asyncio.set_event_loop(loop)
loop.run_until_complete(client.connect())
def create_ipc_client():
loop = asyncio.new_event_loop()
threading.Thread(target=start_ipc_client,
args=(loop,), daemon=True).start()
return IPCClient(secret_key="Vocard", callback=message_handler)
def get_user(user_id: int):
for user in USERS.values():
if user.id == user_id:
return user
return None
def user_join_room(guild_id: int, user: User) -> None:
if not user.sid:
return
join_room(guild_id, sid=user.sid)
user.guild_id = guild_id
def user_leave_room(guild_id: int, user: User) -> None:
if not user.sid:
return
leave_room(guild_id, sid=user.sid)
user.guild_id = None
def message_handler(data: dict):
op = data.get("op")
user_id = data.get("user_id", None)
if user_id:
user: User = get_user(user_id)
else:
user = None
guild_id = data.get("guild_id", None)
if op == "updateGuild":
user_id = data.get("user", {}).get("user_id", None)
is_joined = data.get("is_joined")
user: User = get_user(user_id)
if user and guild_id:
user_join_room(guild_id, user) if is_joined else user_leave_room(guild_id, user)
elif op == "createPlayer":
members_id = data.get("members_id", [])
for member_id in members_id:
user: User = get_user(member_id)
if user:
user_join_room(guild_id, user)
emit('message', {
"op": "updateGuild",
"user": {
"user_id": user.id,
"avatar_url": user.avatar.url,
"name": user.display_name
},
"is_joined": True
}, to=user.sid)
return
if user:
if guild_id and user.sid:
join_room(guild_id, sid=user.sid)
elif guild_id in rooms(user.sid):
pass
else:
emit('message', data, to=user.sid)
if op == "initPlayer":
return emit('message', data, to=user.sid)
if guild_id:
skip_sids = []
if skip_users := data.get("skip_users"):
for user_id in skip_users:
if user := get_user(user_id):
skip_sids.append(user.sid)
emit('message', data, room=guild_id, skip_sid=skip_sids)
ipc_client = create_ipc_client()
def login_required(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
token = session.get("discord_token", None)
if not token:
return redirect(url_for('login'))
if token not in USERS:
resp = requests_api(f'{DISCORD_API_BASE_URL}/users/@me',
headers={'Authorization': f'Bearer {token}'})
if resp:
user = USERS[token] = User(resp)
else:
abort(401, description="Unauthorized")
else:
user = USERS[token]
return func(user, *args, **kwargs)
return wrapper
def requests_api(url: str, headers=None):
resp = requests.get(url=url, headers=headers)
if not resp:
return False
return resp.json()
# home page
@app.route('/')
@login_required
def home(user: User):
return render_template("index.html", user=user)
# login page
@app.route('/login')
def login():
# redirect to Discord OAuth2 login page
params = {
'client_id': CLIENT_ID,
'response_type': 'code',
'redirect_uri': REDIRECT_URI,
'scope': 'identify'
}
return redirect(f'{DISCORD_API_BASE_URL}/oauth2/authorize?{"&".join([f"{k}={v}" for k, v in params.items()])}')
# callback page
@app.route('/callback')
def callback():
# fetch user token from Discord OAuth2
code = request.args.get('code')
data = {
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET,
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': REDIRECT_URI,
'scope': 'identify'
}
response = requests.post(f'{DISCORD_API_BASE_URL}/oauth2/token', data=data)
token_data = json.loads(response.content.decode('utf-8'))
session['discord_token'] = token_data.get("access_token")
return redirect(url_for("home"))
@socketio.on("connect")
@login_required
def handle_connect(user: User):
if user.sid:
disconnect(sid=user.sid)
user.sid = request.sid
asyncio.run(ipc_client.send('{"op": "initPlayer"}', user))
@socketio.on("disconnect")
@login_required
def handle_disconnect(user: User):
user.sid = None
@socketio.on("message")
@login_required
def handle_message(user: User, msg):
asyncio.run(ipc_client.send(msg, user))
if __name__ == '__main__':
socketio.run(app, host="127.0.0.1", port=5000)