525 lines
20 KiB
Python
Executable File
525 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
|
||
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
|
||
|
||
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(f"file:{DB_PATH}?mode=ro", uri=True, 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": "Times New Roman",
|
||
"bg_color": "#000000",
|
||
"fg_color": "#FFFFFF",
|
||
"font_size": 32,
|
||
"screen_width_percent": 60,
|
||
"font_bold": True,
|
||
"show_song_info": True,
|
||
"split_by_stanza": False # TODO: mogoče nekoč (prelom po kitici namesto po višini)
|
||
}
|
||
|
||
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.
|
||
"""
|
||
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":
|
||
# Zaženi inhibitor, dokler tvoj program dela (hrani handle!)
|
||
inhibitor = subprocess.Popen([
|
||
"gnome-session-inhibit",
|
||
"--inhibit", "idle",
|
||
"--inhibit", "suspend",
|
||
"--reason", "Projecting lyrics",
|
||
"sleep", "infinity"
|
||
])
|
||
|
||
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 pod‑vrstic
|
||
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 pesmi
|
||
# ------------------------------------------------------
|
||
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 == "7777":
|
||
subprocess.Popen([sys.executable, os.path.join(BASE_DIR, "nastavitve.py")]) # fixme: novi proces ne dobi stdin-a; predelati na tkinter aplikacijo?
|
||
self.exit_program()
|
||
return
|
||
elif self.song_number == "8888":
|
||
self.exit_program()
|
||
return
|
||
elif self.song_number == "6666":
|
||
subprocess.Popen([sys.executable, os.path.join(BASE_DIR, "add_song.py")]) # fixme: novi proces ne dobi stdin-a; predelati na tkinter aplikacijo?
|
||
self.exit_program()
|
||
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 1‑krat 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 pesmi
|
||
# ------------------------------------------------------
|
||
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.")
|
||
|
||
# ------------------------------------------------------
|
||
# 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:
|
||
# Ponovno vklopi ohranjevalnik in DPMS (privzete vrednosti)
|
||
subprocess.Popen(["xset", "s", "on", "+dpms"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
||
# Za Wayland/DBus inhibit običajno ni treba eksplicitno sprostiti,
|
||
# če se proces konča, vendar bi za popolnost lahko uporabili UnInhibit,
|
||
# če bi shranili cookie. Ker ga nismo, se zanašamo na to, da DBus
|
||
# samodejno sprosti inhibit ob zaprtju povezave/procesa.
|
||
except:
|
||
pass
|
||
|
||
self.conn.close()
|
||
self.root.destroy()
|
||
|
||
|
||
# ----------------------------------------------------------
|
||
# Zagon aplikacije
|
||
# ----------------------------------------------------------
|
||
if __name__ == "__main__":
|
||
root = tk.Tk()
|
||
app = SongProjector(root)
|
||
root.mainloop()
|