diff --git a/.gitignore b/.gitignore index 9f036102..08c6429f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ dist/ songs/ qrcode.png .DS_Store +config.ini docker-compose.yml diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 64af2fd8..c95feb93 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -3,6 +3,7 @@ import hashlib import json import logging +import mimetypes import os import signal import subprocess @@ -16,6 +17,7 @@ from flask import ( Flask, flash, + jsonify, make_response, redirect, render_template, @@ -425,6 +427,13 @@ def logo(): return send_file(k.logo_path, mimetype="image/png") +@app.route("/background_music") +def background_music(): + music_path = k.bg_music_path + mime_type, _ = mimetypes.guess_type(music_path) + return send_file(k.bg_music_path, mimetype=mime_type) + + @app.route("/end_song", methods=["GET"]) def end_song(): k.end_song() @@ -545,6 +554,9 @@ def splash(): hide_url=k.hide_url, hide_overlay=k.hide_overlay, screensaver_timeout=k.screensaver_timeout, + disable_bg_music=k.disable_bg_music, + disable_score=k.disable_score, + bg_music_volume=k.bg_music_volume, ) @@ -594,6 +606,10 @@ def info(): pikaraoke_version=VERSION, admin=is_admin(), admin_enabled=admin_password != None, + disable_bg_music=k.disable_bg_music, + bg_music_volume=int(100 * k.bg_music_volume), + disable_score=k.disable_score, + limit_user_songs_by=k.limit_user_songs_by, ) @@ -690,6 +706,33 @@ def expand_fs(): return redirect(url_for("home")) +@app.route("/change_preferences", methods=["GET"]) +def change_preferences(): + if is_admin(): + preference = request.args["pref"] + val = request.args["val"] + + rc = k.change_preferences(preference, val) + + return jsonify(rc) + else: + flash(_("You don't have permission to define audio output"), "is-danger") + return redirect(url_for("info")) + + +@app.route("/clear_preferences", methods=["GET"]) +def clear_preferences(): + if is_admin(): + rc = k.clear_preferences() + if rc[0]: + flash(rc[1], "is-success") + else: + flash(rc[1], "is-danger") + else: + flash(_("You don't have permission to define audio output"), "is-danger") + return redirect(url_for("home")) + + # Handle sigterm, apparently cherrypy won't shut down without explicit handling signal.signal(signal.SIGTERM, lambda signum, stack_frame: k.stop()) @@ -721,6 +764,7 @@ def main(): default_screensaver_delay = 300 default_log_level = logging.INFO default_prefer_hostname = False + default_bg_music_volume = 0.3 default_dl_dir = get_default_dl_dir(platform) default_youtubedl_path = "yt-dlp" @@ -868,6 +912,38 @@ def main(): default=None, required=False, ), + parser.add_argument( + "--disable-bg-music", + action="store_true", + help="Disable background music on splash screen", + required=False, + ), + parser.add_argument( + "--bg-music-volume", + default=default_bg_music_volume, + help="Set the volume of background music on splash screen. A value between 0 and 1. (default: %s)" + % default_bg_music_volume, + required=False, + ), + parser.add_argument( + "--bg-music-path", + nargs="+", + help="Path to a custom background music for the splash screen. (.mp3, .wav or .ogg)", + default=None, + required=False, + ), + parser.add_argument( + "--disable-score", + help="Disable the score screen after each song", + action="store_true", + required=False, + ), + parser.add_argument( + "--limit-user-songs-by", + help="Limit the number of songs a user can add to queue (default: 0 = illimited)", + default="0", + required=False, + ), args = parser.parse_args() @@ -894,6 +970,14 @@ def main(): ) parsed_volume = default_volume + parsed_bg_volume = float(args.bg_music_volume) + if parsed_bg_volume > 1 or parsed_bg_volume < 0: + # logging.warning("BG music volume must be between 0 and 1. Setting to default: %s" % default_bg_volume) + print( + f"[ERROR] Volume: {args.bg_music_volume} must be between 0 and 1. Setting to default: {default_bg_music_volume}" + ) + parsed_bg_volume = default_bg_music_volume + # Configure karaoke process global k k = karaoke.Karaoke( @@ -915,6 +999,11 @@ def main(): url=args.url, ffmpeg_url=args.ffmpeg_url, prefer_hostname=args.prefer_hostname, + disable_bg_music=args.disable_bg_music, + bg_music_volume=parsed_bg_volume, + bg_music_path=arg_path_parse(args.bg_music_path), + disable_score=args.disable_score, + limit_user_songs_by=args.limit_user_songs_by, ) k.upgrade_youtubedl() diff --git a/pikaraoke/karaoke.py b/pikaraoke/karaoke.py index d1a1e95d..77666f06 100644 --- a/pikaraoke/karaoke.py +++ b/pikaraoke/karaoke.py @@ -1,3 +1,4 @@ +import configparser import contextlib import json import logging @@ -14,6 +15,7 @@ import ffmpeg import qrcode +from flask_babel import _ from unidecode import unidecode from pikaraoke.lib.file_resolver import FileResolver @@ -62,6 +64,7 @@ class Karaoke: volume = None loop_interval = 500 # in milliseconds default_logo_path = os.path.join(base_path, "logo.png") + default_bg_music_path = os.path.join(base_path, "static/sounds/bg-music.ogg") screensaver_timeout = 300 # in seconds ffmpeg_process = None @@ -74,6 +77,8 @@ class Karaoke: raspberry_pi = is_raspberry_pi() os_version = get_os_version() + config_obj = configparser.ConfigParser() + def __init__( self, port=5555, @@ -94,7 +99,18 @@ def __init__( url=None, ffmpeg_url=None, prefer_hostname=True, + disable_bg_music=False, + bg_music_volume=0.3, + bg_music_path=None, + disable_score=False, + limit_user_songs_by=0, ): + logging.basicConfig( + format="[%(asctime)s] %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + level=int(log_level), + ) + # override with supplied constructor args if provided self.port = port self.ffmpeg_port = ffmpeg_port @@ -112,17 +128,18 @@ def __init__( self.screensaver_timeout = screensaver_timeout self.url_override = url self.prefer_hostname = prefer_hostname + self.disable_bg_music = self.get_user_preference("disable_bg_music") or disable_bg_music + self.bg_music_volume = self.get_user_preference("bg_music_volume") or bg_music_volume + self.bg_music_path = self.default_bg_music_path if bg_music_path == None else bg_music_path + self.disable_score = self.get_user_preference("disable_score") or disable_score + self.limit_user_songs_by = ( + self.get_user_preference("limit_user_songs_by") or limit_user_songs_by + ) # other initializations self.platform = get_platform() self.screen = None - logging.basicConfig( - format="[%(asctime)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - level=int(log_level), - ) - logging.debug( f""" http port: {self.port} @@ -142,6 +159,11 @@ def __init__( logo path: {self.logo_path} log_level: {log_level} hide overlay: {self.hide_overlay} + disable bg music: {self.disable_bg_music} + bg music volume: {self.bg_music_volume} + bg music path: {self.bg_music_path} + disable score: {self.disable_score} + limit user songs by: {self.limit_user_songs_by} platform: {self.platform} os version: {self.os_version} @@ -151,6 +173,7 @@ def __init__( youtubedl-version: {self.get_youtubedl_version()} """ ) + # Generate connection URL and QR code, if self.raspberry_pi: # retry in case pi is still starting up @@ -192,6 +215,51 @@ def __init__( self.generate_qr_code() + # def get_user_preferences(self, preference): + def get_user_preference(self, preference, default_value=False): + # Try to read the config file + try: + self.config_obj.read("config.ini") + except FileNotFoundError: + return default_value + + # Check if the section exists + if not self.config_obj.has_section("USERPREFERENCES"): + return default_value + + # Try to get the value + try: + return self.config_obj.get("USERPREFERENCES", preference) + except (configparser.NoOptionError, ValueError): + return default_value + + def change_preferences(self, preference, val): + """Makes changes in the config.ini file that stores the user preferences. + Receives the preference and it's new value""" + + logging.debug("Changing user preference << %s >> to %s" % (preference, val)) + try: + if "USERPREFERENCES" not in self.config_obj: + self.config_obj.add_section("USERPREFERENCES") + + userprefs = self.config_obj["USERPREFERENCES"] + userprefs[preference] = str(val) + setattr(self, preference, eval(str(val))) + with open("config.ini", "w") as conf: + self.config_obj.write(conf) + self.changed_preferences = True + return [True, _("Your preferences were changed successfully")] + except Exception as e: + logging.debug("Failed to change user preference << %s >>: %s", preference, e) + return [False, _("Something went wrong! Your preferences were not changed")] + + def clear_preferences(self): + try: + os.remove("config.ini") + return [True, _("Your preferences were cleared successfully")] + except OSError: + return [False, _("Something went wrong! Your preferences were not cleared")] + def get_ip(self): # python socket.connect will not work on android, access denied. Workaround: use ifconfig which is installed to termux by default, iirc. if self.platform == "android": @@ -569,10 +637,28 @@ def is_song_in_queue(self, song_path): return True return False + def is_user_limited(self, user): + # Returns if a user needs to be limited or not if the limitation is on and if the user reached the limit of songs in queue + if self.limit_user_songs_by == "0" or user == "Pikaraoke": + return False + cont = len([i for i in self.queue if i["user"] == user]) + ( + 1 if self.now_playing_user == user else 0 + ) + return True if cont >= int(self.limit_user_songs_by) else False + def enqueue(self, song_path, user="Pikaraoke", semitones=0, add_to_front=False): + # Check if the song is already in the queue, if not add it if self.is_song_in_queue(song_path): logging.warn("Song is already in queue, will not add: " + song_path) - return False + return [False, _("Song is already in queue, will not add: ") + song_path] + # check if the user has reached the limit of songs in queue + elif self.is_user_limited(user): + logging.debug("User limitted by: " + str(self.limit_user_songs_by)) + return [ + False, + _("You reached the limit of %s song(s) from an user in queue!") + % (str(self.limit_user_songs_by)), + ] else: queue_item = { "user": user, @@ -581,12 +667,12 @@ def enqueue(self, song_path, user="Pikaraoke", semitones=0, add_to_front=False): "semitones": semitones, } if add_to_front: - logging.info("'%s' is adding song to front of queue: %s" % (user, song_path)) + logging.info(_("'%s' is adding song to front of queue: %s") % (user, song_path)) self.queue.insert(0, queue_item) else: - logging.info("'%s' is adding song to queue: %s" % (user, song_path)) + logging.info(_("'%s' is adding song to queue: %s") % (user, song_path)) self.queue.append(queue_item) - return True + return [True, _("Song added to the queue: %s") % (self.filename_from_path(song_path))] def queue_add_random(self, amount): logging.info("Adding %d random songs to queue" % amount) diff --git a/pikaraoke/static/fireworks.js b/pikaraoke/static/fireworks.js new file mode 100644 index 00000000..d71065ae --- /dev/null +++ b/pikaraoke/static/fireworks.js @@ -0,0 +1,101 @@ +const canvas = document.getElementById("fireworks"); +const ctx = canvas.getContext("2d"); + +// Ajusta o tamanho do canvas +canvas.width = window.innerWidth; +canvas.height = window.innerHeight; + +// Função para gerar números aleatórios entre 1 e 100 com duas casas decimais +const getRandomNumber = () => String(Math.floor(Math.random() * 100) + 1).padStart(2, "0"); + +// Função para desenhar partículas de fogos +class Firework { + constructor(x, y, color) { + this.x = x; + this.y = y; + this.color = color; + this.particles = Array.from({ length: 50 }, () => ({ + x: x, + y: y, + angle: Math.random() * 2 * Math.PI, + speed: Math.random() * 2 + 1, + radius: Math.random() * 6 + 3, + })); + } + + draw() { + this.particles.forEach(particle => { + const dx = Math.cos(particle.angle) * particle.speed; + const dy = Math.sin(particle.angle) * particle.speed; + particle.x += dx; + particle.y += dy; + particle.radius *= 0.98; + + ctx.beginPath(); + ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2); + ctx.fillStyle = this.color; + ctx.fill(); + }); + } +} + +// Configurações de fogos +let fireworks = []; +const addFirework = () => { + const x = Math.random() * canvas.width; + const y = Math.random() * canvas.height * 0.6; + const color = `hsl(${Math.random() * 360}, 100%, 60%)`; + fireworks.push(new Firework(x, y, color)); +}; + +// Atualiza e renderiza os fogos +const animateFireworks = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + fireworks.forEach((firework, index) => { + firework.draw(); + firework.particles = firework.particles.filter(p => p.radius > 0.5); + if (firework.particles.length === 0) fireworks.splice(index, 1); + }); + + if (fireworks.length > 0) { + requestAnimationFrame(animateFireworks); + } +}; + +const launchMultipleFireworks = (count) => { + for (let i = 0; i < count; i++) { + addFirework(); + } + animateFireworks(); + }; + + const launchFireworkShow = (score) => { + const showDuration = 5000; // Duração total do show em ms + const startTime = Date.now(); + let simultaneousFireworks = 1; + let intensity = 1300 + + if (score < 30) { + simultaneousFireworks = 1; + intensity = 1300 + } else if (score < 60) { + simultaneousFireworks = 2; + intensity = 800 + } else if (score >= 60) { + simultaneousFireworks = 3; + intensity = 500 + } + + const launchInterval = () => { + if (Date.now() - startTime > showDuration) return; // Para após o tempo definido + + const fireworkCount = Math.floor(Math.random() * simultaneousFireworks) + simultaneousFireworks; // Entre 2 e 5 fogos simultâneos + launchMultipleFireworks(fireworkCount); + + const nextInterval = Math.random() * intensity + 200; // Intervalo entre 200ms e 1s + setTimeout(launchInterval, nextInterval); // Agenda o próximo grupo + }; + + launchInterval(); // Inicia o ciclo + }; diff --git a/pikaraoke/static/images/stage.jpg b/pikaraoke/static/images/stage.jpg new file mode 100644 index 00000000..e6aec6e6 Binary files /dev/null and b/pikaraoke/static/images/stage.jpg differ diff --git a/pikaraoke/static/score.css b/pikaraoke/static/score.css new file mode 100644 index 00000000..dd0f2a5c --- /dev/null +++ b/pikaraoke/static/score.css @@ -0,0 +1,47 @@ +#score{ + position: absolute; + background-image: url("/static/images/stage.jpg"); + background-size: cover; + left: 0px; + top: 0px; + z-index: 10; + height: 100vh; + width: 100vw; +} + +#your-score-text{ + font-size: 5rem; + width: 100vw; + text-align: center; + position: absolute; + top: 15vh; + text-shadow: -1px 1px 5px black; +} + +#score-number-text{ + position: absolute; + width: 100vw; + text-align: center; + font-size: 12rem; + top: 25vh; + font-weight: 800; + text-shadow: -2px 2px 6px black; +} + +#score-review-text{ + position: absolute; + width: 100vw; + text-align: center; + font-size: 3rem; + top: 62vh; + text-shadow: -1px 1px 5px black; +} + +canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + /* z-index: 1; */ + } diff --git a/pikaraoke/static/score.js b/pikaraoke/static/score.js new file mode 100644 index 00000000..559f2fc6 --- /dev/null +++ b/pikaraoke/static/score.js @@ -0,0 +1,71 @@ +// Function that returns the applause sound and one of the reviews, based on the score value. +// The scoreReviews comes from the splash.html so that it can be translated. +function getScoreData(scoreValue){ + if (scoreValue < 30) { + return {applause: "applause-l.ogg", review: scoreReviews.low[Math.floor(Math.random() * scoreReviews.low.length)]} + } else if (scoreValue < 60) { + return {applause: "applause-m.ogg", review: scoreReviews.mid[Math.floor(Math.random() * scoreReviews.mid.length)]} + } else { + return {applause: "applause-h.ogg", review: scoreReviews.high[Math.floor(Math.random() * scoreReviews.high.length)]} + } +} + +// Function that creates a random score biased towards 99 +function getScoreValue() { + const random = Math.random(); + const bias = 2; // adjust this value to control the bias + const scoreValue = Math.pow(random, 1 / bias) * 99; + return Math.floor(scoreValue); +} + +// Function that shows the final score value, the review, fireworks and plays the applause sound +async function showFinalScore(scoreTextElement, scoreValue, scoreReviewElement, scoreData){ + scoreTextElement.text(String(scoreValue).padStart(2, '0')); + scoreReviewElement.text(scoreData.review); + launchFireworkShow(scoreValue) + const applauseElement = new Audio("static/sounds/" + scoreData.applause); + applauseElement.play(); + return new Promise(resolve => { + applauseElement.onended = resolve; + }); +} + +// Function that shows random numbers for the score suspense +async function rotateScore(scoreTextElement, duration) { + interval = 100; + const startTime = performance.now(); + + while (true) { + const elapsed = performance.now() - startTime; + + if (elapsed >= duration) break; + + const randomScore = String(Math.floor(Math.random() * 99) + 1).padStart(2, '0'); + scoreTextElement.text(randomScore); + + const nextUpdate = interval - (performance.now() - (startTime + elapsed)); + await new Promise(resolve => setTimeout(resolve, Math.max(0, nextUpdate))); + } +} + +// Function that starts the score animation +async function startScore(staticPath) { + + const scoreElement = $("#score"); + const scoreTextElement = $("#score-number-text") + const scoreReviewElement = $("#score-review-text") + + const scoreValue = getScoreValue(); + const drums = new Audio(staticPath + "sounds/score-drums.ogg"); + + const scoreData = getScoreData(scoreValue); + + scoreElement.show(); + drums.volume = 0.3; + drums.play(); + const drumDuration = 4100; + + await rotateScore(scoreTextElement, drumDuration); + await showFinalScore(scoreTextElement, scoreValue, scoreReviewElement, scoreData); + scoreElement.hide(); +} diff --git a/pikaraoke/static/sounds/applause-h.ogg b/pikaraoke/static/sounds/applause-h.ogg new file mode 100644 index 00000000..dd28f3b0 Binary files /dev/null and b/pikaraoke/static/sounds/applause-h.ogg differ diff --git a/pikaraoke/static/sounds/applause-l.ogg b/pikaraoke/static/sounds/applause-l.ogg new file mode 100644 index 00000000..eaf9ea3d Binary files /dev/null and b/pikaraoke/static/sounds/applause-l.ogg differ diff --git a/pikaraoke/static/sounds/applause-m.ogg b/pikaraoke/static/sounds/applause-m.ogg new file mode 100644 index 00000000..81728d8a Binary files /dev/null and b/pikaraoke/static/sounds/applause-m.ogg differ diff --git a/pikaraoke/static/sounds/bg-music.ogg b/pikaraoke/static/sounds/bg-music.ogg new file mode 100644 index 00000000..f0ab240a Binary files /dev/null and b/pikaraoke/static/sounds/bg-music.ogg differ diff --git a/pikaraoke/static/sounds/score-drums.ogg b/pikaraoke/static/sounds/score-drums.ogg new file mode 100644 index 00000000..3a66960c Binary files /dev/null and b/pikaraoke/static/sounds/score-drums.ogg differ diff --git a/pikaraoke/templates/files.html b/pikaraoke/templates/files.html index 19536f71..bc385c46 100644 --- a/pikaraoke/templates/files.html +++ b/pikaraoke/templates/files.html @@ -19,17 +19,17 @@ var user = Cookies.get("user"); $.get(this.href + encodeURIComponent(user), function (data) { var obj = JSON.parse(data); - if (obj.success) { + if (obj.success[0]) { // {# MSG: Notification when a song gets added to the queue. The song name comes after this string. #} showNotification( - "{{ _('Song added to the queue: ') }}" + obj.song, - "is-success" + obj.success[1], + 'is-success' ); } else { // {# MSG: Notification when a song does not get added to the queue. The song name comes after this string. #} showNotification( - "{{ _('Song already in the queue: ') }}" + obj.song, - "is-danger" + obj.success[1], + 'is-danger' ); } }); diff --git a/pikaraoke/templates/info.html b/pikaraoke/templates/info.html index f66ba174..8c3e37ec 100644 --- a/pikaraoke/templates/info.html +++ b/pikaraoke/templates/info.html @@ -1,6 +1,46 @@ {% extends 'base.html' %} {% block scripts %} + @@ -354,6 +407,17 @@ +
+