first commit
This commit is contained in:
@@ -0,0 +1,640 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Review a daily JSON video manifest and save its title."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import struct
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
|
||||
DEFAULT_VLC_ROOT = Path(r"C:\PortableApps\VLCPortable3")
|
||||
TITLE_SAVE_DELAY_MS = 700
|
||||
SEEK_SECONDS = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewItem:
|
||||
manifest_value: str
|
||||
path: Path | None
|
||||
error: str = ""
|
||||
|
||||
|
||||
class WindowsShortcutPoller:
|
||||
"""Poll review shortcuts on Tk's thread, including while VLC has focus."""
|
||||
|
||||
VK_LEFT = 0x25
|
||||
VK_RIGHT = 0x27
|
||||
VK_SPACE = 0x20
|
||||
VK_J = 0x4A
|
||||
VK_L = 0x4C
|
||||
GA_ROOT = 2
|
||||
POLL_INTERVAL_MS = 30
|
||||
SHORTCUTS = {
|
||||
VK_LEFT: "Left",
|
||||
VK_RIGHT: "Right",
|
||||
VK_SPACE: "space",
|
||||
VK_J: "j",
|
||||
VK_L: "l",
|
||||
}
|
||||
|
||||
def __init__(self, root: tk.Tk, title_entry: ttk.Entry, action):
|
||||
self.root = root
|
||||
self.title_entry = title_entry
|
||||
self.action = action
|
||||
self.after_id = None
|
||||
self.pressed = {key: False for key in self.SHORTCUTS}
|
||||
self.user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
self.user32.GetForegroundWindow.restype = ctypes.c_void_p
|
||||
self.user32.GetAncestor.argtypes = (ctypes.c_void_p, ctypes.c_uint)
|
||||
self.user32.GetAncestor.restype = ctypes.c_void_p
|
||||
self.user32.GetAsyncKeyState.argtypes = (ctypes.c_int,)
|
||||
self.user32.GetAsyncKeyState.restype = ctypes.c_short
|
||||
|
||||
def start(self):
|
||||
self.poll()
|
||||
|
||||
def _reviewer_is_active(self) -> bool:
|
||||
foreground = self.user32.GetForegroundWindow()
|
||||
root_handle = self.root.winfo_id()
|
||||
app_window = self.user32.GetAncestor(root_handle, self.GA_ROOT) or root_handle
|
||||
return foreground == app_window
|
||||
|
||||
def _title_entry_has_focus(self) -> bool:
|
||||
focus = self.root.focus_get()
|
||||
return focus is not None and str(focus) == str(self.title_entry)
|
||||
|
||||
def poll(self):
|
||||
active = self._reviewer_is_active()
|
||||
title_has_focus = active and self._title_entry_has_focus()
|
||||
for virtual_key, shortcut in self.SHORTCUTS.items():
|
||||
is_down = bool(self.user32.GetAsyncKeyState(virtual_key) & 0x8000)
|
||||
if is_down and not self.pressed[virtual_key] and active and not title_has_focus:
|
||||
self.action(shortcut)
|
||||
self.pressed[virtual_key] = is_down
|
||||
self.after_id = self.root.after(self.POLL_INTERVAL_MS, self.poll)
|
||||
|
||||
def close(self):
|
||||
if self.after_id is not None:
|
||||
self.root.after_cancel(self.after_id)
|
||||
self.after_id = None
|
||||
|
||||
|
||||
def find_vlc_directory(root: Path) -> Path | None:
|
||||
"""Accept a VLC directory or common portable-app root layouts."""
|
||||
for candidate in (root, root / "App" / "vlc", root / "vlc"):
|
||||
if (candidate / "libvlc.dll").is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def pe_architecture(path: Path) -> str | None:
|
||||
"""Return the architecture recorded in a Windows executable or DLL."""
|
||||
try:
|
||||
with path.open("rb") as source:
|
||||
if source.read(2) != b"MZ":
|
||||
return None
|
||||
source.seek(0x3C)
|
||||
header_offset = struct.unpack("<I", source.read(4))[0]
|
||||
source.seek(header_offset)
|
||||
if source.read(4) != b"PE\0\0":
|
||||
return None
|
||||
machine = struct.unpack("<H", source.read(2))[0]
|
||||
except (OSError, struct.error):
|
||||
return None
|
||||
return {0x014C: "x86", 0x8664: "x64", 0xAA64: "ARM64"}.get(machine)
|
||||
|
||||
|
||||
def python_architecture() -> str:
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"arm64", "aarch64"}:
|
||||
return "ARM64"
|
||||
return "x64" if struct.calcsize("P") == 8 else "x86"
|
||||
|
||||
|
||||
def load_vlc(vlc_root: Path | None):
|
||||
"""Load python-vlc, configuring DLL and plugin lookup for portable VLC."""
|
||||
if vlc_root is None:
|
||||
configured = os.environ.get("VLC_DIR")
|
||||
vlc_root = Path(configured) if configured else DEFAULT_VLC_ROOT
|
||||
|
||||
vlc_dir = find_vlc_directory(vlc_root)
|
||||
if vlc_dir is None and vlc_root.exists():
|
||||
raise RuntimeError(f"Could not find libvlc.dll under {vlc_root}")
|
||||
|
||||
if vlc_dir is not None:
|
||||
library = vlc_dir / "libvlc.dll"
|
||||
plugins = vlc_dir / "plugins"
|
||||
if not plugins.is_dir():
|
||||
raise RuntimeError(f"Could not find VLC plugins directory: {plugins}")
|
||||
dll_arch = pe_architecture(library)
|
||||
python_arch = python_architecture()
|
||||
if dll_arch is not None and dll_arch != python_arch:
|
||||
raise RuntimeError(
|
||||
f"VLC is {dll_arch}, but this Python is {python_arch}. "
|
||||
f"Install {python_arch} VLC or run the reviewer with {dll_arch} Python."
|
||||
)
|
||||
os.environ["PYTHON_VLC_LIB_PATH"] = str(library)
|
||||
os.environ["PYTHON_VLC_MODULE_PATH"] = str(plugins)
|
||||
if hasattr(os, "add_dll_directory"):
|
||||
# Keep this handle alive for as long as libVLC can load plugins.
|
||||
dll_directory = os.add_dll_directory(str(vlc_dir))
|
||||
else:
|
||||
dll_directory = None
|
||||
try:
|
||||
ctypes.CDLL(str(library))
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Windows could not load {library}: {exc}") from exc
|
||||
else:
|
||||
dll_directory = None
|
||||
|
||||
try:
|
||||
import vlc
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"python-vlc is missing. Install it with: py -m pip install -r requirements-review.txt"
|
||||
) from exc
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
raise RuntimeError(f"Could not load VLC. Check the VLC runtime path: {exc}") from exc
|
||||
except SystemExit as exc:
|
||||
raise RuntimeError(
|
||||
"python-vlc found the configured DLL path but failed to load the VLC runtime. "
|
||||
"Check that VLC and Python have matching architectures."
|
||||
) from exc
|
||||
|
||||
return vlc, dll_directory, vlc_dir
|
||||
|
||||
|
||||
def resolve_item(value: object, manifest_path: Path) -> ReviewItem:
|
||||
if not isinstance(value, str):
|
||||
return ReviewItem(str(value), None, "Manifest entry is not a path string")
|
||||
|
||||
path = Path(value).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = manifest_path.parent / path
|
||||
path = path.resolve()
|
||||
|
||||
if path.suffix.lower() == ".llc":
|
||||
try:
|
||||
project = json.loads(path.read_text(encoding="utf-8"))
|
||||
media_name = project.get("mediaFileName") if isinstance(project, dict) else None
|
||||
if not isinstance(media_name, str) or not media_name:
|
||||
raise ValueError("project has no mediaFileName")
|
||||
media_path = Path(media_name).expanduser()
|
||||
if not media_path.is_absolute():
|
||||
media_path = path.parent / media_path
|
||||
path = media_path.resolve()
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
return ReviewItem(value, None, f"Could not read LosslessCut project: {exc}")
|
||||
|
||||
return ReviewItem(value, path)
|
||||
|
||||
|
||||
class ManifestReviewer:
|
||||
def __init__(self, root: tk.Tk, vlc, vlc_dir: Path | None):
|
||||
self.root = root
|
||||
self.vlc = vlc
|
||||
self.vlc_dir = vlc_dir
|
||||
self.instance = vlc.Instance()
|
||||
if self.instance is None:
|
||||
raise RuntimeError("Could not initialize VLC. Check its plugins directory.")
|
||||
self.player = self.instance.media_player_new()
|
||||
# Let Tk own shortcuts and mouse handling for the embedded video area.
|
||||
self.player.video_set_key_input(False)
|
||||
self.player.video_set_mouse_input(False)
|
||||
self.current_media = None
|
||||
self.shortcut_poller: WindowsShortcutPoller | None = None
|
||||
self.manifest_path: Path | None = None
|
||||
self.manifest_data: dict = {}
|
||||
self.raw_videos: list = []
|
||||
self.items: list[ReviewItem] = []
|
||||
self.current_index = -1
|
||||
self.completion_handled_index: int | None = None
|
||||
self.title_after_id: str | None = None
|
||||
self.loading_title = False
|
||||
self.title_dirty = False
|
||||
self.duration_ms = 0
|
||||
self.dragging_seek = False
|
||||
|
||||
self.root.title("Home Video Reviewer")
|
||||
self.root.geometry("1120x720")
|
||||
self.root.minsize(800, 520)
|
||||
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
||||
|
||||
outer = ttk.Frame(root, padding=10)
|
||||
outer.pack(fill="both", expand=True)
|
||||
|
||||
title_row = ttk.Frame(outer)
|
||||
title_row.pack(fill="x", pady=(0, 8))
|
||||
ttk.Label(title_row, text="Video title:").pack(side="left")
|
||||
self.title_var = tk.StringVar()
|
||||
self.title_entry = ttk.Entry(title_row, textvariable=self.title_var)
|
||||
self.title_entry.pack(side="left", fill="x", expand=True, padx=(8, 8))
|
||||
self.title_entry.bind("<Button-1>", self.focus_title_entry, add="+")
|
||||
ttk.Button(title_row, text="Open manifest…", command=self.open_dialog).pack(side="right", padx=(0, 6))
|
||||
ttk.Button(title_row, text="Save title", command=lambda: self.save_manifest(force=True)).pack(side="right")
|
||||
|
||||
content = ttk.Frame(outer)
|
||||
content.pack(fill="both", expand=True)
|
||||
content.columnconfigure(1, weight=1)
|
||||
content.rowconfigure(0, weight=1)
|
||||
|
||||
queue_frame = ttk.Frame(content, width=280)
|
||||
queue_frame.grid(row=0, column=0, sticky="nsw", padx=(0, 10))
|
||||
queue_frame.grid_propagate(False)
|
||||
ttk.Label(queue_frame, text="Videos").pack(anchor="w", pady=(0, 5))
|
||||
list_frame = ttk.Frame(queue_frame)
|
||||
list_frame.pack(fill="both", expand=True)
|
||||
self.video_list = tk.Listbox(list_frame, activestyle="dotbox", exportselection=False)
|
||||
self.video_list.pack(side="left", fill="both", expand=True)
|
||||
list_scroll = ttk.Scrollbar(list_frame, orient="vertical", command=self.video_list.yview)
|
||||
list_scroll.pack(side="right", fill="y")
|
||||
self.video_list.configure(yscrollcommand=list_scroll.set)
|
||||
self.video_list.bind("<<ListboxSelect>>", self.on_list_select)
|
||||
|
||||
self.video_frame = tk.Frame(content, background="black")
|
||||
self.video_frame.grid(row=0, column=1, sticky="nsew")
|
||||
|
||||
control_row = ttk.Frame(outer)
|
||||
control_row.pack(fill="x", pady=(8, 4))
|
||||
self.previous_button = ttk.Button(control_row, text="Previous", command=lambda: self.navigate(-1))
|
||||
self.previous_button.pack(side="left")
|
||||
self.play_button = ttk.Button(control_row, text="Play", command=self.toggle_play)
|
||||
self.play_button.pack(side="left", padx=(6, 0))
|
||||
self.next_button = ttk.Button(control_row, text="Next", command=lambda: self.navigate(1))
|
||||
self.next_button.pack(side="left", padx=(6, 12))
|
||||
self.time_var = tk.StringVar(value="0:00 / 0:00")
|
||||
ttk.Label(control_row, textvariable=self.time_var, width=13, anchor="center").pack(side="left")
|
||||
self.seek_var = tk.DoubleVar(value=0)
|
||||
self.seek_scale = ttk.Scale(control_row, from_=0, to=1, variable=self.seek_var, command=self.on_seek_drag)
|
||||
self.seek_scale.pack(side="left", fill="x", expand=True, padx=8)
|
||||
self.seek_scale.bind("<ButtonPress-1>", self.begin_seek)
|
||||
self.seek_scale.bind("<ButtonRelease-1>", self.finish_seek)
|
||||
|
||||
self.status_var = tk.StringVar(value="Open a daily JSON manifest to begin.")
|
||||
self.title_var.trace_add("write", self.schedule_title_save)
|
||||
ttk.Label(outer, textvariable=self.status_var, anchor="w").pack(fill="x", pady=(4, 0))
|
||||
|
||||
self.shortcut_tag = f"HomeVideoReviewerShortcuts{id(self)}"
|
||||
self.mouse_focus_tag = f"HomeVideoReviewerMouseFocus{id(self)}"
|
||||
self.root.bind_class(self.shortcut_tag, "<KeyPress>", self.on_key_press)
|
||||
self.root.bind_class(self.mouse_focus_tag, "<Button-1>", self.on_mouse_down)
|
||||
self.install_shortcut_bindings(self.root)
|
||||
self.root.after(250, self.update_playback_status)
|
||||
|
||||
def install_shortcut_bindings(self, widget):
|
||||
"""Clear stale entry focus on clicks and run shortcuts before class bindings."""
|
||||
tags = widget.bindtags()
|
||||
prefix = []
|
||||
if self.mouse_focus_tag not in tags:
|
||||
prefix.append(self.mouse_focus_tag)
|
||||
if widget is not self.title_entry:
|
||||
if self.shortcut_tag not in tags:
|
||||
prefix.append(self.shortcut_tag)
|
||||
if prefix:
|
||||
widget.bindtags((*prefix, *tags))
|
||||
for child in widget.winfo_children():
|
||||
self.install_shortcut_bindings(child)
|
||||
|
||||
def on_mouse_down(self, event):
|
||||
if event.widget is not self.title_entry:
|
||||
event.widget.focus_set()
|
||||
return None
|
||||
|
||||
def focus_title_entry(self, _event=None):
|
||||
self.root.after_idle(self.title_entry.focus_force)
|
||||
|
||||
def open_manifest(self, path: Path):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
title = ""
|
||||
videos = data
|
||||
manifest_data = {}
|
||||
elif isinstance(data, dict):
|
||||
title = data.get("title", "")
|
||||
videos = data.get("videos", [])
|
||||
if not isinstance(title, str) or not isinstance(videos, list):
|
||||
raise ValueError("manifest needs a string 'title' and an array 'videos'")
|
||||
manifest_data = dict(data)
|
||||
else:
|
||||
raise ValueError("manifest must be a JSON object or a legacy array of paths")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
||||
messagebox.showerror("Cannot open manifest", str(exc), parent=self.root)
|
||||
return
|
||||
|
||||
if not self.save_manifest():
|
||||
return
|
||||
self.stop_video()
|
||||
self.manifest_path = path.resolve()
|
||||
self.manifest_data = manifest_data
|
||||
self.raw_videos = videos
|
||||
self.items = [resolve_item(value, self.manifest_path) for value in videos]
|
||||
self.current_index = -1
|
||||
self.completion_handled_index = None
|
||||
self.loading_title = True
|
||||
self.title_var.set(title)
|
||||
self.loading_title = False
|
||||
self.title_dirty = False
|
||||
self.video_list.delete(0, "end")
|
||||
for index, item in enumerate(self.items, start=1):
|
||||
name = item.path.name if item.path is not None else Path(item.manifest_value).name
|
||||
self.video_list.insert("end", f"{index:02d}. {name}")
|
||||
self.root.title(f"Home Video Reviewer — {self.manifest_path.name}")
|
||||
if self.items:
|
||||
self.select_video(0)
|
||||
self.video_list.focus_set()
|
||||
else:
|
||||
self.status_var.set("This manifest has no videos.")
|
||||
self.update_navigation_buttons()
|
||||
|
||||
def open_dialog(self):
|
||||
selected = filedialog.askopenfilename(
|
||||
parent=self.root,
|
||||
title="Open daily manifest",
|
||||
filetypes=(("JSON manifests", "*.json"), ("All files", "*.*")),
|
||||
)
|
||||
if selected:
|
||||
self.open_manifest(Path(selected))
|
||||
|
||||
def select_video(self, index: int):
|
||||
if index < 0 or index >= len(self.items):
|
||||
return
|
||||
self.current_index = index
|
||||
self.video_list.selection_clear(0, "end")
|
||||
self.video_list.selection_set(index)
|
||||
self.video_list.activate(index)
|
||||
self.video_list.see(index)
|
||||
self.play_item(self.items[index])
|
||||
self.update_navigation_buttons()
|
||||
|
||||
def on_list_select(self, _event):
|
||||
selected = self.video_list.curselection()
|
||||
if selected and selected[0] != self.current_index:
|
||||
self.select_video(selected[0])
|
||||
|
||||
def navigate(self, offset: int):
|
||||
target = self.current_index + offset
|
||||
if 0 <= target < len(self.items):
|
||||
self.select_video(target)
|
||||
|
||||
def update_navigation_buttons(self):
|
||||
self.previous_button.configure(state="normal" if self.current_index > 0 else "disabled")
|
||||
self.next_button.configure(state="normal" if self.current_index + 1 < len(self.items) else "disabled")
|
||||
|
||||
def play_item(self, item: ReviewItem):
|
||||
self.stop_video()
|
||||
self.completion_handled_index = None
|
||||
if item.path is None:
|
||||
self.status_var.set(item.error)
|
||||
self.play_button.configure(text="Play")
|
||||
return
|
||||
if not item.path.is_file():
|
||||
self.status_var.set(f"File not found: {item.path}")
|
||||
self.play_button.configure(text="Play")
|
||||
return
|
||||
|
||||
try:
|
||||
self.root.update_idletasks()
|
||||
self.attach_video_output()
|
||||
self.current_media = self.instance.media_new_path(str(item.path))
|
||||
self.player.set_media(self.current_media)
|
||||
self.player.play()
|
||||
self.play_button.configure(text="Pause")
|
||||
self.duration_ms = 0
|
||||
self.seek_scale.configure(to=1)
|
||||
self.seek_var.set(0)
|
||||
self.time_var.set("0:00 / 0:00")
|
||||
self.status_var.set(f"{self.current_index + 1} of {len(self.items)} — {item.path}")
|
||||
except (AttributeError, OSError, RuntimeError) as exc:
|
||||
self.status_var.set(f"Could not play {item.path.name}: {exc}")
|
||||
|
||||
def attach_video_output(self):
|
||||
window_id = self.video_frame.winfo_id()
|
||||
if sys.platform.startswith("win"):
|
||||
self.player.set_hwnd(window_id)
|
||||
elif sys.platform == "darwin":
|
||||
self.player.set_nsobject(window_id)
|
||||
else:
|
||||
self.player.set_xwindow(window_id)
|
||||
|
||||
def stop_video(self):
|
||||
try:
|
||||
self.player.stop()
|
||||
self.player.set_media(None)
|
||||
except (AttributeError, RuntimeError):
|
||||
pass
|
||||
self.current_media = None
|
||||
self.play_button.configure(text="Play")
|
||||
self.duration_ms = 0
|
||||
self.seek_scale.configure(to=1)
|
||||
self.seek_var.set(0)
|
||||
self.time_var.set("0:00 / 0:00")
|
||||
|
||||
def toggle_play(self):
|
||||
if self.current_index < 0 or self.items[self.current_index].path is None:
|
||||
return
|
||||
if self.player.is_playing():
|
||||
self.player.pause()
|
||||
self.play_button.configure(text="Play")
|
||||
elif self.player.get_media() is not None:
|
||||
self.completion_handled_index = None
|
||||
self.player.play()
|
||||
self.play_button.configure(text="Pause")
|
||||
|
||||
def seek_by(self, seconds: int):
|
||||
if self.player.get_media() is None:
|
||||
return
|
||||
current_ms = self.player.get_time()
|
||||
if current_ms < 0:
|
||||
current_ms = 0
|
||||
target_ms = max(0, current_ms + seconds * 1000)
|
||||
if self.duration_ms > 0:
|
||||
target_ms = min(target_ms, self.duration_ms)
|
||||
self.player.set_time(target_ms)
|
||||
|
||||
def begin_seek(self, _event):
|
||||
self.dragging_seek = True
|
||||
|
||||
def on_seek_drag(self, value):
|
||||
if self.dragging_seek and self.player.get_media() is not None:
|
||||
self.player.set_time(int(float(value) * 1000))
|
||||
|
||||
def finish_seek(self, _event):
|
||||
self.dragging_seek = False
|
||||
if self.player.get_media() is not None:
|
||||
self.player.set_time(int(self.seek_var.get() * 1000))
|
||||
|
||||
@staticmethod
|
||||
def format_time(milliseconds: int) -> str:
|
||||
seconds = max(0, milliseconds // 1000)
|
||||
return f"{seconds // 60}:{seconds % 60:02d}"
|
||||
|
||||
def update_playback_status(self):
|
||||
if self.player.get_media() is not None:
|
||||
if (
|
||||
self.player.get_state() == self.vlc.State.Ended
|
||||
and self.completion_handled_index != self.current_index
|
||||
):
|
||||
self.completion_handled_index = self.current_index
|
||||
if self.current_index + 1 < len(self.items):
|
||||
self.navigate(1)
|
||||
self.root.after(250, self.update_playback_status)
|
||||
return
|
||||
length = self.player.get_length()
|
||||
current = self.player.get_time()
|
||||
if length > 0:
|
||||
self.duration_ms = length
|
||||
self.seek_scale.configure(to=max(1, length / 1000))
|
||||
if current >= 0:
|
||||
if not self.dragging_seek:
|
||||
self.seek_var.set(current / 1000)
|
||||
self.time_var.set(f"{self.format_time(current)} / {self.format_time(self.duration_ms)}")
|
||||
self.play_button.configure(text="Pause" if self.player.is_playing() else "Play")
|
||||
self.root.after(250, self.update_playback_status)
|
||||
|
||||
def _title_entry_has_focus(self) -> bool:
|
||||
focus = self.root.focus_get()
|
||||
return focus is not None and str(focus) == str(self.title_entry)
|
||||
|
||||
def on_key_press(self, event):
|
||||
if event.keysym == "Left":
|
||||
key = "Left"
|
||||
elif event.keysym == "Right":
|
||||
key = "Right"
|
||||
elif event.keysym == "space":
|
||||
key = "space"
|
||||
else:
|
||||
key = event.char.lower()
|
||||
if self.shortcut_poller is not None:
|
||||
if event.widget is self.title_entry:
|
||||
return None
|
||||
if key in WindowsShortcutPoller.SHORTCUTS.values():
|
||||
return "break"
|
||||
return None
|
||||
if self.handle_shortcut(key):
|
||||
return "break"
|
||||
return None
|
||||
|
||||
def handle_shortcut(self, key: str) -> bool:
|
||||
if self._title_entry_has_focus():
|
||||
return False
|
||||
if key == "Left":
|
||||
self.navigate(-1)
|
||||
return True
|
||||
if key == "Right":
|
||||
self.navigate(1)
|
||||
return True
|
||||
if key == "j":
|
||||
self.seek_by(-SEEK_SECONDS)
|
||||
return True
|
||||
if key == "l":
|
||||
self.seek_by(SEEK_SECONDS)
|
||||
return True
|
||||
if key == "space":
|
||||
self.toggle_play()
|
||||
return True
|
||||
return False
|
||||
|
||||
def schedule_title_save(self, *_args):
|
||||
if self.loading_title or self.manifest_path is None:
|
||||
return
|
||||
self.title_dirty = True
|
||||
self.status_var.set("Title changed; saving…")
|
||||
if self.title_after_id is not None:
|
||||
self.root.after_cancel(self.title_after_id)
|
||||
self.title_after_id = self.root.after(TITLE_SAVE_DELAY_MS, self.save_manifest)
|
||||
|
||||
def save_manifest(self, force: bool = False):
|
||||
if self.manifest_path is None or (not self.title_dirty and not force):
|
||||
return True
|
||||
if self.title_after_id is not None:
|
||||
self.root.after_cancel(self.title_after_id)
|
||||
self.title_after_id = None
|
||||
self.manifest_data["title"] = self.title_var.get()
|
||||
self.manifest_data["videos"] = self.raw_videos
|
||||
temp_path = self.manifest_path.with_name(self.manifest_path.name + ".tmp")
|
||||
try:
|
||||
temp_path.write_text(
|
||||
json.dumps(self.manifest_data, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temp_path, self.manifest_path)
|
||||
except OSError as exc:
|
||||
self.status_var.set(f"Could not save title: {exc}")
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
self.title_dirty = False
|
||||
self.status_var.set(f"Title saved to {self.manifest_path.name}")
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
if not self.save_manifest():
|
||||
messagebox.showerror(
|
||||
"Title not saved",
|
||||
"The manifest could not be saved. Fix the file access problem and try closing again.",
|
||||
parent=self.root,
|
||||
)
|
||||
return
|
||||
self.stop_video()
|
||||
if self.shortcut_poller is not None:
|
||||
self.shortcut_poller.close()
|
||||
self.player.release()
|
||||
self.instance.release()
|
||||
self.root.destroy()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("manifest", nargs="?", type=Path, help="daily .json manifest to review")
|
||||
parser.add_argument(
|
||||
"--vlc-dir",
|
||||
type=Path,
|
||||
help="VLC install folder or portable app root (defaults to VLC_DIR or C:\\PortableApps\\VLCPortable3)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
vlc, dll_directory, vlc_dir = load_vlc(args.vlc_dir)
|
||||
except RuntimeError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
root = tk.Tk()
|
||||
try:
|
||||
reviewer = ManifestReviewer(root, vlc, vlc_dir)
|
||||
except Exception as exc:
|
||||
root.destroy()
|
||||
print(f"Error initializing reviewer: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Keep the Windows DLL search directory alive through VLC shutdown.
|
||||
reviewer.dll_directory = dll_directory
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
reviewer.shortcut_poller = WindowsShortcutPoller(root, reviewer.title_entry, reviewer.handle_shortcut)
|
||||
reviewer.shortcut_poller.start()
|
||||
except OSError as exc:
|
||||
reviewer.shortcut_poller = None
|
||||
reviewer.status_var.set(f"Windows keyboard shortcuts unavailable: {exc}")
|
||||
if args.manifest:
|
||||
reviewer.open_manifest(args.manifest.expanduser().resolve())
|
||||
else:
|
||||
root.after(0, reviewer.open_dialog)
|
||||
root.mainloop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user