#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Projekcija - Lyrics Projector # # Izvorna zasnova in implementacija: Uroš Urbanija # Nadgradnje in vzdrževanje: Valentin Korenjak # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import sqlite3 import tkinter as tk from tkinter import font import json import os import math import subprocess import sys import ctypes import tkinter.messagebox as messagebox from web.server import start_server_thread, notify_clients import urllib.request import tempfile from db_schema import create_tables DB_PATH = 'songs.db' SETTINGS_PATH = 'settings.json' BASE_DIR = os.path.dirname(os.path.abspath(__file__)) class SongProjector: def __init__(self, root): self.root = root # ... try: # Odpri read-only; ne bo ustvaril prazne baze, če datoteka manjka # check_same_thread=False omogoča uporabo v večih nitih self.conn = sqlite3.connect(DB_PATH, check_same_thread=False) create_tables(self.conn) self.cursor = self.conn.cursor() except sqlite3.OperationalError as e: # Jasno sporočilo in varen izhod messagebox.showerror("Napaka baze", f"Ne morem odpreti baze '{DB_PATH}':\n{e}") root.destroy() sys.exit(1) self.current_song = None self.pages = [] self.current_page_index = 0 self.song_number = "" self.song_number_last = "" self.all_caps_mode = False # -------------------------------------------------- # Nastavitve # -------------------------------------------------- DEFAULT_SETTINGS = { "font_name": "Noto Sans", "bg_color": "#000000", "fg_color": "#FFFFFF", "font_size": 32, "screen_width_percent": 60, "font_bold": False, "show_song_info": True, "split_by_stanza": False, "web_port": 5000, "db_update_url": "", "ntfy_topic": "", "installation_label": "Projekcija" } if not os.path.exists(SETTINGS_PATH): self.settings = DEFAULT_SETTINGS.copy() self.save_settings() else: try: with open(SETTINGS_PATH, "r", encoding="utf-8") as f: self.settings = json.load(f) except (json.JSONDecodeError, OSError): # Povratek na varne privzete self.settings = DEFAULT_SETTINGS.copy() # -------------------------------------------------- # Pisava (Font) # -------------------------------------------------- font_weight = "bold" if self.settings.get("font_bold", False) else "normal" self.custom_font = font.Font( family=self.settings["font_name"], size=int(self.settings["font_size"]), weight=font_weight ) # Izračunamo dejansko višino vrstice glede na pisavo # metrics("linespace") vrne priporočen razmik med vrsticami v pikslih self.line_height = self.custom_font.metrics("linespace") # -------------------------------------------------- # Okno # -------------------------------------------------- root.attributes('-fullscreen', True) root.configure(bg=self.settings["bg_color"]) root.bind("", self.enter_pressed) root.bind("", self.enter_pressed) root.bind("", self.clear_screen) root.bind("", self.clear_screen) root.bind("", self.prev_page) root.bind("", self.prev_page) root.bind("", self.search_song) root.bind("", self.search_song) root.bind("", self.key_pressed) screen_width = root.winfo_screenwidth() screen_height = root.winfo_screenheight() screen_width_percent = self.settings["screen_width_percent"] color_width = int(screen_width * screen_width_percent / 100) self.screen_height = screen_height self.color_width = color_width black_side_width = (screen_width - color_width) // 2 self.left_frame = tk.Frame(root, bg="black", width=black_side_width, height=screen_height) self.left_frame.pack(side="left", fill="y") self.color_frame = tk.Frame(root, bg=self.settings["bg_color"], width=color_width, height=screen_height) self.color_frame.place(relx=0.5, rely=0.5, anchor="center") self.right_frame = tk.Frame(root, bg="black", width=black_side_width, height=screen_height) self.right_frame.pack(side="right", fill="y") self.display_text = tk.Label( self.color_frame, text="", bg=self.settings["bg_color"], fg=self.settings["fg_color"], font=self.custom_font, wraplength=color_width, justify="center" ) self.display_text.pack(expand=True) right_edge_x = int((screen_width - color_width) / 2 + color_width) self.song_info_label = tk.Label( root, text="", bg=self.settings["bg_color"], fg=self.settings["fg_color"], font=(self.settings["font_name"], int(self.settings["font_size"]) - 5), anchor="se", justify="right" ) self.song_info_label.place(x=right_edge_x - 10, y=screen_height - 10, anchor="se") self.search_label = None self.search_entry = None self.waiting_for_song = True # -------------------------------------------------- # Samodejno skrivanje kazalca # -------------------------------------------------- def hide_cursor_after_delay(): self.root.config(cursor="none") def reset_cursor_timer(event=None): self.root.config(cursor="") if hasattr(self, "cursor_job"): self.root.after_cancel(self.cursor_job) self.cursor_job = self.root.after(0, hide_cursor_after_delay) self.root.bind_all('', reset_cursor_timer) self.root.bind_all('', reset_cursor_timer) self.cursor_job = self.root.after(0, hide_cursor_after_delay) self.clear_screen() # -------------------------------------------------- # Zagon Flask web serverja (če je port nastavljen) # -------------------------------------------------- web_port = self.settings.get("web_port", 0) if web_port and web_port > 0: try: start_server_thread(self, host='0.0.0.0', port=web_port) print(f"Web server zagnan na http://0.0.0.0:{web_port}") except Exception as e: print(f"Napaka pri zagonu web serverja: {e}") # -------------------------------------------------- # Preprečevanje ohranjevalnika zaslona # -------------------------------------------------- self.keep_awake() def keep_awake(self): """ Prepreči vklop ohranjevalnika zaslona ali spanja. """ self.inhibitor_cookie = None try: if sys.platform.startswith("win"): # Windows: ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED # 0x80000000 | 0x00000002 | 0x00000001 # Uporabimo c_uint za zagotovitev pravilnega tipa ctypes.windll.kernel32.SetThreadExecutionState(ctypes.c_uint(0x80000003)) elif sys.platform == "linux": # GNOME DBus inhibitor (org.gnome.SessionManager) # Flags: 1=logout, 2=switch user, 4=suspend, 8=idle # Uporabimo 12 (4+8) za suspend in idle. # toplevel_xid=0 (uint32:0) cmd = [ "dbus-send", "--print-reply", "--dest=org.gnome.SessionManager", "/org/gnome/SessionManager", "org.gnome.SessionManager.Inhibit", "string:projector", "uint32:0", "string:Projecting lyrics", "uint32:12" ] output = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode() # Output format: uint32 12345678 for line in output.splitlines(): if "uint32" in line: self.inhibitor_cookie = line.split()[-1] break except Exception as e: print(f"Napaka pri preprečevanju spanja: {e}") # ------------------------------------------------------ # NOVA METODA: enakomeren prelom predolgih vrstic # ------------------------------------------------------ def split_long_line(self, line): """ Vrne seznam pod-vrstic, ki skupaj tvorijo `line`, pri čemer so dolge največ `self.color_width` pikslov. """ words = line.split() if not words: return [""] sub_lines = [] current_words = [] for word in words: test_line = " ".join(current_words + [word]) # Izmerimo širino v pikslih if self.custom_font.measure(test_line) <= self.color_width: current_words.append(word) else: if current_words: sub_lines.append(" ".join(current_words)) current_words = [word] else: # Beseda je sama po sebi predolga (zelo redko) sub_lines.append(word) current_words = [] if current_words: sub_lines.append(" ".join(current_words)) return sub_lines # ------------------------------------------------------ # Upravljanje tipkovnice # ------------------------------------------------------ def key_pressed(self, event): if event.char and event.char.isdigit(): self.song_number += event.char elif event.keysym == "BackSpace": self.song_number = self.song_number[:-1] elif event.char == "*": self.toggle_split_mode() def enter_pressed(self, event=None): if self.song_number: self.load_song() elif not self.waiting_for_song: self.next_page() def save_settings(self): """Shrani trenutne nastavitve v settings.json.""" try: with open(SETTINGS_PATH, "w", encoding="utf-8") as f: json.dump(self.settings, f, indent=4, ensure_ascii=False) except Exception as e: print(f"Napaka pri shranjevanju nastavitev: {e}") def toggle_split_mode(self): """Preklopi med načinom preloma po kiticah in prostim prelomom.""" self.settings["split_by_stanza"] = not self.settings.get("split_by_stanza", False) # Shranimo v settings.json, da se ohrani ob ponovnem zagonu # TODO: preveri z Urošem, kaj bi bilo bolje (shraniti ali ne) # self.save_settings() # Ponovno naložimo trenutno pesem, da se osveži prelom if self.song_number_last: self.song_number = self.song_number_last self.load_song() # ------------------------------------------------------ # Nalaganje in obdelava besedila # ------------------------------------------------------ def load_song(self): if not self.song_number: return if self.song_number == "0": self.clear_screen() self.song_number = "" return # Ponastavitev izgleda barvnega območja self.color_frame.config(bg=self.settings["bg_color"], width=self.color_width, height=self.screen_height) self.color_frame.place(relx=0.5, rely=0.5, anchor="center") self.display_text.config(bg=self.settings["bg_color"], fg=self.settings["fg_color"]) self.display_text.pack(expand=True) # Posebni ukazi if self.song_number == "9999": if sys.platform.startswith("win"): subprocess.Popen(["shutdown", "/s", "/t", "0"]) # brez /f - manj agresivno else: subprocess.Popen(["shutdown", "-h", "now"]) return elif self.song_number == "9998": if sys.platform.startswith("win"): subprocess.Popen(["shutdown", "/r", "/t", "0"]) else: subprocess.Popen(["shutdown", "-r", "now"]) return elif self.song_number == "9997": self.restart_program() return elif self.song_number == "9988": self.exit_program() return elif self.song_number == "9901": self.update_songs_database() return try: song_id = int(self.song_number) self.cursor.execute("SELECT lyrics FROM songs WHERE id=?", (song_id,)) result = self.cursor.fetchone() if result: lyrics = result[0] # Pustimo 10% varnostnega roba zgoraj in spodaj + prostor za song_info max_height = self.screen_height * 0.90 # 1. Razdelimo na kitice (stanzas) # Kitice so ločene z dvojno prazno vrstico (\n\n\n ali \n\s*\n\s*\n) import re # Razdelimo po vsaj dveh praznih vrsticah (trije ali več \n) stanzas_raw = re.split(r'\n\s*\n\s*\n+', lyrics.strip()) processed_stanzas = [] for stanza in stanzas_raw: # Znotraj kitice ohranimo enojne prazne vrstice kot razmike lines = stanza.splitlines() stanza_lines = [] for line in lines: if line.strip(): stanza_lines.extend(self.split_long_line(line)) else: stanza_lines.append("") # Enojna prazna vrstica znotraj kitice if stanza_lines: processed_stanzas.append(stanza_lines) # 2. Razdeljevanje kitic na strani pages = [] current_page_lines = [] current_height = 0 split_by_stanza = self.settings.get("split_by_stanza", False) for stanza in processed_stanzas: stanza_height = len(stanza) * self.line_height # Če je vklopljen split_by_stanza, vsaka kitica dobi svojo stran if split_by_stanza: if current_page_lines: pages.append("\n".join(current_page_lines)) current_page_lines = [] current_height = 0 # Če je kitica predolga za eno stran, jo še vedno moramo razdeliti if stanza_height > max_height: temp_stanza_lines = [] temp_height = 0 for line in stanza: if temp_height + self.line_height > max_height: pages.append("\n".join(temp_stanza_lines)) temp_stanza_lines = [line] temp_height = self.line_height else: temp_stanza_lines.append(line) temp_height += self.line_height if temp_stanza_lines: pages.append("\n".join(temp_stanza_lines)) else: pages.append("\n".join(stanza)) continue # Standardna logika (več kitic na stran, če grejo) if stanza_height > max_height: if current_page_lines: pages.append("\n".join(current_page_lines)) current_page_lines = [] current_height = 0 temp_stanza_lines = [] temp_height = 0 for line in stanza: if temp_height + self.line_height > max_height: pages.append("\n".join(temp_stanza_lines)) temp_stanza_lines = [line] temp_height = self.line_height else: temp_stanza_lines.append(line) temp_height += self.line_height if temp_stanza_lines: current_page_lines = temp_stanza_lines current_height = temp_height elif current_height + stanza_height + (self.line_height if current_page_lines else 0) > max_height: pages.append("\n".join(current_page_lines)) current_page_lines = stanza current_height = stanza_height else: if current_page_lines: current_page_lines.append("") # Razmik med kiticami current_height += self.line_height current_page_lines.extend(stanza) current_height += stanza_height if current_page_lines: pages.append("\n".join(current_page_lines)) self.pages = pages self.current_page_index = 0 self.waiting_for_song = False self.song_number_last = self.song_number self.show_page() else: self.display_text.config(text="") self.pages = [] self.current_page_index = 0 self.waiting_for_song = True self.song_number_last = self.song_number self.song_info_label.config(text=self.song_number_last) self.song_info_label.lift() except Exception as e: self.display_text.config(text=f"Napaka: {e}") finally: self.song_number = "" # ------------------------------------------------------ # Prikaz trenutne strani # ------------------------------------------------------ def show_page(self): if self.pages: text = self.pages[self.current_page_index] if self.all_caps_mode: text = text.upper() self.display_text.config(text=text) if self.settings.get("show_song_info", False): current_page = self.current_page_index + 1 total_pages = len(self.pages) self.song_info_label.config(text=f"{self.song_number_last} {current_page}/{total_pages}") self.song_info_label.lift() else: self.song_info_label.config(text="") notify_clients() # ------------------------------------------------------ # Navigacija po straneh # ------------------------------------------------------ def next_page(self, event=None): if self.pages and self.current_page_index + 1 < len(self.pages): self.current_page_index += 1 self.show_page() def prev_page(self, event=None): if self.pages and self.current_page_index > 0: self.current_page_index -= 1 self.show_page() # ------------------------------------------------------ # Očisti zaslon # ------------------------------------------------------ def clear_screen(self, event=None): # odstranimo vse strani, da se tudi API posodobi self.pages = [] self.current_page_index = 0 self.waiting_for_song = True self.display_text.config(text="") self.display_text.pack_forget() self.color_frame.config(bg="black", width=self.color_width, height=self.screen_height) self.color_frame.place(relx=0.5, rely=0.5, anchor="center") self.song_info_label.config(text="") notify_clients() # ------------------------------------------------------ # Iskanje po naslovu # ------------------------------------------------------ def search_song(self, event=None): self.clear_screen() if self.search_label: self.search_label.destroy() if self.search_entry: self.search_entry.destroy() self.search_label = tk.Label( self.color_frame, text="Vpiši del naslova:", bg=self.settings["bg_color"], fg=self.settings["fg_color"], font=(self.settings["font_name"], int(self.settings["font_size"])), wraplength=self.color_width, justify="center" ) self.search_label.pack(pady=(40, 10)) self.search_entry = tk.Entry( self.color_frame, font=(self.settings["font_name"], int(self.settings["font_size"])), justify="center", bg=self.settings["bg_color"], fg=self.settings["fg_color"], insertbackground=self.settings["fg_color"], borderwidth=0, highlightthickness=0 ) self.search_entry.pack() self.search_entry.focus() self.search_entry.bind("", self.perform_search) def perform_search(self, event=None): query = self.search_entry.get().strip() self.search_label.destroy() self.search_entry.destroy() self.display_text.pack(expand=True) if not query: self.display_text.config(text="(Prazno iskanje)") return try: self.cursor.execute( "SELECT id, title FROM songs WHERE title LIKE ? COLLATE NOCASE", (f"%{query}%",) ) matched = self.cursor.fetchall() except Exception as e: self.display_text.config(text=f"Napaka pri iskanju: {e}") return if matched: found = "\n".join(f"{sid}: {title}" for sid, title in matched) self.display_text.config(text=found) else: self.display_text.config(text="Ni zadetkov.") # ------------------------------------------------------ # Posodobitev baze pesmi # ------------------------------------------------------ def update_songs_database(self): url = self.settings.get("db_update_url", "").strip() if not url: msg = "URL za posodobitev ni nastavljen v settings.json." print(msg) self.display_text.config(text=msg) self.song_number = "" return msg = f"Prenašam posodobitev iz: {url}..." print(msg) self.display_text.config(text=msg) self.root.update() temp_db_path = None try: # Prenos datoteke with tempfile.NamedTemporaryFile(delete=False) as tmp_file: temp_db_path = tmp_file.name with urllib.request.urlopen(url) as response: tmp_file.write(response.read()) # Preverjanje integritete print("Preverjam integriteto prenesene baze...") check_conn = sqlite3.connect(temp_db_path) check_cursor = check_conn.cursor() # PRAGMA integrity_check check_cursor.execute("PRAGMA integrity_check") integrity_result = check_cursor.fetchone()[0] if integrity_result != "ok": raise Exception(f"Integriteta baze ni OK: {integrity_result}") # Preverjanje obstoja tabele songs check_cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='songs'") if not check_cursor.fetchone(): raise Exception("Tabela 'songs' ne obstaja v preneseni bazi.") # Upsert v trenutno bazo print("Izvajam upsert v lokalno bazo...") check_cursor.execute("SELECT id, title, lyrics FROM songs") new_songs = check_cursor.fetchall() upsert_count = 0 for song_id, title, lyrics in new_songs: self.cursor.execute(""" INSERT INTO songs (id, title, lyrics) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET title=excluded.title, lyrics=excluded.lyrics """, (song_id, title, lyrics)) upsert_count += 1 self.conn.commit() check_conn.close() final_msg = f"Posodobitev uspešna! Posodobljenih/dodanih {upsert_count} pesmi." print(final_msg) self.display_text.config(text=final_msg) except Exception as e: error_msg = f"Napaka pri posodobitvi: {e}" print(error_msg) self.display_text.config(text=error_msg) finally: if temp_db_path and os.path.exists(temp_db_path): try: os.remove(temp_db_path) except: pass self.song_number = "" # ------------------------------------------------------ # Izhod # ------------------------------------------------------ def exit_program(self, event=None): # Ponastavi stanje preprečevanja spanja na Windows if sys.platform.startswith("win"): try: # ES_CONTINUOUS (0x80000000) ctypes.windll.kernel32.SetThreadExecutionState(ctypes.c_uint(0x80000000)) except: pass elif sys.platform == "linux": try: # Sprostimo DBus inhibitor, če ga imamo if hasattr(self, "inhibitor_cookie") and self.inhibitor_cookie: subprocess.Popen([ "dbus-send", "--dest=org.gnome.SessionManager", "/org/gnome/SessionManager", "org.gnome.SessionManager.UnInhibit", f"uint32:{self.inhibitor_cookie}" ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except: pass self.conn.close() self.root.destroy() # ------------------------------------------------------ # Restart programa # ------------------------------------------------------ def restart_program(self): try: if self.conn: self.conn.close() except Exception: pass python = sys.executable args = [python] + sys.argv subprocess.Popen(args) self.root.after(200, self.exit_program) # ------------------------------------------------------ # Pošiljanje ntfy obvestil # ------------------------------------------------------ def send_ntfy_notification(self, song_id, title, lyrics): """ Pošlje obvestilo o spremembi besedila preko ntfy.sh. """ topic = self.settings.get("ntfy_topic", "").strip() if not topic: return label = self.settings.get("installation_label", "Projekcija") # ntfy.sh headers must be ASCII. We'll use base64 encoding for the title to support non-ASCII characters. import base64 encoded_label = "=?UTF-8?B?" + base64.b64encode(label.encode('utf-8')).decode('utf-8') + "?=" message = f"{song_id}\n{title}\n{lyrics}" try: url = f"https://ntfy.sh/{topic}" req = urllib.request.Request( url, data=message.encode('utf-8'), method='POST', headers={ "Title": encoded_label, "Priority": "high", "Tags": "loudspeaker" } ) # Uporabimo kratek timeout, da ne blokiramo aplikacije with urllib.request.urlopen(req, timeout=5) as response: pass except Exception as e: print(f"Napaka pri pošiljanju ntfy obvestila: {e}") # ---------------------------------------------------------- # Zagon aplikacije # ---------------------------------------------------------- if __name__ == "__main__": root = tk.Tk() app = SongProjector(root) root.mainloop()