diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7338351d..68b533a2 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -63,6 +63,12 @@ jobs: - name: Add Poetry to PATH run: echo "/opt/poetry/bin" >> $GITHUB_PATH + - name: Extract version number + id: get_version + run: | + version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) + echo "VERSION=$version" >> $GITHUB_ENV + - name: Install Package run: poetry install --no-interaction --no-ansi @@ -89,4 +95,4 @@ jobs: with: platforms: linux/amd64,linux/arm64 push: true - tags: vicwomg/pikaraoke:latest + tags: vicwomg/pikaraoke:latest,vicwomg/pikaraoke:${{ env.VERSION }} 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/Dockerfile b/Dockerfile index e483e038..625a7eba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,20 +2,23 @@ FROM python:3.12-slim-bullseye # Install required packages -RUN apt-get update --allow-releaseinfo-change -RUN apt-get install ffmpeg wireless-tools -y +RUN apt-get update --allow-releaseinfo-change && \ + apt-get install -y --no-install-recommends ffmpeg wireless-tools && \ + apt-get clean && \ + pip install poetry && \ + rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy minimum required files into the image COPY pyproject.toml ./ -COPY pikaraoke ./pikaraoke COPY docs ./docs -# Install pikaraoke -RUN pip install . +# Only install main dependencies for better docker caching +RUN poetry install --only main -COPY docker/entrypoint.sh ./ -RUN chmod +x entrypoint.sh +# Copy the rest of the files and install the remaining deps in a separate layer +COPY pikaraoke ./pikaraoke +RUN poetry install -ENTRYPOINT ["./entrypoint.sh"] +ENTRYPOINT ["poetry", "run", "pikaraoke", "-d", "/app/pikaraoke-songs/", "--headless"] 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/docker/build_arm64_amd64.sh b/docker/build_arm64_amd64.sh new file mode 100755 index 00000000..4f448a4e --- /dev/null +++ b/docker/build_arm64_amd64.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Check if at least one argument is provided +if [ $# -lt 1 ]; then + echo "Usage: $0 [additional docker build arguments]" + echo "Can only be run from the project root directory" + exit 1 +fi + +# The first argument is the tag +TAG=$1 + +# Shift the arguments so that $2 becomes $1, $3 becomes $2, etc. +shift + +docker buildx build --platform linux/arm64,linux/amd64 . -t $TAG "$@" diff --git a/docker/docker-compose.yml.example b/docker/docker-compose.yml.example index 05e8e626..deda786e 100644 --- a/docker/docker-compose.yml.example +++ b/docker/docker-compose.yml.example @@ -1,14 +1,14 @@ services: pikaraoke: - image: pikaraoke:latest + image: vicwomg/pikaraoke:latest container_name: PiKaraoke - # Below Host network mode may work better on some systems and replace manual IP configuration. Does not work on OSX + ## Below Host network mode may work better on some systems and replace manual IP configuration. Does not work on OSX # network_mode: host - environment: - EXTRA_ARGS: --url http://:5555 # Replace with your LAN IP or DNS url, not necesary if using network_mode: host + ## add additional command line args if needed in example below: debug level logging, manually specified URL + command: -l10 --url http:// volumes: - - :/app/pikaraoke-songs # Replace with local dir. Insures your songs are persisted outside the container + - :/app/pikaraoke-songs # Replace with local dir. to persist songs outside the container restart: unless-stopped + ## Forward host port 80 to the pikaraoke web interface on 5555, adjust host port as necessary ports: - - "5555:5555" # Forward the port for the web interface - - "5556:5556" # Forward the port for the ffmpeg video stream interface + - "80:5555" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh deleted file mode 100755 index 287b403f..00000000 --- a/docker/entrypoint.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -# Run pikaraoke with necessary parameters -pikaraoke -d /app/pikaraoke-songs/ --headless $EXTRA_ARGS - -# Keep the container running -tail -f /dev/null diff --git a/docs/README.md b/docs/README.md index 01669d29..b1e27541 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,18 +21,18 @@ If you want to support this project with a little monetary tip, it's much apprec | **Feature** | **Description** | | --------------------------- | ------------------------------------------------------------- | | Web Interface | Multiple users can queue tracks from their smartphones | -| Player/Splash Screen | Connection QR code and "Next up" display | -| Searching/Browsing | Browse a local song library | +| Player/Splash Screen | Connection QR code and song queue metadata | +| Searching/Browsing | Search and rowse a local song library | | Adding New Songs | Add new songs from Youtube | -| mp3 + cdg Support | Includes compressed .zip bundles | +| mp3 + cdg Support | CDG file support, supports compressed .zip bundles | | Playback Controls | Pause, Skip, Restart, and volume control | -| File Management | Advanced editing of downloaded file names | | Queue Management | Manage the song queue and change the order | | Key Change / Pitch Shifting | Adjust the pitch of songs | +| File Management | Advanced editing of downloaded file names | | Admin Mode | Lock down features with admin mode | -| Headless Mode | Run a dedicated server and stream pikaraoke to remote browser | +| Headless Mode | Run a dedicated pikaraoke server and stream to remote browser | -## Supported Devices / OS +## Supported Devices / OS / Platforms - Raspberry Pi - Requires a Raspberry Pi Model 3 or higher @@ -42,11 +42,23 @@ If you want to support this project with a little monetary tip, it's much apprec - Windows - Linux -## Get Started +## Docker instructions + +For Docker users, you can get going with one command. The deployed images includes everything you need to run in headless mode: + +```sh +docker run vicwomg/pikaraoke:latest +``` + +For more information, [see official Dockerhub repo](https://hub.docker.com/repository/docker/vicwomg/pikaraoke) + +## Native installation ### Install required programs -Pikaraoke requires Python 3.9 or greater. You can check your current version by running `python --version`. [Python downloads](https://www.python.org/downloads/) +Pikaraoke requires Python 3.9 or greater. You can check your current version by running `python --version`. + +[Python downloads](https://www.python.org/downloads/) #### Raspberry Pi OS / Linux distros with `apt`: @@ -56,51 +68,23 @@ sudo apt-get install chromium-browser -y sudo apt-get install chromium-chromedriver -y ``` +Chromium/Chromdriver is optional if you're running with the `--headless` option. + #### Windows / OSX / Linux: - FFmpeg 6.0 or greater: [FFmpeg downloads](https://ffmpeg.org/download.html) - Chrome Browser: [Chrome](http://google.com/chrome) (only required for headed mode) -### Install pikaraoke - -#### Create a virtual environment (optional) - -Using a virtual environment (venv) is recommended to prevent conflicts with other global python packages. - -You may find it more convenient to skip these steps, which allows you to launch pikaraoke without activating a venv first, but you run the risk of package conflicts. - -If you don't install a lot of python projects with pip, that skipping venv is probably be fine. The choice is yours. See [the python documentation](https://docs.python.org/3/library/venv.html) for more details on venv. +### Install pikaraoke via pip -Raspberry Pi/Linux/OSX: - -```sh -# Create a .venv directory in the homedir -python -m venv ~/.venv -# Activate your virtual environment -source ~/.venv/bin/activate -``` - -Windows (Powershell terminal): - -```batch -:: Create a venv in Windows in your homedir -cd $HOME -python -m venv .venv -.venv\Scripts\activate -``` - -You should see a "(venv)" prefix in your terminal prompt if the venv is successfully activated. - -#### Install pikaraoke via pip - -Next, install pikaraoke from PyPi on the host into your venv: +Globally or within a virtual env: ```sh # Install pikaraoke from PyPi pip install pikaraoke ``` -Note: if you did not use a venv, you may need to add the `--break-system-packages` parameter to ignore the warning and install pikaraoke and its dependencies globally. +Note: if you did not use a venv, you may need to add the `--break-system-packages` parameter to ignore the warning and install pikaraoke and its dependencies globally. You may experience package conflicts if you have other python programs installed. ### Run @@ -113,8 +97,7 @@ pikaraoke This will start pikaraoke in headed mode, and open Chrome browser with the splash screen. You can then connect to the QR code via your mobile device and start downloading and queueing songs. -Virtual env users: note that if you close your terminal between launches, you'll need to run: -`source ~/.venv/bin/activate` or `.venv\Scripts\activate` (windows) before launching pikaraoke again. +Virtual env users: note that if you close your terminal between launches, you'll need to reactivate your venv before running pikaraoke. ### More Options @@ -151,13 +134,16 @@ poetry install poetry run pikaraoke ``` -See the [Pikaraoke development guide](https://github.com/vicwomg/pikaraoke/wiki/Pikaraoke-development-guide) for more details. +If you don't want to install poetry, you can alternately install pikaraoke directly from the source code root: -#### Run from repository (legacy) +```sh +pip install . +``` -See [README](../scripts/README.md) for how to install pikaraoke cloning this repo and using the -scripts. This is a legacy method and may no longer work. +See the [Pikaraoke development guide](https://github.com/vicwomg/pikaraoke/wiki/Pikaraoke-development-guide) for more details. -## Troubleshooting +## Troubleshooting and guides See the [TROUBLESHOOTING wiki](https://github.com/vicwomg/pikaraoke/wiki/FAQ-&-Troubleshooting) for help with issues. + +There are also some great guides [on the wiki](https://github.com/vicwomg/pikaraoke/wiki/) to running pikaraoke in all manner of bizarre places including Android, Chromecast, and embedded TVs! diff --git a/pikaraoke/_TRANSLATION.md b/pikaraoke/_TRANSLATION.md index 75a11363..84b45444 100644 --- a/pikaraoke/_TRANSLATION.md +++ b/pikaraoke/_TRANSLATION.md @@ -37,7 +37,7 @@ when translating. ## Rebuilding translations After modifying the templates or code and marking new strings for translation, -run +from the ./pikaraoke subdirectory, run ```shell $ pybabel extract -F babel.cfg -o messages.pot --add-comments="MSG:" --strip-comment-tags --sort-by-file . @@ -51,6 +51,12 @@ The update command will update each languages `translations//LC_MESSAGES/m file, which is what a translator for a particular language will see. The python app consumes `messages.mo` files, which are binary files created by the compile step. +Note: 'Fuzzy' messages are marked with a #, fuzzy line above the msgid line, and are the result of a merge where a message is deemed slightly changed from the previous version. These will be ignored by the translation until they are addressed! A message marked as fuzzy is supposed to be looked at by a human to make sure the translation doesn't need updating, after which the human translator removes that flag. Often line break changes will trigger these, if you want to force compilation, run: + +```shell +$ pybabel compile -f -d translations +``` + In order to start translating a new language, use ```shell @@ -65,4 +71,10 @@ As well as editing the `constants.py` `LANGUAGES` mapping to make that language Currently I have it set based on the Accept-Language header sent with each request, [which can be modified using this guide][accept-language-chrome]. +## Testing a language + +You can force a language locale on a given webpage by adding the lang query to the end of the URL. Example: `http://localhost:5555/?lang=pt_BR` + +This will work for HTML endpoints, but for translations within python code (flashed messages, splash screen notifications), the host's locale is used. + [accept-language-chrome]: https://support.google.com/pixelslate/answer/173424?hl=en&co=GENIE.Platform%3DDesktop diff --git a/pikaraoke/app.py b/pikaraoke/app.py index 64af2fd8..73ba0b33 100644 --- a/pikaraoke/app.py +++ b/pikaraoke/app.py @@ -4,6 +4,7 @@ import json import logging import os +import re import signal import subprocess import sys @@ -15,12 +16,15 @@ import psutil from flask import ( Flask, + Response, flash, + jsonify, make_response, redirect, render_template, request, send_file, + session, url_for, ) from flask_babel import Babel @@ -36,6 +40,8 @@ from pikaraoke import VERSION, karaoke from pikaraoke.constants import LANGUAGES +from pikaraoke.lib.background_music import create_randomized_playlist +from pikaraoke.lib.file_resolver import delete_tmp_dir, get_tmp_dir from pikaraoke.lib.get_platform import get_platform, is_raspberry_pi try: @@ -100,7 +106,12 @@ def is_admin(): @babel.localeselector def get_locale(): """Select the language to display the webpage in based on the Accept-Language header""" - return request.accept_languages.best_match(LANGUAGES.keys()) + if request.args.get("lang"): + session["lang"] = request.args.get("lang") + locale = session.get("lang", "en") + else: + locale = request.accept_languages.best_match(LANGUAGES.keys()) + return locale @app.route("/") @@ -142,7 +153,8 @@ def login(): def logout(): resp = make_response(redirect("/")) resp.set_cookie("admin", "") - flash("Logged out of admin mode!", "is-success") + # MSG: Message shown after logging out as admin successfully + flash(_("Logged out of admin mode!"), "is-success") return resp @@ -159,11 +171,12 @@ def nowplaying(): "now_playing": k.now_playing, "now_playing_user": k.now_playing_user, "now_playing_command": k.now_playing_command, + "now_playing_duration": k.now_playing_duration, + "now_playing_transpose": k.now_playing_transpose, + "now_playing_url": k.now_playing_url, "up_next": next_song, "next_user": next_user, - "now_playing_url": k.now_playing_url, "is_paused": k.is_paused, - "transpose_value": k.now_playing_transpose, "volume": k.volume, # "is_transpose_enabled": k.is_transpose_enabled, } @@ -201,9 +214,11 @@ def add_random(): amount = int(request.args["amount"]) rc = k.queue_add_random(amount) if rc: - flash("Added %s random tracks" % amount, "is-success") + # MSG: Message shown after adding random tracks + flash(_("Added %s random tracks") % amount, "is-success") else: - flash("Ran out of songs!", "is-warning") + # MSG: Message shown after running out songs to add during random track addition + flash(_("Ran out of songs!"), "is-warning") return redirect(url_for("queue")) @@ -212,7 +227,8 @@ def queue_edit(): action = request.args["action"] if action == "clear": k.queue_clear() - flash("Cleared the queue!", "is-warning") + # MSG: Message shown after clearing the queue + flash(_("Cleared the queue!"), "is-warning") return redirect(url_for("queue")) else: song = request.args["song"] @@ -220,21 +236,27 @@ def queue_edit(): if action == "down": result = k.queue_edit(song, "down") if result: - flash("Moved down in queue: " + song, "is-success") + # MSG: Message shown after moving a song down in the queue + flash(_("Moved down in queue") + ": " + song, "is-success") else: - flash("Error moving down in queue: " + song, "is-danger") + # MSG: Message shown after failing to move a song down in the queue + flash(_("Error moving down in queue") + ": " + song, "is-danger") elif action == "up": result = k.queue_edit(song, "up") if result: - flash("Moved up in queue: " + song, "is-success") + # MSG: Message shown after moving a song up in the queue + flash(_("Moved up in queue") + ": " + song, "is-success") else: - flash("Error moving up in queue: " + song, "is-danger") + # MSG: Message shown after failing to move a song up in the queue + flash(_("Error moving up in queue") + ": " + song, "is-danger") elif action == "delete": result = k.queue_edit(song, "delete") if result: - flash("Deleted from queue: " + song, "is-success") + # MSG: Message shown after deleting a song from the queue + flash(_("Deleted from queue") + ": " + song, "is-success") else: - flash("Error deleting from queue: " + song, "is-danger") + # MSG: Message shown after failing to delete a song from the queue + flash(_("Error deleting from queue") + ": " + song, "is-danger") return redirect(url_for("queue")) @@ -393,24 +415,30 @@ def download(): d = request.form.to_dict() song = d["song-url"] user = d["song-added-by"] + title = d["song-title"] if "queue" in d and d["queue"] == "on": queue = True else: queue = False # download in the background since this can take a few minutes - t = threading.Thread(target=k.download_video, args=[song, queue, user]) + t = threading.Thread(target=k.download_video, args=[song, queue, user, title]) t.daemon = True t.start() + displayed_title = title if title else song flash_message = ( - "Download started: '" + song + "'. This may take a couple of minutes to complete. " + # MSG: Message shown after starting a download. Song title is displayed in the message. + _("Download started: %s. This may take a couple of minutes to complete.") + % displayed_title ) if queue: - flash_message += "Song will be added to queue." + # MSG: Message shown after starting a download that will be adding a song to the queue. + flash_message += _("Song will be added to queue.") else: - flash_message += 'Song will appear in the "available songs" list.' + # MSG: Message shown after after starting a download. + flash_message += _('Song will appear in the "available songs" list.') flash(flash_message, "is-info") return redirect(url_for("search")) @@ -425,9 +453,27 @@ def logo(): return send_file(k.logo_path, mimetype="image/png") -@app.route("/end_song", methods=["GET"]) +# Routes for streaming background music +@app.route("/bg_music/", methods=["GET"]) +def bg_music(file): + mp3_path = os.path.join(k.bg_music_path, file) + return send_file(mp3_path, mimetype="audio/mpeg") + + +# Route for getting the randomized background music playlist +@app.route("/bg_playlist", methods=["GET"]) +def bg_playlist(): + if (k.bg_music_path == None) or (not os.path.exists(k.bg_music_path)): + return jsonify([]) + playlist = create_randomized_playlist(k.bg_music_path, "/bg_music") + return jsonify(playlist) + + +@app.route("/end_song", methods=["GET", "POST"]) def end_song(): - k.end_song() + d = request.form.to_dict() + reason = d["reason"] if "reason" in d else None + k.end_song(reason) return "ok" @@ -444,20 +490,26 @@ def delete_file(): exists = any(item.get("file") == song_path for item in k.queue) if exists: flash( - "Error: Can't delete this song because it is in the current queue: " + song_path, + # MSG: Message shown after trying to delete a song that is in the queue. + _("Error: Can't delete this song because it is in the current queue") + + ": " + + song_path, "is-danger", ) else: k.delete(song_path) - flash("Song deleted: " + song_path, "is-warning") + # MSG: Message shown after deleting a song. Followed by the song path + flash(_("Song deleted: %s") % k.filename_from_path(song_path), "is-warning") else: - flash("Error: No song parameter specified!", "is-danger") + # MSG: Message shown after trying to delete a song without specifying the song. + flash(_("Error: No song specified!"), "is-danger") return redirect(url_for("browse")) @app.route("/files/edit", methods=["GET", "POST"]) def edit_file(): - queue_error_msg = "Error: Can't edit this song because it is in the current queue: " + # MSG: Message shown after trying to edit a song that is in the queue. + queue_error_msg = _("Error: Can't edit this song because it is in the current queue: ") if "song" in request.args: song_path = request.args["song"] # print "SONG_PATH" + song_path @@ -484,18 +536,21 @@ def edit_file(): file_extension = os.path.splitext(old_name)[1] if os.path.isfile(os.path.join(k.download_path, new_name + file_extension)): flash( - "Error Renaming file: '%s' to '%s'. Filename already exists." + # MSG: Message shown after trying to rename a file to a name that already exists. + _("Error renaming file: '%s' to '%s', Filename already exists") % (old_name, new_name + file_extension), "is-danger", ) else: k.rename(old_name, new_name) flash( - "Renamed file: '%s' to '%s'." % (old_name, new_name), + # MSG: Message shown after renaming a file. + _("Renamed file: %s to %s") % (old_name, new_name), "is-warning", ) else: - flash("Error: No filename parameters were specified!", "is-danger") + # MSG: Message shown after trying to edit a song without specifying the filename. + flash(_("Error: No filename parameters were specified!"), "is-danger") return redirect(url_for("browse")) @@ -545,6 +600,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, ) @@ -556,7 +614,7 @@ def info(): try: cpu = str(psutil.cpu_percent()) + "%" except: - cpu = "CPU usage query unsupported" + cpu = _("CPU usage query unsupported") # mem memory = psutil.virtual_memory() @@ -594,6 +652,20 @@ 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, + hide_notifications=k.hide_notifications, + hide_overlay=k.hide_overlay, + normalize_audio=k.normalize_audio, + complete_transcode_before_play=k.complete_transcode_before_play, + high_quality_audio=k.high_quality, + splash_delay=k.splash_delay, + screensaver_timeout=k.screensaver_timeout, + volume=int(100 * k.volume), + buffer_size=k.buffer_size, ) @@ -625,13 +697,15 @@ def update_youtube_dl(): def update_ytdl(): if is_admin(): flash( - "Updating youtube-dl! Should take a minute or two... ", + # MSG: Message shown after starting the youtube-dl update. + _("Updating youtube-dl! Should take a minute or two... "), "is-warning", ) th = threading.Thread(target=update_youtube_dl) th.start() else: - flash("You don't have permission to update youtube-dl", "is-danger") + # MSG: Message shown after trying to update youtube-dl without admin permissions. + flash(_("You don't have permission to update youtube-dl"), "is-danger") return redirect(url_for("home")) @@ -640,56 +714,160 @@ def refresh(): if is_admin(): k.get_available_songs() else: - flash("You don't have permission to shut down", "is-danger") + # MSG: Message shown after trying to refresh the song list without admin permissions. + flash(_("You don't have permission to shut down"), "is-danger") return redirect(url_for("browse")) @app.route("/quit") def quit(): if is_admin(): - flash("Quitting pikaraoke now!", "is-warning") + # MSG: Message shown after quitting pikaraoke. + msg = _("Exiting pikaraoke now!") + flash(msg, "is-danger") + k.send_message_to_splash(msg, "danger") th = threading.Thread(target=delayed_halt, args=[0]) th.start() else: - flash("You don't have permission to quit", "is-danger") + # MSG: Message shown after trying to quit pikaraoke without admin permissions. + flash(_("You don't have permission to quit"), "is-danger") return redirect(url_for("home")) @app.route("/shutdown") def shutdown(): if is_admin(): - flash("Shutting down system now!", "is-danger") + # MSG: Message shown after shutting down the system. + msg = _("Shutting down system now!") + flash(msg, "is-danger") + k.send_message_to_splash(msg, "danger") th = threading.Thread(target=delayed_halt, args=[1]) th.start() else: - flash("You don't have permission to shut down", "is-danger") + # MSG: Message shown after trying to shut down the system without admin permissions. + flash(_("You don't have permission to shut down"), "is-danger") return redirect(url_for("home")) @app.route("/reboot") def reboot(): if is_admin(): - flash("Rebooting system now!", "is-danger") + # MSG: Message shown after rebooting the system. + msg = _("Rebooting system now!") + flash(msg, "is-danger") + k.send_message_to_splash(msg, "danger") th = threading.Thread(target=delayed_halt, args=[2]) th.start() else: - flash("You don't have permission to Reboot", "is-danger") + # MSG: Message shown after trying to reboot the system without admin permissions. + flash(_("You don't have permission to Reboot"), "is-danger") return redirect(url_for("home")) @app.route("/expand_fs") def expand_fs(): if is_admin() and raspberry_pi: - flash("Expanding filesystem and rebooting system now!", "is-danger") + # MSG: Message shown after expanding the filesystem. + flash(_("Expanding filesystem and rebooting system now!"), "is-danger") th = threading.Thread(target=delayed_halt, args=[3]) th.start() elif not raspberry_pi: - flash("Cannot expand fs on non-raspberry pi devices!", "is-danger") + # MSG: Message shown after trying to expand the filesystem on a non-raspberry pi device. + flash(_("Cannot expand fs on non-raspberry pi devices!"), "is-danger") + else: + # MSG: Message shown after trying to expand the filesystem without admin permissions + flash(_("You don't have permission to resize the filesystem"), "is-danger") + 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: + # MSG: Message shown after trying to change preferences without admin permissions. + flash(_("You don't have permission to change preferences"), "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 resize the filesystem", "is-danger") + # MSG: Message shown after trying to clear preferences without admin permissions. + flash(_("You don't have permission to clear preferences"), "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 = os.path.join(get_tmp_dir(), f"{id}.mp4") + + def generate(): + position = 0 # Initialize the position variable + chunk_size = 10240 * 1000 * 25 # Read file in up to 25MB chunks + with open(file_path, "rb") as file: + # Keep yielding file chunks as long as ffmpeg process is transcoding + while k.ffmpeg_process.poll() is None: + file.seek(position) # Move to the last read position + chunk = file.read(chunk_size) + if chunk is not None and len(chunk) > 0: + yield chunk + position += len(chunk) # Update the position with the size of the chunk + time.sleep(1) # Wait a bit before checking the file size again + chunk = file.read(chunk_size) # Read the last chunk + yield chunk + position += len(chunk) # Update the position with the size of the chunk + + return Response(generate(), mimetype="video/mp4") + + +# Streams the file in full with proper range headers +# (Safari compatible, but requires the ffmpeg transcoding to be complete to know file size) +@app.route("/stream/full/") +def stream_full(id): + file_path = os.path.join(get_tmp_dir(), f"{id}.mp4") + try: + file_size = os.path.getsize(file_path) + range_header = request.headers.get("Range", None) + if not range_header: + with open(file_path, "rb") as file: + file_content = file.read() + return Response(file_content, mimetype="video/mp4") + # Extract range start and end from Range header (e.g., "bytes=0-499") + range_match = re.search(r"bytes=(\d+)-(\d*)", range_header) + start, end = range_match.groups() + start = int(start) + end = int(end) if end else file_size - 1 + # Generate response with part of file + with open(file_path, "rb") as file: + file.seek(start) + data = file.read(end - start + 1) + status_code = 206 # Partial content + headers = { + "Content-Type": "video/mp4", + "Accept-Ranges": "bytes", + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(len(data)), + } + return Response(data, status=status_code, headers=headers) + except IOError: + # MSG: Message shown after trying to stream a file that does not exist. + flash(_("File not found."), "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()) @@ -714,13 +892,14 @@ def get_default_dl_dir(platform): def main(): platform = get_platform() default_port = 5555 - default_ffmpeg_port = 5556 default_volume = 0.85 default_normalize_audio = False default_splash_delay = 3 default_screensaver_delay = 300 default_log_level = logging.INFO default_prefer_hostname = False + default_bg_music_volume = 0.3 + default_buffer_size = 150 default_dl_dir = get_default_dl_dir(platform) default_youtubedl_path = "yt-dlp" @@ -733,19 +912,7 @@ def main(): "--port", help="Desired http port (default: %d)" % default_port, default=default_port, - required=False, - ) - parser.add_argument( - "--window-size", - help="Desired window geometry in pixels, specified as width,height", - default=0, - required=False, - ) - parser.add_argument( - "-f", - "--ffmpeg-port", - help=f"Desired ffmpeg port. This is where video stream URLs will be pointed (default: {default_ffmpeg_port})", - default=default_ffmpeg_port, + type=int, required=False, ) parser.add_argument( @@ -786,6 +953,7 @@ def main(): help="Delay during splash screen between songs (in secs). (default: %s )" % default_splash_delay, default=default_splash_delay, + type=int, required=False, ) parser.add_argument( @@ -794,6 +962,7 @@ def main(): help="Delay before the screensaver begins (in secs). (default: %s )" % default_screensaver_delay, default=default_screensaver_delay, + type=int, required=False, ) parser.add_argument( @@ -816,6 +985,18 @@ def main(): default=default_prefer_hostname, required=False, ) + parser.add_argument( + "--hide-overlay", + action="store_true", + help="Hide all overlays that show on top of video, including current/next song, pikaraoke QR code and IP", + required=False, + ), + parser.add_argument( + "--hide-notifications", + action="store_true", + help="Hide notifications from the splash screen.", + required=False, + ) parser.add_argument( "--hide-raspiwifi-instructions", action="store_true", @@ -832,9 +1013,24 @@ def main(): parser.add_argument( "--high-quality", action="store_true", - help="Download higher quality video. Note: requires ffmpeg and may cause CPU, download speed, and other performance issues", + help="Download higher quality video. May cause CPU, download speed, and other performance issues", + required=False, + ) + parser.add_argument( + "-c", + "--complete-transcode-before-play", + action="store_true", + help="Wait for ffmpeg video transcoding to fully complete before playback begins. Transcoding occurs when you have normalization on, play a cdg file, or change key. May improve performance and browser compatibility (Safari, Firefox), but will significantly increase the delay before playback begins. On modern hardware, the delay is likely negligible.", required=False, ) + parser.add_argument( + "-b", + "--buffer-size", + help=f"Buffer size for transcoded video (in kilobytes). Increase if you experience songs cutting off early. Higher size will transcode more of the file before streaming it to the client. This will increase the delay before playback begins. This value is ignored if --complete-transcode-before-play was specified. Default is: {default_buffer_size}", + default=default_buffer_size, + type=int, + required=False, + ), parser.add_argument( "--logo-path", nargs="+", @@ -850,24 +1046,49 @@ def main(): required=False, ), parser.add_argument( - "-m", - "--ffmpeg-url", - help="Override the ffmpeg address with a supplied URL.", + "--window-size", + help="Desired window geometry in pixels for headed mode, specified as width,height", + default=0, + required=False, + ) + parser.add_argument( + "--admin-password", + help="Administrator password, for locking down certain features of the web UI such as queue editing, player controls, song editing, and system shutdown. If unspecified, everyone is an admin.", default=None, required=False, ), parser.add_argument( - "--hide-overlay", + "--disable-bg-music", action="store_true", - help="Hide overlay that shows on top of video with pikaraoke QR code and IP", + help="Disable background music on splash screen", required=False, ), parser.add_argument( - "--admin-password", - help="Administrator password, for locking down certain features of the web UI such as queue editing, player controls, song editing, and system shutdown. If unspecified, everyone is an admin.", + "--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 directory for the splash screen background music. Directory must contain mp3 files which will be randomized in a playlist.", 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() @@ -894,18 +1115,28 @@ 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( port=args.port, - ffmpeg_port=args.ffmpeg_port, download_path=dl_path, youtubedl_path=arg_path_parse(args.youtubedl_path), splash_delay=args.splash_delay, log_level=args.log_level, volume=parsed_volume, normalize_audio=args.normalize_audio, + complete_transcode_before_play=args.complete_transcode_before_play, + buffer_size=args.buffer_size, hide_url=args.hide_url, + hide_notifications=args.hide_notifications, hide_raspiwifi_instructions=args.hide_raspiwifi_instructions, hide_splash_screen=args.hide_splash_screen, high_quality=args.high_quality, @@ -913,8 +1144,12 @@ def main(): hide_overlay=args.hide_overlay, screensaver_timeout=args.screensaver_timeout, 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=int(args.limit_user_songs_by), ) k.upgrade_youtubedl() @@ -975,6 +1210,7 @@ def main(): driver.close() cherrypy.engine.exit() + delete_tmp_dir() sys.exit() diff --git a/pikaraoke/constants.py b/pikaraoke/constants.py index 22aee241..ca07b59b 100644 --- a/pikaraoke/constants.py +++ b/pikaraoke/constants.py @@ -1,5 +1,7 @@ LANGUAGES = { "en": "English", + "es_VE": "Spanish (Venezuela)", + "fi_FI": "Finnish", "zh_CN": "Chinese", "pt_BR": "Brazilian Portuguese", "it_IT": "Italian", diff --git a/pikaraoke/karaoke.py b/pikaraoke/karaoke.py index d1a1e95d..23fc54aa 100644 --- a/pikaraoke/karaoke.py +++ b/pikaraoke/karaoke.py @@ -1,28 +1,38 @@ +import configparser import contextlib import json import logging import os import random +import shutil import socket import subprocess +import threading import time from pathlib import Path -from queue import Empty, Queue +from queue import Queue from subprocess import CalledProcessError, check_output from threading import Thread from urllib.parse import urlparse -import ffmpeg import qrcode +from flask_babel import _ from unidecode import unidecode -from pikaraoke.lib.file_resolver import FileResolver -from pikaraoke.lib.get_platform import ( +from pikaraoke.lib.ffmpeg import ( + build_ffmpeg_cmd, get_ffmpeg_version, + is_transpose_enabled, +) +from pikaraoke.lib.file_resolver import ( + FileResolver, + delete_tmp_dir, + is_transcoding_required, +) +from pikaraoke.lib.get_platform import ( get_os_version, get_platform, is_raspberry_pi, - is_transpose_enabled, supports_hardware_h264_encoding, ) @@ -51,6 +61,7 @@ class Karaoke: now_playing_filename = None now_playing_user = None now_playing_transpose = 0 + now_playing_duration = None now_playing_url = None now_playing_command = None @@ -62,29 +73,33 @@ 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/music/") screensaver_timeout = 300 # in seconds ffmpeg_process = None ffmpeg_log = None ffmpeg_version = get_ffmpeg_version() is_transpose_enabled = is_transpose_enabled() - supports_hardware_h264_encoding = supports_hardware_h264_encoding() normalize_audio = False raspberry_pi = is_raspberry_pi() os_version = get_os_version() + config_obj = configparser.ConfigParser() + def __init__( self, port=5555, - ffmpeg_port=5556, download_path="/usr/lib/pikaraoke/songs", hide_url=False, + hide_notifications=False, hide_raspiwifi_instructions=False, hide_splash_screen=False, high_quality=False, volume=0.85, normalize_audio=False, + complete_transcode_before_play=False, + buffer_size=150, log_level=logging.DEBUG, splash_delay=2, youtubedl_path="/usr/local/bin/yt-dlp", @@ -92,41 +107,60 @@ def __init__( hide_overlay=False, screensaver_timeout=300, 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 - self.hide_url = hide_url + self.hide_url = self.get_user_preference("hide_url") or hide_url + self.hide_notifications = ( + self.get_user_preference("hide_notifications") or hide_notifications + ) self.hide_raspiwifi_instructions = hide_raspiwifi_instructions self.hide_splash_screen = hide_splash_screen self.download_path = download_path - self.high_quality = high_quality - self.splash_delay = int(splash_delay) - self.volume = volume - self.normalize_audio = normalize_audio + self.high_quality = self.get_user_preference("high_quality") or high_quality + self.splash_delay = self.get_user_preference("splash_delay") or int(splash_delay) + self.volume = self.get_user_preference("volume") or volume + self.normalize_audio = self.get_user_preference("normalize_audio") or normalize_audio + self.complete_transcode_before_play = ( + self.get_user_preference("complete_transcode_before_play") + or complete_transcode_before_play + ) + self.buffer_size = self.get_user_preference("buffer_size") or buffer_size self.youtubedl_path = youtubedl_path self.logo_path = self.default_logo_path if logo_path == None else logo_path - self.hide_overlay = hide_overlay - self.screensaver_timeout = screensaver_timeout + self.hide_overlay = self.get_user_preference("hide_overlay") or hide_overlay + self.screensaver_timeout = ( + self.get_user_preference("screensaver_timeout") or 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} - ffmpeg port {self.ffmpeg_port} hide URL: {self.hide_url} prefer hostname: {self.prefer_hostname} url override: {self.url_override} @@ -138,19 +172,28 @@ def __init__( download path: {self.download_path} default volume: {self.volume} normalize audio: {self.normalize_audio} + complete transcode before play: {self.complete_transcode_before_play} + buffer size (kb): {self.buffer_size} youtube-dl path: {self.youtubedl_path} 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} os version: {self.os_version} ffmpeg version: {self.ffmpeg_version} ffmpeg transpose support: {self.is_transpose_enabled} - hardware h264 encoding: {self.supports_hardware_h264_encoding} + hardware h264 encoding: {supports_hardware_h264_encoding()} youtubedl-version: {self.get_youtubedl_version()} """ ) + # Generate connection URL and QR code, if self.raspberry_pi: # retry in case pi is still starting up @@ -178,12 +221,6 @@ def __init__( else: self.url = f"http://{self.ip}:{self.port}" self.url_parsed = urlparse(self.url) - if ffmpeg_url is None: - self.ffmpeg_url = ( - f"{self.url_parsed.scheme}://{self.url_parsed.hostname}:{self.ffmpeg_port}" - ) - else: - self.ffmpeg_url = ffmpeg_url # get songs from download_path self.get_available_songs() @@ -192,6 +229,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": @@ -317,8 +410,30 @@ def get_search_results(self, textToSearch): def get_karaoke_search_results(self, songTitle): return self.get_search_results(songTitle + " karaoke") - def download_video(self, video_url, enqueue=False, user="Pikaraoke"): - logging.info("Downloading video: " + video_url) + def send_message_to_splash(self, message, color="primary"): + # Color should be bulma compatible: primary, warning, success, danger + if not self.hide_notifications: + self.send_command("message::" + message + "::is-" + color) + + def log_and_send(self, message, category="info"): + # Category should be one of: info, success, warning, danger + if category == "success": + logging.info(message) + self.send_message_to_splash(message, "success") + elif category == "warning": + logging.warning(message) + self.send_message_to_splash(message, "warning") + elif category == "danger": + logging.error(message) + self.send_message_to_splash(message, "danger") + else: + logging.info(message) + self.send_message_to_splash(message, "primary") + + def download_video(self, video_url, enqueue=False, user="Pikaraoke", title=None): + displayed_title = title if title else video_url + # MSG: Message shown after the download is started + self.log_and_send(_("Downloading video: %s" % displayed_title)) dl_path = self.download_path + "%(title)s---%(id)s.%(ext)s" file_quality = ( "bestvideo[ext!=webm][height<=1080]+bestaudio[ext!=webm]/best[ext!=webm]" @@ -332,17 +447,24 @@ def download_video(self, video_url, enqueue=False, user="Pikaraoke"): logging.error("Error code while downloading, retrying once...") rc = subprocess.call(cmd) # retry once. Seems like this can be flaky if rc == 0: - logging.debug("Song successfully downloaded: " + video_url) + if enqueue: + # MSG: Message shown after the download is completed and queued + self.log_and_send(_("Downloaded and queued: %s" % displayed_title), "success") + else: + # MSG: Message shown after the download is completed but not queued + self.log_and_send(_("Downloaded: %s" % displayed_title), "success") self.get_available_songs() if enqueue: y = self.get_youtube_id_from_url(video_url) s = self.find_song_by_youtube_id(y) if s: - self.enqueue(s, user) + self.enqueue(s, user, log_action=False) else: - logging.error("Error queueing song: " + video_url) + # MSG: Message shown after the download is completed but the adding to queue fails + self.log_and_send(_("Error queueing song: ") + displayed_title, "danger") else: - logging.error("Error downloading song: " + video_url) + # MSG: Message shown after the download process is completed but the song is not found + self.log_and_send(_("Error downloading song: ") + displayed_title, "danger") return rc def get_available_songs(self): @@ -417,14 +539,10 @@ def log_ffmpeg_output(self): def play_file(self, file_path, semitones=0): logging.info(f"Playing file: {file_path} transposed {semitones} semitones") - stream_uid = int(time.time()) - stream_url = f"{self.ffmpeg_url}/{stream_uid}" - # pass a 0.0.0.0 IP to ffmpeg which will work for both hostnames and direct IP access - ffmpeg_url = f"http://0.0.0.0:{self.ffmpeg_port}/{stream_uid}" - pitch = 2 ** ( - semitones / 12 - ) # The pitch value is (2^x/12), where x represents the number of semitones + requires_transcoding = ( + semitones != 0 or self.normalize_audio or is_transcoding_required(file_path) + ) try: fr = FileResolver(file_path) @@ -433,111 +551,106 @@ def play_file(self, file_path, semitones=0): self.queue.pop(0) return False - # use h/w acceleration on pi - default_vcodec = "h264_v4l2m2m" if self.supports_hardware_h264_encoding else "libx264" - # just copy the video stream if it's an mp4 or webm file, since they are supported natively in html5 - # otherwise use the default h264 codec - vcodec = ( - "copy" - if fr.file_extension == ".mp4" or fr.file_extension == ".webm" - else default_vcodec - ) - vbitrate = "5M" # seems to yield best results w/ h264_v4l2m2m on pi, recommended for 720p. - - # copy the audio stream if no transposition/normalization, otherwise reincode with the aac codec - is_transposed = semitones != 0 - acodec = "aac" if is_transposed or self.normalize_audio else "copy" - input = ffmpeg.input(fr.file_path) - audio = input.audio.filter("rubberband", pitch=pitch) if is_transposed else input.audio - # normalize the audio - audio = audio.filter("loudnorm", i=-16, tp=-1.5, lra=11) if self.normalize_audio else audio - - # Ffmpeg outputs "Stream #0" when the stream is ready to consume - stream_ready_string = "Stream #" - - if fr.cdg_file_path != None: # handle CDG files - logging.info("Playing CDG/MP3 file: " + file_path) - # Ffmpeg outputs "Video: cdgraphics" when the stream is ready to consume - stream_ready_string = "Video: cdgraphics" - # copyts helps with sync issues, fps=25 prevents ffmpeg from needlessly encoding cdg at 300fps - cdg_input = ffmpeg.input(fr.cdg_file_path, copyts=None) - video = cdg_input.video.filter("fps", fps=25) - # cdg is very fussy about these flags. - # pi ffmpeg needs to encode to aac and cant just copy the mp3 stream - # It alse appears to have memory issues with hardware acceleration h264_v4l2m2m - output = ffmpeg.output( - audio, - video, - ffmpeg_url, - vcodec="libx264", - acodec="aac", - preset="ultrafast", - pix_fmt="yuv420p", - listen=1, - f="mp4", - video_bitrate="500k", - movflags="frag_keyframe+default_base_moof", - ) + if self.complete_transcode_before_play or not requires_transcoding: + # This route is used for streaming the full video file, and includes more + # accurate headers for safari and other browsers + stream_url_path = f"/stream/full/{fr.stream_uid}" + else: + # This route is used for streaming the video file in chunks, only works on chrome + stream_url_path = f"/stream/{fr.stream_uid}" + + if not requires_transcoding: + # simply copy file path to the tmp directory and the stream is ready + shutil.copy(file_path, fr.output_file) + max_retries = 5 + while max_retries > 0: + if os.path.exists(fr.output_file): + is_transcoding_complete = True + break + max_retries -= 1 + time.sleep(1) + if max_retries == 0: + logging.debug(f"Copying file failed: {fr.output_file}") else: - video = input.video - output = ffmpeg.output( - audio, - video, - ffmpeg_url, - vcodec=vcodec, - acodec=acodec, - preset="ultrafast", - listen=1, - f="mp4", - video_bitrate=vbitrate, - movflags="frag_keyframe+default_base_moof", + self.kill_ffmpeg() + ffmpeg_cmd = build_ffmpeg_cmd( + fr, semitones, self.normalize_audio, self.complete_transcode_before_play ) + self.ffmpeg_process = ffmpeg_cmd.run_async(pipe_stderr=True, pipe_stdin=True) - args = output.get_args() - logging.debug(f"COMMAND: ffmpeg " + " ".join(args)) - - self.kill_ffmpeg() + # ffmpeg outputs everything useful to stderr for some insane reason! + # prevent reading stderr from being a blocking action + self.ffmpeg_log = Queue() + t = Thread(target=enqueue_output, args=(self.ffmpeg_process.stderr, self.ffmpeg_log)) + t.daemon = True + t.start() - self.ffmpeg_process = output.run_async(pipe_stderr=True, pipe_stdin=True) + output_file_size = 0 + transcode_max_retries = 2500 # Transcode completion max: approx 2 minutes - # ffmpeg outputs everything useful to stderr for some insane reason! - # prevent reading stderr from being a blocking action - self.ffmpeg_log = Queue() - t = Thread(target=enqueue_output, args=(self.ffmpeg_process.stderr, self.ffmpeg_log)) - t.daemon = True - t.start() + is_transcoding_complete = False + is_buffering_complete = False - while self.ffmpeg_process.poll() is None: - try: - output = self.ffmpeg_log.get_nowait() - logging.debug("[FFMPEG] " + decode_ignore(output)) - except Empty: - pass - else: - if stream_ready_string in decode_ignore(output): - logging.debug("Stream ready!") - self.now_playing = self.filename_from_path(file_path) - self.now_playing_filename = file_path - self.now_playing_transpose = semitones - self.now_playing_url = stream_url - self.now_playing_user = self.queue[0]["user"] - self.is_paused = False - self.queue.pop(0) - - # Pause until the stream is playing - max_retries = 100 - while self.is_playing == False and max_retries > 0: - time.sleep(0.1) # prevents loop from trying to replay track - max_retries -= 1 - if self.is_playing: - logging.debug("Stream is playing") - break - else: + # Transcoding readiness polling loop + while True: + self.log_ffmpeg_output() + # Check if the ffmpeg process has exited + if self.ffmpeg_process.poll() is not None: + exitcode = self.ffmpeg_process.poll() + if exitcode != 0: logging.error( - "Stream was not playable! Run with debug logging to see output. Skipping track" + f"FFMPEG transcode exited with nonzero exit code ending: {exitcode}. Skipping track" ) self.end_song() break + else: + is_transcoding_complete = True + output_file_size = os.path.getsize(fr.output_file) + logging.debug(f"Transcoding complete. File size: {output_file_size}") + break + # Check if the file has buffered enough to start playback + try: + output_file_size = os.path.getsize(fr.output_file) + if not self.complete_transcode_before_play: + is_buffering_complete = output_file_size > self.buffer_size * 1000 + if is_buffering_complete: + logging.debug(f"Buffering complete. File size: {output_file_size}") + break + except: + pass + # Prevent infinite loop if playback never starts + if transcode_max_retries <= 0: + logging.error("Max retries reached trying to play song. Skipping track") + self.end_song() + break + transcode_max_retries -= 1 + time.sleep(0.05) + + # Check if the stream is ready to play. Determined by: + # - completed transcoding + # - buffered file size being greater than a threshold + if is_transcoding_complete or is_buffering_complete: + logging.debug(f"Stream ready!") + self.now_playing = self.filename_from_path(file_path) + self.now_playing_filename = file_path + self.now_playing_transpose = semitones + self.now_playing_duration = fr.duration + self.now_playing_url = stream_url_path + self.now_playing_user = self.queue[0]["user"] + self.is_paused = False + self.queue.pop(0) + # Pause until the stream is playing + transcode_max_retries = 100 + while self.is_playing == False and transcode_max_retries > 0: + time.sleep(0.1) # prevents loop from trying to replay track + transcode_max_retries -= 1 + if self.is_playing: + logging.debug("Stream is playing") + else: + logging.error( + "Stream was not playable! Run with debug logging to see output. Skipping track" + ) + self.end_song() def kill_ffmpeg(self): logging.debug("Killing ffmpeg process") @@ -548,17 +661,24 @@ def start_song(self): logging.info(f"Song starting: {self.now_playing}") self.is_playing = True - def end_song(self): + def end_song(self, reason=None): logging.info(f"Song ending: {self.now_playing}") + if reason != None: + logging.info(f"Reason: {reason}") + if reason != "complete": + # MSG: Message shown when the song ends abnormally + self.send_message_to_splash(_("Song ended abnormally: %s") % reason, "danger") self.reset_now_playing() self.kill_ffmpeg() + delete_tmp_dir() logging.debug("ffmpeg process killed") def transpose_current(self, semitones): - logging.info(f"Transposing current song {self.now_playing} by {semitones} semitones") + # MSG: Message shown after the song is transposed, first is the semitones and then the song name + self.log_and_send(_("Transposing by %s semitones: %s") % (semitones, self.now_playing)) # Insert the same song at the top of the queue with transposition self.enqueue(self.now_playing_filename, self.now_playing_user, semitones, True) - self.skip() + self.skip(log_action=False) def is_file_playing(self): return self.is_playing @@ -569,10 +689,28 @@ def is_song_in_queue(self, song_path): return True return False - def enqueue(self, song_path, user="Pikaraoke", semitones=0, add_to_front=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.warn("Song is already in queue, will not add: " + 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, @@ -581,37 +719,41 @@ 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)) + # MSG: Message shown after the song is added to the top of the queue + self.log_and_send(_("%s added to top of queue: %s") % (user, queue_item["title"])) self.queue.insert(0, queue_item) else: - logging.info("'%s' is adding song to queue: %s" % (user, song_path)) + if log_action: + # MSG: Message shown after the song is added to the queue + self.log_and_send(_("%s added to the queue: %s") % (user, queue_item["title"])) 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) songs = list(self.available_songs) # make a copy if len(songs) == 0: - logging.warn("No available songs!") + logging.warning("No available songs!") return False i = 0 while i < amount: r = random.randint(0, len(songs) - 1) if self.is_song_in_queue(songs[r]): - logging.warn("Song already in queue, trying another... " + songs[r]) + logging.warning("Song already in queue, trying another... " + songs[r]) else: self.enqueue(songs[r], "Randomizer") i += 1 songs.pop(r) if len(songs) == 0: - logging.warn("Ran out of songs!") + logging.warning("Ran out of songs!") return False return True def queue_clear(self): - logging.info("Clearing queue!") + # MSG: Message shown after the queue is cleared + self.log_and_send(_("Clear queue"), "danger") self.queue = [] - self.skip() + self.skip(log_action=False) def queue_edit(self, song_name, action): index = 0 @@ -627,7 +769,7 @@ def queue_edit(self, song_name, action): return False if action == "up": if index < 1: - logging.warn("Song is up next, can't bump up in queue: " + song["file"]) + logging.warning("Song is up next, can't bump up in queue: " + song["file"]) return False else: logging.info("Bumping song up in queue: " + song["file"]) @@ -636,7 +778,7 @@ def queue_edit(self, song_name, action): return True elif action == "down": if index == len(self.queue) - 1: - logging.warn("Song is already last, can't bump down in queue: " + song["file"]) + logging.warning("Song is already last, can't bump down in queue: " + song["file"]) return False else: logging.info("Bumping song down in queue: " + song["file"]) @@ -651,10 +793,12 @@ def queue_edit(self, song_name, action): logging.error("Unrecognized direction: " + action) return False - def skip(self): + def skip(self, log_action=True): if self.is_file_playing(): - logging.info("Skipping: " + self.now_playing) - self.now_playing_command = "skip" + if log_action: + # MSG: Message shown after the song is skipped, will be followed by song name + self.log_and_send(_("Skip: %s") % self.now_playing) + self.end_song() return True else: logging.warning("Tried to skip, but no file is playing!") @@ -662,8 +806,12 @@ def skip(self): def pause(self): if self.is_file_playing(): - logging.info("Toggling pause: " + self.now_playing) - self.now_playing_command = "pause" + if self.is_paused: + # MSG: Message shown after the song is resumed, will be followed by song name + self.log_and_send(_("Resume: %s") % self.now_playing) + else: + # MSG: Message shown after the song is paused, will be followed by song name + self.log_and_send(_("Pause") + f": {self.now_playing}") self.is_paused = not self.is_paused return True else: @@ -672,42 +820,40 @@ def pause(self): def volume_change(self, vol_level): self.volume = vol_level - logging.debug(f"Setting volume to: {self.volume}") - if self.is_file_playing(): - self.now_playing_command = f"volume_change: {self.volume}" + # MSG: Message shown after the volume is changed, will be followed by the volume level + self.log_and_send(_("Volume: %s%") % (int(self.volume * 100))) return True def vol_up(self): - self.volume += 0.1 - # keep the maximum volume to 1 when volume up is clicked if self.volume > 1.0: - self.volume = 1.0 + new_vol = self.volume = 1.0 logging.debug("max volume reached.") + new_vol = self.volume + 0.1 + self.volume_change(new_vol) logging.debug(f"Increasing volume by 10%: {self.volume}") - if self.is_file_playing(): - self.now_playing_command = "vol_up" - return True - else: - logging.warning("Tried to volume up, but no file is playing!") - return False def vol_down(self): - self.volume -= 0.1 - # keep the minimum volume to 0 when volume down is clicked - if self.volume < 0: - self.volume = 0 - logging.debug("minimum volume reached.") + if self.volume < 0.1: + new_vol = self.volume = 0.0 + logging.debug("min volume reached.") + new_vol = self.volume - 0.1 + self.volume_change(new_vol) logging.debug(f"Decreasing volume by 10%: {self.volume}") - if self.is_file_playing(): - self.now_playing_command = "vol_down" - return True - else: - logging.warning("Tried to volume down, but no file is playing!") - return False + + def send_command(self, command): + # don't allow new messages to clobber existing commands, one message at a time + # other commands have a higher priority + if command.startswith("message::") and self.now_playing_command != None: + return + self.now_playing_command = command + threading.Timer(2, self.reset_now_playing_command).start() + # Clear the command asynchronously. 2s should be enough for client polling to pick it up def restart(self): if self.is_file_playing(): - self.now_playing_command = "restart" + self.send_command("restart") + logging.info("Restarting: " + self.now_playing) + self.is_paused = False return True else: logging.warning("Tried to restart, but no file is playing!") @@ -719,6 +865,9 @@ def stop(self): def handle_run_loop(self): time.sleep(self.loop_interval / 1000) + def reset_now_playing_command(self): + self.now_playing_command = None + def reset_now_playing(self): self.now_playing = None self.now_playing_filename = None @@ -727,6 +876,7 @@ def reset_now_playing(self): self.is_paused = True self.is_playing = False self.now_playing_transpose = 0 + self.now_playing_duration = None self.ffmpeg_log = None def run(self): @@ -748,5 +898,5 @@ def run(self): self.log_ffmpeg_output() self.handle_run_loop() except KeyboardInterrupt: - logging.warn("Keyboard interrupt: Exiting pikaraoke...") + logging.warning("Keyboard interrupt: Exiting pikaraoke...") self.running = False diff --git a/pikaraoke/lib/background_music.py b/pikaraoke/lib/background_music.py new file mode 100644 index 00000000..7a0f4e4c --- /dev/null +++ b/pikaraoke/lib/background_music.py @@ -0,0 +1,20 @@ +import os +import random +import urllib + + +def create_randomized_playlist(input_directory, base_url): + # Get all mp3 files in the given directory + mp3_files = [f for f in os.listdir(input_directory) if f.endswith(".mp3")] + + # Shuffle the list of mp3 files + random.shuffle(mp3_files) + + # Create the playlist + playlist = [] + for mp3 in mp3_files: + mp3 = urllib.parse.quote(mp3.encode("utf8")) + url = f"{base_url}/{mp3}" + playlist.append(f"{url}") + + return playlist diff --git a/pikaraoke/lib/ffmpeg.py b/pikaraoke/lib/ffmpeg.py new file mode 100644 index 00000000..b8f7a46b --- /dev/null +++ b/pikaraoke/lib/ffmpeg.py @@ -0,0 +1,109 @@ +import logging +import subprocess + +import ffmpeg + +from pikaraoke.lib.get_platform import supports_hardware_h264_encoding + + +def get_media_duration(file_path): + try: + duration = ffmpeg.probe(file_path)["format"]["duration"] + return round(float(duration)) + except: + return None + + +def build_ffmpeg_cmd(fr, semitones=0, normalize_audio=True, buffer_fully_before_playback=False): + # use h/w acceleration on pi + default_vcodec = "h264_v4l2m2m" if supports_hardware_h264_encoding() else "libx264" + # just copy the video stream if it's an mp4 or webm file, since they are supported natively in html5 + # otherwise use the default h264 codec + vcodec = ( + "copy" if fr.file_extension == ".mp4" or fr.file_extension == ".webm" else default_vcodec + ) + vbitrate = "5M" # seems to yield best results w/ h264_v4l2m2m on pi, recommended for 720p. + + # copy the audio stream if no transposition/normalization, otherwise reincode with the aac codec + is_transposed = semitones != 0 + acodec = "aac" if is_transposed or normalize_audio else "copy" + input = ffmpeg.input(fr.file_path) + + # The pitch value is (2^x/12), where x represents the number of semitones + pitch = 2 ** (semitones / 12) + + audio = input.audio.filter("rubberband", pitch=pitch) if is_transposed else input.audio + # normalize the audio + audio = audio.filter("loudnorm", i=-16, tp=-1.5, lra=11) if normalize_audio else audio + + # frag_keyframe+default_base_moof is used to set the correct headers for streaming incomplete files, + # without it, there's better compatibility for streaming on certain browsers like Firefox + movflags = "+faststart" if buffer_fully_before_playback else "frag_keyframe+default_base_moof" + + if fr.cdg_file_path != None: # handle CDG files + logging.info("Playing CDG/MP3 file: " + fr.file_path) + # copyts helps with sync issues, fps=25 prevents ffmpeg from needlessly encoding cdg at 300fps + cdg_input = ffmpeg.input(fr.cdg_file_path, copyts=None) + video = cdg_input.video.filter("fps", fps=25) + # cdg is very fussy about these flags. + # pi ffmpeg needs to encode to aac and cant just copy the mp3 stream + # It also appears to have memory issues with hardware acceleration h264_v4l2m2m + output = ffmpeg.output( + audio, + video, + fr.output_file, + vcodec="libx264", + acodec="aac", + preset="ultrafast", + pix_fmt="yuv420p", + listen=1, + f="mp4", + video_bitrate="500k", + movflags=movflags, + ) + else: + video = input.video + output = ffmpeg.output( + audio, + video, + fr.output_file, + vcodec=vcodec, + acodec=acodec, + preset="ultrafast", + listen=1, + f="mp4", + video_bitrate=vbitrate, + movflags=movflags, + ) + + args = output.get_args() + logging.debug(f"COMMAND: ffmpeg " + " ".join(args)) + return output + + +def get_ffmpeg_version(): + try: + # Execute the command 'ffmpeg -version' + result = subprocess.run( + ["ffmpeg", "-version"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) + # Parse the first line to get the version + first_line = result.stdout.split("\n")[0] + version_info = first_line.split(" ")[2] # Assumes the version info is the third element + return version_info + except FileNotFoundError: + return "FFmpeg is not installed" + except IndexError: + return "Unable to parse FFmpeg version" + + +def is_transpose_enabled(): + try: + filters = subprocess.run(["ffmpeg", "-filters"], capture_output=True) + except FileNotFoundError: + # FFmpeg is not installed + return False + except IndexError: + # Unable to parse FFmpeg filters + return False + return "rubberband" in filters.stdout.decode() diff --git a/pikaraoke/lib/file_resolver.py b/pikaraoke/lib/file_resolver.py index 68c221fc..fe8e79e7 100644 --- a/pikaraoke/lib/file_resolver.py +++ b/pikaraoke/lib/file_resolver.py @@ -2,26 +2,61 @@ import re import shutil import zipfile +from sys import maxsize +from pikaraoke.lib.ffmpeg import get_media_duration from pikaraoke.lib.get_platform import get_platform +def get_tmp_dir(): + # Determine tmp directories (for things like extracted cdg files) + pid = os.getpid() # for scoping tmp directories to this process + if get_platform() == "windows": + tmp_dir = os.path.expanduser(r"~\\AppData\\Local\\Temp\\pikaraoke\\" + str(pid) + r"\\") + else: + tmp_dir = f"/tmp/pikaraoke/{pid}" + return tmp_dir + + +def create_tmp_dir(): + tmp_dir = get_tmp_dir() + # create tmp_dir if it doesn't exist + if not os.path.exists(tmp_dir): + os.makedirs(tmp_dir) + + +def delete_tmp_dir(): + tmp_dir = get_tmp_dir() + if os.path.exists(tmp_dir): + shutil.rmtree(tmp_dir) + + +def string_to_hash(s): + return hash(s) % ((maxsize + 1) * 2) + + +def is_cdg_file(file_path): + file_extension = os.path.splitext(file_path)[1].casefold() + return file_extension == ".zip" or file_extension == ".mp3" + + +def is_transcoding_required(file_path): + file_extension = os.path.splitext(file_path)[1].casefold() + return file_extension != ".mp4" and file_extension != ".webm" + + # Processes a given file path and determines the file format and file path, extracting zips into cdg + mp3 if necessary. class FileResolver: file_path = None cdg_file_path = None file_extension = None - pid = os.getpid() # for scoping tmp directories to this process def __init__(self, file_path): - # Determine tmp directories (for things like extracted cdg files) - if get_platform() == "windows": - self.tmp_dir = os.path.expanduser( - r"~\\AppData\\Local\\Temp\\pikaraoke\\" + str(self.pid) + r"\\" - ) - else: - self.tmp_dir = f"/tmp/pikaraoke/{self.pid}" + create_tmp_dir() + self.tmp_dir = get_tmp_dir() self.resolved_file_path = self.process_file(file_path) + self.stream_uid = string_to_hash(file_path) + self.output_file = f"{self.tmp_dir}/{self.stream_uid}.mp4" # Extract zipped cdg + mp3 files into a temporary directory, and set the paths to both files. def handle_zipped_cdg(self, file_path): @@ -34,7 +69,6 @@ def handle_zipped_cdg(self, file_path): mp3_file = None cdg_file = None files = os.listdir(extracted_dir) - print(files) for file in files: ext = os.path.splitext(file)[1] if ext.casefold() == ".mp3": @@ -55,8 +89,6 @@ def handle_mp3_cdg(self, file_path): pattern = f + ".cdg" rule = re.compile(re.escape(pattern), re.IGNORECASE) p = os.path.dirname(file_path) # get the path, not the filename - print(p) - print(pattern) for n in os.listdir(p): if rule.match(n): self.file_path = file_path @@ -74,3 +106,4 @@ def process_file(self, file_path): self.handle_mp3_cdg(file_path) else: self.file_path = file_path + self.duration = get_media_duration(self.file_path) diff --git a/pikaraoke/lib/omxclient.py b/pikaraoke/lib/omxclient.py index f0aa706f..fe7785bc 100644 --- a/pikaraoke/lib/omxclient.py +++ b/pikaraoke/lib/omxclient.py @@ -3,6 +3,8 @@ import subprocess import time +# This is a legacy class for interacting with raspberry pi's OMXPlayer to play back the video. It is no longer in use. All playback is handled by the browser + class OMXClient: def __init__(self, path=None, adev=None, dual_screen=False, volume_offset=None): diff --git a/pikaraoke/lib/vlcclient.py b/pikaraoke/lib/vlcclient.py index b3a69b19..8405ef9b 100644 --- a/pikaraoke/lib/vlcclient.py +++ b/pikaraoke/lib/vlcclient.py @@ -29,6 +29,7 @@ def get_default_vlc_path(platform): return "/usr/bin/vlc" +# This is a legacy class for interacting with VLC to play back the video. It is no longer in use. All playback is handled by the browser class VLCClient: def __init__(self, port=5002, path=None, qrcode=None, url=None): # HTTP remote control server diff --git a/pikaraoke/messages.pot b/pikaraoke/messages.pot index 04c85681..721ca04a 100644 --- a/pikaraoke/messages.pot +++ b/pikaraoke/messages.pot @@ -1,39 +1,334 @@ # Translations template for PROJECT. -# Copyright (C) 2024 ORGANIZATION +# Copyright (C) 2025 ORGANIZATION # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2024. +# FIRST AUTHOR , 2025. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-10-30 14:43-0300\n" +"POT-Creation-Date: 2025-01-03 00:00-0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.16.0\n" +"Generated-By: Babel 2.9.1\n" #. Message shown after logging in as admin successfully -#: app.py:126 +#: app.py:139 msgid "Admin mode granted!" msgstr "" #. Message shown after failing to login as admin -#: app.py:130 +#: app.py:143 msgid "Incorrect admin password!" msgstr "" +#. Message shown after logging out as admin successfully +#: app.py:157 +msgid "Logged out of admin mode!" +msgstr "" + +#. Message shown after adding random tracks +#: app.py:218 +#, python-format +msgid "Added %s random tracks" +msgstr "" + +#. Message shown after running out songs to add during random track addition +#: app.py:221 +msgid "Ran out of songs!" +msgstr "" + +#. Message shown after clearing the queue +#: app.py:231 +msgid "Cleared the queue!" +msgstr "" + +#. Message shown after moving a song down in the queue +#: app.py:240 +msgid "Moved down in queue" +msgstr "" + +#. Message shown after failing to move a song down in the queue +#: app.py:243 +msgid "Error moving down in queue" +msgstr "" + +#. Message shown after moving a song up in the queue +#: app.py:248 +msgid "Moved up in queue" +msgstr "" + +#. Message shown after failing to move a song up in the queue +#: app.py:251 +msgid "Error moving up in queue" +msgstr "" + +#. Message shown after deleting a song from the queue +#: app.py:256 +msgid "Deleted from queue" +msgstr "" + +#. Message shown after failing to delete a song from the queue +#: app.py:259 +msgid "Error deleting from queue" +msgstr "" + #. Title of the files page. #. Navigation link for the page where the user can add existing songs to the #. queue. -#: app.py:382 templates/base.html:201 +#: app.py:407 templates/base.html:201 msgid "Browse" msgstr "" +#. Message shown after starting a download. Song title is displayed in the +#. message. +#: app.py:432 +#, python-format +msgid "Download started: %s. This may take a couple of minutes to complete." +msgstr "" + +#. Message shown after starting a download that will be adding a song to the +#. queue. +#: app.py:438 +msgid "Song will be added to queue." +msgstr "" + +#. Message shown after after starting a download. +#: app.py:441 +msgid "Song will appear in the \"available songs\" list." +msgstr "" + +#. Message shown after trying to delete a song that is in the queue. +#: app.py:494 +msgid "Error: Can't delete this song because it is in the current queue" +msgstr "" + +#. Message shown after deleting a song. Followed by the song path +#: app.py:502 +#, python-format +msgid "Song deleted: %s" +msgstr "" + +#. Message shown after trying to delete a song without specifying the song. +#: app.py:505 +msgid "Error: No song specified!" +msgstr "" + +#. Message shown after trying to edit a song that is in the queue. +#: app.py:512 +msgid "Error: Can't edit this song because it is in the current queue: " +msgstr "" + +#. Message shown after trying to rename a file to a name that already exists. +#: app.py:540 +#, python-format +msgid "Error renaming file: '%s' to '%s', Filename already exists" +msgstr "" + +#. Message shown after renaming a file. +#: app.py:548 +#, python-format +msgid "Renamed file: %s to %s" +msgstr "" + +#. Message shown after trying to edit a song without specifying the filename. +#: app.py:553 +msgid "Error: No filename parameters were specified!" +msgstr "" + +#: app.py:617 +msgid "CPU usage query unsupported" +msgstr "" + +#. Message shown after starting the youtube-dl update. +#: app.py:701 +msgid "Updating youtube-dl! Should take a minute or two... " +msgstr "" + +#. Message shown after trying to update youtube-dl without admin permissions. +#: app.py:708 +msgid "You don't have permission to update youtube-dl" +msgstr "" + +#. Message shown after trying to refresh the song list without admin +#. permissions. +#. Message shown after trying to shut down the system without admin +#. permissions. +#: app.py:718 app.py:748 +msgid "You don't have permission to shut down" +msgstr "" + +#. Message shown after quitting pikaraoke. +#: app.py:726 +msgid "Exiting pikaraoke now!" +msgstr "" + +#. Message shown after trying to quit pikaraoke without admin permissions. +#: app.py:733 +msgid "You don't have permission to quit" +msgstr "" + +#. Message shown after shutting down the system. +#: app.py:741 +msgid "Shutting down system now!" +msgstr "" + +#. Message shown after rebooting the system. +#: app.py:756 +msgid "Rebooting system now!" +msgstr "" + +#. Message shown after trying to reboot the system without admin permissions. +#: app.py:763 +msgid "You don't have permission to Reboot" +msgstr "" + +#. Message shown after expanding the filesystem. +#: app.py:771 +msgid "Expanding filesystem and rebooting system now!" +msgstr "" + +#. Message shown after trying to expand the filesystem on a non-raspberry pi +#. device. +#: app.py:776 +msgid "Cannot expand fs on non-raspberry pi devices!" +msgstr "" + +#. Message shown after trying to expand the filesystem without admin +#. permissions +#: app.py:779 +msgid "You don't have permission to resize the filesystem" +msgstr "" + +#. Message shown after trying to change preferences without admin permissions. +#: app.py:794 +msgid "You don't have permission to change preferences" +msgstr "" + +#. Message shown after trying to clear preferences without admin permissions. +#: app.py:808 +msgid "You don't have permission to clear preferences" +msgstr "" + +#. Message shown after trying to stream a file that does not exist. +#: app.py:867 +msgid "File not found." +msgstr "" + +#: karaoke.py:276 +msgid "Your preferences were changed successfully" +msgstr "" + +#: karaoke.py:279 +msgid "Something went wrong! Your preferences were not changed" +msgstr "" + +#: karaoke.py:284 +msgid "Your preferences were cleared successfully" +msgstr "" + +#: karaoke.py:286 +msgid "Something went wrong! Your preferences were not cleared" +msgstr "" + +#. Message shown after the download is started +#: karaoke.py:436 +#, python-format +msgid "Downloading video: %s" +msgstr "" + +#. Message shown after the download is completed and queued +#: karaoke.py:452 +#, python-format +msgid "Downloaded and queued: %s" +msgstr "" + +#. Message shown after the download is completed but not queued +#: karaoke.py:455 +#, python-format +msgid "Downloaded: %s" +msgstr "" + +#. Message shown after the download is completed but the adding to queue fails +#: karaoke.py:464 +msgid "Error queueing song: " +msgstr "" + +#. Message shown after the download process is completed but the song is not +#. found +#: karaoke.py:467 +msgid "Error downloading song: " +msgstr "" + +#. Message shown when the song ends abnormally +#: karaoke.py:670 +#, python-format +msgid "Song ended abnormally: %s" +msgstr "" + +#. Message shown after the song is transposed, first is the semitones and then +#. the song name +#: karaoke.py:678 +#, python-format +msgid "Transposing by %s semitones: %s" +msgstr "" + +#: karaoke.py:711 +#, python-format +msgid "You reached the limit of %s song(s) from an user in queue!" +msgstr "" + +#. Message shown after the song is added to the top of the queue +#: karaoke.py:723 +#, python-format +msgid "%s added to top of queue: %s" +msgstr "" + +#. Message shown after the song is added to the queue +#: karaoke.py:728 +#, python-format +msgid "%s added to the queue: %s" +msgstr "" + +#: karaoke.py:730 +#, python-format +msgid "Song added to the queue: %s" +msgstr "" + +#. Message shown after the queue is cleared +#: karaoke.py:754 +msgid "Clear queue" +msgstr "" + +#. Message shown after the song is skipped, will be followed by song name +#: karaoke.py:800 +#, python-format +msgid "Skip: %s" +msgstr "" + +#. Message shown after the song is resumed, will be followed by song name +#: karaoke.py:811 +#, python-format +msgid "Resume: %s" +msgstr "" + +#. Message shown after the song is paused, will be followed by song name +#: karaoke.py:814 +msgid "Pause" +msgstr "" + +#. Message shown after the volume is changed, will be followed by the volume +#. level +#: karaoke.py:824 +#, python-format +msgid "Volume: %s%" +msgstr "" + #. Prompt which asks the user their name when they first try to add to the #. queue. #: templates/base.html:53 @@ -63,7 +358,7 @@ msgstr "" #. Navigation link for the search page add songs to the queue. #. Submit button on the search form for searching YouTube. -#: templates/base.html:196 templates/search.html:364 +#: templates/base.html:196 templates/search.html:368 msgid "Search" msgstr "" @@ -107,21 +402,9 @@ msgstr "" msgid "Delete this song" msgstr "" -#. Notification when a song gets added to the queue. The song name comes after -#. this string. -#: templates/files.html:25 -msgid "Song added to the queue: " -msgstr "" - -#. Notification when a song does not get added to the queue. The song name -#. comes after this string. -#: templates/files.html:31 -msgid "Song already in the queue: " -msgstr "" - #. Label which displays that the songs are currently sorted by alphabetical #. order. -#: templates/files.html:87 +#: templates/files.html:82 msgid "" "Sorted\n" " Alphabetically" @@ -129,18 +412,18 @@ msgstr "" #. Button which changes how the songs are sorted so they become sorted by #. date. -#: templates/files.html:91 +#: templates/files.html:86 msgid "Sort by Date" msgstr "" #. Label which displays that the songs are currently sorted by date. -#: templates/files.html:94 +#: templates/files.html:89 msgid "Sorted by date" msgstr "" #. Button which changes how the songs are sorted so they become sorted by #. name. -#: templates/files.html:98 +#: templates/files.html:93 msgid "Sort by Alphabetical" msgstr "" @@ -161,171 +444,288 @@ msgid "No song is queued." msgstr "" #. Confirmation message when clicking a button to skip a track. -#: templates/home.html:144 +#: templates/home.html:148 msgid "" "Are you sure you want to skip this track? If you didn't add this song, " "ask permission first!" msgstr "" #. Header showing the currently playing song. -#: templates/home.html:176 +#: templates/home.html:180 msgid "Now Playing" msgstr "" #. Title for the section displaying the next song to be played. -#: templates/home.html:191 +#: templates/home.html:195 msgid "Next Song" msgstr "" #. Title of the box with controls such as pause and skip. -#: templates/home.html:202 +#: templates/home.html:206 msgid "Player Control" msgstr "" #. Title attribute on the button to restart the current song. -#: templates/home.html:206 +#: templates/home.html:210 msgid "Restart Song" msgstr "" #. Title attribute on the button to play or pause the current song. -#: templates/home.html:210 +#: templates/home.html:214 msgid "Play/Pause" msgstr "" #. Title attribute on the button to skip to the next song. -#: templates/home.html:214 +#: templates/home.html:218 msgid "Stop Current Song" msgstr "" #. Title of a control to change the key/pitch of the playing song. -#: templates/home.html:236 +#: templates/home.html:242 msgid "Change Key" msgstr "" #. Label on the button to confirm the change in key/pitch of the #. playing song. -#: templates/home.html:264 +#: templates/home.html:269 msgid "Change" msgstr "" #. Confirmation text whe the user selects quit. -#: templates/info.html:7 +#: templates/info.html:47 msgid "Are you sure you want to quit?" msgstr "" #. Confirmation text whe the user starts to turn off the machine running #. Pikaraoke. -#: templates/info.html:15 +#: templates/info.html:55 msgid "Are you sure you want to shut down?" msgstr "" +#. Confirmation text whe the user clears preferences. +#: templates/info.html:63 +msgid "Are you sure you want to clear your preferences?" +msgstr "" + #. Confirmation text whe the user starts to reboot the machine running #. Pikaraoke. -#: templates/info.html:23 +#: templates/info.html:71 msgid "Are you sure you want to reboot?" msgstr "" #. Confirmation text whe the user asks to update the Youtube-dl tool. -#: templates/info.html:33 +#: templates/info.html:81 msgid "" "Are you sure you want to update Youtube-dl right now? Current and pending" " downloads may fail." msgstr "" #. Title of the information page. -#: templates/info.html:54 +#: templates/info.html:102 msgid "Information" msgstr "" #. Label which appears before a url which links to the current page. -#: templates/info.html:64 +#: templates/info.html:112 #, python-format msgid "URL of %(site_title)s:" msgstr "" #. Label before a QR code which brings a frind (pal) to the main page if #. scanned, so they can also add songs. QR code follows this text. -#: templates/info.html:70 +#: templates/info.html:118 msgid "Handy URL QR code to share with a pal:" msgstr "" #. Header of the information section about the computer running Pikaraoke. -#: templates/info.html:78 +#: templates/info.html:129 msgid "System Info" msgstr "" #. The hardware platform -#: templates/info.html:81 +#: templates/info.html:132 msgid "Platform:" msgstr "" #. The os version -#: templates/info.html:83 +#: templates/info.html:134 msgid "OS Version:" msgstr "" #. The version of the program "Youtube-dl". -#: templates/info.html:85 +#: templates/info.html:136 msgid "Youtube-dl (yt-dlp) version:" msgstr "" #. The version of the program "ffmpeg". -#: templates/info.html:87 +#: templates/info.html:138 msgid "FFmpeg version:" msgstr "" #. The version of Pikaraoke running right now. -#: templates/info.html:89 +#: templates/info.html:140 msgid "Pikaraoke version:" msgstr "" -#: templates/info.html:91 +#: templates/info.html:142 msgid "System stats" msgstr "" #. The CPU usage of the computer running Pikaraoke. -#: templates/info.html:94 +#: templates/info.html:145 #, python-format msgid "CPU: %(cpu)s" msgstr "" #. The disk usage of the computer running Pikaraoke. Used by downloaded songs. -#: templates/info.html:96 +#: templates/info.html:147 #, python-format msgid "Disk Usage: %(disk)s" msgstr "" #. The memory (RAM) usage of the computer running Pikaraoke. -#: templates/info.html:98 +#: templates/info.html:149 #, python-format msgid "Memory: %(memory)s" msgstr "" +#. Title of the user preferences section. +#: templates/info.html:155 +msgid "User Preferences" +msgstr "" + +#. Title text for the splash screen settings section of preferences +#: templates/info.html:157 +msgid "Splash screen settings" +msgstr "" + +#. Help text explaining the the need to restart after changing splash screen +#. preferences +#: templates/info.html:160 +msgid "" +"*You may need to refresh the splash screen for these changes to take " +"effect." +msgstr "" + +#. Checkbox label which enable/disables background music on the Splash Screen +#: templates/info.html:164 +msgid "Disable background music" +msgstr "" + +#. Checkbox label which enable/disables the Score Screen +#: templates/info.html:170 +msgid "Disable the score screen after each song" +msgstr "" + +#. Checkbox label which enable/disables notifications on the splash screen +#: templates/info.html:176 +msgid "Hide notifications" +msgstr "" + +#. Checkbox label which enable/disables the URL display +#: templates/info.html:182 +msgid "Hide the URL and QR code" +msgstr "" + +#. Checkbox label which enable/disables showing overlay data on the splash +#. screen +#: templates/info.html:188 +msgid "Hide all overlays, including now playing, up next, and QR code" +msgstr "" + +#. Numberbox label for setting the default video volume +#: templates/info.html:194 +msgid "Default volume of the videos (min 0, max 100)" +msgstr "" + +#. Numberbox label for setting the background music volume +#: templates/info.html:200 +msgid "Volume of the background music (min 0, max 100)" +msgstr "" + +#. Numberbox label for setting the inactive delay before showing the +#. screensaver +#: templates/info.html:207 +msgid "The amount of idle time in seconds before the screen saver activates" +msgstr "" + +#. Numberbox label for setting the delay before playing the next song +#: templates/info.html:214 +msgid "The delay in seconds before starting the next song" +msgstr "" + +#. Title text for the server settings section of preferences +#: templates/info.html:218 +msgid "Server settings" +msgstr "" + +#. Checkbox label which enable/disables audio volume normalization +#: templates/info.html:222 +msgid "Normalize audio volume" +msgstr "" + +#. Checkbox label which enable/disables high quality video downloads +#: templates/info.html:228 +msgid "Download high quality videos" +msgstr "" + +#. Checkbox label which enable/disables full transcode before playback +#: templates/info.html:234 +msgid "" +"Transcode video completely before playing (better browser compatibility, " +"slower starts). Buffer size will be ignored.*" +msgstr "" + +#. Numberbox label for limitting the number of songs for each player +#: templates/info.html:241 +msgid "Limit of songs an individual user can add to the queue (0 = unlimited)" +msgstr "" + +#. Numberbox label for setting the buffer size in kilobytes +#: templates/info.html:248 +msgid "" +"Buffer size in kilobytes. Transcode this amount of the video before " +"sending it to the splash screen. " +msgstr "" + +#. Help text explaining when videos will be transcoded +#: templates/info.html:252 +msgid "" +"* Videos are only transcoded when: normalization is on, a song is " +"transposed, playing a CDG/MOV/AVI/MKV file. Most unmodified MP4 files " +"will not need to be transcoded." +msgstr "" + +#. Text for the link where the user can clear all user preferences +#: templates/info.html:256 +msgid "Clear preferences" +msgstr "" + #. Title of the updates section. -#: templates/info.html:105 +#: templates/info.html:262 msgid "Updates" msgstr "" #. Label before a link which forces Pikaraoke to rescan and pick up any new #. songs. -#: templates/info.html:107 +#: templates/info.html:264 msgid "Refresh the song list:" msgstr "" #. Text on the link which forces Pikaraoke to rescan and pick up any new songs. -#: templates/info.html:112 +#: templates/info.html:269 msgid "Rescan song directory" msgstr "" #. Help text explaining the Rescan song directory link. -#: templates/info.html:117 +#: templates/info.html:274 msgid "" "You should only need to do this if you manually copied files to the " "download directory while pikaraoke was running." msgstr "" #. Text explaining why you might want to update youtube-dl. -#: templates/info.html:122 +#: templates/info.html:279 #, python-format msgid "" "If downloads or searches stopped working, updating youtube-dl will " @@ -335,13 +735,13 @@ msgstr "" #. Text for the link which will try and update youtube-dl on the machine #. running Pikaraoke. -#: templates/info.html:128 +#: templates/info.html:285 msgid "Update youtube-dl" msgstr "" -#. Help text which explains why updating youtube-dl can fail. The log is a -#. file on the machine running Pikaraoke. -#: templates/info.html:134 +#. Help text which explains why updating youtube-dl can fail. The log is a file +#. on the machine running Pikaraoke. +#: templates/info.html:291 msgid "" "This update link above may fail if you don't have proper file " "permissions.\n" @@ -350,12 +750,12 @@ msgstr "" #. Title of the section on shutting down / turning off the machine running #. Pikaraoke. -#: templates/info.html:141 +#: templates/info.html:298 msgid "Shutdown" msgstr "" #. Explainitory text which explains why to use the shutdown link. -#: templates/info.html:144 +#: templates/info.html:301 msgid "" "Don't just pull the plug! Always shut down your server properly to avoid " "data corruption." @@ -363,33 +763,33 @@ msgstr "" #. Text for button which turns off Pikaraoke for everyone using it at your #. house. -#: templates/info.html:150 +#: templates/info.html:307 msgid "Quit Pikaraoke" msgstr "" #. Text for button which reboots the machine running Pikaraoke. -#: templates/info.html:153 +#: templates/info.html:310 msgid "Reboot System" msgstr "" #. Text for button which turn soff the machine running Pikaraoke. -#: templates/info.html:156 +#: templates/info.html:313 msgid "Shutdown System" msgstr "" #. Title for section containing a few other options on the Info page. -#: templates/info.html:163 +#: templates/info.html:320 msgid "Other" msgstr "" #. Text for button -#: templates/info.html:166 +#: templates/info.html:323 msgid "Expand Raspberry Pi filesystem" msgstr "" #. Explainitory text which explains why you might want to expand the #. filesystem. -#: templates/info.html:169 +#: templates/info.html:326 msgid "" "If you just installed the pre-built pikaraoke pi image and your SD card " "is larger than 4GB,\n" @@ -399,13 +799,13 @@ msgid "" msgstr "" #. Link which will log out the user from admin mode. -#: templates/info.html:179 +#: templates/info.html:336 #, python-format msgid "Disable admin mode: Log out" msgstr "" #. Link which will let the user log into admin mode. -#: templates/info.html:185 +#: templates/info.html:342 #, python-format msgid "" "\n" @@ -445,32 +845,32 @@ msgid "Available songs in local library" msgstr "" #. Title for the search page. -#: templates/search.html:336 +#: templates/search.html:340 msgid "Search / Add New" msgstr "" -#: templates/search.html:356 +#: templates/search.html:360 msgid "Available Songs" msgstr "" #. Submit button on the search form when selecting a locally #. downloaded song. The button adds it to the queue. -#: templates/search.html:369 +#: templates/search.html:373 msgid "Add to queue" msgstr "" #. Link which clears the text from the search box. -#: templates/search.html:380 +#: templates/search.html:384 msgid "Clear" msgstr "" #. Checkbox label which enables more options when searching. -#: templates/search.html:386 +#: templates/search.html:390 msgid "Advanced" msgstr "" #. Help text below the search bar. -#: templates/search.html:392 +#: templates/search.html:396 msgid "" "Type a song\n" " (title/artist) to search the available songs and click 'Add to " @@ -479,7 +879,7 @@ msgid "" msgstr "" #. Additonal help text below the search bar. -#: templates/search.html:397 +#: templates/search.html:401 msgid "" "If\n" " the song doesn't appear in the \"Available Songs\" dropdown, " @@ -490,19 +890,19 @@ msgstr "" #. Checkbox label which enables matching songs which are not karaoke #. versions (i.e. the songs still have a singer and are not just #. instrumentals.) -#: templates/search.html:420 +#: templates/search.html:424 msgid "Include non-karaoke matches" msgstr "" #. Label for an input which takes a YouTube url directly instead of #. searching titles. -#: templates/search.html:428 +#: templates/search.html:432 msgid "Direct download YouTube url:" msgstr "" #. Checkbox label which marks the song to be added to the queue #. after it finishes downloading. -#: templates/search.html:443 +#: templates/search.html:448 msgid "" "Add to queue\n" " once downloaded" @@ -511,13 +911,13 @@ msgstr "" #. Button label for the direct download form's submit button. #. Label on the button which starts the download of the selected #. song. -#: templates/search.html:455 templates/search.html:527 +#: templates/search.html:460 templates/search.html:535 msgid "Download" msgstr "" #. Html text which displays what was searched for, in quotes while the page #. is loading. -#: templates/search.html:476 +#: templates/search.html:481 #, python-format msgid "" "Searching YouTube for\n" @@ -525,7 +925,7 @@ msgid "" msgstr "" #. Html text which displays what was searched for, in quotes. -#: templates/search.html:486 +#: templates/search.html:491 #, python-format msgid "" "Search results for\n" @@ -534,7 +934,7 @@ msgstr "" #. Help text which explains that the select box above can be operated #. to select different search results. -#: templates/search.html:501 +#: templates/search.html:509 msgid "" "Click\n" " dropdown to show more results" @@ -542,38 +942,112 @@ msgstr "" #. Label displayed before the YouTube url for the chosen search #. result. -#: templates/search.html:506 +#: templates/search.html:514 msgid "Link:" msgstr "" #. Checkbox label which marks the song to be added to the queue after #. it finishes downloading. -#: templates/search.html:516 +#: templates/search.html:524 msgid "" "Add to queue once\n" " downloaded" msgstr "" +#. Score review message +#: templates/splash.html:54 +msgid "Never sing again... ever." +msgstr "" + +#. Score review message +#: templates/splash.html:55 +msgid "That was a really good impression of a dying cat!" +msgstr "" + +#. Score review message +#: templates/splash.html:56 +msgid "Thank God it's over." +msgstr "" + +#. Score review message +#: templates/splash.html:57 +msgid "Pass the mic, please!" +msgstr "" + +#. Score review message +#: templates/splash.html:58 +msgid "Well, I'm sure you're very good at your day job." +msgstr "" + +#. Score review message +#: templates/splash.html:61 +msgid "I've seen better." +msgstr "" + +#. Score review message +#: templates/splash.html:62 +msgid "Ok... just ok." +msgstr "" + +#. Score review message +#: templates/splash.html:63 +msgid "Not bad for an amateur." +msgstr "" + +#. Score review message +#: templates/splash.html:64 +msgid "You put on a decent show." +msgstr "" + +#. Score review message +#: templates/splash.html:65 +msgid "That was... something." +msgstr "" + +#. Score review message +#: templates/splash.html:68 +msgid "Congratulations! That was unbelievable!" +msgstr "" + +#. Score review message +#: templates/splash.html:69 +msgid "Wow, have you tried auditioning for The Voice?" +msgstr "" + +#. Score review message +#: templates/splash.html:70 +msgid "Please, sing another one!" +msgstr "" + +#. Score review message +#: templates/splash.html:71 +msgid "You rock! You know that?!" +msgstr "" + +#. Score review message +#: templates/splash.html:72 +msgid "Woah, who let Freddie Mercury in here?" +msgstr "" + #. Label for the next song to be played in the queue. -#: templates/splash.html:92 templates/splash.html:337 +#: templates/splash.html:209 templates/splash.html:501 msgid "Up next:" msgstr "" #. Label of the singer for next song to be played in the queue. (Who added it #. to the queue.) -#: templates/splash.html:94 +#. Label for the next singer in the queue. +#: templates/splash.html:211 templates/splash.html:508 msgid "Next singer:" msgstr "" -#. Label for the next singer in the queue. -#: templates/splash.html:343 -msgid "" -"Next\n" -" singer:" +#. The title of the score screen, telling the user their singing score +#: templates/splash.html:524 +msgid "Your Score" msgstr "" #. Prompt for interaction in order to enable video autoplay. -#: templates/splash.html:362 +#: templates/splash.html:538 msgid "" "Due to limititations with browser permissions, you must interact\n" " with the page once before it allows autoplay of videos. Pikaraoke " @@ -583,6 +1057,6 @@ msgid "" msgstr "" #. Button to confirm to enable video autoplay. -#: templates/splash.html:374 +#: templates/splash.html:550 msgid "Confirm" msgstr "" 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/music/midnight-dorufin.mp3 b/pikaraoke/static/music/midnight-dorufin.mp3 new file mode 100644 index 00000000..6b848f51 Binary files /dev/null and b/pikaraoke/static/music/midnight-dorufin.mp3 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/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/base.html b/pikaraoke/templates/base.html index f2336851..2f54f714 100644 --- a/pikaraoke/templates/base.html +++ b/pikaraoke/templates/base.html @@ -135,7 +135,7 @@ .navbar-item > i { margin-right: 2px; } - .notification { + .base-notification { position: fixed; width: 500px; bottom: 5px; @@ -156,7 +156,7 @@ .navbar-brand > .navbar-item > span { display: none; } - .notification { + .base-notification { position: fixed; width: 100%; bottom: 5px; @@ -222,7 +222,7 @@ {% if get_flashed_messages() %} {% for category, message in get_flashed_messages(with_categories=true) %} -
+
{{ message }}
@@ -230,7 +230,7 @@ {% endif %} -