Files
Projekcija/projector.py

525 lines
20 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 -*-
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 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 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 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 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()