diff --git a/.env Example b/.env Example index 5bd0292..19e70ce 100644 --- a/.env Example +++ b/.env Example @@ -1,5 +1,8 @@ TOKEN = YOUR_BOT_TOKEN CLIENT_ID = YOUR_BOT_CLIENT_ID +CLIENT_SECRET_ID = YOUR_BOT_CLIENT_SECRET_ID +SERCET_KEY = DASHBOARD_SERCET_KEY + BUG_REPORT_CHANNEL_ID = YOUR_DISCORD_CHANNEL_ID SPOTIFY_CLIENT_ID = YOUR_SPOTIFY_CLIENT_ID diff --git a/README.md b/README.md index 8baa726..63f2778 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,26 @@ # Vocard (Discord Music Bot) > Vocard is a simple custom Disocrd Music Bot built with Python & [discord.py](https://discordpy.readthedocs.io/en/stable/) +# Demo (Bot & Dashboard) +* [Discord Bot](https://discord.com/api/oauth2/authorize?client_id=890399639008866355&permissions=36708608&scope=bot%20applications.commands) +* [Dashboard](http://vocard.xyz) + ## Tutorial Click on the image below to watch the tutorial on Youtube. [![Discord Music Bot](https://img.youtube.com/vi/f_Z0RLRZzWw/maxresdefault.jpg)](https://www.youtube.com/watch?v=f_Z0RLRZzWw) ## Screenshot -
- - - - -
+### Discord Bot + + + + + +### Dashboard + + + ## Run the Projects [![Run on Repl.it](https://replit.com/badge/github/ChocoMeow/Vocard)](https://replit.com/new/github/ChocoMeow/Vocard) @@ -40,6 +48,9 @@ Start your bot with `python main.py` ```sh TOKEN = XXXXXXXXXXXXXXXXXXXXXXXX.XXXXXX.XXXXXXXXXXXXXXXXXXXXXXXXXXX CLIENT_ID = 123456789012345678 +CLIENT_SECRET_ID = YOUR_BOT_CLIENT_SECRET_ID +SERCET_KEY = DASHBOARD_SERCET_KEY + BUG_REPORT_CHANNEL_ID = 123456789012345678 SPOTIFY_CLIENT_ID = 0XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX @@ -54,6 +65,8 @@ MONGODB_NAME = Vocard | --- | --- | | TOKEN | Your Discord bot token [(Discord Portal)](https://discord.com/developers/applications) | | CLIENT_ID | Your Discord bot client id [(Discord Portal)](https://discord.com/developers/applications) | +| CLIENT_SECRET_ID | Your Discord bot client secret id [(Discord Portal)](https://discord.com/developers/applications) ***(optional)*** | +| SERCET_KEY | Secret key for dashboard ***(optional)*** | | BUG_REPORT_CHANNEL_ID | All the error messages will send to this text channel ***(optional)*** | | SPOTIFY_CLIENT_ID | Your Spoity client id [(Spotify Portal)](https://developer.spotify.com/dashboard/applications) ***(optional)*** | | SPOTIFY_CLIENT_SECRET | Your Spoity client sercret id [(Spotify Portal)](https://developer.spotify.com/dashboard/applications) ***(optional)*** | @@ -78,6 +91,11 @@ MONGODB_NAME = Vocard "bot_access_user": [], "color_code":"0xb3b3b3", "default_max_queue": 1000, + "ipc_server": { + "host": "127.0.0.1", + "port": 8000, + "enable": false + }, "emoji_source_raw": { "youtube": "<:youtube:826661982760992778>", "youtube music": "<:youtube:826661982760992778>", @@ -111,6 +129,7 @@ MONGODB_NAME = Vocard * For `bot_access_user` you can pass the [discord user id](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-). Example: `[123456789012345678]` * For `color_code` you must pass a [Hexadecimal color code](https://htmlcolorcodes.com/) and add `0x` before the color code. Example: `"0xb3b3b3"` * For `default_max_queue` you can set a default maximum number of tracks that can be added to the queue. +* For `ipc_server` you can set the host, password and enable of the ipc server. * For `emoji_source_raw` you can change the source emoji of the track with discord emoji like `<:EMOJI_NAME:EMOJI_ID>` * For `cooldowns` you can set a custom cooldown in the command. Example: `"command_name": [The total number of tokens available, The length of the cooldown period in seconds]` * For `aliases` you can set custom aliases in the command. Example: `"command_name": [alias1, alias2, ...]` diff --git a/addons/__init__.py b/addons/__init__.py index 088e3ad..aa4e903 100644 --- a/addons/__init__.py +++ b/addons/__init__.py @@ -1 +1,2 @@ -from .lyrics import getLyrics \ No newline at end of file +from .lyrics import getLyrics +from .settings import Settings \ No newline at end of file diff --git a/addons/settings.py b/addons/settings.py new file mode 100644 index 0000000..91ad991 --- /dev/null +++ b/addons/settings.py @@ -0,0 +1,18 @@ +class Settings: + def __init__(self, settings: dict) -> None: + self.invite_link = "https://discord.gg/wRCgB7vBQv" + self.nodes = settings.get("nodes", {}) + self.max_queue = settings.get("default_max_queue", 1000) + self.bot_prefix = settings.get("prefix", "") + self.embed_color = int(settings.get("embed_color", "0xb3b3b3"), 16) + self.bot_access_user = settings.get("bot_access_user", []) + self.emoji_source_raw = settings.get("emoji_source_raw", {}) + self.cooldowns_settings = settings.get("cooldowns", {}) + self.aliases_settings = settings.get("aliases", {}) + self.controller_settings = settings.get("controller", [["back", "resume", "skip", {"stop": "red"}, "add"], ["tracks"]]) + self.ipc_server = settings.get("ipc_server", { + "host": "127.0.0.1", + "port": 8000, + "enable": False + } + ) \ No newline at end of file diff --git a/cogs/admin.py b/cogs/admin.py index 0bee764..a040e34 100644 --- a/cogs/admin.py +++ b/cogs/admin.py @@ -13,7 +13,7 @@ from function import ( update_settings, get_settings, get_lang, - embed_color, + settings as sett, time as ctime, get_aliases, cooldown_check @@ -138,13 +138,13 @@ class Admin(commands.Cog, name="settings"): async def view(self, ctx: commands.Context): "Show all the bot settings in your server." player, settings = self.get_settings(ctx) - embed = discord.Embed(color=embed_color) + embed = discord.Embed(color=sett.embed_color) embed.set_author(name=get_lang(ctx.guild.id, 'settingsMenu').format(ctx.guild.name), icon_url=self.bot.user.display_avatar.url) if ctx.guild.icon: embed.set_thumbnail(url=ctx.guild.icon.url) embed.add_field(name=get_lang(ctx.guild.id, 'settingsTitle'), value=get_lang(ctx.guild.id, 'settingsValue').format( - settings.get('prefix', func.bot_prefix), + settings.get('prefix', func.settings.bot_prefix), settings.get('lang', 'EN'), settings.get('controller', True), f"<@&{settings['dj']}>" if 'dj' in settings else '`None`', @@ -156,7 +156,7 @@ class Admin(commands.Cog, name="settings"): ) embed.add_field(name=get_lang(ctx.guild.id, 'settingsTitle2'), value=get_lang(ctx.guild.id, 'settingsValue2').format( settings.get("queueType", "Queue"), - func.max_queue, + func.settings.max_queue, settings.get("duplicateTrack", True) ) ) @@ -179,7 +179,7 @@ class Admin(commands.Cog, name="settings"): player: voicelink.Player = ctx.guild.voice_client if player: player.settings['volume'] = value - await player.set_volume(value) + await player.set_volume(value, ctx.author) update_settings(ctx.guild.id, {'volume': value}) await ctx.send(get_lang(ctx.guild.id, 'setVolume').format(value)) @@ -219,7 +219,7 @@ class Admin(commands.Cog, name="settings"): @app_commands.command(name="debug") async def debug(self, interaction: discord.Interaction): - if interaction.user.id not in func.bot_access_user: + if interaction.user.id not in func.settings.bot_access_user: return await interaction.response.send_message("You are not able to use this command!") def clear_code(content): diff --git a/cogs/basic.py b/cogs/basic.py index 1c67752..7d5e827 100644 --- a/cogs/basic.py +++ b/cogs/basic.py @@ -11,7 +11,7 @@ from function import ( youtube_api_key, requests_api, get_lang, - embed_color, + settings, cooldown_check, get_aliases, connect_channel @@ -36,7 +36,7 @@ async def nowplay(ctx: commands.Context, player: voicelink.Player): upnext = "\n".join(f"`{index}.` `[{track.formatLength}]` [{track.title[:30]}]({track.uri})" for index, track in enumerate( player.queue.tracks()[:2], start=2)) embed = discord.Embed(description=player.get_msg( - 'nowplayingDesc').format(track.title), color=embed_color) + 'nowplayingDesc').format(track.title), color=settings.embed_color) embed.set_author(name=track.requester if track.requester else ctx.bot, icon_url=track.requester.display_avatar.url if track.requester else ctx.me.display_avatar.url) @@ -187,7 +187,7 @@ class Basic(commands.Cog): query_track = "\n".join( f"`{index}.` `[{track.formatLength}]` **{track.title[:35]}**" for index, track in enumerate(tracks[0:10], start=1)) embed = discord.Embed(title=player.get_msg('searchTitle').format(query), description=player.get_msg( - 'searchDesc').format(emoji_source(platform), platform, len(tracks[0:10]), query_track), color=embed_color) + 'searchDesc').format(emoji_source(platform), platform, len(tracks[0:10]), query_track), color=settings.embed_color) view = SearchView(tracks=tracks[0:10], lang=player.lang) message = await ctx.send(embed=embed, view=view, ephemeral=True) view.response = message @@ -256,7 +256,7 @@ class Basic(commands.Cog): else: return await ctx.send(player.get_msg('pauseVote').format(ctx.author, len(player.pause_votes), required)) - await player.set_pause(True) + await player.set_pause(True, ctx.author) player.pause_votes.clear() await ctx.send(player.get_msg('paused').format(ctx.author)) @@ -281,7 +281,7 @@ class Basic(commands.Cog): else: return await ctx.send(player.get_msg('resumeVote').format(ctx.author, len(player.resume_votes), required)) - await player.set_pause(False) + await player.set_pause(False, ctx.author) player.resume_votes.clear() await ctx.send(player.get_msg('resumed').format(ctx.author)) @@ -318,7 +318,7 @@ class Basic(commands.Cog): await ctx.send(player.get_msg('skipped').format(ctx.author)) if player.queue._repeat == 1: - player.queue.set_repeat("off") + await player.queue.set_repeat("off") await player.stop() @commands.hybrid_command(name="back", aliases=get_aliases("back")) @@ -353,7 +353,7 @@ class Basic(commands.Cog): await ctx.send(player.get_msg('backed').format(ctx.author)) if player.queue._repeat == 1: - player.queue.set_repeat("off") + await player.queue.set_repeat("off") @commands.hybrid_command(name="seek", aliases=get_aliases("seek")) @app_commands.describe(position="Input position. Exmaple: 1:20.") @@ -374,7 +374,7 @@ class Basic(commands.Cog): if num is None: return await ctx.send(player.get_msg('timeFormatError'), ephemeral=True) - await player.seek(num) + await player.seek(num, ctx.author) await ctx.send(player.get_msg('seek').format(position)) @commands.hybrid_command(name="queue", aliases=get_aliases("queue")) @@ -463,9 +463,7 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): return await ctx.send(player.get_msg('missingPerms_mode'), ephemeral=True) - if mode.lower() not in ['off', 'track', 'queue']: - mode = "off" - player.queue.set_repeat(mode) + await player.set_repeat(mode) await ctx.send(player.get_msg('repeat').format(mode.capitalize())) @commands.hybrid_command(name="clear", aliases=get_aliases("clear")) @@ -509,8 +507,17 @@ class Basic(commands.Cog): if not player.is_privileged(ctx.author): return await ctx.send(player.get_msg('missingPerms_queue'), ephemeral=True) - removedTrack = player.queue.remove(position1, position2, member) - await ctx.send(player.get_msg('removed').format(removedTrack)) + removedTrack = player.queue.remove(position1, position2, member=member) + + if player.is_ipc_connected and removedTrack: + await player.send_ws({ + "op": "removeTrack", + "positions": [track["position"] for track in removedTrack], + "track_ids": [track["track"].track_id for track in removedTrack], + "current_queue_position": player.queue._position + }, requester=ctx.author) + + await ctx.send(player.get_msg('removed').format(len(removedTrack))) @commands.hybrid_command(name="forward", aliases=get_aliases("forward")) @app_commands.describe(position="Input a amount that you to forward to. Exmaple: 1:20") @@ -591,7 +598,7 @@ class Basic(commands.Cog): else: return await ctx.send(player.get_msg('shuffleVote').format(ctx.author, len(player.skip_votes), required)) - await player.shuffle("queue") + await player.shuffle("queue", ctx.author) await ctx.send(player.get_msg('shuffled')) @commands.hybrid_command(name="swap", aliases=get_aliases("swap")) @@ -610,6 +617,11 @@ class Basic(commands.Cog): return await ctx.send(player.get_msg('missingPerms_pos'), ephemeral=True) track1, track2 = player.queue.swap(position1, position2) + await player.send_ws({ + "op": "swapTrack", + "position1": {"index": position1, "track_id": track1.track_id}, + "position2": {"index": position2, "track_id": track2.track_id} + }, requester=ctx.author) await ctx.send(player.get_msg('swapped').format(track1.title, track2.title)) @commands.hybrid_command(name="move", aliases=get_aliases("move")) @@ -628,6 +640,11 @@ class Basic(commands.Cog): return await ctx.send(player.get_msg('missingPerms_pos'), ephemeral=True) moved_track = player.queue.move(target, to) + await player.send_ws({ + "op": "moveTrack", + "position": {"index": target, "track_id": moved_track.track_id}, + "newPosition": {"index": to} + }, requester=ctx.author) await ctx.send(player.get_msg('moved').format(moved_track.title, to)) @commands.hybrid_command(name="lyrics", aliases=get_aliases("lyrics")) @@ -749,7 +766,7 @@ class Basic(commands.Cog): async def ping(self, ctx: commands.Context): "Test if the bot is alive, and see the delay between your commands and my response." player: voicelink.Player = ctx.guild.voice_client - embed = discord.Embed(color=embed_color) + embed = discord.Embed(color=settings.embed_color) embed.add_field(name=get_lang(ctx.guild.id, 'pingTitle1'), value=get_lang(ctx.guild.id, 'pingfield1').format( "0", "0", self.bot.latency, '😭' if self.bot.latency > 5 else ('😨' if self.bot.latency > 1 else 'πŸ‘Œ'), "St Louis, MO, United States")) if player: diff --git a/cogs/listener.py b/cogs/listeners.py similarity index 56% rename from cogs/listener.py rename to cogs/listeners.py index d33a04a..c4a9f8b 100644 --- a/cogs/listener.py +++ b/cogs/listeners.py @@ -19,7 +19,7 @@ class Nodes(commands.Cog): async def start_nodes(self) -> None: """Connect and intiate nodes.""" await self.bot.wait_until_ready() - for n in func.nodes.values(): + for n in func.settings.nodes.values(): try: await self.voicelink.create_node(bot=self.bot, spotify_client_id=getenv('SPOTIFY_CLIENT_ID'), @@ -46,5 +46,43 @@ class Nodes(commands.Cog): await asyncio.sleep(5) await player.do_next() + @commands.Cog.listener() + async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState): + if member.bot: + return + + if before.channel == after.channel: + return + + player: voicelink.Player = member.guild.voice_client + if not player: + return + + guild = member.guild.id + is_joined = True + + if not before.channel and after.channel: + if after.channel.id != player.channel.id: + return + + elif before.channel and not after.channel: + is_joined = False + + elif before.channel and after.channel: + if after.channel.id != player.channel.id: + is_joined = False + + await self.bot.ipc.send({ + "op": "updateGuild", + "user": { + "user_id": member.id, + "avatar_url": member.avatar.url, + "name": member.name, + }, + "channel_name": member.voice.channel.name if is_joined else "", + "guild_id": guild, + "is_joined": is_joined + }) + async def setup(bot: commands.Bot) -> None: await bot.add_cog(Nodes(bot)) diff --git a/cogs/playlist.py b/cogs/playlist.py index d027c72..e10571b 100644 --- a/cogs/playlist.py +++ b/cogs/playlist.py @@ -13,7 +13,7 @@ from function import ( update_inbox, get_lang, playlist_name, - embed_color, + settings, get_aliases, cooldown_check, connect_channel @@ -179,7 +179,7 @@ class Playlists(commands.Cog, name="playlist"): embed = discord.Embed(title=get_lang(ctx.guild.id, 'playlistViewTitle').format(ctx.author.name), description='```%0s %4s %10s %10s %10s\n' % tuple(get_lang(ctx.guild.id, 'playlistViewHeaders')) + '\n'.join( '%0s %3s. %10s %10s %10s' % (info['emoji'], info['id'], f"[{info['time']}]", info['name'], len(info['tracks'])) for info in results) + '```', - color=embed_color) + color=settings.embed_color) embed.add_field(name=get_lang(ctx.guild.id, 'playlistMaxP'), value=f"βž₯ {len(user)}/{max_p}", inline=True) embed.add_field(name=get_lang(ctx.guild.id, diff --git a/function.py b/function.py index 2e88a2c..665085c 100644 --- a/function.py +++ b/function.py @@ -3,6 +3,7 @@ import json import aiohttp import os +from importlib import import_module from discord.ext import commands from dotenv import load_dotenv from random import choice @@ -11,6 +12,7 @@ from time import strptime from io import BytesIO from pymongo import MongoClient from typing import Optional +from addons import Settings root_dir = os.path.dirname(os.path.abspath(__file__)) @@ -36,29 +38,18 @@ Playlist = mongodb[MONGODB_NAME]['Playlist'] youtube_api_key = os.getenv('YOUTUBE_API_KEY') #--------------- Cache Var --------------- -invite_link = "https://discord.gg/wRCgB7vBQv" #Template of invite link -embed_color = None - try: report_channel_id = int(os.getenv("BUG_REPORT_CHANNEL_ID")) except: report_channel_id = 0 -emoji_source_raw = {} #Stores all source emoji for track +settings: Settings error_log = {} #Stores error that not a Voicelink Exception -bot_access_user = [] #Stores bot access user id langs = {} #Stores all the languages in ./langs guild_settings = {} #Cache guild language local_langs = {} #Stores all the localization languages in ./local_langs playlist_name = {} #Cache the user's playlist name -bot_prefix = "?" #The default bot prefix -max_queue = 1000 #The default maximum number of tracks in the queue -cooldowns_settings = {} #Stores command cooldown settings -aliases_settings = {} #Stores command aliases settings -controller_settings = {} #Stores the layout of music controller -#----------------- Nodes ----------------- -nodes = {} #-------------- Vocard Functions -------------- def get_settings(guild_id:int): @@ -85,37 +76,12 @@ def update_settings(guild_id:int, data: dict, mode="Set"): collection.update_one({"_id":guild_id}, {"$unset":data}) return -def langs_setup(): - for language in os.listdir(os.path.join(root_dir, "langs")): - if language.endswith('.json'): - with open(os.path.join(root_dir, "langs", language), encoding="utf8") as json_file: - lang = json.load(json_file) - - langs[language[:-5]] = lang - - for language in os.listdir(os.path.join(root_dir, "local_langs")): - if language.endswith('.json'): - with open(os.path.join(root_dir, "local_langs", language), encoding="utf8") as json_file: - lang = json.load(json_file) - - local_langs[language[:-5]] = lang - return - -def settings_setup(): - with open(os.path.join(root_dir, "settings.json"), encoding="utf8") as json_file: - rawSettings = json.load(json_file) - - global nodes, max_queue, bot_prefix, embed_color, bot_access_user, emoji_source_raw, cooldowns_settings, aliases_settings, controller_settings - nodes = rawSettings.get("nodes", {}) - if (new_max_queue := rawSettings.get("default_max_queue", max_queue)): - max_queue = new_max_queue - bot_prefix = rawSettings.get("prefix", "") - embed_color = int(rawSettings.get("embed_color", "0xb3b3b3"), 16) - bot_access_user = rawSettings.get("bot_access_user", []) - emoji_source_raw = rawSettings.get("emoji_source_raw", {}) - cooldowns_settings = rawSettings.get("cooldowns", {}) - aliases_settings = rawSettings.get("aliases", {}) - controller_settings = rawSettings.get("controller", [["back", "resume", "skip", {"stop": "red"}, "add"], ["tracks"]]) +def open_json(path: str): + try: + with open(path, encoding="utf8") as json_file: + return json.load(json_file) + except: + return {} def get_lang(guild_id:int, key:str): lang = get_settings(guild_id).get("lang", "EN") @@ -129,12 +95,30 @@ async def requests_api(url: str): return await resp.json(encoding="utf-8") +def init(): + global settings + + json = open_json(os.path.join(root_dir, "settings.json")) + if json: + settings = Settings(json) + +def langs_setup(): + for language in os.listdir(os.path.join(root_dir, "langs")): + if language.endswith('.json'): + langs[language[:-5]] = open_json(os.path.join(root_dir, "langs", language)) + + for language in os.listdir(os.path.join(root_dir, "local_langs")): + if language.endswith('.json'): + local_langs[language[:-5]] = open_json(os.path.join(root_dir, "local_langs", language)) + + return + async def create_account(ctx: commands.Context): if not ctx.author: return from views import CreateView view = CreateView() - embed=discord.Embed(title="Do you want to create an account on Vocard?", color=embed_color) + embed=discord.Embed(title="Do you want to create an account on Vocard?", color=settings.embed_color) embed.description = f"> Plan: `Default` | `5` Playlist | `500` tracks in each playlist." embed.add_field(name="Terms of Service:", value="β€Œ βž₯ We assure you that all your data on Vocard will not be disclosed to any third party\n" "β€Œ βž₯ We will not perform any data analysis on your data\n" @@ -221,7 +205,7 @@ async def similar_track(player) -> bool: return False async def connect_channel(ctx: commands.Context, channel: discord.VoiceChannel = None): - import voicelink + voicelink = import_module("voicelink"); try: channel = channel or ctx.author.voice.channel except: @@ -233,6 +217,7 @@ async def connect_channel(ctx: commands.Context, channel: discord.VoiceChannel = get_lang(ctx.guild.id, 'noPermission')) player: voicelink.Player = await channel.connect(cls=voicelink.Player(ctx.bot, channel, ctx)) + await player.send_ws({"op": "createPlayer", "members_id": [member.id for member in channel.members]}) return player def time(millis:int) -> str: @@ -259,7 +244,7 @@ def formatTime(number:str) -> Optional[int]: return (int(num.tm_hour) * 3600 + int(num.tm_min) * 60 + int(num.tm_sec)) * 1000 def emoji_source(emoji:str): - return emoji_source_raw.get(emoji.lower(), "πŸ”—") + return settings.emoji_source_raw.get(emoji.lower(), "πŸ”—") def gen_report() -> Optional[discord.File]: if error_log: @@ -275,12 +260,12 @@ def gen_report() -> Optional[discord.File]: return None def cooldown_check(ctx: commands.Context) -> Optional[commands.Cooldown]: - if ctx.author.id in bot_access_user: + if ctx.author.id in settings.bot_access_user: return None - cooldown = cooldowns_settings.get(f"{ctx.command.parent.qualified_name} {ctx.command.name}" if ctx.command.parent else ctx.command.name) + cooldown = settings.cooldowns_settings.get(f"{ctx.command.parent.qualified_name} {ctx.command.name}" if ctx.command.parent else ctx.command.name) if not cooldown: return None return commands.Cooldown(cooldown[0], cooldown[1]) def get_aliases(name: str) -> list: - return aliases_settings.get(name, []) + return settings.aliases_settings.get(name, []) diff --git a/main.py b/main.py index 9868d78..c153baf 100644 --- a/main.py +++ b/main.py @@ -1,39 +1,52 @@ import discord -import sys, os, traceback, aiohttp +import sys +import os +import traceback +import aiohttp import update import function as func from discord.ext import commands +from web import IPCServer from dotenv import load_dotenv from datetime import datetime from voicelink import VoicelinkException load_dotenv() -func.settings_setup() +func.init() class Translator(discord.app_commands.Translator): async def load(self): print("Loaded Translator") - + async def unload(self): print("Unload Translator") - + async def translate(self, string: discord.app_commands.locale_str, locale: discord.Locale, context: discord.app_commands.TranslationContext): if str(locale) in func.local_langs: return func.local_langs[str(locale)].get(string.message, None) return None class Vocard(commands.Bot): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.ipc = IPCServer( + self, + host=func.settings.ipc_server["host"], + port=func.settings.ipc_server["port"], + sercet_key=os.getenv("SERCET_KEY") + ) async def on_message(self, message: discord.Message, /) -> None: if message.author.bot or not message.guild: return False - + if self.user.mentioned_in(message) and not message.mention_everyone: await message.channel.send(f"My prefix is `{await self.command_prefix(self, message)}`") await self.process_commands(message) - + async def setup_hook(self): func.langs_setup() for module in os.listdir(func.root_dir + '/cogs'): @@ -44,6 +57,9 @@ class Vocard(commands.Bot): except Exception as e: print(traceback.format_exc()) + if func.settings.ipc_server["enable"]: + await self.ipc.start() + await bot.tree.set_translator(Translator()) await bot.tree.sync() @@ -58,9 +74,11 @@ class Vocard(commands.Bot): async def on_command_error(self, ctx: commands.Context, exception, /) -> None: error = getattr(exception, 'original', exception) + if ctx.interaction: + error = getattr(error, 'original', error) if isinstance(error, (commands.CommandNotFound, aiohttp.client_exceptions.ClientOSError)): return - + elif isinstance(error, (commands.CommandOnCooldown, commands.MissingPermissions, commands.RangeError, commands.BadArgument)): pass @@ -68,34 +86,34 @@ class Vocard(commands.Bot): command = f" Correct Usage: {ctx.prefix}" + (f"{ctx.command.parent.qualified_name} " if ctx.command.parent else "") + f"{ctx.command.name} {ctx.command.signature}" position = command.find(f"<{ctx.current_parameter.name}>") + 1 error = f"```css\n[You are missing argument!]\n{command}\n" + " " * position + "^" * len(ctx.current_parameter.name) + "```" - + elif not issubclass(error.__class__, VoicelinkException): - error = function.get_lang(ctx.guild.id, "unknownException") + function.invite_link - if (guildId := ctx.guild.id) not in function.error_log: - function.error_log[guildId] = {} - function.error_log[guildId][round(datetime.timestamp(datetime.now()))] = str(traceback.format_exc()) - + error = func.get_lang(ctx.guild.id, "unknownException") + func.settings.invite_link + if (guildId := ctx.guild.id) not in func.error_log: + func.error_log[guildId] = {} + func.error_log[guildId][round(datetime.timestamp(datetime.now()))] = "".join(traceback.format_exception(type(exception), exception, exception.__traceback__)) + try: return await ctx.reply(error, ephemeral=True) except: pass class CommandCheck(discord.app_commands.CommandTree): - + async def interaction_check(self, interaction: discord.Interaction, /) -> bool: if not interaction.guild: await interaction.response.send_message("This command can only be used in guilds!") return False - + return await super().interaction_check(interaction) async def get_prefix(bot, message: discord.Message): settings = func.get_settings(message.guild.id) - return settings.get("prefix", func.bot_prefix) + return settings.get("prefix", func.settings.bot_prefix) intents = discord.Intents.default() intents.members = True -intents.message_content = True if func.bot_prefix else False +intents.message_content = True if func.settings.bot_prefix else False member_cache = discord.MemberCacheFlags( voice=True, joined=False @@ -110,26 +128,6 @@ bot = Vocard(command_prefix=get_prefix, case_insensitive=True, intents=intents) -@bot.tree.error -async def app_command_error(interaction: discord.Interaction, error): - error = getattr(error, 'original', error) - if isinstance(error, (discord.errors.NotFound, aiohttp.client_exceptions.ClientOSError)): - return - elif isinstance(error, (discord.app_commands.CommandOnCooldown, discord.app_commands.errors.MissingPermissions)): - pass - elif not issubclass(error.__class__, VoicelinkException): - error = function.get_lang(interaction.guild_id, "unknownException") + function.invite_link - if (guildId := interaction.guild_id) not in function.error_log: - function.error_log[guildId] = {} - function.error_log[guildId][round(datetime.timestamp(datetime.now()))] = str(traceback.format_exc()) - try: - if interaction.response.is_done(): - return await interaction.followup.send(error, ephemeral=True) - - return await interaction.response.send_message(error, ephemeral=True) - except: - pass - if __name__ == "__main__": update.checkVersion(withMsg=True) bot.run(os.getenv("TOKEN"), log_handler=None) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 04ddf2b..30a25d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -discord.py==2.1.0 +discord.py==2.2.2 pymongo==4.1.1 dnspython==2.2.1 tldextract==3.2.1 @@ -6,3 +6,6 @@ validators==0.18.2 humanize==4.0.0 python-dotenv==0.20.0 beautifulsoup4==4.11.1 +websockets==10.4 +Flask==2.2.3 +Flask-SocketIO==5.3.2 diff --git a/settings Example.json b/settings Example.json index 0847813..bbc1608 100644 --- a/settings Example.json +++ b/settings Example.json @@ -12,6 +12,11 @@ "bot_access_user": [], "color_code":"0xb3b3b3", "default_max_queue": 1000, + "ipc_server": { + "host": "127.0.0.1", + "port": 8000, + "enable": false + }, "emoji_source_raw": { "youtube": "<:youtube:826661982760992778>", "youtube music": "<:youtube:826661982760992778>", diff --git a/update.py b/update.py index 997aad2..552517f 100644 --- a/update.py +++ b/update.py @@ -3,7 +3,7 @@ from io import BytesIO root_dir = os.path.dirname(os.path.abspath(__file__)) install_pack_dir = os.path.join(root_dir, "Vocard.zip") -__version__ = "v2.5.7" +__version__ = "v2.6.0" def checkVersion(withMsg = False): resp = requests.get("https://api.github.com/repos/ChocoMeow/Vocard/releases/latest") diff --git a/views/chapter.py b/views/chapter.py index 93b2caa..325baa6 100644 --- a/views/chapter.py +++ b/views/chapter.py @@ -41,7 +41,7 @@ class Dropdown(discord.ui.Select): super().__init__( placeholder=self.player.get_msg('chaptersDropdown'), min_values=1, max_values=1, - options=options, + options=options[:25], ) async def callback(self, interaction: discord.Interaction): @@ -52,6 +52,7 @@ class Dropdown(discord.ui.Select): return await self.view.stop_view() await self.player.seek(position) + await interaction.response.send_message(self.player.get_msg('seek').format(formatTime(position))) class ChapterView(discord.ui.View): def __init__(self, player, chapters, author): @@ -61,7 +62,7 @@ class ChapterView(discord.ui.View): self.chapters = chapters self.author = author self.response = None - self.add_item(self.Dropdown(player, chapters)) + self.add_item(Dropdown(player, chapters)) async def on_error(self, error: Exception, item, interaction) -> None: return diff --git a/views/controller.py b/views/controller.py index 6c28b19..6a3d080 100644 --- a/views/controller.py +++ b/views/controller.py @@ -63,7 +63,7 @@ class Back(discord.ui.Button): await interaction.response.send_message(self.player.get_msg("backed").format(interaction.user)) if self.player.queue._repeat == 1: - self.player.queue.set_repeat("off") + await self.player.set_repeat("off") class Resume(discord.ui.Button): def __init__(self, player, style, row): @@ -85,7 +85,7 @@ class Resume(discord.ui.Button): self.player.resume_votes.clear() self.emoji = "⏸️" self.label = self.player.get_msg("buttonPause") - await self.player.set_pause(False) + await self.player.set_pause(False, interaction.user) else: if not self.player.is_privileged(interaction.user): @@ -101,7 +101,7 @@ class Resume(discord.ui.Button): self.player.pause_votes.clear() self.emoji = "▢️" self.label = self.player.get_msg("buttonResume") - await self.player.set_pause(True) + await self.player.set_pause(True, interaction.user) await interaction.response.edit_message(view=self.view) class Skip(discord.ui.Button): @@ -127,7 +127,7 @@ class Skip(discord.ui.Button): await interaction.response.send_message(self.player.get_msg("skipped").format(interaction.user)) if self.player.queue._repeat == 1: - self.player.queue.set_repeat("off") + await self.player.set_repeat("off") await self.player.stop() class Stop(discord.ui.Button): @@ -182,20 +182,13 @@ class Add(discord.ui.Button): class Loop(discord.ui.Button): def __init__(self, player, style, row): self.player = player - self.loopType = { - 0: "off", - 1: "track", - 2: "queue", - } super().__init__(emoji="πŸ”", label=player.get_msg('buttonLoop'), style=style, row=row) async def callback(self, interaction: discord.Interaction): if not self.player.is_privileged(interaction.user): return await interaction.response.send_message(self.player.get_msg('missingPerms_mode'), ephemeral=True) - current_repeat = self.player.queue._repeat - mode = self.loopType.get((current_repeat + 1)%len(self.loopType), 'off') - self.player.queue.set_repeat(mode) + mode = await self.player.set_repeat() await interaction.response.send_message(self.player.get_msg('repeat').format(mode.capitalize())) class VolumeUp(discord.ui.Button): @@ -208,7 +201,7 @@ class VolumeUp(discord.ui.Button): return interaction.response.send_message(self.player.get_msg("missingPerms_function")) value = value if (value := self.player.volume + 20) <= 150 else 150 - await self.player.set_volume(value) + await self.player.set_volume(value, interaction.user) await interaction.response.send_message(self.player.get_msg('setVolume').format(value), ephemeral=True) @@ -222,7 +215,7 @@ class VolumeDown(discord.ui.Button): return interaction.response.send_message(self.player.get_msg("missingPerms_function")) value = value if (value := self.player.volume - 20) >= 0 else 150 - await self.player.set_volume(value) + await self.player.set_volume(value, interaction.user) await interaction.response.send_message(self.player.get_msg('setVolume').format(value), ephemeral=True) @@ -246,7 +239,7 @@ class VolumeMute(discord.ui.Button): self.emoji = "πŸ”‡" self.label = self.player.get_msg("buttonVolumeMute") - await self.player.set_volume(value) + await self.player.set_volume(value, interaction.user) await interaction.response.edit_message(view=self.view) @@ -301,7 +294,7 @@ class InteractiveController(discord.ui.View): super().__init__(timeout=None) self.player = player - for row, btnRow in enumerate(func.controller_settings): + for row, btnRow in enumerate(func.settings.controller_settings): for btn in btnRow: color = "" if isinstance(btn, Dict): @@ -320,7 +313,7 @@ class InteractiveController(discord.ui.View): await interaction.response.send_message(self.player.get_msg("nodeReconnect"), ephemeral=True) return False - if interaction.user.id in func.bot_access_user: + if interaction.user.id in func.settings.bot_access_user: return True if self.player.channel and self.player.is_user_join(interaction.user): diff --git a/views/help.py b/views/help.py index 0a94774..506fde1 100644 --- a/views/help.py +++ b/views/help.py @@ -58,7 +58,7 @@ class HelpView(discord.ui.View): self.response = None self.categorys = [ name.capitalize() for name, cog in bot.cogs.items() if len([c for c in cog.walk_commands()]) ] - self.add_item(discord.ui.Button(label='Support', emoji=':support:915152950471581696', url=func.invite_link)) + self.add_item(discord.ui.Button(label='Support', emoji=':support:915152950471581696', url=func.settings.invite_link)) self.add_item(discord.ui.Button(label='Invite', emoji=':invite:915152589056790589', url='https://discord.com/oauth2/authorize?client_id={}&permissions=2184260928&scope=bot%20applications.commands'.format(getenv('CLIENT_ID')))) self.add_item(discord.ui.Button(label='Github', url='https://github.com/ChocoMeow/Vocard')) self.add_item(discord.ui.Button(label='Donate', emoji=':patreon:913397909024800878', url='https://www.patreon.com/Vocard')) @@ -84,7 +84,7 @@ class HelpView(discord.ui.View): def build_embed(self, category: str): category = category.lower() if category == "news": - embed = discord.Embed(title="Vocard Help Menu", url="https://discord.com/channels/811542332678996008/811909963718459392/1069971173116481636", color=func.embed_color) + embed = discord.Embed(title="Vocard Help Menu", url="https://discord.com/channels/811542332678996008/811909963718459392/1069971173116481636", color=func.settings.embed_color) embed.add_field(name=f"Available Categories: [{2 + len(self.categorys)}]", value="```py\nπŸ‘‰ News\n2. Tutorial\n{}```".format("".join(f"{i}. {c}\n" for i, c in enumerate(self.categorys, start=3))), @@ -101,7 +101,7 @@ class HelpView(discord.ui.View): return embed - embed = discord.Embed(title=f"Category: {category.capitalize()}", color=func.embed_color) + embed = discord.Embed(title=f"Category: {category.capitalize()}", color=func.settings.embed_color) embed.add_field(name=f"Categories: [{2 + len(self.categorys)}]", value="```py\n" + "\n".join(("πŸ‘‰ " if c == category.capitalize() else f"{i}. ") + c for i, c in enumerate(['News', 'Tutorial'] + self.categorys, start=1)) + "```", inline=True) if category == 'tutorial': diff --git a/views/inbox.py b/views/inbox.py index e169e9f..68597ff 100644 --- a/views/inbox.py +++ b/views/inbox.py @@ -51,7 +51,7 @@ class InboxView(discord.ui.View): def build_embed(self): embed=discord.Embed(title=f"πŸ“­ All {self.author}'s Inbox", description=f'Max Messages: {len(self.inbox)}/10' + '```%0s %2s %20s\n' % (" ", "ID:", "Title:") + '\n'.join('%0s %2s. %35s'% ('βœ‰οΈ' if mail['type'] == 'invite' else 'πŸ“’', index, mail['title'][:35] + "...") for index, mail in enumerate(self.inbox, start=1)) + '```', - color=func.embed_color) + color=func.settings.embed_color) if self.current: embed.add_field(name="Message Info:", value=f"```{self.current['description']}\nSender ID: {self.current['sender']}\nPlaylist ID: {self.current['referId']}\nInvite Time: {self.current['time'].strftime('%d-%m %H:%M:%S')}```") return embed diff --git a/views/list.py b/views/list.py index 5a4d623..cbc582a 100644 --- a/views/list.py +++ b/views/list.py @@ -64,7 +64,7 @@ class ListView(discord.ui.View): offset = self.current_page * 7 tracks = self.tracks[(offset-7):offset] - embed = discord.Embed(title=self.player.get_msg('viewTitle'), color=func.embed_color) + embed = discord.Embed(title=self.player.get_msg('viewTitle'), color=func.settings.embed_color) embed.description=self.player.get_msg('viewDesc').format(self.player.current.uri, f"```{self.player.current.title}```") if self.player.current else self.player.get_msg('nowplayingDesc').format("None") queueText = "" for index, track in enumerate(tracks, start=offset - 6): diff --git a/views/lyrics.py b/views/lyrics.py index c374be9..26fbe3c 100644 --- a/views/lyrics.py +++ b/views/lyrics.py @@ -72,7 +72,7 @@ class LyricsView(discord.ui.View): def build_embed(self): chunk = self.source.get(self.lang)[self.current_page - 1] - embed=discord.Embed(description=chunk, color=func.embed_color) + embed=discord.Embed(description=chunk, color=func.settings.embed_color) embed.set_author(name=f"Searching Query: {self.name}", icon_url=self.author.display_avatar.url) embed.set_footer(text=f"Page: {self.current_page}/{self.pages}") return embed diff --git a/views/playlist.py b/views/playlist.py index f749891..e4ab7df 100644 --- a/views/playlist.py +++ b/views/playlist.py @@ -88,7 +88,7 @@ class PlaylistView(discord.ui.View): offset = self.current_page * 7 tracks = self.current['tracks'][(offset-7):offset] - embed = discord.Embed(title=func.get_lang(self.guildID, 'playlistView'), color=func.embed_color) + embed = discord.Embed(title=func.get_lang(self.guildID, 'playlistView'), color=func.settings.embed_color) embed.description= func.get_lang(self.guildID, 'playlistViewDesc').format(self.current['name'], self.current['id'], len(self.current['tracks']), self.current.get('owner', "None"), self.current['type']) @@ -163,7 +163,7 @@ class CreateView(discord.ui.View): self.value = None self.response = None self.add_item(agree()) - self.add_item(discord.ui.Button(label='Support', emoji=':support:915152950471581696', url=func.invite_link)) + self.add_item(discord.ui.Button(label='Support', emoji=':support:915152950471581696', url=func.settings.invite_link)) async def on_timeout(self): for child in self.children: diff --git a/voicelink/objects.py b/voicelink/objects.py index 12aada4..100a741 100644 --- a/voicelink/objects.py +++ b/voicelink/objects.py @@ -83,6 +83,13 @@ class Track: self.is_seekable = info.get("isSeekable", True) self.position = info.get("position", 0) + def toDict(self): + return { + "track_id": self.track_id, + "info": self.info, + "thumbnail": self.thumbnail + } + def __eq__(self, other): if not isinstance(other, Track): return False diff --git a/voicelink/player.py b/voicelink/player.py index 5aaebd9..1986495 100644 --- a/voicelink/player.py +++ b/voicelink/player.py @@ -43,7 +43,8 @@ from discord import ( StageChannel, Member, Embed, - ui + ui, + Message ) from discord.ext import commands @@ -83,29 +84,30 @@ class Player(VoiceProtocol): self.dj: Member = ctx.author self.channel: VoiceChannel = channel self._guild = channel.guild if channel else None + self._ipc_connection: bool = False self.settings: dict = func.get_settings(ctx.guild.id) - self.joinTime = round(time.time()) - self._volume = self.settings.get('volume', 100) - self.lang = self.settings.get('lang', 'EN') if self.settings.get('lang', 'EN') in func.langs else "EN" - self.queue = eval(self.settings.get("queueType", "Queue"))(self.settings.get("maxQueue", func.max_queue), self.settings.get("duplicateTrack", True), self.get_msg) + self.joinTime: float = round(time.time()) + self._volume: int = self.settings.get('volume', 100) + self.lang: dict = self.settings.get('lang', 'EN') if self.settings.get('lang', 'EN') in func.langs else "EN" + self.queue: Queue = eval(self.settings.get("queueType", "Queue"))(self.settings.get("maxQueue", func.settings.max_queue), self.settings.get("duplicateTrack", True), self.get_msg) self._node = NodePool.get_node() self._current: Track = None self._filters: Filters = Filters() - self._paused = False - self._is_connected = False - self._ping = 0.0 + self._paused: bool = False + self._is_connected: bool = False + self._ping: float = 0.0 - self._position = 0 - self._last_position = 0 - self._last_update = 0 + self._position: int = 0 + self._last_position: int = 0 + self._last_update: int = 0 self._ending_track: Optional[Track] = None - self._voice_state = {} + self._voice_state: dict = {} - self.controller = None - self.updating = False + self.controller: Message = None + self.updating: bool = False self.pause_votes = set() self.resume_votes = set() @@ -195,8 +197,8 @@ class Player(VoiceProtocol): def ping(self) -> float: return round(self._ping / 1000, 2) - def get_msg(self, mKey: str) -> str: - return func.langs.get(self.lang, func.langs["EN"])[mKey] + def get_msg(self, mKey: str, lang: str = None) -> str: + return func.langs.get(lang if lang else self.lang, func.langs["EN"])[mKey] def required(self, leave=False): if self.settings.get('votedisable'): @@ -209,14 +211,18 @@ class Player(VoiceProtocol): return required + @property + def is_ipc_connected(self) -> bool: + return bool(self._ipc_connection and len(self.bot.ipc.connections)) + def is_user_join(self, user: Member): if user not in self.channel.members: if not user.guild_permissions.manage_guild: return False return True - def is_privileged(self, user: Member, check_user_join: bool = True): - if user.id in func.bot_access_user: + def is_privileged(self, user: Member, check_user_join: bool = True) -> bool: + if user.id in func.settings.bot_access_user: return True manage_perm = user.guild_permissions.manage_guild @@ -227,12 +233,19 @@ class Player(VoiceProtocol): return manage_perm or (self.settings['dj'] in [role.id for role in user.roles]) return self.dj.id == user.id or manage_perm - async def _update_state(self, data: dict): + async def _update_state(self, data: dict) -> None: state: dict = data.get("state") self._last_update = time.time() * 1000 self._is_connected = state.get("connected") self._last_position = state.get("position") self._ping = state.get("ping") + if self.is_ipc_connected: + await self.send_ws({ + "op": "playerUpdate", + "last_update": self._last_update, + "is_connected": self._is_connected, + "last_position": self._last_position + }) async def _dispatch_voice_update(self, voice_data: Dict[str, Any]): if {"sessionId", "event"} != self._voice_state.keys(): @@ -310,7 +323,15 @@ class Player(VoiceProtocol): if self.settings.get('controller', True): await self.invoke_controller() - + + if self.is_ipc_connected: + await self.send_ws({ + "op": "trackUpdate", + "current_queue_position": self.queue._position if track else self.queue._position + 1, + "track_id": track.track_id if track else None, + "is_paused": self._paused + }) + async def invoke_controller(self): if self.updating or not self.channel: return @@ -346,18 +367,18 @@ class Player(VoiceProtocol): track = self.current if not track: - embed=Embed(title=self.get_msg("noTrackPlaying"), description=f"[Vote](https://top.gg/bot/605618911471468554/vote/) | [Support]({func.invite_link}) | [Invite](https://discord.com/oauth2/authorize?client_id=605618911471468554&permissions=2184260928&scope=bot%20applications.commands) | [Questionnaire](https://forms.gle/UqeeEv4GEdCq9hi3A)", color=func.embed_color) + embed=Embed(title=self.get_msg("noTrackPlaying"), description=f"[Vote](https://top.gg/bot/605618911471468554/vote/) | [Support]({func.settings.invite_link}) | [Invite](https://discord.com/oauth2/authorize?client_id=605618911471468554&permissions=2184260928&scope=bot%20applications.commands) | [Questionnaire](https://forms.gle/UqeeEv4GEdCq9hi3A)", color=func.settings.embed_color) embed.set_image(url='https://i.imgur.com/dIFBwU7.png') else: try: - embed = Embed(color=func.embed_color) + embed = Embed(color=func.settings.embed_color) embed.set_author(name=self.get_msg("playerAuthor").format(self.channel.name), icon_url=self.client.user.avatar.url) embed.description = self.get_msg("playerDesc").format(track.title, track.uri, (track.requester.mention if track.requester else "<@605618911471468554>"), (f"<@&{self.settings['dj']}>" if self.settings.get('dj') else f"{self.dj.mention}")) embed.set_image(url=track.thumbnail if track.thumbnail else "https://cdn.discordapp.com/attachments/674788144931012638/823086668445384704/eq-dribbble.gif") embed.set_footer(text=self.get_msg("playerFooter").format(self.queue.count, (self.get_msg("live") if track.is_stream else func.time(track.length)), self.volume, self.get_msg("playerFooter2").format(self.queue.repeat.capitalize()) if self.queue._repeat else "")) except: - embed = Embed(description=self.get_msg("missingTrackInfo"), color=func.embed_color) + embed = Embed(description=self.get_msg("missingTrackInfo"), color=func.settings.embed_color) return embed async def is_position_fresh(self): @@ -373,7 +394,9 @@ class Player(VoiceProtocol): async def teardown(self): timeNow = round(time.time()) func.update_settings(self.guild.id, {"lastActice": timeNow, "playTime": round(self.settings.get("playTime", 0) + ((timeNow - self.joinTime) / 60), 2)}) - + if self.is_ipc_connected: + await self.send_ws({"op": "playerClose"}) + try: await self.controller.delete() except: @@ -543,27 +566,34 @@ class Player(VoiceProtocol): position = self.queue.put_at_front(raw_tracks) if at_font else self.queue.put(raw_tracks) tracks.append(raw_tracks) finally: - return len(tracks) if isList else position + if tracks: + if self.is_ipc_connected: + await self.send_ws({"op": "addTrack", "tracks": [track.toDict() for track in tracks]}, tracks[0].requester) + return len(tracks) if isList else position - async def seek(self, position: float) -> float: + async def seek(self, position: float, requester: Member = None) -> float: """Seeks to a position in the currently playing track milliseconds""" if position < 0 or position > self._current.original.length: - raise TrackInvalidPosition( - "Seek position must be between 0 and the track length" - ) + raise TrackInvalidPosition("Seek position must be between 0 and the track length") await self._node.send(method=0, guild_id=self._guild.id, data={"position": position}) + if self.is_ipc_connected: + await self.send_ws({"op": "updatePosition", "position": position}, requester) return self._position - async def set_pause(self, pause: bool) -> bool: + async def set_pause(self, pause: bool, requester: Member = None) -> bool: """Sets the pause state of the currently playing track.""" await self._node.send(method=0, guild_id=self._guild.id, data={"paused": pause}) + if self.is_ipc_connected: + await self.send_ws({"op": "updatePause", "pause": pause}, requester) self._paused = pause return self._paused - async def set_volume(self, volume: int) -> int: + async def set_volume(self, volume: int, requester: Member = None) -> int: """Sets the volume of the player as an integer. Lavalink accepts values from 0 to 500.""" await self._node.send(method=0, guild_id=self._guild.id, data={"volume": volume}) + if self.is_ipc_connected: + await self.send_ws({"op": "updateVolume", "volume": volume}, requester) self._volume = volume return self._volume @@ -575,6 +605,37 @@ class Player(VoiceProtocol): shuffle(replacement) self.queue.replace(queue_type, replacement) self.shuffle_votes.clear() + if self.is_ipc_connected: + await self.send_ws({ + "op": "shuffleTrack", + "tracks": [track.toDict() for track in self.queue._queue], + "verified": { + "index": self.queue._position if queue_type == "queue" else 0, + "track_id": replacement[0].track_id, + } + }, requester) + + async def set_repeat(self, mode:str = None): + if not mode: + mode = self.queue._repeat_mode.get((self.queue._repeat + 1)%len(self.queue._repeat_mode), 'off') + + is_found = False + for i, m in self.queue._repeat_mode.items(): + if m == mode.lower(): + is_found = True + self.queue._repeat = i + if i == 2: + self._repeat_position = self._position - 1 + break + + if not is_found: + raise VoicelinkException("Invalid repeat mode.") + + if self.is_ipc_connected: + await self.send_ws({"op": "repeatTrack", "repeatMode": mode}) + + return mode + async def add_filter(self, filter: Filter, fast_apply=False) -> Filters: try: self._filters.add_filter(filter=filter) @@ -625,4 +686,10 @@ class Player(VoiceProtocol): await self.set_pause(True) if self.volume != 100: - await self.set_volume(self.volume) \ No newline at end of file + await self.set_volume(self.volume) + + async def send_ws(self, payload, requester: Member = None): + payload['guild_id'] = self.guild.id + if requester: + payload['requester_id'] = requester.id + await self.bot.ipc.send(payload) diff --git a/voicelink/queue.py b/voicelink/queue.py index 20ff437..716fa86 100644 --- a/voicelink/queue.py +++ b/voicelink/queue.py @@ -26,19 +26,27 @@ from .objects import Track from discord import Member class Queue: - def __init__(self, size:int, duplicateTrack: bool, get_msg): + def __init__(self, size: int, duplicate_track: bool, get_msg): self._queue = [] self._position = 0 self._size = size self._repeat = 0 self._repeat_position = 0 - self._duplicateTrack = duplicateTrack + self._duplicate_track = duplicate_track + + self._repeat_mode = { + 0: "off", + 1: "track", + 2: "queue", + } + self.get_msg = get_msg - + def get(self): track = None try: - track = self._queue[self._position - 1 if self._repeat == 1 else self._position] + track = self._queue[self._position - + 1 if self._repeat == 1 else self._position] if self._repeat != 1: self._position += 1 except: @@ -54,30 +62,30 @@ class Queue: def put(self, item: Track) -> int: if self.count >= self._size: raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) - - if not self._duplicateTrack: + + if not self._duplicate_track: if item.uri in [track.uri for track in self._queue]: raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack")) self._queue.append(item) return self.count - + def put_at_front(self, item: Track): if self.count >= self._size: raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) - - if not self._duplicateTrack: + + if not self._duplicate_track: if item.uri in [track.uri for track in self._queue]: raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack")) self._queue.insert(self._position, item) return 1 - + def put_at_index(self, index: int, item: Track): if self.count >= self._size: raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) - - if not self._duplicateTrack: + + if not self._duplicate_track: if item.uri in [track.uri for track in self._queue]: raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack")) @@ -88,21 +96,12 @@ class Queue: raise OutofList(self.get_msg("voicelinkOutofList")) else: self._position += index - 1 - + def backto(self, index: int): if not self._position - index >= 0: raise OutofList(self.get_msg("voicelinkOutofList")) else: self._position -= index - - def set_repeat(self, mode:str): - if mode == 'track': - self._repeat = 1 - elif mode == 'queue': - self._repeat = 2 - self._repeat_position = self._position - 1 - else: - self._repeat = 0 def history_clear(self, is_playing: bool): self._queue[:self._position - 1 if is_playing else self._position] = [] @@ -111,14 +110,14 @@ class Queue: def clear(self): del self._queue[self._position:] - def replace(self, queue_type:str, replacement:list): + def replace(self, queue_type: str, replacement: list): if queue_type == "queue": self.clear() self._queue += replacement elif queue_type == "history": self._queue[:self._position] = replacement - - def swap(self, num1:int, num2:int): + + def swap(self, num1: int, num2: int): try: pos = self._position - 1 self._queue[pos + num1], self._queue[pos + num2] = self._queue[pos + num2], self._queue[pos + num1] @@ -126,10 +125,10 @@ class Queue: except IndexError: raise OutofList(self.get_msg("voicelinkOutofList")) - def move(self, target:int, to:int): + def move(self, target: int, to: int): if not 0 < target <= self.count or not 0 < to: raise OutofList(self.get_msg("voicelinkOutofList")) - + try: moveItem = self._queue[self._position + target - 1] self._queue.remove(moveItem) @@ -138,25 +137,31 @@ class Queue: except: raise OutofList(self.get_msg("voicelinkOutofList")) - def remove(self, index:int, index2:int, member: Member = None): + def remove(self, index: int, index2: int = None, member: Member = None): pos = self._position - 1 - if not index2: + + if index2 is None: index2 = index - count = 0 + + if index2 < index: + temp = index + index = index2 + index2 = temp + try: - for track in self._queue[pos + index: pos + index2 + 1]: + count = [] + for i, track in enumerate(self._queue[pos + index: pos + index2 + 1], start=0): if member: - if track.requester == member: - self._queue.remove(track) - count += 1 - continue - else: - self._queue.remove(track) - count += 1 + if track.requester != member: + continue + + self._queue.remove(track) + count.append({"position": pos + index + i, "track": track}) + return count except: raise OutofList(self.get_msg("voicelinkOutofList")) - + def history(self, incTrack: bool = False) -> list: if incTrack: return self._queue[:self._position] @@ -170,10 +175,10 @@ class Queue: @property def count(self): return len(self._queue[self._position:]) - + @property def repeat(self): - return "Off" if self._repeat == 0 else ("Track" if self._repeat == 1 else "Queue") + return self._repeat_mode.get(self._repeat, "Off").capitalize() @property def is_empty(self): @@ -183,16 +188,17 @@ class Queue: return True return False + class FairQueue(Queue): - def __init__(self, size:int, duplicateTrack: bool, get_msg): - super().__init__(size, duplicateTrack, get_msg) + def __init__(self, size: int, duplicate_track: bool, get_msg): + super().__init__(size, duplicate_track, get_msg) self._set = set() async def put(self, item: Track) -> int: if len(self._queue) >= self._size: raise QueueFull(self.get_msg("voicelinkQueueFull").format(self._size)) - if not self._duplicateTrack: + if not self._duplicate_track: if item.uri in [track.uri for track in self._queue]: raise DuplicateTrack(self.get_msg("voicelinkDuplicateTrack")) @@ -205,8 +211,8 @@ class FairQueue(Queue): self._set.clear() for track in tracks[lastIndex:]: if track.requester in self._set: - break; + break lastIndex += 1 self._set.add(track.requester) await self.put_at_index(lastIndex, item) - return lastIndex \ No newline at end of file + return lastIndex diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..4bafd1f --- /dev/null +++ b/web/__init__.py @@ -0,0 +1,2 @@ +from .ipc import * +from .objects import * \ No newline at end of file diff --git a/web/ipc/__init__.py b/web/ipc/__init__.py new file mode 100644 index 0000000..8d6d350 --- /dev/null +++ b/web/ipc/__init__.py @@ -0,0 +1,2 @@ +from .server import IPCServer +from .client import IPCClient \ No newline at end of file diff --git a/web/ipc/client.py b/web/ipc/client.py new file mode 100644 index 0000000..4ce5ff9 --- /dev/null +++ b/web/ipc/client.py @@ -0,0 +1,57 @@ +import websockets, json, asyncio +from uuid import uuid4 + +class IPCClient: + def __init__( + self, + host = "127.0.0.1", + port = 8000, + secret_key = None, + callback = None + ): + + self.host = host + self.port = port + self.websocket = None + self.secret_key = secret_key + self.callback = callback + self.id = uuid4() + self.is_connected = False + + async def connect_and_reconnect(self): + while True: + await self.connect() + print("Reconnect in next 10s!") + await asyncio.sleep(10) + + async def connect(self): + try: + self.websocket = await websockets.connect(f"ws://{self.host}:{self.port}", extra_headers={"Client-Id": str(self.id)}) + await self.start_receiver() + except: + self.is_connected = False + pass + + async def send(self, message, user): + if not self.is_connected: + await self.connect_and_reconnect() + + data = json.loads(message) + + payload = { + "sercet": self.secret_key, + "data": data | {"user_id": user.id, "guild_id": user.guild_id} + } + + await self.websocket.send(json.dumps(payload)) + + async def receive(self): + print("Connected ipc server!") + self.is_connected = True + async for message in self.websocket: + if self.callback: + data = json.loads(message) + self.callback(data) + + async def start_receiver(self): + await self.receive() \ No newline at end of file diff --git a/web/ipc/methods.py b/web/ipc/methods.py new file mode 100644 index 0000000..a540e3b --- /dev/null +++ b/web/ipc/methods.py @@ -0,0 +1,281 @@ +import json + +from discord import Member +from discord.ext import commands +from voicelink import Player, Track, Playlist + +def missingPermission(user_id:int): + payload = {"op": "errorMsg", "level": "info", "msg": "Only the DJ or admins may use this funciton!"} + payload["user_id"] = user_id + return payload + +def error_msg(msg: str, *, user_id: int = None, guild_id: int = None, level: str = "info"): + payload = {"op": "errorMsg", "level": level} + if user_id: + payload["user_id"]: user_id + if guild_id: + payload["guild_id"]: guild_id + + return payload + +async def initPlayer(player: Player, member: Member, data: dict): + player._ipc_connection = True + return { + "op": "initPlayer", + "guild_id": player.guild.id, + "user_id": data.get("user_id"), + "users": [{ + "user_id": member.id, + "avatar_url": member.avatar.url, + "name": member.name + } for member in player.channel.members ], + "tracks": [ track.toDict() for track in player.queue._queue ], + "repeat_mode": player.queue.repeat.lower(), + "channel_name": player.channel.name, + "current_queue_position": player.queue._position if player.is_playing else player.queue._position + 1, + "current_position": 0 or player.position if player.is_playing else 0, + "is_playing": player.is_playing, + "is_paused": player.is_paused, + "is_dj": player.is_privileged(member, check_user_join=False) + } + +async def skipTo(player: Player, member: Member, data: dict): + if not player.is_privileged(member): + if player.current and member == player.current.requester: + pass + + elif member in player.skip_votes: + return error_msg(player.get_msg('voted', 'EN'), user_id=member.id) + else: + player.skip_votes.add(member) + if len(player.skip_votes) >= (required := player.required()): + pass + else: + return error_msg(player.get_msg('skipVote', 'EN').format(member, len(player.skip_votes), required), guild_id=player.guild.id) + + index = data.get("index", 1) + if index > 1: + player.queue.skipto(index) + + if player.queue._repeat == 1: + await player.set_repeat("off") + await player.stop() + +async def backTo(player: Player, member: Member, data: dict): + if not player.is_privileged(member): + if player.current and member == player.current.requester: + pass + + elif member in player.skip_votes: + return error_msg(player.get_msg('voted', 'EN'), user_id=member.id) + else: + player.skip_votes.add(member) + if len(player.skip_votes) >= (required := player.required()): + pass + else: + return error_msg(player.get_msg('backVote', 'EN').format(member, len(player.skip_votes), required), guild_id=player.guild.id) + + index = data.get("index", 1) + if not player.is_playing: + player.queue.backto(index) + await player.do_next() + else: + player.queue.backto(index + 1) + await player.stop() + +async def moveTrack(player: Player, member: Member, data: dict): + if not player.is_privileged(member): + return missingPermission(member.id) + + c = player.queue._position - 1 + position = data.get("position") + new_position = data.get("newPosition") + + moveItem = player.queue._queue[position] + player.queue._queue.remove(moveItem) + player.queue._queue.insert(new_position, moveItem) + + if position > c and new_position <= c: + player.queue._position += 1 + + elif position < c and new_position >= c: + player.queue._position -= 1 + + elif position == c: + player.queue._position = new_position + 1 + + return { + "op": "moveTrack", + "position": { + "index": position - c, + "track_id": moveItem.track_id + }, + "newPosition": { + "index": new_position - c + }, + "guild_id": player.guild.id, + "requester_id": member.id, + "skip_users": [member.id] + } + +async def addTracks(player: Player, member: Member, data: dict): + raw_tracks = data.get("tracks", []) + tracks = [Track( + track_id=track["track_id"], + info=track["info"], + requester=member + ) for track in raw_tracks] + + await player.add_track(tracks) + + if not player.is_playing: + await player.do_next() + +async def getTracks(player: Player, member: Member, data: dict): + query = data.get("query", None) + if query: + payload = {"op": "getTracks", "user_id": member.id} + tracks = await player.get_tracks(query, requester=member) + if not tracks: + return payload + + if isinstance(tracks, Playlist): + tracks = [ track for track in tracks.tracks[:20] ] + + payload["tracks"] = [ track.toDict() for track in tracks ] + return payload + +async def shuffleTrack(player: Player, member: Member, data: dict): + if not player.is_privileged(member): + + if member in player.shuffle_votes: + return error_msg(player.get_msg('voted'), user_id=member.id) + else: + player.shuffle_votes.add(member) + if len(player.shuffle_votes) >= (required := player.required()): + pass + else: + return error_msg(player.get_msg('shuffleVote').format(member, len(player.skip_votes), required), guild_id=player.guild.id) + + await player.shuffle(data.get("type", "queue"), member) + +async def repeatTrack(player: Player, member: Member, data: dict): + if not player.is_privileged(member): + return missingPermission(member.id) + + await player.set_repeat() + +async def removeTrack(player: Player, member: Member, data: dict): + if not player.is_privileged(member): + return missingPermission(member.id) + + position = data.get("position") + verify_id = data.get("track_id") + + track = player.queue._queue[position] + if track.track_id == verify_id: + player.queue._queue.remove(track) + + if position < player.queue._position: + player.queue._position -= 1 + + return { + "op": "removeTrack", + "positions": [position], + "track_ids": [track.track_id], + "current_queue_position": player.queue._position, + "requester_id": member.id, + "guild_id": player.guild.id + } + +async def updatePause(player: Player, member: Member, data: dict): + pause = data.get("pause", True) + if not player.is_privileged(member): + if pause: + if member in player.pause_votes: + return error_msg(player.get_msg('voted'), user_id=member.id) + else: + player.pause_votes.add(member) + if len(player.pause_votes) >= (required := player.required()): + pass + else: + return error_msg(player.get_msg('pauseVote').format(member, len(player.pause_votes), required), guild_id=player.guild.id) + else: + if member in player.resume_votes: + return error_msg(player.get_msg('voted'), user_id=member.id) + else: + player.resume_votes.add(member) + if len(player.resume_votes) >= (required := player.required()): + pass + else: + return error_msg(player.get_msg('resumeVote').format(member, len(player.resume_votes), required), guild_id=player.guild.id) + + if pause: + player.pause_votes.clear() + else: + player.resume_votes.clear() + + await player.set_pause(pause, member) + +async def updatePosition(player: Player, member: Member, data: dict): + if not player.is_privileged(member): + return missingPermission(member.id) + + position = data.get("position"); + await player.seek(position, member); + +methods = { + "initPlayer": initPlayer, + "skipTo": skipTo, + "backTo": backTo, + "moveTrack": moveTrack, + "addTracks": addTracks, + "getTracks": getTracks, + "shuffleTrack": shuffleTrack, + "repeatTrack": repeatTrack, + "removeTrack": removeTrack, + "updatePause": updatePause, + "updatePosition": updatePosition, +} + +async def process_methods(websocket, bot: commands.Bot, data: dict) -> None: + method = methods.get(data.get("op", ""), None) + if not method: + return + + guild, member = None, None + guild_id = data.get("guild_id", None) + user_id = data.get("user_id", None) + if guild_id is None: + user = bot.get_user(user_id) + if not user: + return + + for g in user.mutual_guilds: + m = g.get_member(user.id) + if m.voice and m.voice.channel: + guild = g + member = m + + else: + guild = bot.get_guild(guild_id) + member = guild.get_member(user_id) + + if not guild: + return + + player: Player = guild.voice_client + if not player or not Member: + return + try: + resp: dict = await method(player, member, data) + if resp: + await websocket.send(json.dumps(resp)) + except Exception as e: + payload = { + "op": "errorMsg", + "level": "error", + "msg": str(e), + "user_id": member.id + } + await websocket.send(json.dumps(payload)) \ No newline at end of file diff --git a/web/ipc/server.py b/web/ipc/server.py new file mode 100644 index 0000000..df0894b --- /dev/null +++ b/web/ipc/server.py @@ -0,0 +1,67 @@ +import json + +from websockets.server import serve, WebSocketServerProtocol +from websockets.exceptions import ConnectionClosed +from discord.ext import commands + +from typing import ( + Optional, + Dict, + Any +) + +class IPCServer: + def __init__( + self, + bot: commands.Bot, + host: str = "127.0.0.1", + port: int = 8000, + sercet_key: Optional[str] = None + ): + self.bot = bot + self.host = host + self.port = port + self.sercet_key = sercet_key + + self.user = {} + self.connections = set() + + def is_secure(self, data: dict) -> bool: + if (key := data.get("sercet")): + return str(key) == str(self.sercet_key) + return bool(self.sercet_key is None) + + async def start(self): + try: + print("Starting IPC") + await serve(self.handle_ipc_connection, self.host, self.port) + except Exception as e: + print(e) + + async def handle_ipc_connection(self, websocket: WebSocketServerProtocol): + from .methods import process_methods + + client_id = websocket.request_headers.get("Client-ID", None) + if not client_id: + return + + self.connections.add(websocket) + try: + async for message in websocket: + data: Dict[str, Any] = json.loads(message) + if self.is_secure(data): + action = data.get("data") + await process_methods(websocket, self.bot, action) + + except ConnectionClosed: + pass + + self.connections.remove(websocket) + + async def send(self, payload): + for conn in self.connections: + try: + await conn.send(json.dumps(payload)) + except: + await conn.close() + self.connections.remove(conn) \ No newline at end of file diff --git a/web/objects.py b/web/objects.py new file mode 100644 index 0000000..72121f6 --- /dev/null +++ b/web/objects.py @@ -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 \ No newline at end of file diff --git a/web/static/css/main.css b/web/static/css/main.css new file mode 100644 index 0000000..fc0d3a8 --- /dev/null +++ b/web/static/css/main.css @@ -0,0 +1,659 @@ +@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%; + overflow-x: hidden; +} + +a { + text-decoration: none; +} + +:root { + --grey: rgba(142, 142, 142, 20%); + --grey-hover: rgba(142, 142, 142, 30%); +} + +/* 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%; +} + +.box { + position: absolute; + opacity: 20%; + border-radius: 40%; + filter: blur(60px); + transition: all 2s ease-in-out; +} + +.dropdown-container { + position: relative; + display: inline-block; + padding: 10px; + border-radius: 10px 10px 0 0; + cursor: pointer; +} + +.dropdown-container:hover { + background-color: var(--grey); +} + +.dropdown-container:hover .dropdown-item { + display: block; + background-color: var(--grey); +} + +.dropdown-content { + display: flex; + align-items: center; +} + +.dropdown-content img { + height: 40px; + border-radius: 50%; + margin-right: .6rem; +} + +.dropdown-item { + display: none; + position: absolute; + left: 0; + margin-top: 10px; + padding: 5px; + border-radius: 0 0 10px 10px; + width: 100%; + font-size: 15px; +} + +.dropdown-item a { + display: block; + padding: .5rem 1rem; + border-radius: 8px; +} + +.dropdown-item i { + margin-right: .6rem; +} + +.dropdown-item a:hover { + background-color: var(--grey-hover); +} + +.header { + display: flex; + padding: 1rem; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 2; +} + +.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: var(--grey); +} + +.header .center .search-container { + display: flex; + justify-content: center; + position: relative; +} + +.header .center .search-container .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 ease-in-out 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: var(--grey); +} + +.header .search-result-left { + display: flex; + align-items: center; + overflow: hidden; + margin-right: .6rem; + max-width: 85%; +} + +.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; +} + +.main { + display: flex; + flex-grow: 1; + padding: 0rem 4rem; + justify-content: space-around; +} + +.main .toastContrainer { + position: absolute; + left: 2%; + z-index: 2; +} + +.toastContrainer .toast { + display: flex; + position: relative; + padding: .8rem; + margin-top: 1rem; + background-color: var(--grey-hover); + border-radius: 10px; + overflow: hidden; + transform: translateX(-100%); + animation: moveRight .5s ease-in-out 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 ease-in-out 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; + position: relative; + justify-content: center; + align-items: center; + flex: 1; +} + +.main .thumbnail img { + padding: 1rem; + width: 75%; + height: auto; + z-index: 1; +} + +.main .thumbnail-background { + position: absolute; + padding: 1rem; + filter: blur(80px); + transform: translate3d(0px, 0px, 0) scale(1); + opacity: var(0.6); + border-radius: inherit; + transition: all 1s ease-in-out; +} + +.player-controller { + position: sticky; + bottom: 0; + z-index: 2; +} + +.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; + transition: color .2s ease-in-out; +} + +.player-controller .right i:hover { + color: #fff; +} + +.main .queue-list { + overflow-y: auto; + overflow-x: hidden; + flex: 1; + display: flex; + justify-content: center; +} + +.track { + display: flex; + justify-content: space-between; + 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: var(--grey); +} + +.track:hover .time { + display: none; +} + +.track:hover i.action { + display: block; +} + +.track.active { + background-color: var(--grey-hover); +} + +.track .left { + display: flex; + justify-content: left; + align-items: center; + margin-right: .5rem; + max-width: 90%; + overflow: hidden; +} + +.track i { + cursor: move; + margin-right: 1rem; + font-size: 20px; +} + +.track i.action { + display: none; + padding: .5rem 1rem; + border-radius: 50%; + margin-right: 0; + cursor: pointer; + transition: background-color .2s; +} + +.track i.action:hover { + background-color: rgba(157, 157, 157, 50%); +} + +.track .left img { + padding: 5px; + height: 50; + border-radius: .5rem; +} + +.track .left .info { + margin-left: .5rem; + overflow: hidden; +} + +.track .left .info p { + text-overflow: ellipsis; + overflow: hidden; +} + +.track .left .info p.desc { + color: grey; + font-size: 13; +} + +.main .queue-list ul { + list-style: none; + width: 90%; + padding: 1rem; + padding-bottom: 5rem; + max-height: 300px; +} + +.users-bar { + position: absolute; + right: 0; + padding: 1.5rem 0; + background-color: rgb(37, 37, 37); + width: 0px; + height: 100%; + overflow: hidden; + transition: all .5s ease-in-out; +} + +.users-bar.active { + width: 350px; + padding: 1.5rem 1rem; +} + +.users-bar .info { + padding: 0 .5rem; + margin-bottom: 1rem; +} + +.users-bar .info h5 { + color: rgba(142, 142, 142); +} + +.users-container { + width: 100%; + overflow-y: auto; + overflow-x: hidden; +} + +.user { + padding: .5rem; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: .5rem; +} + +.user .left { + display: flex; + align-items: center; +} + +.user img { + width: 35px; + height: auto; + border-radius: 8px; + margin-right: .5rem; +} + +.context-menu { + display: none; + position: absolute; +} + +.context-menu ul { + list-style: none; + padding: .5rem; + border-radius: .5rem; + background-color: rgb(37, 37, 37); +} + +.context-menu li { + padding: .5rem; + border-radius: .3rem; + cursor: pointer; + transition: background-color .2s; +} + +.context-menu li:hover { + padding: .5rem; + background-color: rgb(98, 98, 98); +} + +.context-menu i { + margin-right: .8rem; +} + +@media (max-width: 935px) { + .main { + flex-direction: column; + justify-content: center; + padding: 0; + } + + .main .toastContrainer { + display: none; + } + + .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 .queue-list { + overflow-y: auto; + overflow-x: hidden; + flex: 1; + display: flex; + justify-content: center; + min-width: 100%; + } + +} \ No newline at end of file diff --git a/web/static/img/error.png b/web/static/img/error.png new file mode 100644 index 0000000..1cb4e9e Binary files /dev/null and b/web/static/img/error.png differ diff --git a/web/static/img/info.png b/web/static/img/info.png new file mode 100644 index 0000000..a914ef4 Binary files /dev/null and b/web/static/img/info.png differ diff --git a/web/static/js/Socket.js b/web/static/js/Socket.js new file mode 100644 index 0000000..74ac9f2 --- /dev/null +++ b/web/static/js/Socket.js @@ -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); + } + } +} \ No newline at end of file diff --git a/web/static/js/action.js b/web/static/js/action.js new file mode 100644 index 0000000..e0ca080 --- /dev/null +++ b/web/static/js/action.js @@ -0,0 +1,200 @@ +$(document).ready(function () { + const player = new Player(userId); + const img = document.getElementById("largeImage"); + const canvas = document.createElement('canvas'); + + var startPos = null; + var selectedTrack = null; + + var typingTimer; + var doneTypingInterval = 2000; + + img.crossOrigin = "Anonymous"; + img.onload = function () { + try { + const context = canvas.getContext('2d', {willReadFrequently: true}); + const width = canvas.width = img.width; + const height = canvas.height = Math.round(img.height * 0.7); // set canvas height to 80% of image height + const startY = Math.round((img.height - height) / 2); // calculate starting Y coordinate to center the canvas + context.drawImage(img, 0, startY, width, height, 0, 0, width, height); + getMainColorsFromImage(4) + .then(colors => { + $(".thumbnail-background").css({ + "background": `linear-gradient(-132deg, ${colors[0]}, ${colors[1]}, ${colors[2]}, ${colors[3]})`, + "width": "70%", + "padding-bottom": "70%" + }) + }) + .catch(error => { return }); + + } catch(e) { + + } + + } + + function getMainColorsFromImage(numColors) { + return new Promise((resolve, reject) => { + const imageData = canvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, canvas.width, canvas.height).data; + const colorCount = {}; + const threshold = 20; // adjust this value to change the threshold for black/white similarity + const colorGap = 30; // adjust this value to change the minimum color difference between each main color + for (let i = 0; i < imageData.length; i += 4) { + const r = imageData[i]; + const g = imageData[i + 1]; + const b = imageData[i + 2]; + const color = `rgb(${r}, ${g}, ${b})`; + if (!colorCount[color]) { + // Check if the color is too close to black or white + const isBlackOrWhite = r < threshold || r > 255 - threshold || g < threshold || g > 255 - threshold || b < threshold || b > 255 - threshold; + if (isBlackOrWhite) continue; + // Check if the color is too similar to previous colors + const prevColors = Object.keys(colorCount); + const isTooSimilar = prevColors.some(prevColor => { + const [pr, pg, pb] = prevColor.match(/\d+/g).map(Number); + const diff = Math.sqrt(Math.pow(r - pr, 2) + Math.pow(g - pg, 2) + Math.pow(b - pb, 2)); + return diff < colorGap; + }); + if (isTooSimilar) continue; + colorCount[color] = 0; + } + colorCount[color]++; + } + const colors = Object.keys(colorCount).sort((color1, color2) => colorCount[color2] - colorCount[color1]).slice(0, numColors); + resolve(colors); + }); + } + + $('body').click(function (event) { + var $target = $(event.target); + + if (!$target.closest(".search-contrainer").is('.search-contrainer') && $('#search-result-list').css("display") != 'none') { + $("#search-result-list").fadeOut(200); + } + + if (!$target.closest(".users-bar").is('.users-bar') && + !$target.closest("#users-button").is('#users-button') && + $(".users-bar").hasClass("active")) { + $(".users-bar").removeClass("active"); + $("#users-button").css({ "color": "" }); + } + + if (!$target.closest(".action").is(".action") && + !$target.closest("#context-menu li").is("#context-menu li") && + $("#context-menu").css("display") != "none") { + $("#context-menu").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 (event) { + var index = $(this).index(); + var position = player.current_queue_position; + + if ($(event.target).hasClass('action')) { + selectedTrack = { position: index, track: player.queue[index] }; + $("#context-menu").css({ "left": `${event.pageX - 150}px`, "top": `${event.pageY + 30}px` }).fadeIn(200); + return + } + + 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()); + }) + + $("#repeat-button").on('click', function () { + player.repeatMode(); + }) + + $("#shuffle-button").on('click', function () { + player.shuffle(); + }) + + $("#users-button").on('click', function () { + const userBar = $(".users-bar") + userBar.toggleClass("active"); + if (userBar.hasClass("active")) { + $(this).css({ "color": "#fff" }); + } else { + $(this).css({ "color": "" }); + } + + }) + + $("#remove-track-button").on('click', function () { + player.removeTrack(selectedTrack?.position, selectedTrack?.track) + $("#context-menu").fadeOut(200); + }) + + $("#copy-track-button").on('click', function () { + navigator.clipboard.writeText(selectedTrack?.track.uri); + $("#context-menu").fadeOut(200); + }) +}); diff --git a/web/static/js/objects.js b/web/static/js/objects.js new file mode 100644 index 0000000..2b3d4f6 --- /dev/null +++ b/web/static/js/objects.js @@ -0,0 +1,490 @@ +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.isDJ = data['is_dj']; + player.addTrack(data["tracks"]); + player.updateCurrentQueuePos(data['current_queue_position']); + player.is_paused = data['is_paused']; + player.current_position = data['current_position']; + player.repeat = data['repeat_mode']; + player.channelName = data["channel_name"]; + data["users"].forEach(user => { + player.addUser(user); + }) + + $("#sortable").sortable("option", "disabled", !player.isDJ); + }, + + 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(`
  • ${track.title}

    ${track.author}

    ${player.msToReadableTime(track.length)}

  • `) + } + } + $("#search-result-list").fadeIn(200); + $("#search-loader").fadeOut(200); + }, + + playerClose: function (player, data) { + player.init(); + }, + + updateGuild: function (player, data) { + const user = data["user"]; + player.channelName = data["channel_name"]; + + if (user["user_id"] == player.userId) { + if (data['is_joined']) { + player.send({ "op": "initPlayer" }); + } else { + player.init(); + } + } + if (data['is_joined']) { + player.addUser(user); + if (user["user_id"] != player.userId) { + 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']]; + player.updateUser(); + } + } + }, + + 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.") + } + }, + + repeatTrack: function (player, data) { + player.repeat = data["repeatMode"]; + }, + + removeTrack: function (player, data) { + const positions = data["positions"]; + const trackIds = data["track_ids"]; + var removeTracks = [] + var newQueue = []; + + player.queue.forEach((track, index) => { + if (positions.includes(index)) { + if (trackIds.includes(track.track_id)) { + if (index < player.current_queue_position) { + player.current_queue_position -= 1; + } + removeTracks.push(track); + } else { + return player.send({ "op": "initPlayer" }) + } + } else { + newQueue.push(track); + } + }) + + player.queue = newQueue; + player.initSortable(); + + if (removeTracks.length == 1) { + var msg = `Removed ${removeTracks[0].title} from the queue.`; + } else { + var msg = `Removed ${removeTracks.length} tracks from the queue.`; + } + + player.showToast(data["requester_id"], msg); + }, + + errorMsg: function (player, data) { + var level = data["level"]; + player.showToast(level, data["msg"]); + } + +} + +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"]; + this.uri = object["info"]["uri"]; + } +} + +class Player { + constructor(userId) { + this.socket = new Socket(`${window.location.protocol}//${window.location.hostname}:${window.location.port}`); + 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.repeat = "off"; + 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; + + this.channelName = ""; + } + + 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; + this.users = []; + $('#sortable').empty(); + this.updateInfo(); + } + + initSortable() { + $('#sortable').empty(); + for (var i in this.queue) { + var track = this.queue[i]; + $("#sortable").append(`
  • ${(this.isDJ) ? '' : ''}

    ${track.title}

    ${track.author}

    ${this.msToReadableTime(track.length)}

  • `) + } + this.updateCurrentQueuePos(); + } + + updateCurrentQueuePos(pos) { + if (pos != undefined) { + 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'); + + const queue = $('.queue-list') + if (queue.prop('scrollHeight') > queue.prop('clientHeight')) { + queue.animate({ scrollTop: li.position().top - queue.position().top }, 'slow'); + } + return this.currentTrack; + } + + addUser(user) { + this.users[user['user_id']] = { avatar_url: user["avatar_url"], name: user["name"] }; + this.updateUser(); + } + + updateUser() { + $("#users-container").empty(); + for (const [id, user] of Object.entries(this.users)) { + $("#users-container").append(`
  • ${user.name}

  • `) + }; + } + addTrack(tracks) { + for (var i in tracks) { + var track = new Track(tracks[i]); + this.queue.push(track); + $("#sortable").append(`
  • ${(this.isDJ) ? '' : ''}

    ${track.title}

    ${track.author}

    ${this.msToReadableTime(track.length)}

  • `) + } + } + + moveTrack(target, to) { + const c = this.current_queue_position; + let element = this.queue.splice(target, 1)[0]; + this.queue.splice(to, 0, element); + + const $ul = $('#sortable'); + const $li = $ul.children().eq(position); + $li.detach(); + $ul.children().eq(to).before($li); + + if (target > c && to <= c) { + this.current_queue_position += 1; + } else if (target < c && to >= c) { + this.current_queue_position -= 1; + } else if (target == c) { + this.current_queue_position = to; + } + + this.send({ "op": "moveTrack", "position": target, "newPosition": to }) + } + + removeTrack(position, track) { + var rawTrack = this.queue[position]; + if (position == this.current_queue_position) { + return this.showToast("error", "You are not allow to remove playing track!"); + } + if (rawTrack.track_id == track.track_id) { + this.send({ "op": "removeTrack", "position": position, "track_id": track.track_id }); + } else { + this.showToast("error", "Track not found!"); + } + } + + 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("info", "Add more songs to the queue before shuffling."); + } + } + + repeatMode() { + this.send({ "op": "repeatTrack" }) + } + + 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) { + if (userId == "info") { + var user = { avatar_url: "/static/img/info.png", name: "Info" } + } else if (userId == "error") { + var user = { avatar_url: "/static/img/error.png", name: "Error" } + } else { + var user = this.users[userId]; + } + if (user != null) { + var $element = $(`
    user-icon

    ${user['name']}

    ${msg}

    `) + $(".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", `${window.location.protocol}//${window.location.hostname}:${window.location.port}/` + currentTrack.imageUrl); + } + $("#channel-name").text((this.channelName == "") ? "Not Found" : this.channelName); + var play_pause_btn = $("#play-pause-button"); + var repeat_btn = $("#repeat-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(); + } + + repeat_btn.removeClass("fa-repeat-1").addClass("fa-repeat") + if (this.repeat == "off") { + repeat_btn.css('color', ''); + } else if (this.repeat == "track") { + repeat_btn.css('color', '#fff'); + } else { + repeat_btn.removeClass("fa-repeat").addClass("fa-repeat-1"); + } + } + +} \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..0810add --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,122 @@ + + + + + Vocard Dashboard + + + + + + + + + + + + + + + +
    +
    +
    +

    Home

    +

    Playlist

    +
    +
    +
    + +
      +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    +
      +
    +
    +
    + +
    +
    +

    +
    You are listen in
    +
    + +
    + +
    +
    + +
    +
    +
    +
    + + + +
    + +
    +

    00:00

    /

    00:00

    +
    + +
    + +
    + +
    +

    +

    +
    +
    + +
    + + + +
    +
    +
    +
    + +
    + +
    + + + \ No newline at end of file diff --git a/web/webapp.py b/web/webapp.py new file mode 100644 index 0000000..8c4966f --- /dev/null +++ b/web/webapp.py @@ -0,0 +1,208 @@ +from flask import Flask, redirect, url_for, session, request, render_template, abort, Response, stream_with_context +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 = os.getenv("SERCET_KEY") +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 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.username + }, + "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 = IPCClient(secret_key=app.secret_key, callback=message_handler) + +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() + +@app.route('/', methods=["GET"]) +def proxy(url): + request = requests.get(url, stream=True) + response = Response( + stream_with_context(request.iter_content()), + content_type=request.headers['content-type'], + status=request.status_code + ) + response.headers['Access-Control-Allow-Origin'] = "*" + return response + +# 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()])}') + +# Logout page +@app.route('/logout') +@login_required +def logout(user: User): + session.pop("discord_token", None) + + return redirect(url_for("home")) + +# 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)