Files
Projekcija/projector.py

681 lines
26 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <https://www.gnu.org/licenses/>.
import sqlite3
import tkinter as tk
import json
import os
import math
import subprocess
import sys
import ctypes
import tkinter.messagebox as messagebox
from web.server import start_server_thread
import urllib.request
import tempfile
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)
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()
with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
json.dump(self.settings, f, indent=4, ensure_ascii=False)
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()
self.line_height = int(int(self.settings["font_size"]) * 1.5)
# --------------------------------------------------
# Okno
# --------------------------------------------------
root.attributes('-fullscreen', True)
root.configure(bg=self.settings["bg_color"])
root.bind("<Return>", self.enter_pressed)
root.bind("<KP_Enter>", self.enter_pressed)
root.bind("<plus>", self.clear_screen)
root.bind("<KP_Add>", self.clear_screen)
root.bind("<minus>", self.prev_page)
root.bind("<KP_Subtract>", self.prev_page)
root.bind("<slash>", self.search_song)
root.bind("<KP_Divide>", self.search_song)
root.bind("<Key>", 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")
font_weight = "bold" if self.settings.get("font_bold", False) else "normal"
self.label = tk.Label(
self.color_frame,
text="",
bg=self.settings["bg_color"],
fg=self.settings["fg_color"],
font=(self.settings["font_name"], int(self.settings["font_size"]), font_weight),
wraplength=color_width,
justify="center"
)
self.label.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('<Any-Motion>', reset_cursor_timer)
self.root.bind_all('<Key>', 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 (približno) enako dolge glede na število znakov.
"""
avg_char_width = int(self.settings["font_size"]) * 0.57
wraplength_px = self.color_width
# Koliko znakov približno gre v eno vrstico
approx_chars_per_line = max(1, int(wraplength_px / avg_char_width))
if len(line) <= approx_chars_per_line:
return [line]
# Potrebno število podvrstic
n_sub = math.ceil(len(line) / approx_chars_per_line)
words = line.split()
total_chars = sum(len(w) for w in words) + len(words) - 1 # vključno s presledki
target_len = total_chars / n_sub
sub_lines = []
current_words = []
current_len = 0
for word in words:
added_len = len(word) + (1 if current_words else 0) # presledek pred besedo, če ni prva
if current_len + added_len > target_len and len(sub_lines) < n_sub - 1:
# Začni novo vrstico
sub_lines.append(" ".join(current_words))
current_words = [word]
current_len = len(word)
else:
current_words.append(word)
current_len += added_len
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.all_caps_mode = not self.all_caps_mode
self.show_page()
def enter_pressed(self, event=None):
if self.song_number:
self.load_song()
elif not self.waiting_for_song:
self.next_page()
# ------------------------------------------------------
# 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.label.config(bg=self.settings["bg_color"], fg=self.settings["fg_color"])
self.label.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]
max_height = self.screen_height - (self.line_height * 1.5)
avg_char_width = int(self.settings["font_size"]) * 0.57
wraplength = self.color_width
# -------------------------------------------
# 1. Razbijanje besedila na (pod-)vrstice
# -------------------------------------------
raw_lines = lyrics.strip().splitlines()
processed_lines = []
for raw in raw_lines:
if raw.strip() == "":
processed_lines.append("") # ohranimo prazno vrstico
else:
processed_lines.extend(self.split_long_line(raw))
# -------------------------------------------
# 2. Razdeljevanje vrstic na strani
# -------------------------------------------
pages = []
current_page_lines = []
current_height = 0
blank_line_count = 0
for line in processed_lines:
is_blank = (line.strip() == "")
if is_blank:
blank_line_count += 1
else:
blank_line_count = 0
approx_line_length = len(line)
approx_line_count = 1 if is_blank else math.ceil((approx_line_length * avg_char_width) / wraplength)
needed_height = self.line_height * approx_line_count
# Prazna vrstica več kot 1krat pomeni nova kitica ⇒ nova stran
if blank_line_count >= 2:
if current_page_lines:
pages.append("\n".join(current_page_lines).strip())
current_page_lines = []
current_height = 0
blank_line_count = 0
continue
# Če čez spodnji rob strani …
if current_height + needed_height > max_height + self.line_height // 2:
# poskusimo prelomiti na prazni vrstici znotraj strani
found_split = False
for i in reversed(range(len(current_page_lines))):
if current_page_lines[i].strip() == "":
pages.append("\n".join(current_page_lines[:i]).strip())
current_page_lines = current_page_lines[i + 1:]
current_height = sum(
self.line_height * (
1 if l.strip() == "" else math.ceil(len(l) * avg_char_width / wraplength)
)
for l in current_page_lines
)
found_split = True
break
if not found_split:
pages.append("\n".join(current_page_lines).strip())
current_page_lines = []
current_height = 0
current_page_lines.append(line)
current_height += needed_height
if current_page_lines:
pages.append("\n".join(current_page_lines).strip())
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.label.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.label.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.label.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="")
# ------------------------------------------------------
# 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.label.config(text="")
self.label.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="")
# ------------------------------------------------------
# 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("<Return>", self.perform_search)
def perform_search(self, event=None):
query = self.search_entry.get().strip()
self.search_label.destroy()
self.search_entry.destroy()
self.label.pack(expand=True)
if not query:
self.label.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.label.config(text=f"Napaka pri iskanju: {e}")
return
if matched:
found = "\n".join(f"{sid}: {title}" for sid, title in matched)
self.label.config(text=found)
else:
self.label.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.label.config(text=msg)
self.song_number = ""
return
msg = f"Prenašam posodobitev iz: {url}..."
print(msg)
self.label.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.label.config(text=final_msg)
except Exception as e:
error_msg = f"Napaka pri posodobitvi: {e}"
print(error_msg)
self.label.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()