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/code_quality/.pre-commit-config.yaml b/code_quality/.pre-commit-config.yaml index b24179cd..7d73b333 100644 --- a/code_quality/.pre-commit-config.yaml +++ b/code_quality/.pre-commit-config.yaml @@ -71,6 +71,7 @@ repos: - id: requirements-txt-fixer name: Sort requirements.txt - id: check-added-large-files + args: [--maxkb=3000] - id: check-case-conflict - id: check-merge-conflict - id: end-of-file-fixer diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 42003a98..0f36a902 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -3,6 +3,7 @@ import hashlib import json import logging +import mimetypes import os import re import signal @@ -18,6 +19,7 @@ Flask, Response, flash, + jsonify, make_response, redirect, render_template, @@ -431,6 +433,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", "POST"]) def end_song(): d = request.form.to_dict() @@ -553,6 +562,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, ) @@ -602,6 +614,11 @@ 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, + hide_url=k.hide_url, + limit_user_songs_by=k.limit_user_songs_by, ) @@ -704,7 +721,36 @@ 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")) + + # Streams the file in chunks from the filesystem (chrome supports it, safari does not) + + @app.route("/stream/") def stream(id): file_path = f"{get_tmp_dir()}/{id}.mp4" @@ -792,6 +838,7 @@ def main(): default_screensaver_delay = 300 default_log_level = logging.INFO default_prefer_hostname = False + default_bg_music_volume = 0.3 default_buffer_size = 150000 default_dl_dir = get_default_dl_dir(platform) @@ -881,7 +928,7 @@ def main(): parser.add_argument( "--hide-overlay", action="store_true", - help="Hide overlay that shows on top of video with pikaraoke QR code and IP", + help="Hide all overlays that show on top of video, including current/next song, pikaraoke QR code and IP", required=False, ), parser.add_argument( @@ -950,6 +997,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. User name 'Pikaraoke' is always unlimited (default: 0 = unlimited)", + default="0", + required=False, + ), args = parser.parse_args() @@ -976,6 +1055,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( @@ -998,6 +1085,11 @@ def main(): screensaver_timeout=args.screensaver_timeout, url=args.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=int(args.limit_user_songs_by), ) k.upgrade_youtubedl() diff --git a/pikaraoke/karaoke.py b/pikaraoke/karaoke.py index b86787ea..ab0eafb4 100644 --- a/pikaraoke/karaoke.py +++ b/pikaraoke/karaoke.py @@ -1,3 +1,4 @@ +import configparser import contextlib import json import logging @@ -15,6 +16,7 @@ from urllib.parse import urlparse import qrcode +from flask_babel import _ from unidecode import unidecode from pikaraoke.lib.ffmpeg import ( @@ -71,6 +73,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/midnight-dorufin.mp3") screensaver_timeout = 300 # in seconds ffmpeg_process = None @@ -82,6 +85,8 @@ class Karaoke: raspberry_pi = is_raspberry_pi() os_version = get_os_version() + config_obj = configparser.ConfigParser() + def __init__( self, port=5555, @@ -103,10 +108,21 @@ def __init__( screensaver_timeout=300, 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.hide_url = hide_url + self.hide_url = self.get_user_preference("hide_url") or hide_url self.hide_notifications = hide_notifications self.hide_raspiwifi_instructions = hide_raspiwifi_instructions self.hide_splash_screen = hide_splash_screen @@ -123,17 +139,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} @@ -154,6 +171,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} hide notifications: {self.hide_notifications} platform: {self.platform} @@ -164,6 +186,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 @@ -199,6 +222,62 @@ 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: + pref = self.config_obj.get("USERPREFERENCES", preference) + if pref == "True": + return True + elif pref == "False": + return False + elif pref.isnumeric(): + return int(pref) + elif pref.replace(".", "", 1).isdigit(): + return float(pref) + else: + return pref + + 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": @@ -594,12 +673,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" or user == "Randomizer": + 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, log_action=True ): if self.is_song_in_queue(song_path): logging.warning("Song is already in queue, will not add: " + song_path) return False + 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, @@ -614,7 +709,7 @@ def enqueue( if log_action: self.log_and_send(f"{user} added to the queue: {queue_item['title']}", "info") 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..aaebfe08 --- /dev/null +++ b/pikaraoke/static/score.js @@ -0,0 +1,98 @@ +// 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.mp3", + review: + scoreReviews.low[Math.floor(Math.random() * scoreReviews.low.length)], + }; + } else if (scoreValue < 60) { + return { + applause: "applause-m.mp3", + review: + scoreReviews.mid[Math.floor(Math.random() * scoreReviews.mid.length)], + }; + } else { + return { + applause: "applause-h.mp3", + 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.mp3"); + + 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 + ); + scoreReviewElement.text(""); + scoreElement.hide(); +} diff --git a/pikaraoke/static/sounds/applause-h.mp3 b/pikaraoke/static/sounds/applause-h.mp3 new file mode 100644 index 00000000..1a889540 Binary files /dev/null and b/pikaraoke/static/sounds/applause-h.mp3 differ diff --git a/pikaraoke/static/sounds/applause-l.mp3 b/pikaraoke/static/sounds/applause-l.mp3 new file mode 100644 index 00000000..3cb615b0 Binary files /dev/null and b/pikaraoke/static/sounds/applause-l.mp3 differ diff --git a/pikaraoke/static/sounds/applause-m.mp3 b/pikaraoke/static/sounds/applause-m.mp3 new file mode 100644 index 00000000..16b25b78 Binary files /dev/null and b/pikaraoke/static/sounds/applause-m.mp3 differ diff --git a/pikaraoke/static/sounds/bg-music-piano.mp3 b/pikaraoke/static/sounds/bg-music-piano.mp3 new file mode 100644 index 00000000..c8be92b9 Binary files /dev/null and b/pikaraoke/static/sounds/bg-music-piano.mp3 differ diff --git a/pikaraoke/static/sounds/midnight-dorufin.mp3 b/pikaraoke/static/sounds/midnight-dorufin.mp3 new file mode 100644 index 00000000..6b848f51 Binary files /dev/null and b/pikaraoke/static/sounds/midnight-dorufin.mp3 differ diff --git a/pikaraoke/static/sounds/score-drums.mp3 b/pikaraoke/static/sounds/score-drums.mp3 new file mode 100644 index 00000000..ea3eebe4 Binary files /dev/null and b/pikaraoke/static/sounds/score-drums.mp3 differ diff --git a/pikaraoke/templates/files.html b/pikaraoke/templates/files.html index 0bbf4e0d..2209c25f 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..24e230ea 100644 --- a/pikaraoke/templates/info.html +++ b/pikaraoke/templates/info.html @@ -1,6 +1,46 @@ {% extends 'base.html' %} {% block scripts %} + @@ -443,11 +472,22 @@
-
+ + + + + + + + + +