#!/usr/bin/env python3
# Govibe Nexus Video Player
# Debian/Ubuntu GTK front end for mpv with video stats, audio EQ, video filters, rendering presets, color control, OSD banner, and tray icon.

import os
import warnings
os.environ.setdefault("GDK_BACKEND", "x11")

import sys
import json
import time
import math
import array
import signal
import socket
import shutil
import subprocess
import threading
from pathlib import Path

warnings.filterwarnings('ignore', category=DeprecationWarning)
warnings.filterwarnings('ignore', category=PendingDeprecationWarning)

import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
try:
    gi.require_version("GdkX11", "3.0")
except Exception:
    pass
from gi.repository import Gtk, Gdk, GLib, Gio
try:
    from gi.repository import GdkX11  # noqa: F401
except Exception:
    GdkX11 = None

APP_ID = "org.govibe.NexusVideoPlayer"
APP_NAME = "Govibe Nexus Video Player"
DESKTOP_ID = "govibe-nexus-video-player.desktop"
MPV_LOG_PATH = "/tmp/govibe-nexus-video-player-mpv.log"
VERSION = "1.0.59"
VIDEO_MIME_TYPES = [
    "video/mp4", "video/x-matroska", "video/webm", "video/quicktime", "video/x-msvideo",
    "video/x-ms-wmv", "video/mpeg", "video/ogg", "video/3gpp", "video/3gpp2", "video/x-flv",
    "video/mp2t", "application/ogg"
]

AUDIO_MIME_TYPES = [
    "audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/wav", "audio/x-wav", "audio/flac", "audio/x-flac",
    "audio/ogg", "audio/x-ogg", "audio/opus", "audio/aac", "audio/x-aac", "audio/mp4", "audio/x-m4a",
    "audio/x-ms-wma", "audio/aiff", "audio/x-aiff", "audio/x-ape", "audio/x-wavpack",
    "audio/amr", "audio/ac3", "audio/vnd.dts", "audio/midi", "audio/x-midi", "audio/rmid", "application/ogg"
]

AUDIO_EXTENSIONS = {
    ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".wma",
    ".aiff", ".aif", ".alac", ".ape", ".wv", ".amr", ".ac3", ".dts", ".mid", ".midi", ".rmi"
}

THEMES = {
    "Nexus Green": {
        "bg": "#030805", "panel": "#07170a", "text": "#e8ffe8", "muted": "#7efc8d", "accent": "#00ff66", "accent2": "#8cff32",
    },
    "Classic Blue": {
        "bg": "#050812", "panel": "#09152c", "text": "#edf4ff", "muted": "#7bbcff", "accent": "#3ca1ff", "accent2": "#72e8ff",
    },
    "Windows Classic Silver": {
        "bg": "#101216", "panel": "#2b3038", "text": "#f2f2f2", "muted": "#c7d7ff", "accent": "#9bb7ff", "accent2": "#ffffff",
    },
    "Winamp Dark": {
        "bg": "#030303", "panel": "#111111", "text": "#f7f7f7", "muted": "#c6c6c6", "accent": "#ffb000", "accent2": "#fff05a",
    },
    "Mortal Red": {
        "bg": "#090101", "panel": "#180404", "text": "#fff4f4", "muted": "#ff8a8a", "accent": "#ff1f2d", "accent2": "#ffcc33",
    },
    "Crimson Skin": {
        "bg": "#0e0204", "panel": "#1c060a", "text": "#fff1f1", "muted": "#ff8080", "accent": "#ff243d", "accent2": "#ff9e3d",
    },
    "Purple Neon": {
        "bg": "#090413", "panel": "#160a2b", "text": "#fbf2ff", "muted": "#cb95ff", "accent": "#a100ff", "accent2": "#00d5ff",
    },
    "Cyber Cyan": {
        "bg": "#02090d", "panel": "#061821", "text": "#effcff", "muted": "#85ecff", "accent": "#00d9ff", "accent2": "#7cfffa",
    },
    "Ocean Blue": {
        "bg": "#020615", "panel": "#061a33", "text": "#f1f8ff", "muted": "#8ec7ff", "accent": "#0078ff", "accent2": "#57f2ff",
    },
    "Amber Studio": {
        "bg": "#100903", "panel": "#211305", "text": "#fff8e8", "muted": "#ffd17a", "accent": "#ffb000", "accent2": "#fff04c",
    },
    "Matrix Terminal": {
        "bg": "#000000", "panel": "#021006", "text": "#d6ffd6", "muted": "#66ff88", "accent": "#00ff41", "accent2": "#b9ff00",
    },
    "Arctic White": {
        "bg": "#f3f6fb", "panel": "#e4ebf5", "text": "#061018", "muted": "#305f8f", "accent": "#1f77d0", "accent2": "#003b73",
    },
    "Graphite Steel": {
        "bg": "#080a0d", "panel": "#171b21", "text": "#f0f3f7", "muted": "#aab4c0", "accent": "#8fa3b8", "accent2": "#ffffff",
    },
    "Royal Violet": {
        "bg": "#07020f", "panel": "#160628", "text": "#f8f0ff", "muted": "#c8a2ff", "accent": "#7d35ff", "accent2": "#e1c4ff",
    },
    "Magenta Laser": {
        "bg": "#100016", "panel": "#23002f", "text": "#fff0ff", "muted": "#ff9df2", "accent": "#ff2bd6", "accent2": "#80f7ff",
    },
    "Emerald Glass": {
        "bg": "#00110b", "panel": "#052319", "text": "#eafff7", "muted": "#7fffd4", "accent": "#00c983", "accent2": "#b8ffe7",
    },
    "Lime Black": {
        "bg": "#050800", "panel": "#101900", "text": "#f5ffe6", "muted": "#baff5a", "accent": "#99ff00", "accent2": "#e6ff55",
    },
    "Deep Teal": {
        "bg": "#001015", "panel": "#032a32", "text": "#eaffff", "muted": "#78e8e8", "accent": "#00b8b8", "accent2": "#a5ffff",
    },
    "Ice Blue": {
        "bg": "#06111f", "panel": "#0c2742", "text": "#eef8ff", "muted": "#a8dbff", "accent": "#69c9ff", "accent2": "#ffffff",
    },
    "Gold Premium": {
        "bg": "#0c0902", "panel": "#1b1505", "text": "#fff9e6", "muted": "#d8c36a", "accent": "#d6b43f", "accent2": "#fff0a3",
    },
    "Ruby Cinema": {
        "bg": "#100004", "panel": "#23000a", "text": "#fff3f6", "muted": "#ff94ad", "accent": "#c90036", "accent2": "#ffd0da",
    },
    "Midnight Indigo": {
        "bg": "#030417", "panel": "#0b0d2e", "text": "#f0f3ff", "muted": "#9ca8ff", "accent": "#4f63ff", "accent2": "#dbe0ff",
    },
    "White Cinema": {
        "bg": "#f7f7f2", "panel": "#e8e8df", "text": "#111111", "muted": "#555555", "accent": "#2f2f2f", "accent2": "#000000",
    },
}

TEMPLATES = {
    "Wide Theater": {"width": 1360, "height": 820, "paned": 1000, "side": 320, "video_w": 800, "video_h": 500},
}



EQ_PRESETS = {
    "Flat": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    "Bass Boost": [4, 4, 3, 2, 1, 0, 0, 0, 0, 0],
    "Deep Bass": [5, 5, 4, 2, 1, 0, -1, -1, -1, -1],
    "Loudness": [4, 3, 2, 1, 0, 1, 2, 3, 3, 3],
    "Cinema": [3, 3, 2, 1, 0, 1, 2, 2, 2, 2],
    "Movie Night": [3, 2, 1, 1, -1, 0, 1, 2, 2, 2],
    "Voice Clear": [-2, -1, 0, 2, 3, 4, 3, 1, 0, -1],
    "Podcast": [-3, -2, -1, 2, 4, 4, 3, 0, -1, -2],
    "Rock": [3, 3, 2, -1, -1, 1, 3, 3, 3, 2],
    "Metal": [4, 3, 2, -1, -2, 1, 3, 4, 4, 3],
    "Pop": [2, 3, 3, 1, 0, 1, 2, 3, 3, 2],
    "Dance": [4, 4, 2, 0, -1, 1, 2, 3, 3, 3],
    "Electronic": [4, 3, 2, 0, -1, 1, 3, 3, 4, 4],
    "Hip Hop": [4, 4, 3, 1, -1, 0, 1, 2, 2, 2],
    "Jazz": [2, 1, 1, 2, 2, 2, 1, 1, 0, 0],
    "Classical": [1, 1, 0, 1, 2, 2, 2, 2, 2, 1],
    "Acoustic": [1, 1, 1, 2, 2, 3, 2, 1, 0, 0],
    "Warm Movie": [2, 2, 1, 0, 1, 1, 1, 0, -1, -1],
    "Treble Boost": [-1, -1, -1, 0, 1, 1, 2, 3, 3, 4],
    "Headphones": [2, 2, 1, 0, -1, 1, 2, 2, 2, 1],
    "Laptop Speakers": [1, 2, 3, 3, 2, 1, 1, 1, 0, 0],
    "Small Speakers": [1, 1, 3, 3, 2, 1, 1, 0, 0, -1],
    "Night Mode": [-3, -3, -2, -1, 0, 1, 1, 0, -1, -2],
    "Soft Background": [-2, -2, -1, 0, 1, 1, 0, -1, -2, -2],
}

EQ_FREQS = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000]

VIDEO_FILTERS = {
    "None": "",
    "Sharper Detail": "lavfi=[unsharp=5:5:0.75:3:3:0.35]",
    "Ultra Sharp": "lavfi=[unsharp=7:7:1.00:5:5:0.50]",
    "Soft Clean": "lavfi=[hqdn3d=1.5:1.5:4:4]",
    "Strong Noise Clean": "lavfi=[hqdn3d=4:3:8:6]",
    "Anime Clean": "lavfi=[hqdn3d=1.0:1.0:3:3,unsharp=3:3:0.45]",
    "Film Contrast": "lavfi=[eq=contrast=1.10:saturation=1.08:gamma=0.98]",
    "Vivid Colour": "lavfi=[eq=contrast=1.12:saturation=1.25:gamma=0.98]",
    "Bright Room": "lavfi=[eq=brightness=0.04:contrast=1.08:saturation=1.06]",
    "Low Light Boost": "lavfi=[eq=brightness=0.07:contrast=1.15:saturation=1.10]",
    "Dark Scene Rescue": "lavfi=[eq=brightness=0.10:contrast=1.22:saturation=1.12:gamma=0.92]",
    "Warm Cinema": "lavfi=[eq=contrast=1.08:saturation=1.12:gamma=0.94]",
    "Cool Clean": "lavfi=[eq=contrast=1.05:saturation=1.06:gamma=1.05]",
    "Old Film": "lavfi=[eq=contrast=1.15:saturation=0.75:gamma=0.92,noise=alls=8:allf=t+u]",
    "Black And White": "lavfi=[format=gray]",
    "Sepia Look": "lavfi=[colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131]",
    "Deinterlace": "lavfi=[yadif]",
    "Fast Sharpen": "lavfi=[unsharp=3:3:0.35]",
    "Edge Enhance": "lavfi=[edgedetect=low=0.05:high=0.20]",
}

RENDERING_SETTINGS = {
    "Balanced": {"deband": "no", "interpolation": "no", "scale": "bilinear", "cscale": "bilinear", "dscale": "bilinear"},
    "High Quality": {"deband": "yes", "interpolation": "yes", "scale": "ewa_lanczossharp", "cscale": "ewa_lanczossharp", "dscale": "mitchell", "correct-downscaling": "yes", "linear-downscaling": "yes"},
    "GPU Next HQ": {"deband": "yes", "interpolation": "yes", "scale": "ewa_lanczossharp", "cscale": "ewa_lanczossharp", "dscale": "ewa_lanczossoft", "correct-downscaling": "yes", "sigmoid-upscaling": "yes"},
    "Sharp Upscale": {"deband": "yes", "interpolation": "no", "scale": "spline36", "cscale": "spline36", "dscale": "spline36", "scale-antiring": "0.7", "cscale-antiring": "0.7"},
    "Anime Upscale": {"deband": "yes", "interpolation": "no", "scale": "ewa_lanczossharp", "cscale": "ewa_lanczossharp", "dscale": "mitchell", "scale-antiring": "0.8"},
    "Smooth Motion": {"deband": "yes", "interpolation": "yes", "video-sync": "display-resample", "scale": "ewa_lanczos", "cscale": "ewa_lanczos", "tscale": "oversample"},
    "Film Quality": {"deband": "yes", "interpolation": "yes", "scale": "ewa_lanczos", "cscale": "ewa_lanczos", "dscale": "mitchell", "dither-depth": "auto"},
    "Low Latency": {"deband": "no", "interpolation": "no", "video-sync": "audio", "framedrop": "vo", "scale": "bilinear", "cscale": "bilinear"},
    "Low CPU": {"deband": "no", "interpolation": "no", "scale": "bilinear", "cscale": "bilinear", "dscale": "bilinear"},
    "Old GPU Safe": {"deband": "no", "interpolation": "no", "scale": "bilinear", "cscale": "bilinear", "dscale": "bilinear", "correct-downscaling": "no"},
    "Battery Saver": {"deband": "no", "interpolation": "no", "framedrop": "decoder+vo", "scale": "bilinear", "cscale": "bilinear"},
}


RENDERING_PRESETS = list(RENDERING_SETTINGS.keys())


def safe_str(value):
    if value is None:
        return "unknown"
    return str(value)


def clamp(value, low, high):
    return max(low, min(high, value))


class MPVController:
    def __init__(self, window_id, initial_file=None, audio_mode=False):
        self.window_id = window_id
        self.audio_mode = bool(audio_mode)
        self.instance_id = f"{os.getpid()}-{int(time.time() * 1000)}-{id(self)}"
        self.socket_path = f"/tmp/govibe-nexus-video-player-{self.instance_id}.sock"
        self.input_conf_path = f"/tmp/govibe-nexus-video-player-input-{self.instance_id}.conf"
        self.event_file_path = f"/tmp/govibe-nexus-video-player-events-{self.instance_id}.log"
        self.event_helper_path = f"/tmp/govibe-nexus-video-player-event-{self.instance_id}.sh"
        self.event_callback = None
        self.event_thread = None
        self.event_file_thread = None
        self.event_file_pos = 0
        self.event_stop = False
        self.proc = None
        self.req_id = 0
        self.ready = False
        self.start(initial_file=initial_file)

    def start(self, initial_file=None):
        if not shutil.which("mpv"):
            raise RuntimeError("mpv is not installed. Install it with: sudo apt install mpv")
        try:
            if os.path.exists(self.socket_path):
                os.unlink(self.socket_path)
        except Exception:
            pass
        for tmp_path in [self.input_conf_path, self.event_file_path, self.event_helper_path]:
            try:
                if os.path.exists(tmp_path):
                    os.unlink(tmp_path)
            except Exception:
                pass
        try:
            Path(self.event_file_path).write_text("", encoding="utf-8")
            helper = "#!/bin/sh\n"
            helper += "event=\"$1\"\n"
            helper += "[ -n \"$event\" ] || exit 0\n"
            helper += f"printf '%s\n' \"$event\" >> '{self.event_file_path}'\n"
            helper += "exit 0\n"
            Path(self.event_helper_path).write_text(helper, encoding="utf-8")
            os.chmod(self.event_helper_path, 0o700)
            input_conf = (
                f'MBTN_LEFT_DBL run "{self.event_helper_path}" "toggle-fullscreen"\n'
                f'MOUSE_BTN0_DBL run "{self.event_helper_path}" "toggle-fullscreen"\n'
                f'MBTN_RIGHT run "{self.event_helper_path}" "right-click"\n'
                f'MOUSE_BTN2 run "{self.event_helper_path}" "right-click"\n'
                f'ESC run "{self.event_helper_path}" "exit-fullscreen"\n'
                'MOUSE_MOVE script-message govibe-mouse-move\n'
            )
            Path(self.input_conf_path).write_text(input_conf, encoding="utf-8")
        except Exception:
            pass

        base_args = [
            "mpv",
            "--no-config",
            "--idle=yes",
            "--keep-open=yes",
            "--no-terminal",
            "--input-default-bindings=yes",
            "--input-vo-keyboard=yes",
            f"--input-conf={self.input_conf_path}",
            "--osc=no",
            "--cursor-autohide=4000",
            "--cursor-autohide-fs-only=yes",
            "--osd-level=1",
            "--volume-max=300",
            f"--input-ipc-server={self.socket_path}",
            "--audio-display=no",
            "--no-resume-playback",
            "--keepaspect=yes",
        ]
        if self.audio_mode:
            base_args += ["--force-window=no", "--vo=null", "--vid=no"]
            video_backends = [[]]
        else:
            # v1.0.59: use the realized native GTK XID as mpv's parent window.
            # Keep gpu on the X11 context first for smooth playback, but avoid EGL startup.
            base_args += ["--force-window=immediate", f"--wid={self.window_id}"]
            video_backends = [
                ["--vo=gpu", "--gpu-context=x11", "--hwdec=auto-safe"],
                ["--vo=gpu", "--gpu-context=x11", "--hwdec=no"],
                ["--vo=x11", "--hwdec=no"],
            ]
        last_error = None
        for backend in video_backends:
            try:
                if os.path.exists(self.socket_path):
                    os.unlink(self.socket_path)
            except Exception:
                pass
            args = list(base_args) + list(backend) + ["--border=no", "--msg-level=all=warn"]
            # Never pass the video file during embedded mpv process startup.
            # mpv must first attach to the native GTK XID, then load the media by IPC.
            if initial_file and self.audio_mode:
                args.append(str(initial_file))
            try:
                log = open(MPV_LOG_PATH, "ab", buffering=0)
                log.write(("\n--- Govibe Nexus mpv start " + time.strftime("%Y-%m-%d %H:%M:%S") + " backend=" + " ".join(backend) + " ---\n").encode("utf-8", "replace"))
            except Exception:
                log = subprocess.DEVNULL
            try:
                self.proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=log, start_new_session=True)
                self.ready = self.wait_until_ready(4.0 if initial_file else 6.0)
                if self.process_alive():
                    return
            except Exception as exc:
                last_error = exc
            try:
                if self.proc and self.proc.poll() is None:
                    self.proc.terminate()
                    self.proc.wait(timeout=0.8)
            except Exception:
                try:
                    if self.proc:
                        self.proc.kill()
                except Exception:
                    pass
            self.proc = None
        if last_error:
            raise last_error

    def wait_until_ready(self, timeout=6.0):
        deadline = time.time() + float(timeout)
        while time.time() < deadline:
            if self.proc and self.proc.poll() is not None:
                return False
            if os.path.exists(self.socket_path):
                resp = self._send({"command": ["get_property", "mpv-version"], "request_id": 1}, wait=True, timeout=0.55, attempts=1)
                if resp and resp.get("error") == "success":
                    return True
            time.sleep(0.08)
        return os.path.exists(self.socket_path)

    def _send(self, payload, wait=False, timeout=0.35, attempts=3):
        expected_id = payload.get("request_id") if isinstance(payload, dict) else None
        for _attempt in range(max(1, int(attempts))):
            if self.proc and self.proc.poll() is not None:
                return None
            if not os.path.exists(self.socket_path):
                time.sleep(0.08)
                continue
            try:
                with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
                    client.settimeout(timeout)
                    client.connect(self.socket_path)
                    client.sendall((json.dumps(payload) + "\n").encode("utf-8"))
                    if not wait:
                        return True
                    deadline = time.time() + float(timeout)
                    data = b""
                    last_response = None
                    while time.time() < deadline:
                        try:
                            chunk = client.recv(65536)
                        except socket.timeout:
                            break
                        if not chunk:
                            break
                        data += chunk
                        while b"\n" in data:
                            line, data = data.split(b"\n", 1)
                            if not line:
                                continue
                            try:
                                decoded = json.loads(line.decode("utf-8", "replace"))
                            except Exception:
                                continue
                            if expected_id is not None:
                                if decoded.get("request_id") == expected_id:
                                    return decoded
                                continue
                            if "error" in decoded:
                                return decoded
                            last_response = decoded
                    return last_response
            except Exception:
                time.sleep(0.10)
        return None

    def command(self, *args):
        return bool(self._send({"command": list(args)}, wait=False, attempts=4))

    def command_wait(self, *args, timeout=1.4):
        self.req_id += 1
        resp = self._send({"command": list(args), "request_id": self.req_id}, wait=True, timeout=timeout, attempts=5)
        return bool(resp and resp.get("error") == "success")

    def process_alive(self):
        return bool(self.proc and self.proc.poll() is None)

    def is_alive(self):
        return self.process_alive()

    def get_property(self, prop):
        self.req_id += 1
        resp = self._send({"command": ["get_property", prop], "request_id": self.req_id}, wait=True, attempts=3)
        if not resp or resp.get("error") != "success":
            return None
        return resp.get("data")

    def set_property(self, prop, value):
        return self.command("set_property", prop, value)

    def load_file(self, path):
        if not self.process_alive():
            return False
        if not self.ready:
            self.ready = self.wait_until_ready(8.0)
        if not self.ready:
            return False
        return self.command_wait("loadfile", path, "replace", timeout=5.0)

    def set_event_callback(self, callback):
        self.event_callback = callback
        self.event_stop = False
        if not self.event_thread:
            self.event_thread = threading.Thread(target=self._event_loop, daemon=True)
            self.event_thread.start()
        if not self.event_file_thread:
            self.event_file_thread = threading.Thread(target=self._event_file_loop, daemon=True)
            self.event_file_thread.start()

    def _event_file_loop(self):
        # Robust fallback for mouse events inside mpv's embedded child window.
        # mpv runs a tiny helper that appends event names to this file; this thread forwards them to GTK.
        while not self.event_stop:
            try:
                if os.path.exists(self.event_file_path):
                    with open(self.event_file_path, "r", encoding="utf-8", errors="replace") as fh:
                        fh.seek(self.event_file_pos)
                        lines = fh.readlines()
                        self.event_file_pos = fh.tell()
                    for line in lines:
                        msg = line.strip()
                        if msg and self.event_callback:
                            GLib.idle_add(self.event_callback, msg)
                time.sleep(0.08)
            except Exception:
                time.sleep(0.20)

    def _event_loop(self):
        deadline = time.time() + 4
        while not os.path.exists(self.socket_path) and time.time() < deadline and not self.event_stop:
            time.sleep(0.05)
        if self.event_stop or not os.path.exists(self.socket_path):
            return
        try:
            with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
                client.settimeout(0.5)
                client.connect(self.socket_path)
                buffer = b""
                while not self.event_stop:
                    try:
                        chunk = client.recv(65536)
                    except socket.timeout:
                        continue
                    if not chunk:
                        break
                    buffer += chunk
                    while b"\n" in buffer:
                        line, buffer = buffer.split(b"\n", 1)
                        if not line:
                            continue
                        try:
                            event = json.loads(line.decode("utf-8", "replace"))
                        except Exception:
                            continue
                        if event.get("event") == "client-message":
                            args = event.get("args") or []
                            if args and args[0] == "govibe-toggle-fullscreen" and self.event_callback:
                                GLib.idle_add(self.event_callback, "toggle-fullscreen")
                            elif args and args[0] == "govibe-right-click" and self.event_callback:
                                GLib.idle_add(self.event_callback, "right-click")
                            elif args and args[0] == "govibe-exit-fullscreen" and self.event_callback:
                                GLib.idle_add(self.event_callback, "exit-fullscreen")
                            elif args and args[0] == "govibe-mouse-move" and self.event_callback:
                                GLib.idle_add(self.event_callback, "mouse-move")
        except Exception:
            pass

    def terminate(self):
        self.event_stop = True
        try:
            self.command("quit")
        except Exception:
            pass
        try:
            if self.proc and self.proc.poll() is None:
                self.proc.terminate()
                try:
                    self.proc.wait(timeout=1.5)
                except subprocess.TimeoutExpired:
                    self.proc.kill()
        except Exception:
            pass
        try:
            if os.path.exists(self.socket_path):
                os.unlink(self.socket_path)
        except Exception:
            pass
        for tmp_path in [self.input_conf_path, self.event_file_path, self.event_helper_path]:
            try:
                if os.path.exists(tmp_path):
                    os.unlink(tmp_path)
            except Exception:
                pass


class GovibeNexusVideoPlayer(Gtk.Application):
    def __init__(self):
        super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.HANDLES_OPEN)
        self.window = None
        self.mpv = None
        self.current_file = None
        self.current_theme = "Nexus Green"
        self.osd_enabled = True
        self.is_seeking = False
        self.tray = None
        self.status_icon = None
        self.eq_sliders = []
        self.last_stats = {}
        self.pending_open_files = []
        self.top_menu_visible = False
        self.stats_visible = True
        self.is_fullscreen_mode = False
        self.fullscreen_auto_hide_id = None
        self.fullscreen_pointer_poll_id = None
        self.last_pointer_xy = None
        self.controls_visible = True
        self.options_hidden = False
        self.always_on_top = False
        self.loop_enabled = False
        self.muted = False
        self.volume_level = 100
        self.volume_boost_percent = 100
        self.context_popup = None
        self.color_window = None
        self.last_mpv_mouse_event = 0
        self.last_context_menu_open_ts = 0
        self.color_values = {"brightness": 0, "contrast": 0, "saturation": 0, "gamma": 0, "hue": 0}
        self.default_paned_position = 1000
        self.did_initial_window_fit = False
        self.pending_open_attempts = 0
        self.pending_open_source_id = None
        # v1.0.57: protect the embedded mpv surface from rapid video replace storms.
        # This keeps the v1.0.57 playback design, but queues fast repeated opens so
        # GTK/X11 and mpv finish one transition before the next one starts.
        self.media_open_in_progress = False
        self.media_open_queue_path = None
        self.media_open_cooldown_source_id = None
        self.media_open_cooldown_until = 0.0
        self.video_replace_count = 0
        self.playlist_window = None
        self.playlist_toggle_button = None
        self.playlist_tree = None
        self.playlist_store = Gtk.ListStore(str, str)
        self.playlist_paths = []
        self.playlist_current_index = -1
        self.playlist_drag_selecting = False
        self.playlist_anchor_path = None
        self.eof_handled_for_file = None
        self.audio_visualizer_active = False
        self.audio_visualizer = None
        self.audio_visualizer_timer_id = None
        self.audio_visualizer_phase = 0.0
        self.audio_visualizer_bars = [0.12 for _ in range(96)]
        self.audio_visualizer_tick_count = 0
        self.audio_position = 0.0
        self.audio_paused = False
        self.audio_waveform = []
        self.audio_waveform_rate = 30.0
        self.audio_waveform_ready = False
        self.audio_waveform_path = None
        self.audio_analysis_token = 0
        self.audio_visualizer_last_sync_time = 0.0
        self.audio_visualizer_last_sync_pos = 0.0
        self.audio_visualizer_levels_cache = []
        self.audio_visualizer_last_tick_time = 0.0
        self.audio_visualizer_light_mode = True
        self.audio_visualizer_last_mpv_poll = 0.0
        self.audio_visualizer_energy = 0.34
        self.audio_visualizer_target_fps = 24
        self.audio_analysis_process = None
        self.audio_analysis_started_once = False
        self.connect("activate", self.on_activate)
        self.connect("open", self.on_open)

    def on_open(self, app, files, n_files, hint):
        self.pending_open_files = [f.get_path() for f in files if f.get_path()]
        self.pending_open_attempts = 0
        self.activate()
        if self.pending_open_files:
            self.add_to_playlist(self.pending_open_files, play_first=False)
            self.schedule_pending_open(900)

    def on_activate(self, app):
        if self.window:
            self.force_launch_visible()
            return
        self.build_ui()
        self.window.show_all()
        self.apply_theme(self.current_theme)
        self.force_launch_visible()
        GLib.idle_add(self.launch_window_full_visible)
        GLib.timeout_add(250, self.launch_window_full_visible)
        GLib.timeout_add(700, self.force_launch_visible)
        self.setup_tray()
        if self.pending_open_files:
            self.add_to_playlist(self.pending_open_files, play_first=False)
        # Do not start mpv on an empty window. Starting mpv too early can cover the GTK skin on some Ubuntu/GNOME sessions.
        # mpv starts only when a media file is opened, and embeds into the dedicated video host widget.
        GLib.timeout_add_seconds(1, self.refresh_stats)
        if self.pending_open_files:
            self.schedule_pending_open(900)

    def build_ui(self):
        self.window = Gtk.ApplicationWindow(application=self)
        self.window.set_title(APP_NAME)
        self.window.set_default_size(1360, 820)
        self.window.set_size_request(1060, 700)
        try:
            self.window.set_position(Gtk.WindowPosition.CENTER)
        except Exception:
            pass
        self.window.set_icon_name("govibe-nexus-video-player")
        try:
            self.window.set_wmclass("govibe-nexus-video-player", "Govibe Nexus Video Player")
        except Exception:
            pass
        self.window.connect("delete-event", self.on_window_close)
        self.window.connect("window-state-event", self.on_window_state_event)
        self.window.connect("configure-event", self.on_window_configure)
        self.window.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.KEY_PRESS_MASK)
        self.window.connect("button-press-event", self.on_right_click)
        self.window.connect("key-press-event", self.on_key_press)
        self.window.connect("motion-notify-event", self.on_mouse_motion)

        main = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.main_box = main
        main.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
        main.connect("button-press-event", self.on_right_click)
        main.connect("motion-notify-event", self.on_mouse_motion)
        self.window.add(main)

        menubar = Gtk.MenuBar()
        self.menubar = menubar
        menubar.set_no_show_all(True)
        file_menu = Gtk.Menu()
        file_item = Gtk.MenuItem(label="File")
        file_item.set_submenu(file_menu)
        open_item = Gtk.MenuItem(label="Open Video or Audio")
        open_item.connect("activate", lambda *_: self.choose_file())
        playlist_item = Gtk.MenuItem(label="Open Playlist")
        playlist_item.connect("activate", lambda *_: self.open_playlist_window())
        default_item = Gtk.MenuItem(label="Set as the default video player")
        default_item.connect("activate", lambda *_: self.set_default_video_player())
        quit_item = Gtk.MenuItem(label="Exit")
        quit_item.connect("activate", lambda *_: self.quit_app())
        file_menu.append(open_item)
        file_menu.append(playlist_item)
        file_menu.append(default_item)
        file_menu.append(Gtk.SeparatorMenuItem())
        file_menu.append(quit_item)

        view_menu = Gtk.Menu()
        view_item = Gtk.MenuItem(label="View")
        view_item.set_submenu(view_menu)
        fullscreen_item = Gtk.MenuItem(label="Full Screen")
        fullscreen_item.connect("activate", lambda *_: self.toggle_fullscreen())
        osd_item = Gtk.CheckMenuItem(label="Show title banner on start")
        osd_item.set_active(True)
        osd_item.connect("toggled", self.toggle_osd)
        view_menu.append(fullscreen_item)
        view_menu.append(osd_item)

        help_menu = Gtk.Menu()
        help_item = Gtk.MenuItem(label="Help")
        help_item.set_submenu(help_menu)
        govibe_item = Gtk.MenuItem(label="About Govibe.org")
        govibe_item.connect("activate", lambda *_: self.open_govibe_website())
        about_item = Gtk.MenuItem(label="About")
        about_item.connect("activate", lambda *_: self.about_dialog())
        help_menu.append(govibe_item)
        help_menu.append(about_item)

        menubar.append(file_item)
        menubar.append(view_item)
        menubar.append(help_item)
        main.pack_start(menubar, False, False, 0)
        menubar.hide()

        titlebar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.titlebar = titlebar
        titlebar.get_style_context().add_class("topbar")
        titlebar.set_border_width(8)
        main.pack_start(titlebar, False, False, 0)

        logo = Gtk.Label(label="NEXUS")
        logo.get_style_context().add_class("logo")
        titlebar.pack_start(logo, False, False, 6)

        self.file_label = Gtk.Label(label="No media loaded")
        self.file_label.set_xalign(0)
        self.file_label.get_style_context().add_class("filename")
        titlebar.pack_start(self.file_label, True, True, 4)

        self.time_label = Gtk.Label(label="00:00 / 00:00")
        self.time_label.get_style_context().add_class("timecode")
        titlebar.pack_start(self.time_label, False, False, 6)

        paned = Gtk.Paned.new(Gtk.Orientation.HORIZONTAL)
        self.paned = paned
        main.pack_start(paned, True, True, 0)

        video_column = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        video_column.set_hexpand(True)
        video_column.set_vexpand(True)
        paned.pack1(video_column, True, True)

        self.overlay = Gtk.Overlay()
        self.overlay.set_hexpand(True)
        self.overlay.set_vexpand(True)
        self.overlay.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
        self.overlay.connect("button-press-event", self.on_right_click)
        self.overlay.connect("motion-notify-event", self.on_mouse_motion)
        self.overlay.get_style_context().add_class("videowrap")

        # Dedicated native X11/XWayland host for mpv --wid.
        # v1.0.59: do not use Gtk.Socket here. mpv --wid is more stable when
        # it receives the native GdkWindow XID from a realized GTK widget.
        # Gtk.Socket is for XEmbed clients and caused the intermittent MP4 open crash.
        self.video_host_kind = "native"
        self.video_area = Gtk.EventBox()
        self.video_area.set_visible_window(True)
        self.video_area.set_above_child(False)
        self.video_area.set_can_focus(True)
        self.video_area.set_size_request(640, 360)
        self.video_area.set_hexpand(True)
        self.video_area.set_vexpand(True)
        self.video_area.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
        self.video_area.connect("button-press-event", self.on_right_click)
        self.video_area.connect("motion-notify-event", self.on_mouse_motion)
        self.video_area.get_style_context().add_class("videohost")
        self.video_area.connect("realize", self.on_video_realize)

        # v1.0.39: keep the native mpv video surface out of Gtk.Stack.
        # Gtk.Socket inside Gtk.Stack can crash on some sessions when a video opens first.
        # A plain container keeps direct video playback on the stable original path.
        self.video_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.video_container.set_hexpand(True)
        self.video_container.set_vexpand(True)
        self.video_container.pack_start(self.video_area, True, True, 0)
        self.overlay.add(self.video_container)

        self.audio_visualizer = Gtk.DrawingArea()
        self.audio_visualizer.set_no_show_all(True)
        self.audio_visualizer.set_size_request(640, 360)
        self.audio_visualizer.set_hexpand(True)
        self.audio_visualizer.set_vexpand(True)
        try:
            self.audio_visualizer.set_app_paintable(True)
        except Exception:
            pass
        self.audio_visualizer.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
        self.audio_visualizer.connect("draw", self.draw_audio_visualizer)
        self.audio_visualizer.connect("button-press-event", self.on_right_click)
        self.audio_visualizer.connect("motion-notify-event", self.on_mouse_motion)
        self.audio_visualizer.hide()
        # v1.0.41 minimal audio-only fix based on stable v1.0.39:
        # keep the mpv video socket as-is, but make the spectrum a normal GTK child
        # of the video container during audio mode. This avoids Gtk.Overlay over a
        # native X11 socket, which could leave audio files with a blank black screen.
        self.video_container.pack_start(self.audio_visualizer, True, True, 0)

        # No transparent GTK shield is placed above mpv.
        # The v1.0.12 shield could cover the embedded X11 video surface and leave playback black on some Ubuntu sessions.
        # Right click and fullscreen video shortcuts are handled by mpv input bindings instead.

        self.placeholder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        self.placeholder.set_halign(Gtk.Align.CENTER)
        self.placeholder.set_valign(Gtk.Align.CENTER)
        self.placeholder.get_style_context().add_class("placeholder")
        ph_title = Gtk.Label(label="GOVIBE NEXUS VIDEO PLAYER")
        ph_title.set_line_wrap(True)
        ph_title.set_max_width_chars(32)
        ph_title.get_style_context().add_class("placeholder_title")
        ph_sub = Gtk.Label(label="Open a video or audio file to start mpv powered playback")
        ph_sub.get_style_context().add_class("placeholder_sub")
        ph_btn = Gtk.Button(label="OPEN VIDEO OR AUDIO")
        ph_btn.get_style_context().add_class("nexusbtn")
        ph_btn.connect("clicked", lambda *_: self.choose_file())
        self.placeholder.pack_start(ph_title, False, False, 0)
        self.placeholder.pack_start(ph_sub, False, False, 0)
        self.placeholder.pack_start(ph_btn, False, False, 0)
        self.overlay.add_overlay(self.placeholder)

        self.banner = Gtk.Label(label="")
        self.banner.get_style_context().add_class("osdbanner")
        self.banner.set_halign(Gtk.Align.CENTER)
        self.banner.set_valign(Gtk.Align.START)
        self.banner.set_margin_top(12)
        self.banner.set_no_show_all(True)
        self.overlay.add_overlay(self.banner)

        self.fullscreen_overlay_controls = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        self.fullscreen_overlay_controls.get_style_context().add_class("overlaycontrols")
        self.fullscreen_overlay_controls.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
        self.fullscreen_overlay_controls.connect("button-press-event", self.on_right_click)
        self.fullscreen_overlay_controls.connect("motion-notify-event", self.on_mouse_motion)
        self.fullscreen_overlay_controls.set_halign(Gtk.Align.FILL)
        self.fullscreen_overlay_controls.set_valign(Gtk.Align.END)
        self.fullscreen_overlay_controls.set_margin_left(18)
        self.fullscreen_overlay_controls.set_margin_right(18)
        self.fullscreen_overlay_controls.set_margin_bottom(18)

        overlay_seek = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.overlay_seek_box = overlay_seek
        self.overlay_position = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 0.1)
        self.overlay_position.set_draw_value(False)
        self.overlay_position.connect("button-press-event", self.seek_start)
        self.overlay_position.connect("button-release-event", self.seek_finish)
        overlay_seek.pack_start(self.overlay_position, True, True, 0)
        self.fullscreen_overlay_controls.pack_start(overlay_seek, False, False, 0)

        overlay_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=7)
        self.overlay_buttons_box = overlay_buttons
        overlay_buttons.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
        overlay_buttons.connect("button-press-event", self.on_right_click)
        overlay_buttons.connect("motion-notify-event", self.on_mouse_motion)
        self.add_button(overlay_buttons, "OPEN", self.choose_file)
        self.add_button(overlay_buttons, "PLAY", lambda *_: self.set_pause(False))
        self.add_button(overlay_buttons, "PAUSE", lambda *_: self.set_pause(True))
        self.add_button(overlay_buttons, "STOP", lambda *_: self.stop_media())
        self.overlay_mute_button = self.add_button(overlay_buttons, "MUTE", lambda *_: self.set_mute_enabled(not self.muted))
        self.overlay_volume_boost_button = self.add_button(overlay_buttons, "BOOST OFF", lambda btn: self.open_volume_boost_menu(btn))
        self.overlay_loop_button = self.add_button(overlay_buttons, "LOOP", lambda *_: self.set_loop_enabled(not self.loop_enabled))
        self.add_button(overlay_buttons, "FULL", lambda *_: self.toggle_fullscreen())
        self.add_button(overlay_buttons, "OPTIONS", lambda *_: self.open_options_panel())
        self.fullscreen_overlay_controls.pack_start(overlay_buttons, False, False, 0)
        self.fullscreen_overlay_controls.set_no_show_all(True)
        self.fullscreen_overlay_controls.hide()
        self.overlay.add_overlay(self.fullscreen_overlay_controls)
        try:
            self.overlay.set_overlay_pass_through(self.fullscreen_overlay_controls, False)
        except Exception:
            pass

        video_column.pack_start(self.overlay, True, True, 0)

        seek_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.seek_box = seek_box
        seek_box.get_style_context().add_class("controlbar")
        seek_box.set_border_width(8)
        video_column.pack_start(seek_box, False, False, 0)
        self.position = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 0.1)
        self.position.set_draw_value(False)
        self.position.connect("button-press-event", self.seek_start)
        self.position.connect("button-release-event", self.seek_finish)
        seek_box.pack_start(self.position, True, True, 0)

        controls_scroll = Gtk.ScrolledWindow()
        self.controls_scroll = controls_scroll
        controls_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER)
        controls_scroll.set_min_content_height(62)
        controls_scroll.set_shadow_type(Gtk.ShadowType.NONE)
        controls_scroll.get_style_context().add_class("controlbar")
        video_column.pack_start(controls_scroll, False, False, 0)

        controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
        self.controls_box = controls
        controls.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
        controls.connect("button-press-event", self.on_right_click)
        controls.connect("motion-notify-event", self.on_mouse_motion)
        controls.get_style_context().add_class("controlbar")
        controls.set_border_width(6)
        controls_scroll.add(controls)
        self.add_button(controls, "OPEN", self.choose_file)
        self.add_button(controls, "PLAY", lambda *_: self.set_pause(False))
        self.add_button(controls, "PAUSE", lambda *_: self.set_pause(True))
        self.add_button(controls, "STOP", lambda *_: self.stop_media())
        self.mute_button = self.add_button(controls, "MUTE", lambda *_: self.set_mute_enabled(not self.muted))
        self.loop_button = self.add_button(controls, "LOOP", lambda *_: self.set_loop_enabled(not self.loop_enabled))
        self.add_button(controls, "FULL", lambda *_: self.toggle_fullscreen())
        self.focus_button = self.add_button(controls, "FOCUS VIDEO", lambda *_: self.toggle_focus_mode())
        self.options_button = self.add_button(controls, "OPTIONS", lambda *_: self.open_options_panel())
        self.menu_button = self.add_button(controls, "MENU", lambda *_: self.toggle_top_menu())
        self.stats_button = self.add_button(controls, "HIDE STATS", lambda *_: self.toggle_stats_details())
        volume_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
        volume_box.get_style_context().add_class("volumecluster")
        controls.pack_start(volume_box, False, False, 0)
        volume_label = Gtk.Label(label="VOL")
        volume_label.get_style_context().add_class("tiny")
        volume_box.pack_start(volume_label, False, False, 0)
        self.volume_slider = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1)
        self.volume_slider.set_value(self.volume_level)
        self.volume_slider.set_draw_value(True)
        self.volume_slider.set_size_request(185, -1)
        self.volume_slider.connect("value-changed", self.on_volume_slider_changed)
        volume_box.pack_start(self.volume_slider, False, False, 0)
        # Volume boost moved to the right side action panel so the bottom control row stays fully visible.

        self.stats_label = Gtk.Label(label="Stats will appear when media starts.")
        self.stats_label.set_xalign(0)
        self.stats_label.set_line_wrap(True)
        self.stats_label.get_style_context().add_class("stats")
        video_column.pack_start(self.stats_label, False, False, 8)

        side_scroll = Gtk.ScrolledWindow()
        self.side_scroll = side_scroll
        side_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        side_scroll.set_size_request(320, -1)
        side_scroll.set_min_content_width(300)
        side_scroll.set_hexpand(False)
        paned.pack2(side_scroll, False, False)
        side = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        side.set_border_width(8)
        side.set_size_request(300, -1)
        side_scroll.add(side)

        self.build_theme_panel(side)
        self.build_eq_panel(side)
        self.build_video_filter_panel(side)
        self.build_govibe_bottom_link(side)

        self.default_paned_position = 1000
        paned.set_position(self.default_paned_position)
        GLib.idle_add(self.fit_layout_to_window)

    def add_button(self, box, label, callback):
        btn = Gtk.Button(label=label)
        btn.get_style_context().add_class("nexusbtn")
        btn.connect("clicked", callback)
        box.pack_start(btn, False, False, 0)
        return btn

    def build_theme_panel(self, side):
        frame = self.panel_frame("Skins and colour theme")
        side.pack_start(frame, False, False, 0)
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        frame.add(box)
        self.theme_combo = Gtk.ComboBoxText()
        for name in THEMES:
            self.theme_combo.append_text(name)
        self.theme_combo.set_active(0)
        self.theme_combo.connect("changed", lambda combo: self.apply_theme(combo.get_active_text() or "Nexus Green"))
        box.pack_start(Gtk.Label(label="Colour skin"), False, False, 0)
        self.theme_combo.set_size_request(300, -1)
        box.pack_start(self.theme_combo, False, False, 0)
        hint = Gtk.Label(label="Default layout is Wide Theater. Theme, EQ, filter, and render controls stay clean in this skin.")
        hint.set_xalign(0)
        hint.set_line_wrap(True)
        hint.get_style_context().add_class("hint")
        box.pack_start(hint, False, False, 0)

    def build_eq_panel(self, side):
        frame = self.panel_frame("Video sound equalizer")
        side.pack_start(frame, False, False, 0)
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        frame.add(box)
        preset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        box.pack_start(preset_row, False, False, 0)
        self.eq_combo = Gtk.ComboBoxText()
        for name in EQ_PRESETS:
            self.eq_combo.append_text(name)
        self.eq_combo.set_active(0)
        self.eq_combo.connect("changed", self.eq_preset_changed)
        self.eq_combo.set_size_request(230, -1)
        preset_row.pack_start(self.eq_combo, True, True, 0)
        self.add_button(preset_row, "RESET EQ", lambda *_: self.apply_eq_preset("Flat"))

        grid = Gtk.Grid(column_spacing=6, row_spacing=4)
        box.pack_start(grid, False, False, 0)
        self.eq_sliders = []
        for i, freq in enumerate(EQ_FREQS):
            slider = Gtk.Scale.new_with_range(Gtk.Orientation.VERTICAL, -6, 6, 1)
            slider.set_value(0)
            slider.set_inverted(True)
            slider.set_size_request(28, 120)
            slider.set_draw_value(True)
            slider.connect("value-changed", lambda *_: self.apply_custom_eq())
            self.eq_sliders.append(slider)
            grid.attach(slider, i, 0, 1, 1)
            label = Gtk.Label(label=(str(freq) if freq < 1000 else f"{int(freq/1000)}K"))
            label.get_style_context().add_class("tiny")
            grid.attach(label, i, 1, 1, 1)

    def build_video_filter_panel(self, side):
        frame = self.panel_frame("Video filter and rendering")
        side.pack_start(frame, False, False, 0)
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=7)
        frame.add(box)
        self.filter_combo = Gtk.ComboBoxText()
        for name in VIDEO_FILTERS:
            self.filter_combo.append_text(name)
        self.filter_combo.set_active(0)
        self.filter_combo.connect("changed", self.video_filter_changed)
        box.pack_start(Gtk.Label(label="Video filter preset"), False, False, 0)
        self.filter_combo.set_size_request(300, -1)
        box.pack_start(self.filter_combo, False, False, 0)

        self.render_combo = Gtk.ComboBoxText()
        for name in RENDERING_PRESETS:
            self.render_combo.append_text(name)
        self.render_combo.set_active(0)
        self.render_combo.connect("changed", self.rendering_changed)
        box.pack_start(Gtk.Label(label="Rendering preset"), False, False, 0)
        self.render_combo.set_size_request(300, -1)
        box.pack_start(self.render_combo, False, False, 0)

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        box.pack_start(row, False, False, 0)
        self.add_button(row, "DEFAULT FILTER", lambda *_: self.reset_video_filters())
        self.add_button(row, "DEFAULT RENDER", lambda *_: self.apply_rendering("Balanced"))

    def build_govibe_bottom_link(self, side):
        spacer = Gtk.Box()
        spacer.set_vexpand(True)
        side.pack_start(spacer, True, True, 0)
        link_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        link_box.get_style_context().add_class("panel")
        link_box.set_border_width(8)

        self.playlist_toggle_button = Gtk.Button(label="OPEN PLAYLIST")
        self.playlist_toggle_button.get_style_context().add_class("nexusbtn")
        self.playlist_toggle_button.connect("clicked", lambda *_: self.toggle_playlist_window())
        link_box.pack_start(self.playlist_toggle_button, False, False, 0)

        quick_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        link_box.pack_start(quick_row, False, False, 0)

        self.color_adjust_button = Gtk.Button(label="OPEN COLOUR ADJUSTMENT")
        self.color_adjust_button.get_style_context().add_class("nexusbtn")
        self.color_adjust_button.connect("clicked", lambda *_: self.toggle_color_adjustment_dialog())
        quick_row.pack_start(self.color_adjust_button, True, True, 0)

        self.volume_boost_button = Gtk.Button(label="BOOST VOLUME")
        self.volume_boost_button.get_style_context().add_class("nexusbtn")
        self.volume_boost_button.connect("clicked", lambda btn: self.open_volume_boost_menu(btn))
        quick_row.pack_start(self.volume_boost_button, True, True, 0)

        link = Gtk.Button(label="govibe.org")
        link.get_style_context().add_class("linkbtn")
        link.connect("clicked", lambda *_: self.open_govibe_website())
        link_box.pack_start(link, False, False, 0)
        side.pack_start(link_box, False, False, 0)

    def build_color_panel(self, side):
        frame = self.panel_frame("Colour adjustment")
        side.pack_start(frame, False, False, 0)
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=7)
        frame.add(box)
        self.color_controls = {}
        for name, low, high, default in [
            ("brightness", -100, 100, 0),
            ("contrast", -100, 100, 0),
            ("saturation", -100, 100, 0),
            ("gamma", -100, 100, 0),
            ("hue", -100, 100, 0),
        ]:
            row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
            label = Gtk.Label(label=name.capitalize())
            label.set_size_request(90, -1)
            label.set_xalign(0)
            row.pack_start(label, False, False, 0)
            scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, low, high, 1)
            scale.set_value(default)
            scale.set_draw_value(True)
            scale.connect("value-changed", self.color_changed, name)
            row.pack_start(scale, True, True, 0)
            self.color_controls[name] = scale
            box.pack_start(row, False, False, 0)
        self.add_button(box, "SET COLOUR TO DEFAULT", lambda *_: self.reset_color())

    def build_settings_panel(self, side):
        frame = self.panel_frame("Settings")
        side.pack_start(frame, False, False, 0)
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        frame.add(box)
        self.osd_check = Gtk.CheckButton(label="Show title and resolution banner for 4 seconds")
        self.osd_check.set_active(True)
        self.osd_check.connect("toggled", self.toggle_osd)
        box.pack_start(self.osd_check, False, False, 0)
        self.keep_open_check = Gtk.CheckButton(label="Keep video open after ending")
        self.keep_open_check.set_active(True)
        self.keep_open_check.connect("toggled", lambda w: self.mpv and self.mpv.set_property("keep-open", "yes" if w.get_active() else "no"))
        box.pack_start(self.keep_open_check, False, False, 0)
        self.always_top_check = Gtk.CheckButton(label="Always on top")
        self.always_top_check.connect("toggled", self.toggle_always_on_top)
        box.pack_start(self.always_top_check, False, False, 0)
        self.loop_check = Gtk.CheckButton(label="Loop current media")
        self.loop_check.connect("toggled", self.toggle_loop)
        box.pack_start(self.loop_check, False, False, 0)
        self.mute_check = Gtk.CheckButton(label="Mute audio")
        self.mute_check.connect("toggled", self.toggle_mute)
        box.pack_start(self.mute_check, False, False, 0)
        self.add_button(box, "TAKE SCREENSHOT", lambda *_: self.take_screenshot())
        self.add_button(box, "ABOUT GOVIBE.ORG", lambda *_: self.open_govibe_website())
        info = Gtk.Label(label="Right click anywhere in the player for playback, playlist, fullscreen, focus video, themes, EQ presets, video filters, rendering presets, colour adjustment, screenshot, default video or audio player, and Govibe.org.")
        info.set_xalign(0)
        info.set_line_wrap(True)
        info.get_style_context().add_class("hint")
        box.pack_start(info, False, False, 0)

    def apply_template(self, name):
        settings = TEMPLATES.get(name, TEMPLATES["Wide Theater"])
        try:
            if self.window:
                self.window.resize(settings["width"], settings["height"])
            self.default_paned_position = settings.get("paned", 860)
            if hasattr(self, "paned"):
                self.paned.set_position(self.default_paned_position)
            if hasattr(self, "side_scroll"):
                self.side_scroll.set_size_request(settings["side"], -1)
                self.side_scroll.set_min_content_width(max(300, settings["side"] - 20))
            if hasattr(self, "video_area"):
                self.video_area.set_size_request(settings["video_w"], settings["video_h"])
            if hasattr(self, "audio_visualizer"):
                self.audio_visualizer.set_size_request(settings["video_w"], settings["video_h"])
            if hasattr(self, "overlay"):
                self.overlay.set_size_request(settings["video_w"], settings["video_h"])
        except Exception:
            pass

    def panel_frame(self, title):
        frame = Gtk.Frame(label=title)
        frame.get_style_context().add_class("panel")
        frame.set_shadow_type(Gtk.ShadowType.NONE)
        return frame

    def on_video_realize(self, widget):
        # mpv is intentionally started only after the user opens media.
        return False

    def recreate_video_surface(self):
        # Minimal v1.0.42 transition fix:
        # after audio mode, Gtk.Socket can lose its usable XID on some Ubuntu sessions.
        # Rebuild only the video host, only when the video XID is missing.
        try:
            old_area = getattr(self, "video_area", None)
            if old_area is not None:
                try:
                    self.video_container.remove(old_area)
                except Exception:
                    pass
            self.video_host_kind = "native"
            new_area = Gtk.EventBox()
            new_area.set_visible_window(True)
            new_area.set_above_child(False)
            new_area.set_can_focus(True)
            new_area.set_size_request(640, 360)
            new_area.set_hexpand(True)
            new_area.set_vexpand(True)
            new_area.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
            new_area.connect("button-press-event", self.on_right_click)
            new_area.connect("motion-notify-event", self.on_mouse_motion)
            new_area.get_style_context().add_class("videohost")
            new_area.connect("realize", self.on_video_realize)
            self.video_area = new_area
            self.video_container.pack_start(self.video_area, True, True, 0)
            try:
                self.video_container.reorder_child(self.video_area, 0)
            except Exception:
                pass
            if hasattr(self, "audio_visualizer"):
                self.audio_visualizer.hide()
            self.video_container.show()
            self.video_area.show_all()
            self.video_area.queue_resize()
            while Gtk.events_pending():
                Gtk.main_iteration_do(False)
            return True
        except Exception:
            return False

    def get_video_window_id(self):
        try:
            if hasattr(self, "video_area") and self.video_area:
                self.video_area.set_no_show_all(False)
                self.video_area.show()
                self.video_area.queue_resize()
            if self.window:
                self.window.show()
            if not self.video_area.get_realized():
                self.video_area.realize()
            # Let GTK create and map the native child window before mpv receives --wid.
            for _ in range(8):
                while Gtk.events_pending():
                    Gtk.main_iteration_do(False)
                gdk_window = self.video_area.get_window()
                if gdk_window:
                    break
                time.sleep(0.025)
            gdk_window = self.video_area.get_window()
            if not gdk_window:
                return None
            try:
                gdk_window.ensure_native()
            except Exception:
                pass
            while Gtk.events_pending():
                Gtk.main_iteration_do(False)
            xid = None
            try:
                xid = int(gdk_window.get_xid())
            except Exception:
                try:
                    if GdkX11 is not None:
                        xid = int(GdkX11.X11Window.get_xid(gdk_window))
                except Exception:
                    xid = None
            if not xid or xid <= 0:
                return None
            return xid
        except Exception:
            return None

    def schedule_pending_open(self, delay_ms=800):
        if not self.pending_open_files:
            return False
        if self.pending_open_source_id:
            try:
                GLib.source_remove(self.pending_open_source_id)
            except Exception:
                pass
            self.pending_open_source_id = None
        self.pending_open_source_id = GLib.timeout_add(int(delay_ms), self.try_pending_open)
        return False

    def try_pending_open(self):
        self.pending_open_source_id = None
        if not self.pending_open_files:
            self.pending_open_attempts = 0
            return False
        path = self.pending_open_files[0]
        if not path or not os.path.exists(path):
            self.pending_open_files = []
            self.pending_open_attempts = 0
            return False
        if hasattr(self, "stats_label"):
            self.stats_label.set_text("Loading media engine...")
        ok = self.open_media(path)
        if ok:
            self.pending_open_files = []
            self.pending_open_attempts = 0
            return False
        self.pending_open_attempts += 1
        if self.pending_open_attempts < 20:
            delay = 450 if self.pending_open_attempts < 6 else 900
            self.pending_open_source_id = GLib.timeout_add(delay, self.try_pending_open)
            return False
        if hasattr(self, "stats_label"):
            self.stats_label.set_text("The media engine did not answer yet. Click Open Video or Audio again, or restart the player.")
        return False

    def settle_gtk_events(self, duration_ms=180):
        # Let GTK/X11 finish mapping the native video host before mpv receives --wid.
        try:
            end_time = time.monotonic() + max(0.0, float(duration_ms)) / 1000.0
            while time.monotonic() < end_time:
                while Gtk.events_pending():
                    Gtk.main_iteration_do(False)
                time.sleep(0.015)
        except Exception:
            pass
        return False

    def prepare_video_surface_for_mpv(self):
        try:
            if hasattr(self, "video_container"):
                self.video_container.set_no_show_all(False)
                self.video_container.show()
            if hasattr(self, "audio_visualizer"):
                self.audio_visualizer.hide()
            if hasattr(self, "video_area"):
                self.video_area.set_no_show_all(False)
                self.video_area.show()
                self.video_area.queue_resize()
            if hasattr(self, "overlay"):
                self.overlay.queue_resize()
            if self.window:
                self.window.show()
                self.window.present()
            # Build the native X11 child window before mpv starts. This is the important fix.
            self.settle_gtk_events(320)
            xid = self.get_video_window_id()
            if not xid:
                self.settle_gtk_events(240)
                xid = self.get_video_window_id()
            if xid:
                self.settle_gtk_events(120)
            return xid
        except Exception:
            return None

    def ensure_mpv_started(self, initial_file=None, audio_mode=False):
        wanted_audio_mode = bool(audio_mode)
        if self.mpv:
            try:
                if self.mpv.process_alive() and bool(getattr(self.mpv, "audio_mode", False)) == wanted_audio_mode:
                    return True
                self.mpv.terminate()
            except Exception:
                pass
            self.mpv = None
        xid = None
        if not wanted_audio_mode:
            xid = self.prepare_video_surface_for_mpv()
            if not xid:
                try:
                    self.recreate_video_surface()
                    xid = self.prepare_video_surface_for_mpv()
                except Exception:
                    xid = None
            if not xid:
                self.show_message("Could not create the internal video surface. On Ubuntu Wayland, use the Ubuntu on Xorg session for embedded mpv video.", Gtk.MessageType.WARNING)
                return False
        try:
            self.mpv = MPVController(xid, initial_file=initial_file, audio_mode=wanted_audio_mode)
            if not self.mpv.process_alive():
                try:
                    self.mpv.terminate()
                except Exception:
                    pass
                self.mpv = None
                return False
            try:
                self.mpv.set_event_callback(self.on_mpv_client_message)
            except Exception:
                pass
            self.apply_rendering("Balanced")
            self.apply_volume_settings()
            return True
        except Exception as exc:
            self.mpv = None
            self.show_message(str(exc), Gtk.MessageType.ERROR)
            return False

    def choose_file(self, *_):
        dialog = Gtk.FileChooserDialog(
            title="Open video or audio",
            transient_for=self.window,
            action=Gtk.FileChooserAction.OPEN,
        )
        dialog.set_select_multiple(True)
        dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
        filt = Gtk.FileFilter()
        filt.set_name("Video and audio files")
        for pat in [
            "*.mp4", "*.mkv", "*.webm", "*.mov", "*.avi", "*.wmv", "*.mpeg", "*.mpg", "*.m4v", "*.flv",
            "*.mp3", "*.wav", "*.flac", "*.ogg", "*.oga", "*.opus", "*.m4a", "*.aac", "*.wma",
            "*.aiff", "*.aif", "*.alac", "*.ape", "*.wv", "*.amr", "*.ac3", "*.dts", "*.mid", "*.midi", "*.rmi"
        ]:
            filt.add_pattern(pat)
        dialog.add_filter(filt)
        resp = dialog.run()
        if resp == Gtk.ResponseType.OK:
            selected_files = [p for p in dialog.get_filenames() if p]
            if selected_files:
                self.add_to_playlist(selected_files, play_first=False)
                # Open after the file chooser is destroyed and GTK/X11 settles.
                # This keeps the v1.0.46 playback path but avoids a race where
                # mpv can attach to the socket while the dialog is still closing.
                self.pending_open_files = selected_files
                self.pending_open_attempts = 0
                GLib.idle_add(lambda: self.schedule_pending_open(260))
        dialog.destroy()

    def is_audio_file(self, path):
        try:
            return Path(path).suffix.lower() in AUDIO_EXTENSIONS
        except Exception:
            return False

    def start_audio_waveform_analysis(self, path):
        # v1.0.43 minimal rhythm upgrade based on stable v1.0.42.
        # This only touches audio spectrum timing. Video, playlist, layout, and mpv surface stay unchanged.
        # ffmpeg decodes a tiny mono stream in a low priority worker and feeds the visualizer gradually,
        # so the spectrum can follow the song without blocking GTK hover effects.
        self.audio_analysis_token = int(getattr(self, "audio_analysis_token", 0) or 0) + 1
        token = self.audio_analysis_token
        self.audio_waveform_path = path
        self.audio_waveform = []
        self.audio_waveform_ready = False
        self.audio_analysis_started_once = False
        self.audio_position = 0.0
        self.audio_paused = False
        now = time.monotonic()
        self.audio_visualizer_last_sync_time = now
        self.audio_visualizer_last_sync_pos = 0.0
        self.audio_visualizer_last_tick_time = now
        self.audio_visualizer_last_mpv_poll = 0.0
        self.audio_visualizer_levels_cache = [0.18 for _ in range(64)]
        self.audio_visualizer_bars = [0.18 for _ in range(64)]
        self.audio_visualizer_energy = 0.34
        self.audio_waveform_rate = 24.0
        if not path or not shutil.which("ffmpeg"):
            return False

        def worker():
            amps = []
            proc = None
            running_ref = 0.08
            last_value = 0.0
            last_push = time.monotonic()
            max_levels = 24 * 60 * 60 * 6  # six hours at 24 levels per second, enough without growing forever
            try:
                cmd = [
                    "ffmpeg", "-v", "error", "-nostdin", "-threads", "1", "-filter_threads", "1",
                    "-i", str(path), "-map", "0:a:0", "-vn", "-ac", "1", "-ar", "48", "-f", "s16le", "pipe:1",
                ]

                def low_priority():
                    try:
                        os.nice(12)
                    except Exception:
                        pass

                proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, preexec_fn=low_priority, bufsize=4096)
                self.audio_analysis_process = proc
                pending = b""
                samples_per_bin = 2  # 48 Hz source / 2 = 24 rhythm levels per second

                def push_partial(force=False):
                    nonlocal last_push
                    try:
                        if int(token) != int(getattr(self, "audio_analysis_token", 0) or 0):
                            return False
                        if path != getattr(self, "audio_waveform_path", None):
                            return False
                        now_push = time.monotonic()
                        if force or (len(amps) > 12 and now_push - last_push >= 0.45):
                            last_push = now_push
                            GLib.idle_add(self.apply_audio_waveform_analysis, token, path, list(amps), False)
                    except BaseException:
                        pass
                    return True

                while True:
                    if int(token) != int(getattr(self, "audio_analysis_token", 0) or 0):
                        break
                    if path != getattr(self, "audio_waveform_path", None):
                        break
                    chunk = proc.stdout.read(4096) if proc and proc.stdout else b""
                    if not chunk:
                        break
                    pending += chunk
                    usable = len(pending) - (len(pending) % 2)
                    if usable <= 0:
                        continue
                    raw = pending[:usable]
                    pending = pending[usable:]
                    samples = array.array("h")
                    samples.frombytes(raw)
                    if sys.byteorder != "little":
                        samples.byteswap()
                    for i in range(0, len(samples), samples_per_bin):
                        if len(amps) >= max_levels:
                            break
                        small = samples[i:i + samples_per_bin]
                        if not small:
                            continue
                        peak = max(abs(int(v)) for v in small) / 32768.0
                        avg = sum(abs(int(v)) for v in small) / (len(small) * 32768.0)
                        level = max(avg * 1.85, peak * 0.70)
                        # Dynamic reference makes quiet MP3 files move and loud files stop clipping.
                        running_ref = max(0.035, (running_ref * 0.998) + (level * 0.002))
                        if level > running_ref:
                            running_ref = (running_ref * 0.965) + (level * 0.035)
                        value = max(0.0, min(1.0, level / (running_ref * 1.65)))
                        value = math.sqrt(value)
                        # Fast attack, slower release, so kicks and vocals visibly hit the wave.
                        if value >= last_value:
                            value = (last_value * 0.22) + (value * 0.78)
                        else:
                            value = (last_value * 0.70) + (value * 0.30)
                        last_value = value
                        amps.append(max(0.035, min(1.0, value)))
                    push_partial(False)
                    if len(amps) >= max_levels:
                        break
                try:
                    if proc:
                        proc.wait(timeout=0.2)
                except Exception:
                    pass
            except BaseException:
                pass
            finally:
                try:
                    if getattr(self, "audio_analysis_process", None) is proc:
                        self.audio_analysis_process = None
                except Exception:
                    pass
                try:
                    if int(token) == int(getattr(self, "audio_analysis_token", 0) or 0) and path == getattr(self, "audio_waveform_path", None):
                        GLib.idle_add(self.apply_audio_waveform_analysis, token, path, list(amps), True)
                except BaseException:
                    pass

        def delayed_start():
            try:
                if int(token) != int(getattr(self, "audio_analysis_token", 0) or 0):
                    return False
                if path != getattr(self, "audio_waveform_path", None):
                    return False
                threading.Thread(target=worker, daemon=True).start()
            except BaseException:
                pass
            return False

        try:
            GLib.timeout_add(250, delayed_start)
            return True
        except BaseException:
            return False

    def apply_audio_waveform_analysis(self, token, path, amps, final=False):
        try:
            if int(token) != int(getattr(self, "audio_analysis_token", 0) or 0):
                return False
            if path != getattr(self, "audio_waveform_path", None):
                return False
            if amps and len(amps) > 8:
                self.audio_waveform = list(amps)
                self.audio_waveform_rate = 24.0
                self.audio_waveform_ready = True
                if final:
                    self.audio_analysis_started_once = True
            elif final:
                self.audio_waveform = []
                self.audio_waveform_ready = False
            if self.audio_visualizer:
                self.audio_visualizer.queue_draw()
        except BaseException:
            pass
        return False

    def stop_audio_visualizer_work(self):
        try:
            self.audio_analysis_token = int(getattr(self, "audio_analysis_token", 0) or 0) + 1
            proc = getattr(self, "audio_analysis_process", None)
            if proc:
                try:
                    proc.kill()
                except Exception:
                    pass
            self.audio_analysis_process = None
            self.audio_waveform_path = None
            self.audio_waveform = []
            self.audio_waveform_ready = False
            self.audio_visualizer_levels_cache = []
            self.audio_visualizer_bars = [0.12 for _ in range(64)]
            self.audio_position = 0.0
            self.audio_visualizer_last_sync_time = 0.0
            self.audio_visualizer_last_sync_pos = 0.0
            self.audio_visualizer_last_tick_time = 0.0
            self.audio_visualizer_last_mpv_poll = 0.0
            self.audio_visualizer_energy = 0.25
        except BaseException:
            pass
        return False

    def get_audio_energy_at(self, pos, fallback_phase=0.0):
        try:
            data = getattr(self, "audio_waveform", None) or []
            rate = float(getattr(self, "audio_waveform_rate", 18.0) or 18.0)
            if data and rate > 0:
                f = max(0.0, float(pos or 0.0) * rate)
                i = int(f)
                if i >= len(data):
                    i = len(data) - 1
                j = min(len(data) - 1, i + 1)
                t = f - i
                base = float(data[i]) * (1.0 - t) + float(data[j]) * t
                prev_value = float(data[i - 1]) if i > 0 else base
                next_value = float(data[j + 1]) if j + 1 < len(data) else base
                return max(0.025, min(1.0, (prev_value * 0.16) + (base * 0.68) + (next_value * 0.16)))
        except BaseException:
            pass
        try:
            p = float(pos or 0.0)
            ph = float(fallback_phase or 0.0)
            low = abs(math.sin(p * 2.20 + ph * 0.30))
            mid = abs(math.sin(p * 4.75 - ph * 0.42))
            high = abs(math.sin(p * 9.10 + ph * 0.18))
            return max(0.10, min(1.0, 0.26 + low * 0.35 + mid * 0.24 + high * 0.12))
        except BaseException:
            return 0.48

    def enable_audio_visualizer(self):
        # Audio loads normally first. The GTK visualizer is separate and safe.
        self.audio_visualizer_active = True
        self.show_audio_visualizer(True)
        return True

    def disable_audio_visualizer(self):
        self.audio_visualizer_active = False
        self.stop_audio_visualizer_work()
        self.show_audio_visualizer(False)
        return True

    def load_audio_with_visualizer(self, path):
        if not self.mpv:
            return False
        loaded = self.mpv.load_file(path)
        if loaded:
            self.start_audio_waveform_analysis(path)
            self.enable_audio_visualizer()
            return True
        self.disable_audio_visualizer()
        return False

    def load_video_safely(self, path):
        # v1.0.59: real fix is the host surface. mpv receives a native GTK XID,
        # then the file is loaded after the parent window is mapped and stable.
        if not self.mpv or not self.mpv.process_alive():
            return False
        try:
            if hasattr(self, "video_container"):
                self.video_container.show_all()
            if hasattr(self, "video_area"):
                self.video_area.show_all()
                self.video_area.queue_resize()
            if hasattr(self, "audio_visualizer"):
                self.audio_visualizer.hide()
            if hasattr(self, "prepare_video_surface_for_mpv"):
                self.prepare_video_surface_for_mpv()
            else:
                while Gtk.events_pending():
                    Gtk.main_iteration_do(False)
            self.settle_gtk_events(90)
        except Exception:
            pass
        try:
            loaded = bool(self.mpv.load_file(path))
            if not loaded:
                return False
            # Some mpv/GPU/X11 crashes happen just after loadfile returns.
            # Keep GTK responsive and verify the process survived the first frames.
            end_time = time.monotonic() + 0.75
            while time.monotonic() < end_time:
                while Gtk.events_pending():
                    Gtk.main_iteration_do(False)
                if not self.mpv or not self.mpv.process_alive():
                    return False
                time.sleep(0.035)
            return bool(self.mpv and self.mpv.process_alive())
        except BaseException:
            return False

    def queue_media_open(self, path, delay_ms=520):
        if not path:
            return False
        self.media_open_queue_path = path
        try:
            if self.media_open_cooldown_source_id:
                GLib.source_remove(self.media_open_cooldown_source_id)
        except Exception:
            pass
        self.media_open_cooldown_source_id = GLib.timeout_add(max(80, int(delay_ms)), self.drain_media_open_queue)
        return False

    def drain_media_open_queue(self):
        self.media_open_cooldown_source_id = None
        path = self.media_open_queue_path
        self.media_open_queue_path = None
        if not path or not os.path.exists(path):
            return False
        if getattr(self, "media_open_in_progress", False):
            self.queue_media_open(path, 420)
            return False
        self.open_media(path)
        return False

    def open_media(self, path):
        if not path:
            return False
        # Debounce rapid file-open clicks. Returning True here is intentional: the
        # newest file is queued and will open after the current transition settles.
        try:
            now = time.monotonic()
            cooldown_until = float(getattr(self, "media_open_cooldown_until", 0.0) or 0.0)
            if getattr(self, "media_open_in_progress", False):
                self.queue_media_open(path, 520)
                return True
            if now < cooldown_until:
                self.queue_media_open(path, int((cooldown_until - now) * 1000) + 100)
                return True
        except Exception:
            pass
        self.media_open_in_progress = True
        is_audio_for_gap = False
        ok = False
        try:
            is_audio_for_gap = self.is_audio_file(path)
            ok = bool(self.open_media_now(path))
            return ok
        finally:
            self.media_open_in_progress = False
            # Video needs a longer gap because the embedded mpv child window and GPU
            # decoder need a moment to release the previous MP4. Audio can stay snappy.
            gap_ms = 620 if not is_audio_for_gap else 260
            self.media_open_cooldown_until = time.monotonic() + (gap_ms / 1000.0)
            queued = getattr(self, "media_open_queue_path", None)
            if queued and queued != path:
                self.queue_media_open(queued, gap_ms)

    def open_media_now(self, path):
        if not path:
            return False
        is_audio = self.is_audio_file(path)
        if not is_audio:
            # Hard isolation: audio visualizer never runs during video playback.
            self.disable_audio_visualizer()
            try:
                if hasattr(self, "video_container"):
                    self.video_container.show_all()
                if hasattr(self, "video_area"):
                    self.video_area.show_all()
                if hasattr(self, "audio_visualizer"):
                    self.audio_visualizer.hide()
                while Gtk.events_pending():
                    Gtk.main_iteration_do(False)
            except Exception:
                pass
        current_audio_mode = bool(self.mpv and self.mpv.process_alive() and getattr(self.mpv, "audio_mode", False))
        if not is_audio and self.mpv and self.mpv.process_alive() and not current_audio_mode:
            # After many fast MP4 replacements, refresh mpv before the GPU/X11 state gets unstable.
            # This is only for video-to-video stress opening and does not change the normal layout.
            try:
                if int(getattr(self, "video_replace_count", 0) or 0) >= 8:
                    self.mpv.terminate()
                    self.mpv = None
                    self.video_replace_count = 0
                    self.settle_gtk_events(180)
            except Exception:
                self.mpv = None
                self.video_replace_count = 0
        first_start = not (self.mpv and self.mpv.process_alive()) or current_audio_mode != is_audio
        if first_start:
            # v1.0.59: start mpv idle first, then load video by IPC after the
            # embedded GTK/X11 surface is already mapped. This restores the stable
            # startup model and keeps audio visualizer mode isolated.
            if not self.ensure_mpv_started(initial_file=None, audio_mode=is_audio):
                return False
            loaded = self.load_audio_with_visualizer(path) if is_audio else self.load_video_safely(path)
        else:
            if is_audio:
                loaded = self.load_audio_with_visualizer(path)
            else:
                self.disable_audio_visualizer()
                try:
                    if hasattr(self, "video_container"):
                        self.video_container.show_all()
                    if hasattr(self, "video_area"):
                        self.video_area.show_all()
                    if hasattr(self, "audio_visualizer"):
                        self.audio_visualizer.hide()
                except Exception:
                    pass
                loaded = self.load_video_safely(path)
            if not loaded:
                try:
                    self.mpv.terminate()
                except Exception:
                    pass
                self.mpv = None
                if not self.ensure_mpv_started(initial_file=None, audio_mode=is_audio):
                    return False
                loaded = self.load_audio_with_visualizer(path) if is_audio else self.load_video_safely(path)
        if not loaded:
            if hasattr(self, "stats_label"):
                self.stats_label.set_text("Embedded mpv surface is restarting. Retrying automatically...")
            return False
        if is_audio:
            self.video_replace_count = 0
        else:
            self.video_replace_count = int(getattr(self, "video_replace_count", 0) or 0) + 1
        self.current_file = path
        self.eof_handled_for_file = None
        if path not in self.playlist_paths:
            self.add_to_playlist([path], play_first=False)
        self.sync_playlist_current(path)
        self.file_label.set_text(Path(path).name)
        if hasattr(self, "placeholder"):
            self.placeholder.hide()
        if hasattr(self, "stats_label"):
            if is_audio:
                self.stats_label.set_text("Audio loaded with optimized rhythm spectrum visualizer. Stats will appear when playback starts.")
            else:
                self.stats_label.set_text("Media loaded. Stats will appear when playback starts.")
        self.post_open_setup_attempts = 0
        GLib.timeout_add(250, self.finish_media_runtime_setup)
        GLib.timeout_add(900, lambda: self.show_start_banner() or False)
        return True

    def finish_media_runtime_setup(self):
        if not self.mpv or not self.mpv.process_alive():
            return False
        if not self.mpv.ready:
            self.mpv.ready = self.mpv.wait_until_ready(0.5)
        if not self.mpv.ready:
            self.post_open_setup_attempts = getattr(self, "post_open_setup_attempts", 0) + 1
            return self.post_open_setup_attempts < 16
        try:
            self.mpv.set_event_callback(self.on_mpv_client_message)
        except Exception:
            pass
        try:
            self.mpv.set_property("keep-open", "yes")
        except Exception:
            pass
        self.set_loop_enabled(self.loop_enabled)
        self.set_mute_enabled(self.muted)
        self.apply_volume_settings()
        self.apply_color_values()
        self.set_pause(False)
        return False

    def _hex_to_rgb(self, value, fallback=(0.0, 1.0, 0.3)):
        try:
            value = str(value).strip().lstrip("#")
            if len(value) != 6:
                return fallback
            return tuple(int(value[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
        except Exception:
            return fallback

    def show_audio_visualizer(self, show=True):
        widget = getattr(self, "audio_visualizer", None)
        if not widget:
            return False
        if show:
            try:
                if hasattr(self, "placeholder"):
                    self.placeholder.hide()
                if hasattr(self, "video_area"):
                    self.video_area.hide()
                if hasattr(self, "video_container"):
                    self.video_container.show()
                widget.set_visible(True)
                widget.show()
                widget.queue_resize()
                widget.queue_draw()
                GLib.idle_add(lambda: (widget.queue_draw() or False))
            except Exception:
                pass
            if not self.audio_visualizer_timer_id:
                # 24 FPS with cached waveform levels keeps hover responsive and glow smooth.
                self.audio_visualizer_timer_id = GLib.timeout_add(41, self.tick_audio_visualizer)
        else:
            try:
                widget.hide()
                if hasattr(self, "video_container"):
                    self.video_container.show()
                if hasattr(self, "video_area"):
                    self.video_area.show_all()
            except Exception:
                pass
            if self.audio_visualizer_timer_id:
                try:
                    GLib.source_remove(self.audio_visualizer_timer_id)
                except Exception:
                    pass
                self.audio_visualizer_timer_id = None
        return False

    def tick_audio_visualizer(self):
        if not self.audio_visualizer_active:
            self.audio_visualizer_timer_id = None
            return False
        self.audio_visualizer_tick_count = int(getattr(self, "audio_visualizer_tick_count", 0) or 0) + 1
        now = time.monotonic()
        last_tick = float(getattr(self, "audio_visualizer_last_tick_time", 0.0) or 0.0)
        if last_tick <= 0.0 or last_tick > now:
            last_tick = now
        dt = max(0.0, min(0.20, now - last_tick))
        self.audio_visualizer_last_tick_time = now

        try:
            # Poll mpv only about four times per second, not on every visual frame.
            if self.mpv and self.mpv.process_alive() and now - float(getattr(self, "audio_visualizer_last_mpv_poll", 0.0) or 0.0) > 0.24:
                self.audio_visualizer_last_mpv_poll = now
                pos = self.mpv.get_property("time-pos")
                pause = self.mpv.get_property("pause")
                if pos is not None:
                    self.audio_position = max(0.0, float(pos or 0.0))
                    self.audio_visualizer_last_sync_pos = self.audio_position
                    self.audio_visualizer_last_sync_time = now
                self.audio_paused = bool(pause)
            elif not getattr(self, "audio_paused", False):
                self.audio_position = max(0.0, float(getattr(self, "audio_position", 0.0) or 0.0) + dt)
        except BaseException:
            if not getattr(self, "audio_paused", False):
                self.audio_position = max(0.0, float(getattr(self, "audio_position", 0.0) or 0.0) + dt)

        try:
            beat = self.get_audio_energy_at(getattr(self, "audio_position", 0.0), getattr(self, "audio_visualizer_phase", 0.0))
        except BaseException:
            beat = 0.5
        try:
            old_energy = float(getattr(self, "audio_visualizer_energy", 0.34) or 0.34)
            self.audio_visualizer_energy = (old_energy * 0.68) + (float(beat) * 0.32)
        except BaseException:
            self.audio_visualizer_energy = beat

        if getattr(self, "audio_paused", False):
            self.audio_visualizer_phase += 0.008
        else:
            self.audio_visualizer_phase += 0.060 + (0.115 * max(0.0, min(1.0, beat)))

        try:
            columns = 64
            pos = float(getattr(self, "audio_position", 0.0) or 0.0)
            phase = float(getattr(self, "audio_visualizer_phase", 0.0) or 0.0)
            levels = []
            # Small time window gives a spectrum style view from the waveform data without live DSP cost.
            window_seconds = 3.6
            for i in range(columns):
                n = i / max(1, columns - 1)
                sample_time = pos + (n - 0.5) * window_seconds
                real = self.get_audio_energy_at(sample_time, phase + i * 0.018)
                center_shape = 0.28 + 0.72 * math.sin(math.pi * n)
                shimmer = 0.90 + 0.10 * math.sin(phase * 1.35 + i * 0.23)
                levels.append(max(0.035, min(1.0, real * center_shape * shimmer)))
            old = getattr(self, "audio_visualizer_bars", []) or []
            if len(old) != columns:
                old = [0.16 for _ in range(columns)]
            speed = 0.46 if getattr(self, "audio_waveform_ready", False) else 0.28
            self.audio_visualizer_bars = [(old[i] * (1.0 - speed)) + (levels[i] * speed) for i in range(columns)]
            self.audio_visualizer_levels_cache = self.audio_visualizer_bars
        except BaseException:
            pass

        try:
            if self.audio_visualizer:
                self.audio_visualizer.queue_draw()
        except Exception:
            pass
        return True

    def draw_audio_visualizer(self, widget, cr):
        # v1.0.37 optimized glowing rhythm visualizer.
        # Rhythm is prepared outside the draw call. The draw call only paints cached bars and waves.
        try:
            alloc = widget.get_allocation()
            width = float(max(2, int(getattr(alloc, "width", 2) or 2)))
            height = float(max(2, int(getattr(alloc, "height", 2) or 2)))
            if width < 4.0 or height < 4.0:
                return False

            theme = THEMES.get(self.current_theme, THEMES.get("Nexus Green"))

            def safe_rgb(name, fallback):
                try:
                    rgb = self._hex_to_rgb(theme.get(name, ""), fallback)
                    return tuple(max(0.0, min(1.0, float(v))) for v in rgb)
                except Exception:
                    return fallback

            bg = safe_rgb("bg", (0.0, 0.01, 0.02))
            panel = safe_rgb("panel", (0.0, 0.05, 0.08))
            accent = safe_rgb("accent", (0.0, 0.85, 1.0))
            accent2 = safe_rgb("accent2", (0.35, 1.0, 1.0))
            text = safe_rgb("text", (0.90, 1.0, 1.0))

            phase = float(getattr(self, "audio_visualizer_phase", 0.0) or 0.0)
            rhythm = max(0.05, min(1.0, float(getattr(self, "audio_visualizer_energy", 0.34) or 0.34)))
            if getattr(self, "audio_paused", False):
                rhythm *= 0.55

            # Fill all available video area to remove the boxed in look.
            cr.set_source_rgb(bg[0] * 0.36, bg[1] * 0.36, bg[2] * 0.36)
            cr.rectangle(0, 0, width, height)
            cr.fill()

            visual_w = width
            visual_h = height
            left = 0.0
            top = 0.0
            right = width
            bottom = height
            center_y = height * 0.50

            # Theme panel and soft glow, cheap rectangle layers instead of expensive radial effects.
            cr.set_source_rgba(panel[0], panel[1], panel[2], 0.70)
            cr.rectangle(left, top, visual_w, visual_h)
            cr.fill()
            for k, alpha in enumerate((0.125, 0.074, 0.044)):
                band_h = visual_h * (0.12 + k * 0.18) * (0.72 + rhythm * 0.78)
                y = center_y - band_h * 0.5
                cr.set_source_rgba(accent[0], accent[1], accent[2], alpha)
                cr.rectangle(left, y, visual_w, band_h)
                cr.fill()

            # Very light grid, few strokes only.
            cr.set_line_width(1.0)
            cr.set_source_rgba(accent[0], accent[1], accent[2], 0.045)
            for line in range(5):
                y = top + visual_h * (line + 1) / 6.0
                cr.move_to(left, y)
                cr.line_to(right, y)
            cr.stroke()
            cr.set_source_rgba(accent2[0], accent2[1], accent2[2], 0.34 + rhythm * 0.18)
            cr.move_to(left, center_y)
            cr.line_to(right, center_y)
            cr.stroke()

            bars = list(getattr(self, "audio_visualizer_bars", []) or getattr(self, "audio_visualizer_levels_cache", []) or [])
            if not bars:
                bars = [0.16 for _ in range(64)]
            columns = len(bars)
            gap = max(1.5, visual_w / 320.0)
            col_w = max(3.0, (visual_w - gap * (columns - 1)) / columns)
            max_h = visual_h * 0.82

            # Glow pass and solid pass. This gives a neon look without hundreds of dotted rectangles.
            for glow, alpha_mul, widen in ((True, 0.18, 1.9), (False, 0.78, 1.0)):
                for i, level in enumerate(bars):
                    n = i / max(1, columns - 1)
                    x = left + i * (col_w + gap)
                    mix = n
                    r = max(0.0, min(1.0, accent[0] * (1.0 - mix) + accent2[0] * mix))
                    g = max(0.0, min(1.0, accent[1] * (1.0 - mix) + accent2[1] * mix))
                    b = max(0.0, min(1.0, accent[2] * (1.0 - mix) + accent2[2] * mix))
                    h = max(8.0, float(level) * max_h * (0.82 + rhythm * 0.32))
                    y = center_y - h * 0.5
                    w = col_w * widen
                    xx = x - (w - col_w) * 0.5
                    cr.set_source_rgba(r, g, b, alpha_mul)
                    cr.rectangle(xx, y, w, h)
                    cr.fill()

            # Central glowing wave, larger and smooth, but only 120 points.
            points = 120
            amp = visual_h * (0.13 + rhythm * 0.22)
            def level_at(xnorm):
                if not bars:
                    return rhythm
                f = max(0.0, min(1.0, xnorm)) * (len(bars) - 1)
                i = int(f)
                j = min(len(bars) - 1, i + 1)
                t = f - i
                return bars[i] * (1.0 - t) + bars[j] * t
            def wave_y(p):
                xnorm = p / float(points)
                local = level_at(xnorm)
                envelope = 0.18 + 0.82 * math.sin(math.pi * xnorm)
                w1 = math.sin(p * 0.145 + phase * 2.45)
                w2 = math.sin(p * 0.285 - phase * 1.12) * 0.44
                w3 = math.sin(p * 0.067 + phase * 3.05) * 0.24
                return center_y + (w1 + w2 + w3) * amp * envelope * (0.44 + local * 0.86)
            for line_width, alpha, color in (
                (14.0, 0.052 + rhythm * 0.030, accent),
                (7.0, 0.115 + rhythm * 0.050, accent),
                (2.4, 0.92, accent2),
            ):
                cr.set_line_width(line_width)
                cr.set_source_rgba(color[0], color[1], color[2], alpha)
                cr.move_to(left, center_y)
                for p in range(points + 1):
                    x = left + visual_w * p / float(points)
                    cr.line_to(x, wave_y(p))
                cr.stroke()

            # Small status text only, no heavy font effects.
            try:
                title = "GOVIBE AUDIO SPECTRUM"
                cr.select_font_face("Sans", 0, 1)
                cr.set_font_size(max(10.0, min(16.0, visual_h * 0.040)))
                ext = cr.text_extents(title)
                tx = left + (visual_w - float(ext.width)) * 0.5
                ty = bottom - max(12.0, visual_h * 0.035)
                cr.set_source_rgba(text[0], text[1], text[2], 0.30)
                cr.move_to(tx, ty)
                cr.show_text(title)
            except Exception:
                pass
        except BaseException:
            try:
                cr.set_source_rgb(0.0, 0.0, 0.0)
                cr.rectangle(0, 0, 2, 2)
                cr.fill()
            except BaseException:
                pass
        return False

    def stop_media(self):
        # Keep the loaded file available so PLAY can restart it.
        # This avoids the bug where Stop unloaded mpv and Play had nothing left to resume.
        if self.mpv and self.current_file:
            try:
                self.mpv.command("seek", 0, "absolute")
                self.mpv.set_property("pause", True)
                self.audio_paused = True
            except Exception:
                pass
            if hasattr(self, "placeholder"):
                self.placeholder.hide()
            self.time_label.set_text("00:00 / " + self.format_time(self.last_stats.get("duration") or 0))
            self.stats_label.set_text("Stopped at beginning. Press PLAY to restart.")
            return
        if self.mpv:
            self.mpv.command("stop")
        self.file_label.set_text("No media loaded")
        if hasattr(self, "placeholder"):
            self.placeholder.show_all()
        self.stats_label.set_text("Stopped.")
        self.time_label.set_text("00:00 / 00:00")

    def set_pause(self, pause):
        if not self.mpv:
            return
        if not pause and self.current_file:
            try:
                idle = self.mpv.get_property("idle-active")
                eof = self.mpv.get_property("eof-reached")
                pos = self.mpv.get_property("time-pos") or 0
                dur = self.mpv.get_property("duration") or 0
                if idle:
                    self.mpv.load_file(self.current_file)
                elif eof or (dur and float(pos) >= max(0, float(dur) - 0.25)):
                    self.mpv.command("seek", 0, "absolute")
            except Exception:
                pass
        self.mpv.set_property("pause", bool(pause))
        try:
            self.audio_paused = bool(pause)
            self.audio_visualizer_last_tick_time = time.monotonic()
        except Exception:
            pass

    def relative_seek(self, seconds):
        if self.mpv:
            self.mpv.command("seek", seconds, "relative")

    def seek_start(self, *_):
        self.is_seeking = True
        return False

    def seek_finish(self, slider=None, *_):
        if self.mpv:
            try:
                value = float(slider.get_value()) if slider is not None and hasattr(slider, "get_value") else float(self.position.get_value())
            except Exception:
                value = float(self.position.get_value())
            self.mpv.command("seek", value, "absolute")
            if hasattr(self, "position"):
                self.position.set_value(value)
            if hasattr(self, "overlay_position"):
                self.overlay_position.set_value(value)
        self.is_seeking = False
        return False

    def refresh_stats(self):
        if not self.mpv:
            return True
        props = {}
        for prop in [
            "time-pos", "duration", "pause", "width", "height", "container-fps", "estimated-vf-fps",
            "video-codec", "audio-codec", "audio-params/samplerate", "audio-params/channel-count",
            "video-bitrate", "audio-bitrate", "packet-video-bitrate", "packet-audio-bitrate", "media-title", "path", "eof-reached",
        ]:
            props[prop] = self.mpv.get_property(prop)
        self.last_stats = props
        if props.get("eof-reached") and self.current_file and self.eof_handled_for_file != self.current_file:
            self.eof_handled_for_file = self.current_file
            if self.play_next_playlist_item(auto=True):
                return True
        elif not props.get("eof-reached"):
            self.eof_handled_for_file = None
        pos = props.get("time-pos") or 0
        dur = props.get("duration") or 0
        if dur and not self.is_seeking:
            self.position.set_range(0, float(dur))
            self.position.set_value(float(pos))
            if hasattr(self, "overlay_position"):
                self.overlay_position.set_range(0, float(dur))
                self.overlay_position.set_value(float(pos))
        self.time_label.set_text(f"{self.format_time(pos)} / {self.format_time(dur)}")
        w = props.get("width") or "?"
        h = props.get("height") or "?"
        fps = props.get("container-fps") or props.get("estimated-vf-fps") or "unknown"
        hz = props.get("audio-params/samplerate") or "unknown"
        vbit = props.get("video-bitrate") or props.get("packet-video-bitrate")
        abit = props.get("audio-bitrate") or props.get("packet-audio-bitrate")
        vbit_s = self.format_bitrate(vbit)
        abit_s = self.format_bitrate(abit)
        vcodec = props.get("video-codec") or "unknown"
        acodec = props.get("audio-codec") or "unknown"
        channels = props.get("audio-params/channel-count") or "unknown"
        title = props.get("media-title") or (Path(self.current_file).name if self.current_file else "unknown")
        title_res = f"{title} | {w}x{h}" if w != "?" and h != "?" else str(title)
        try:
            self.file_label.set_text(title_res)
            self.window.set_title(f"{APP_NAME} - {title_res}")
        except Exception:
            pass
        self.stats_label.set_text(
            f"Title: {title}\n"
            f"Video: {w}x{h} | FPS: {fps} | Codec: {vcodec} | Bitrate: {vbit_s}\n"
            f"Audio: {hz} Hz | Channels: {channels} | Codec: {acodec} | Bitrate: {abit_s}"
        )
        return True

    def format_time(self, value):
        try:
            value = int(float(value or 0))
        except Exception:
            value = 0
        h = value // 3600
        m = (value % 3600) // 60
        s = value % 60
        if h:
            return f"{h:02d}:{m:02d}:{s:02d}"
        return f"{m:02d}:{s:02d}"

    def format_bitrate(self, value):
        if value is None:
            return "unknown"
        try:
            value = float(value)
            if value <= 0:
                return "unknown"
            if value > 1000000:
                return f"{value / 1000000:.2f} Mbps"
            if value > 1000:
                return f"{value / 1000:.0f} kbps"
            return f"{value:.0f} bps"
        except Exception:
            return str(value)

    def show_start_banner(self):
        if not self.osd_enabled:
            return False
        title = self.last_stats.get("media-title") or (Path(self.current_file).name if self.current_file else "Media")
        w = self.last_stats.get("width") or "?"
        h = self.last_stats.get("height") or "?"
        fps = self.last_stats.get("container-fps") or self.last_stats.get("estimated-vf-fps") or ""
        suffix = f" | {w}x{h}" if w != "?" else ""
        if fps:
            suffix += f" | {fps} FPS"
        self.banner.set_text(f"{title}{suffix}")
        self.banner.show()
        GLib.timeout_add_seconds(4, self.hide_banner)
        return False

    def hide_banner(self):
        self.banner.hide()
        return False

    def toggle_osd(self, widget):
        self.osd_enabled = widget.get_active()
        if hasattr(self, "osd_check") and widget is not self.osd_check:
            self.osd_check.set_active(self.osd_enabled)

    def eq_preset_changed(self, combo):
        name = combo.get_active_text()
        if name:
            self.apply_eq_preset(name)

    def apply_eq_preset(self, name):
        gains = EQ_PRESETS.get(name, EQ_PRESETS["Flat"])
        for slider, gain in zip(self.eq_sliders, gains):
            slider.handler_block_by_func(self.apply_custom_eq) if False else None
            slider.set_value(gain)
        self.apply_custom_eq()
        if hasattr(self, "eq_combo"):
            for i, key in enumerate(EQ_PRESETS.keys()):
                if key == name:
                    self.eq_combo.set_active(i)
                    break

    def apply_custom_eq(self, *_):
        if not self.mpv or not self.eq_sliders:
            return
        gains = [int(slider.get_value()) for slider in self.eq_sliders]
        if all(g == 0 for g in gains):
            self.mpv.command("af", "clr", "")
            return
        filters = []
        for freq, gain in zip(EQ_FREQS, gains):
            if gain != 0:
                filters.append(f"equalizer=f={freq}:t=q:w=1:g={gain}")
        filter_chain = "lavfi=[" + ",".join(filters) + "]"
        self.mpv.command("af", "set", filter_chain)

    def video_filter_changed(self, combo):
        name = combo.get_active_text() or "None"
        self.apply_video_filter(name)

    def apply_video_filter(self, name):
        if not self.mpv:
            return
        filt = VIDEO_FILTERS.get(name, "")
        if not filt:
            self.mpv.command("vf", "clr", "")
        else:
            self.mpv.command("vf", "set", filt)

    def reset_video_filters(self):
        if hasattr(self, "filter_combo"):
            self.filter_combo.set_active(0)
        if self.mpv:
            self.mpv.command("vf", "clr", "")

    def rendering_changed(self, combo):
        self.apply_rendering(combo.get_active_text() or "Balanced")

    def apply_rendering(self, name):
        if hasattr(self, "render_combo"):
            for i, key in enumerate(RENDERING_PRESETS):
                if key == name:
                    self.render_combo.set_active(i)
                    break
        if not self.mpv:
            return
        settings = RENDERING_SETTINGS.get(name, RENDERING_SETTINGS.get("Balanced", {}))
        for prop, val in settings.items():
            self.mpv.set_property(prop, val)

    def color_changed(self, slider, name):
        try:
            self.color_values[name] = int(slider.get_value())
        except Exception:
            pass
        if self.mpv:
            self.mpv.set_property(name, int(self.color_values.get(name, 0)))

    def apply_color_values(self):
        if not self.mpv:
            return
        for name, value in self.color_values.items():
            try:
                self.mpv.set_property(name, int(value))
            except Exception:
                pass

    def adjust_color(self, name, delta):
        if name not in self.color_values:
            return
        self.color_values[name] = clamp(int(self.color_values.get(name, 0)) + int(delta), -100, 100)
        if self.mpv:
            self.mpv.set_property(name, int(self.color_values[name]))

    def reset_color(self):
        self.color_values = {"brightness": 0, "contrast": 0, "saturation": 0, "gamma": 0, "hue": 0}
        if hasattr(self, "color_controls"):
            for name, scale in self.color_controls.items():
                try:
                    scale.set_value(0)
                except Exception:
                    pass
        if self.mpv:
            for name in self.color_values:
                self.mpv.set_property(name, 0)

    def close_context_menu(self, *_):
        if self.context_popup:
            try:
                self.context_popup.popdown()
            except Exception:
                pass
            self.context_popup = None
        return False

    def media_file_patterns(self):
        return [
            "*.mp4", "*.mkv", "*.webm", "*.mov", "*.avi", "*.wmv", "*.mpeg", "*.mpg", "*.m4v", "*.flv", "*.ogv", "*.3gp", "*.ts", "*.m2ts",
            "*.mp3", "*.wav", "*.flac", "*.ogg", "*.oga", "*.opus", "*.m4a", "*.aac", "*.wma",
            "*.aiff", "*.aif", "*.alac", "*.ape", "*.wv", "*.amr", "*.ac3", "*.dts", "*.mid", "*.midi", "*.rmi"
        ]

    def is_media_path(self, path):
        try:
            suffix = Path(path).suffix.lower()
            video_exts = {".mp4", ".mkv", ".webm", ".mov", ".avi", ".wmv", ".mpeg", ".mpg", ".m4v", ".flv", ".ogv", ".3gp", ".ts", ".m2ts"}
            return suffix in video_exts or suffix in AUDIO_EXTENSIONS
        except Exception:
            return False

    def normalize_media_path(self, path):
        try:
            return str(Path(path).expanduser().resolve())
        except Exception:
            return str(path)

    def add_to_playlist(self, paths, play_first=False):
        added = []
        for raw in paths or []:
            if not raw:
                continue
            path = self.normalize_media_path(raw)
            if not os.path.isfile(path):
                continue
            if not self.is_media_path(path):
                continue
            if path in self.playlist_paths:
                continue
            self.playlist_paths.append(path)
            self.playlist_store.append([Path(path).name, path])
            added.append(path)
        self.refresh_playlist_title()
        if play_first and added:
            self.open_media(added[0])
        return added

    def sync_playlist_current(self, path):
        try:
            self.playlist_current_index = self.playlist_paths.index(path)
        except Exception:
            self.playlist_current_index = -1
        if self.playlist_tree and self.playlist_current_index >= 0:
            try:
                selection = self.playlist_tree.get_selection()
                tree_path = self.make_tree_path(self.playlist_current_index)
                selection.select_path(tree_path)
                self.playlist_tree.scroll_to_cell(tree_path, None, True, 0.5, 0.0)
            except Exception:
                pass
        self.refresh_playlist_title()

    def refresh_playlist_title(self):
        if self.playlist_window:
            total = len(self.playlist_paths)
            if self.current_file:
                name = Path(self.current_file).name
                self.playlist_window.set_title(f"Govibe Nexus Playlist - {total} item(s) - {name}")
            else:
                self.playlist_window.set_title(f"Govibe Nexus Playlist - {total} item(s)")
        self.update_playlist_toggle_label()

    def update_playlist_toggle_label(self):
        button = getattr(self, "playlist_toggle_button", None)
        if not button:
            return
        try:
            button.set_label("CLOSE PLAYLIST" if self.playlist_window else "OPEN PLAYLIST")
        except Exception:
            pass

    def toggle_playlist_window(self, *_):
        if self.playlist_window:
            win = self.playlist_window
            self.playlist_window = None
            self.playlist_tree = None
            self.playlist_drag_selecting = False
            self.playlist_anchor_path = None
            self.update_playlist_toggle_label()
            try:
                win.destroy()
            except Exception:
                pass
            return
        self.open_playlist_window()

    def open_playlist_window(self, *_):
        if self.playlist_window:
            try:
                self.playlist_window.present()
                self.update_playlist_toggle_label()
                return
            except Exception:
                self.playlist_window = None
                self.playlist_tree = None
        self.build_playlist_window()
        self.apply_theme(self.current_theme)
        self.playlist_window.show_all()
        self.refresh_playlist_title()
        self.update_playlist_toggle_label()

    def build_playlist_window(self):
        win = Gtk.Window(title="Govibe Nexus Playlist")
        win.set_transient_for(self.window)
        win.set_default_size(620, 520)
        win.set_size_request(440, 320)
        win.set_icon_name("govibe-nexus-video-player")
        win.connect("delete-event", self.on_playlist_window_close)
        win.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
        self.playlist_window = win

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        outer.set_border_width(10)
        win.add(outer)

        hint = Gtk.Label(label="Drop video or audio files here. Double click to play. Use Ctrl or Shift for normal multi select, or left click and drag over rows to select a bunch.")
        hint.set_xalign(0)
        hint.set_line_wrap(True)
        hint.get_style_context().add_class("playlist_hint")
        outer.pack_start(hint, False, False, 0)

        scroll = Gtk.ScrolledWindow()
        scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        scroll.set_shadow_type(Gtk.ShadowType.IN)
        outer.pack_start(scroll, True, True, 0)

        tree = Gtk.TreeView(model=self.playlist_store)
        self.playlist_tree = tree
        tree.get_style_context().add_class("playlistview")
        tree.set_headers_visible(True)
        tree.set_reorderable(False)
        tree.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
        renderer = Gtk.CellRendererText()
        column = Gtk.TreeViewColumn("Playlist items", renderer, text=0)
        column.set_expand(True)
        tree.append_column(column)
        path_renderer = Gtk.CellRendererText()
        path_column = Gtk.TreeViewColumn("File path", path_renderer, text=1)
        path_column.set_expand(True)
        tree.append_column(path_column)
        selection = tree.get_selection()
        selection.set_mode(Gtk.SelectionMode.MULTIPLE)
        tree.connect("row-activated", self.on_playlist_row_activated)
        tree.connect("button-press-event", self.on_playlist_button_press)
        tree.connect("motion-notify-event", self.on_playlist_motion)
        tree.connect("button-release-event", self.on_playlist_button_release)
        targets = [Gtk.TargetEntry.new("text/uri-list", 0, 0)]
        tree.drag_dest_set(Gtk.DestDefaults.ALL, targets, Gdk.DragAction.COPY)
        tree.connect("drag-data-received", self.on_playlist_drag_data_received)
        win.drag_dest_set(Gtk.DestDefaults.ALL, targets, Gdk.DragAction.COPY)
        win.connect("drag-data-received", self.on_playlist_drag_data_received)
        scroll.add(tree)

        btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        outer.pack_start(btn_row, False, False, 0)
        self.add_button(btn_row, "ADD FILES", lambda *_: self.choose_playlist_files())
        self.add_button(btn_row, "PLAY SELECTED", lambda *_: self.play_selected_playlist_item())
        self.add_button(btn_row, "PREVIOUS", lambda *_: self.play_previous_playlist_item())
        self.add_button(btn_row, "NEXT", lambda *_: self.play_next_playlist_item())
        self.add_button(btn_row, "REMOVE SELECTED", lambda *_: self.remove_selected_playlist_items())
        self.add_button(btn_row, "CLEAR", lambda *_: self.clear_playlist())

    def on_playlist_window_close(self, *_):
        self.playlist_window = None
        self.playlist_tree = None
        self.playlist_drag_selecting = False
        self.playlist_anchor_path = None
        self.update_playlist_toggle_label()
        return False

    def choose_playlist_files(self):
        dialog = Gtk.FileChooserDialog(
            title="Add files to playlist",
            transient_for=self.playlist_window or self.window,
            action=Gtk.FileChooserAction.OPEN,
        )
        dialog.set_select_multiple(True)
        dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
        filt = Gtk.FileFilter()
        filt.set_name("Video and audio files")
        for pat in self.media_file_patterns():
            filt.add_pattern(pat)
        dialog.add_filter(filt)
        resp = dialog.run()
        if resp == Gtk.ResponseType.OK:
            selected_files = [p for p in dialog.get_filenames() if p]
            self.add_to_playlist(selected_files, play_first=False)
        dialog.destroy()

    def on_playlist_drag_data_received(self, widget, context, x, y, selection_data, info, time_value):
        paths = []
        try:
            uris = selection_data.get_uris() or []
            for uri in uris:
                try:
                    gfile = Gio.File.new_for_uri(uri)
                    local = gfile.get_path()
                    if local:
                        paths.append(local)
                except Exception:
                    pass
        except Exception:
            pass
        added = self.add_to_playlist(paths, play_first=False)
        try:
            context.finish(bool(added), False, time_value)
        except Exception:
            pass

    def on_playlist_row_activated(self, tree, path, column):
        index = self.tree_path_index(path)
        if index is not None:
            self.play_playlist_index(index)

    def tree_path_index(self, path):
        try:
            return int(path.get_indices()[0])
        except Exception:
            try:
                return int(str(path).split(":")[0])
            except Exception:
                return None

    def make_tree_path(self, index):
        try:
            return Gtk.TreePath.new_from_indices([int(index)])
        except Exception:
            return Gtk.TreePath.new_from_string(str(int(index)))

    def get_selected_playlist_indices(self):
        if not self.playlist_tree:
            return []
        try:
            model, paths = self.playlist_tree.get_selection().get_selected_rows()
            indices = []
            for path in paths:
                idx = self.tree_path_index(path)
                if idx is not None:
                    indices.append(idx)
            return sorted(set(indices))
        except Exception:
            return []

    def play_selected_playlist_item(self):
        indices = self.get_selected_playlist_indices()
        if indices:
            return self.play_playlist_index(indices[0])
        if self.playlist_paths:
            return self.play_playlist_index(0)
        return False

    def play_playlist_index(self, index):
        try:
            index = int(index)
            path = self.playlist_paths[index]
        except Exception:
            return False
        return self.open_media(path)

    def play_next_playlist_item(self, auto=False):
        if not self.playlist_paths:
            return False
        if self.loop_enabled and auto:
            return False
        index = self.playlist_current_index
        if index < 0 and self.current_file in self.playlist_paths:
            index = self.playlist_paths.index(self.current_file)
        next_index = index + 1
        if next_index >= len(self.playlist_paths):
            if auto:
                return False
            next_index = 0
        return self.play_playlist_index(next_index)

    def play_previous_playlist_item(self):
        if not self.playlist_paths:
            return False
        index = self.playlist_current_index
        if index < 0 and self.current_file in self.playlist_paths:
            index = self.playlist_paths.index(self.current_file)
        prev_index = index - 1
        if prev_index < 0:
            prev_index = len(self.playlist_paths) - 1
        return self.play_playlist_index(prev_index)

    def remove_selected_playlist_items(self):
        indices = self.get_selected_playlist_indices()
        if not indices:
            return
        current = self.current_file
        for idx in sorted(indices, reverse=True):
            try:
                del self.playlist_paths[idx]
                tree_iter = self.playlist_store.get_iter(self.make_tree_path(idx))
                self.playlist_store.remove(tree_iter)
            except Exception:
                pass
        if current in self.playlist_paths:
            self.playlist_current_index = self.playlist_paths.index(current)
        else:
            self.playlist_current_index = -1
        self.refresh_playlist_title()

    def clear_playlist(self):
        self.playlist_paths = []
        self.playlist_current_index = -1
        self.playlist_store.clear()
        self.refresh_playlist_title()

    def on_playlist_button_press(self, tree, event):
        if getattr(event, "button", 0) != 1:
            return False
        info = tree.get_path_at_pos(int(event.x), int(event.y))
        if not info:
            return False
        path = info[0]
        self.playlist_anchor_path = path
        self.playlist_drag_selecting = True
        return False

    def on_playlist_motion(self, tree, event):
        try:
            state = event.state
        except Exception:
            state = 0
        if not self.playlist_drag_selecting or not (state & Gdk.ModifierType.BUTTON1_MASK):
            return False
        info = tree.get_path_at_pos(int(event.x), int(event.y))
        if not info or self.playlist_anchor_path is None:
            return False
        current_path = info[0]
        self.select_playlist_range(self.playlist_anchor_path, current_path)
        return True

    def on_playlist_button_release(self, tree, event):
        self.playlist_drag_selecting = False
        self.playlist_anchor_path = None
        return False

    def select_playlist_range(self, start_path, end_path):
        if not self.playlist_tree:
            return
        selection = self.playlist_tree.get_selection()
        start = self.tree_path_index(start_path)
        end = self.tree_path_index(end_path)
        if start is None or end is None:
            return
        lo, hi = sorted((start, end))
        try:
            selection.unselect_all()
            selection.select_range(self.make_tree_path(lo), self.make_tree_path(hi))
            return
        except Exception:
            pass
        try:
            selection.unselect_all()
            for idx in range(lo, hi + 1):
                selection.select_path(self.make_tree_path(idx))
        except Exception:
            pass

    def open_options_panel(self, *_):
        self.open_context_menu()
        return False

    def make_menu_item(self, label, callback=None, check=False, active=False):
        item = Gtk.CheckMenuItem(label=label) if check else Gtk.MenuItem(label=label)
        if check:
            item.set_active(bool(active))
        if callback:
            def run(_item):
                callback()
            item.connect("activate", run)
        return item

    def append_menu_item(self, menu, label, callback=None, check=False, active=False):
        item = self.make_menu_item(label, callback, check, active)
        menu.append(item)
        return item

    def append_separator(self, menu):
        menu.append(Gtk.SeparatorMenuItem())

    def append_submenu(self, menu, label, names, callback, active_name=None):
        item = Gtk.MenuItem(label=label)
        sub = Gtk.Menu()
        item.set_submenu(sub)
        for name in names:
            sub.append(self.make_menu_item(str(name), lambda n=name: callback(n), check=(active_name is not None), active=(name == active_name)))
        menu.append(item)
        return item

    def append_volume_boost_submenu(self, menu):
        item = Gtk.MenuItem(label="Volume boost")
        sub = Gtk.Menu()
        item.set_submenu(sub)
        for label, percent in [
            ("Boost off", 100),
            ("Boost 125%", 125),
            ("Boost 150%", 150),
            ("Boost 175%", 175),
            ("Boost 200%", 200),
            ("Boost 250%", 250),
            ("Boost 300% maximum", 300),
        ]:
            sub.append(self.make_menu_item(label, lambda p=percent: self.set_volume_boost(p), check=True, active=(int(self.volume_boost_percent) == int(percent))))
        menu.append(item)
        return item

    def open_context_menu(self, event=None):
        # Prevent duplicate opens when both the transparent GTK shield and mpv fallback report the same right click.
        now = time.time()
        if now - getattr(self, "last_context_menu_open_ts", 0) < 0.18:
            return True
        self.last_context_menu_open_ts = now
        self.close_context_menu()
        menu = Gtk.Menu()
        self.context_popup = menu

        self.append_menu_item(menu, "Open video or audio", self.choose_file)
        self.append_menu_item(menu, "Playlist", self.open_playlist_window)
        self.append_menu_item(menu, "Play", lambda: self.set_pause(False))
        self.append_menu_item(menu, "Pause", lambda: self.set_pause(True))
        self.append_menu_item(menu, "Stop", self.stop_media)
        self.append_menu_item(menu, "Mute audio", lambda: self.set_mute_enabled(not self.muted), check=True, active=self.muted)
        self.append_volume_boost_submenu(menu)
        self.append_menu_item(menu, "Loop current media", lambda: self.set_loop_enabled(not self.loop_enabled), check=True, active=self.loop_enabled)
        self.append_menu_item(menu, "Fullscreen", self.toggle_fullscreen)
        self.append_menu_item(menu, "Focus video", self.toggle_focus_mode)
        self.append_menu_item(menu, "Show top menu" if not self.top_menu_visible else "Hide top menu", self.toggle_top_menu)
        self.append_menu_item(menu, "Hide stats details" if self.stats_visible else "Show stats details", self.toggle_stats_details)

        self.append_separator(menu)
        self.append_menu_item(menu, "Title banner on start", lambda: self.set_osd_enabled(not self.osd_enabled), check=True, active=self.osd_enabled)
        self.append_menu_item(menu, "Always on top", lambda: self.set_always_on_top(not self.always_on_top), check=True, active=self.always_on_top)
        speed_labels = [("Speed 0.50x", 0.5), ("Speed 0.75x", 0.75), ("Speed 1.00x normal", 1.0), ("Speed 1.25x", 1.25), ("Speed 1.50x", 1.5), ("Speed 2.00x", 2.0)]
        speed_item = Gtk.MenuItem(label="Playback speed")
        speed_sub = Gtk.Menu()
        speed_item.set_submenu(speed_sub)
        for label, value in speed_labels:
            speed_sub.append(self.make_menu_item(label, lambda v=value: self.set_playback_speed(v)))
        menu.append(speed_item)

        self.append_separator(menu)
        self.append_submenu(menu, "Theme", list(THEMES.keys()), self.select_theme_from_menu, self.current_theme)
        active_eq = self.eq_combo.get_active_text() if hasattr(self, "eq_combo") else None
        self.append_submenu(menu, "Sound EQ preset", list(EQ_PRESETS.keys()), self.apply_eq_preset, active_eq)
        active_filter = self.filter_combo.get_active_text() if hasattr(self, "filter_combo") else None
        self.append_submenu(menu, "Video filter", list(VIDEO_FILTERS.keys()), self.select_filter_from_menu, active_filter)
        active_render = self.render_combo.get_active_text() if hasattr(self, "render_combo") else None
        self.append_submenu(menu, "Rendering preset", list(RENDERING_PRESETS), self.apply_rendering, active_render)
        self.append_menu_item(menu, "Open or close colour adjustment", self.toggle_color_adjustment_dialog)

        self.append_separator(menu)
        self.append_menu_item(menu, "Take screenshot", self.take_screenshot)
        self.append_menu_item(menu, "Set as the default video player", self.set_default_video_player)
        self.append_menu_item(menu, "Set as the default audio player", self.set_default_audio_player)
        self.append_menu_item(menu, "About Govibe.org", self.open_govibe_website)
        self.append_menu_item(menu, "About this player", self.about_dialog)
        self.append_separator(menu)
        self.append_menu_item(menu, "Exit", self.quit_app)

        menu.attach_to_widget(self.window, None)
        menu.show_all()
        if event is not None:
            menu.popup_at_pointer(event)
        else:
            try:
                xy = self.pointer_xy() or (20, 20)
                wx, wy = self.window.get_position()
                rect = Gdk.Rectangle()
                rect.x = max(0, int(xy[0] - wx))
                rect.y = max(0, int(xy[1] - wy))
                rect.width = 1
                rect.height = 1
                menu.popup_at_rect(self.window.get_window(), rect, Gdk.Gravity.NORTH_WEST, Gdk.Gravity.NORTH_WEST, None)
            except Exception:
                menu.popup(None, None, None, None, 0, Gtk.get_current_event_time())
        return True

    def toggle_color_adjustment_dialog(self):
        if self.color_window:
            try:
                self.color_window.destroy()
                self.color_window = None
                if hasattr(self, "color_adjust_button"):
                    self.color_adjust_button.set_label("OPEN COLOUR ADJUSTMENT")
                return
            except Exception:
                self.color_window = None
        self.open_color_adjustment_dialog()

    def open_color_adjustment_dialog(self):
        if self.color_window:
            try:
                self.color_window.present()
                return
            except Exception:
                self.color_window = None

        win = Gtk.Window(title="Colour adjustment")
        self.color_window = win
        win.set_default_size(540, 290)
        win.set_resizable(True)
        win.set_modal(False)
        win.set_destroy_with_parent(False)
        try:
            win.set_transient_for(self.window)
        except Exception:
            pass
        try:
            win.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
        except Exception:
            pass
        def color_window_destroyed(*_):
            self.color_window = None
            if hasattr(self, "color_adjust_button"):
                self.color_adjust_button.set_label("OPEN COLOUR ADJUSTMENT")
        win.connect("destroy", color_window_destroyed)
        if hasattr(self, "color_adjust_button"):
            self.color_adjust_button.set_label("CLOSE COLOUR ADJUSTMENT")
        win.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        outer.set_border_width(10)
        outer.get_style_context().add_class("panel")
        win.add(outer)

        header = Gtk.Label(label="Colour adjustment")
        header.set_xalign(0)
        header.get_style_context().add_class("filename")
        outer.pack_start(header, False, False, 0)

        local_scales = {}
        for name, label_text in [("brightness", "Brightness"), ("contrast", "Contrast"), ("saturation", "Saturation"), ("gamma", "Gamma"), ("hue", "Hue")]:
            row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
            label = Gtk.Label(label=label_text)
            label.set_size_request(98, -1)
            label.set_xalign(0)
            row.pack_start(label, False, False, 0)
            scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, -100, 100, 1)
            scale.set_value(int(self.color_values.get(name, 0)))
            scale.set_draw_value(True)
            scale.set_size_request(350, -1)
            scale.connect("value-changed", self.color_changed, name)
            row.pack_start(scale, True, True, 0)
            outer.pack_start(row, False, False, 0)
            local_scales[name] = scale

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        def reset_window(*_):
            self.reset_color()
            for scale in local_scales.values():
                try:
                    scale.set_value(0)
                except Exception:
                    pass
        reset = Gtk.Button(label="SET COLOUR TO DEFAULT")
        reset.get_style_context().add_class("nexusbtn")
        reset.connect("clicked", reset_window)
        row.pack_start(reset, False, False, 0)
        close = Gtk.Button(label="CLOSE")
        close.get_style_context().add_class("nexusbtn")
        close.connect("clicked", lambda *_: win.destroy())
        row.pack_start(close, False, False, 0)
        outer.pack_start(row, False, False, 0)

        hint = Gtk.Label(label="This is a normal movable window, not a dark modal overlay.")
        hint.set_xalign(0)
        hint.get_style_context().add_class("hint")
        outer.pack_start(hint, False, False, 0)

        self.apply_theme(self.current_theme)
        win.show_all()
        win.present()

    def combo_set_text(self, combo, names, name):
        if not combo or not name:
            return
        for i, key in enumerate(list(names)):
            if key == name:
                combo.set_active(i)
                break

    def select_theme_from_menu(self, name):
        self.combo_set_text(getattr(self, "theme_combo", None), THEMES.keys(), name)
        self.apply_theme(name)

    def select_template_from_menu(self, name):
        self.combo_set_text(getattr(self, "template_combo", None), TEMPLATES.keys(), name)
        self.apply_template(name)

    def select_filter_from_menu(self, name):
        self.combo_set_text(getattr(self, "filter_combo", None), VIDEO_FILTERS.keys(), name)
        self.apply_video_filter(name)

    def toggle_focus_mode(self):
        self.options_hidden = not self.options_hidden
        if self.options_hidden:
            for w in [getattr(self, "side_scroll", None), getattr(self, "stats_label", None)]:
                if w:
                    w.hide()
            if hasattr(self, "paned"):
                try:
                    self.paned.set_position(self.window.get_allocated_width())
                except Exception:
                    pass
            if hasattr(self, "focus_button"):
                self.focus_button.set_label("SHOW OPTIONS")
        else:
            if hasattr(self, "side_scroll"):
                self.side_scroll.show_all()
            if hasattr(self, "paned"):
                self.paned.set_position(self.default_paned_position)
            self.sync_stats_visibility()
            if hasattr(self, "focus_button"):
                self.focus_button.set_label("FOCUS VIDEO")

    def set_osd_enabled(self, enabled):
        self.osd_enabled = bool(enabled)
        if hasattr(self, "osd_check") and self.osd_check.get_active() != self.osd_enabled:
            self.osd_check.set_active(self.osd_enabled)

    def set_always_on_top(self, enabled):
        self.always_on_top = bool(enabled)
        if self.window:
            self.window.set_keep_above(self.always_on_top)
        if hasattr(self, "always_top_check") and self.always_top_check.get_active() != self.always_on_top:
            self.always_top_check.set_active(self.always_on_top)

    def toggle_always_on_top(self, widget):
        self.set_always_on_top(widget.get_active())

    def set_loop_enabled(self, enabled):
        self.loop_enabled = bool(enabled)
        if self.mpv:
            self.mpv.set_property("loop-file", "inf" if self.loop_enabled else "no")
        if hasattr(self, "loop_check") and self.loop_check.get_active() != self.loop_enabled:
            self.loop_check.set_active(self.loop_enabled)
        for btn_name in ["loop_button", "overlay_loop_button"]:
            btn = getattr(self, btn_name, None)
            if btn:
                btn.set_label("LOOP ON" if self.loop_enabled else "LOOP")

    def toggle_loop(self, widget):
        self.set_loop_enabled(widget.get_active())

    def set_mute_enabled(self, enabled):
        self.muted = bool(enabled)
        if self.mpv:
            self.mpv.set_property("mute", self.muted)
        if hasattr(self, "mute_check") and self.mute_check.get_active() != self.muted:
            self.mute_check.set_active(self.muted)
        for btn_name in ["mute_button", "overlay_mute_button"]:
            btn = getattr(self, btn_name, None)
            if btn:
                btn.set_label("MUTED" if self.muted else "MUTE")

    def toggle_mute(self, widget):
        self.set_mute_enabled(widget.get_active())

    def on_volume_slider_changed(self, slider):
        try:
            self.volume_level = int(slider.get_value())
        except Exception:
            self.volume_level = 100
        self.apply_volume_settings()

    def get_effective_volume(self):
        try:
            base = float(self.volume_level)
            boost = float(self.volume_boost_percent)
            return clamp(base * boost / 100.0, 0, 300)
        except Exception:
            return 100

    def apply_volume_settings(self):
        effective_volume = self.get_effective_volume()
        if self.mpv:
            try:
                self.mpv.set_property("volume", float(effective_volume))
            except Exception:
                pass
        self.sync_volume_ui()

    def sync_volume_ui(self):
        label = "BOOST VOLUME" if int(self.volume_boost_percent) <= 100 else f"BOOST {int(self.volume_boost_percent)}%"
        for btn_name in ["volume_boost_button", "overlay_volume_boost_button"]:
            btn = getattr(self, btn_name, None)
            if btn:
                btn.set_label(label)
        slider = getattr(self, "volume_slider", None)
        if slider and int(slider.get_value()) != int(self.volume_level):
            try:
                slider.set_value(int(self.volume_level))
            except Exception:
                pass

    def set_volume_boost(self, percent):
        try:
            self.volume_boost_percent = int(percent)
        except Exception:
            self.volume_boost_percent = 100
        self.volume_boost_percent = int(clamp(self.volume_boost_percent, 100, 300))
        self.apply_volume_settings()

    def open_volume_boost_menu(self, button=None):
        menu = Gtk.Menu()
        for label, percent in [
            ("Boost off", 100),
            ("Boost 125%", 125),
            ("Boost 150%", 150),
            ("Boost 175%", 175),
            ("Boost 200%", 200),
            ("Boost 250%", 250),
            ("Boost 300% maximum", 300),
        ]:
            item = Gtk.CheckMenuItem(label=label)
            item.set_active(int(self.volume_boost_percent) == int(percent))
            item.connect("activate", lambda _item, p=percent: self.set_volume_boost(p))
            menu.append(item)
        menu.show_all()
        try:
            if button:
                menu.popup_at_widget(button, Gdk.Gravity.SOUTH_WEST, Gdk.Gravity.NORTH_WEST, None)
            else:
                menu.popup(None, None, None, None, 0, Gtk.get_current_event_time())
        except Exception:
            menu.popup(None, None, None, None, 0, Gtk.get_current_event_time())
        return True

    def set_playback_speed(self, value):
        if self.mpv:
            self.mpv.set_property("speed", float(value))

    def take_screenshot(self):
        if not self.mpv:
            self.show_message("Open a video first, then take a screenshot.", Gtk.MessageType.INFO)
            return
        pictures = Path.home() / "Pictures"
        try:
            pictures.mkdir(parents=True, exist_ok=True)
        except Exception:
            pictures = Path.home()
        filename = pictures / ("govibe-nexus-video-screenshot-" + time.strftime("%Y%m%d-%H%M%S") + ".png")
        self.mpv.command("screenshot-to-file", str(filename), "subtitles")
        self.show_message(f"Screenshot saved to:\n{filename}", Gtk.MessageType.INFO)

    def toggle_top_menu(self):
        self.top_menu_visible = not self.top_menu_visible
        self.sync_top_menu_visibility()

    def sync_top_menu_visibility(self):
        if hasattr(self, "menubar"):
            if self.top_menu_visible and not self.is_fullscreen_mode:
                self.menubar.set_no_show_all(False)
                self.menubar.show_all()
            else:
                self.menubar.set_no_show_all(True)
                self.menubar.hide()
        if hasattr(self, "menu_button"):
            self.menu_button.set_label("HIDE MENU" if self.top_menu_visible else "MENU")

    def toggle_stats_details(self):
        self.stats_visible = not self.stats_visible
        self.sync_stats_visibility()

    def sync_stats_visibility(self):
        if hasattr(self, "stats_label"):
            if self.stats_visible and not self.is_fullscreen_mode:
                self.stats_label.set_no_show_all(False)
                self.stats_label.show_all()
            else:
                self.stats_label.set_no_show_all(True)
                self.stats_label.hide()
        if hasattr(self, "stats_button"):
            self.stats_button.set_label("HIDE STATS" if self.stats_visible else "SHOW STATS")

    def is_double_click(self, event):
        try:
            nick = getattr(event.type, "value_nick", "") or str(event.type).lower()
            return getattr(event, "button", 0) == 1 and ("2button" in nick or "double" in nick)
        except Exception:
            return False

    def on_right_click(self, widget, event):
        if self.is_double_click(event):
            self.toggle_fullscreen()
            return True
        if getattr(event, "button", 0) == 3:
            self.open_context_menu(event)
            return True
        if self.is_fullscreen_mode:
            self.show_fullscreen_controls_temporarily()
        return False

    def on_key_press(self, widget, event):
        try:
            key = Gdk.keyval_name(event.keyval)
        except Exception:
            key = None
        if key == "Escape" and self.is_fullscreen_mode:
            self.window.unfullscreen()
            return True
        return False

    def on_mouse_motion(self, *_):
        if self.is_fullscreen_mode:
            self.show_fullscreen_controls_temporarily()
        return False

    def set_blank_cursor(self):
        if not self.window or not self.window.get_window():
            return
        try:
            display = Gdk.Display.get_default()
            cursor = Gdk.Cursor.new_for_display(display, Gdk.CursorType.BLANK_CURSOR)
            self.window.get_window().set_cursor(cursor)
        except Exception:
            pass

    def set_normal_cursor(self):
        if not self.window or not self.window.get_window():
            return
        try:
            self.window.get_window().set_cursor(None)
        except Exception:
            pass

    def on_mpv_client_message(self, message):
        if message == "toggle-fullscreen":
            self.toggle_fullscreen()
        elif message == "right-click":
            self.open_context_menu()
        elif message == "exit-fullscreen":
            if self.is_fullscreen_mode and self.window:
                self.window.unfullscreen()
        elif message == "mouse-move":
            now = time.time()
            if self.is_fullscreen_mode and now - self.last_mpv_mouse_event > 0.15:
                self.last_mpv_mouse_event = now
                self.show_fullscreen_controls_temporarily()
        return False

    def on_window_configure(self, widget, event):
        if not self.is_fullscreen_mode and not self.options_hidden:
            GLib.idle_add(self.fit_layout_to_window)
        return False

    def fit_layout_to_window(self):
        try:
            if not hasattr(self, "paned") or not self.window:
                return False
            width = int(self.window.get_allocated_width() or 0)
            if width <= 0:
                return False
            side_width = 320
            if width < 1220:
                side_width = max(290, min(320, int(width * 0.28)))
            if hasattr(self, "side_scroll"):
                self.side_scroll.set_size_request(side_width, -1)
                self.side_scroll.set_min_content_width(max(280, side_width - 20))
            position = max(780, width - side_width - 22)
            max_position = max(700, width - 290)
            position = min(position, max_position)
            self.default_paned_position = position
            if not self.is_fullscreen_mode and not self.options_hidden:
                self.paned.set_position(position)
        except Exception:
            pass
        return False

    def on_window_state_event(self, widget, event):
        is_full = bool(event.new_window_state & Gdk.WindowState.FULLSCREEN)
        if is_full != self.is_fullscreen_mode:
            self.is_fullscreen_mode = is_full
            if is_full:
                self.enter_fullscreen_view()
            else:
                self.exit_fullscreen_view()
        return False

    def fullscreen_control_widgets(self):
        return [getattr(self, "fullscreen_overlay_controls", None)]

    def show_fullscreen_controls_temporarily(self):
        if not self.is_fullscreen_mode:
            return False
        self.set_normal_cursor()
        current_xy = self.pointer_xy()
        if current_xy is not None:
            self.last_pointer_xy = current_xy
        for w in self.fullscreen_control_widgets():
            if w:
                w.set_no_show_all(False)
                w.show_all()
        self.controls_visible = True
        if self.fullscreen_auto_hide_id:
            try:
                GLib.source_remove(self.fullscreen_auto_hide_id)
            except Exception:
                pass
        self.fullscreen_auto_hide_id = GLib.timeout_add_seconds(4, self.hide_fullscreen_controls)
        return False

    def hide_fullscreen_controls(self):
        if not self.is_fullscreen_mode:
            self.fullscreen_auto_hide_id = None
            return False
        for w in self.fullscreen_control_widgets():
            if w:
                w.hide()
        self.set_blank_cursor()
        self.controls_visible = False
        self.fullscreen_auto_hide_id = None
        return False

    def pointer_xy(self):
        try:
            display = Gdk.Display.get_default()
            seat = display.get_default_seat()
            pointer = seat.get_pointer()
            _screen, x, y = pointer.get_position()
            return int(x), int(y)
        except Exception:
            pass
        try:
            if self.window:
                _x, _y, mask = self.window.get_pointer()
                root = self.window.get_window()
                if root:
                    ox, oy = self.window.get_position()
                    return int(ox + _x), int(oy + _y)
        except Exception:
            pass
        return None

    def start_fullscreen_pointer_poll(self):
        if self.fullscreen_pointer_poll_id:
            return
        self.last_pointer_xy = self.pointer_xy()
        self.fullscreen_pointer_poll_id = GLib.timeout_add(150, self.poll_fullscreen_pointer)

    def stop_fullscreen_pointer_poll(self):
        if self.fullscreen_pointer_poll_id:
            try:
                GLib.source_remove(self.fullscreen_pointer_poll_id)
            except Exception:
                pass
        self.fullscreen_pointer_poll_id = None
        self.last_pointer_xy = None
        self.set_normal_cursor()

    def poll_fullscreen_pointer(self):
        if not self.is_fullscreen_mode:
            self.fullscreen_pointer_poll_id = None
            return False
        xy = self.pointer_xy()
        if xy is not None and xy != self.last_pointer_xy:
            self.last_pointer_xy = xy
            self.show_fullscreen_controls_temporarily()
        return True

    def enter_fullscreen_view(self):
        self.close_context_menu()
        if hasattr(self, "side_scroll"):
            self.side_scroll.hide()
        if hasattr(self, "menubar"):
            self.menubar.hide()
        if hasattr(self, "titlebar"):
            self.titlebar.hide()
        if hasattr(self, "stats_label"):
            self.stats_label.hide()
        if hasattr(self, "seek_box"):
            self.seek_box.hide()
        if hasattr(self, "controls_box"):
            self.controls_box.hide()
        if hasattr(self, "paned") and self.window:
            try:
                self.paned.set_position(self.window.get_allocated_width())
            except Exception:
                pass
        self.start_fullscreen_pointer_poll()
        self.show_fullscreen_controls_temporarily()

    def exit_fullscreen_view(self):
        self.stop_fullscreen_pointer_poll()
        if self.fullscreen_auto_hide_id:
            try:
                GLib.source_remove(self.fullscreen_auto_hide_id)
            except Exception:
                pass
            self.fullscreen_auto_hide_id = None
        for w in self.fullscreen_control_widgets():
            if w:
                w.set_no_show_all(True)
                w.hide()
        if hasattr(self, "seek_box"):
            self.seek_box.show_all()
        if hasattr(self, "controls_box"):
            self.controls_box.show_all()
        if hasattr(self, "side_scroll"):
            self.side_scroll.show_all()
        if hasattr(self, "paned"):
            self.paned.set_position(self.default_paned_position)
        if hasattr(self, "titlebar"):
            self.titlebar.show_all()
        self.sync_top_menu_visibility()
        self.sync_stats_visibility()

    def open_govibe_website(self, *_):
        try:
            Gio.AppInfo.launch_default_for_uri("https://govibe.org", None)
        except Exception:
            self.show_message("Could not open https://govibe.org", Gtk.MessageType.WARNING)

    def set_default_video_player(self):
        errors = []
        for mime in VIDEO_MIME_TYPES:
            try:
                subprocess.run(["xdg-mime", "default", DESKTOP_ID, mime], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
            except Exception as exc:
                errors.append(str(exc))
        if errors:
            self.show_message("Some video MIME associations could not be changed. Make sure xdg-utils is installed.", Gtk.MessageType.WARNING)
        else:
            self.show_message("Govibe Nexus Video Player is now set as the default video player for common video formats.", Gtk.MessageType.INFO)

    def set_default_audio_player(self):
        errors = []
        for mime in AUDIO_MIME_TYPES:
            try:
                subprocess.run(["xdg-mime", "default", DESKTOP_ID, mime], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
            except Exception as exc:
                errors.append(str(exc))
        if errors:
            self.show_message("Some audio MIME associations could not be changed. Make sure xdg-utils is installed.", Gtk.MessageType.WARNING)
        else:
            self.show_message("Govibe Nexus Video Player is now set as the default audio player for common audio formats.", Gtk.MessageType.INFO)

    def setup_tray(self):
        menu = Gtk.Menu()
        open_item = Gtk.MenuItem(label="Open Software")
        open_item.connect("activate", lambda *_: self.show_main_window())
        exit_item = Gtk.MenuItem(label="Exit")
        exit_item.connect("activate", lambda *_: self.quit_app())
        menu.append(open_item)
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(exit_item)
        menu.show_all()

        # Prefer AppIndicator/Ayatana for Ubuntu. Fallback to Gtk.StatusIcon for older desktops.
        try:
            gi.require_version("AyatanaAppIndicator3", "0.1")
            from gi.repository import AyatanaAppIndicator3 as AppIndicator
            self.tray = AppIndicator.Indicator.new(APP_ID, "govibe-nexus-video-player", AppIndicator.IndicatorCategory.APPLICATION_STATUS)
            self.tray.set_status(AppIndicator.IndicatorStatus.ACTIVE)
            self.tray.set_menu(menu)
            return
        except Exception:
            pass
        try:
            gi.require_version("AppIndicator3", "0.1")
            from gi.repository import AppIndicator3 as AppIndicator
            self.tray = AppIndicator.Indicator.new(APP_ID, "govibe-nexus-video-player", AppIndicator.IndicatorCategory.APPLICATION_STATUS)
            self.tray.set_status(AppIndicator.IndicatorStatus.ACTIVE)
            self.tray.set_menu(menu)
            return
        except Exception:
            pass
        # Fedora/GNOME: avoid deprecated Gtk.StatusIcon fallback because AppIndicator is preferred.
        self.status_icon = None

    def launch_window_full_visible(self):
        if not self.window:
            return False
        if self.did_initial_window_fit:
            return False
        self.did_initial_window_fit = True
        try:
            try:
                self.window.unmaximize()
            except Exception:
                pass
            gdk_window = self.window.get_window()
            display = Gdk.Display.get_default()
            monitor = None
            if display and gdk_window:
                try:
                    monitor = display.get_monitor_at_window(gdk_window)
                except Exception:
                    monitor = None
            if not monitor and display:
                try:
                    monitor = display.get_primary_monitor()
                except Exception:
                    monitor = None
            width = 1360
            height = 830
            if monitor:
                area = monitor.get_workarea()
                width = max(1060, min(1360, int(area.width) - 60))
                height = max(680, min(830, int(area.height) - 30))
                self.window.resize(width, height)
                try:
                    self.window.move(int(area.x + max(0, (area.width - width) // 2)), int(area.y + max(0, (area.height - height) // 2)))
                except Exception:
                    pass
            else:
                self.window.resize(width, height)
            if hasattr(self, "paned"):
                self.paned.set_position(min(self.default_paned_position, max(780, width - 340)))
            self.force_launch_visible()
        except Exception:
            try:
                self.window.resize(1360, 830)
                self.force_launch_visible()
            except Exception:
                pass
        return False

    def force_launch_visible(self):
        if not self.window:
            return False
        try:
            self.window.set_skip_taskbar_hint(False)
            self.window.set_skip_pager_hint(False)
            self.window.show()
            self.window.deiconify()
            self.window.present_with_time(Gtk.get_current_event_time())
            gdk_window = self.window.get_window()
            if gdk_window:
                gdk_window.show()
                gdk_window.raise_()
        except Exception:
            try:
                self.window.show()
                self.window.deiconify()
                self.window.present()
            except Exception:
                pass
        return False

    def show_main_window(self):
        if self.window:
            self.force_launch_visible()

    def on_window_close(self, *_):
        # The window X button exits the player. The normal minimize button is still available for reducing the window.
        self.quit_app()
        return True

    def quit_app(self):
        if self.playlist_window:
            try:
                self.playlist_window.destroy()
            except Exception:
                pass
            self.playlist_window = None
        if self.mpv:
            self.mpv.terminate()
            self.mpv = None
        self.quit()

    def toggle_fullscreen(self):
        if not self.window:
            return
        state = self.window.get_window().get_state() if self.window.get_window() else 0
        if state & Gdk.WindowState.FULLSCREEN:
            self.window.unfullscreen()
        else:
            self.window.fullscreen()

    def apply_theme(self, name):
        if name not in THEMES:
            name = "Nexus Green"
        self.current_theme = name
        c = THEMES[name]
        css = f"""
        * {{ font-family: Sans, Arial; }}
        window {{ background: {c['bg']}; color: {c['text']}; }}
        menubar, menu, menuitem {{ background: {c['panel']}; color: {c['text']}; border-color: {c['accent']}; }}
        menuitem:hover {{ background: {c['accent']}; color: #000000; }}
        .topbar {{ background: linear-gradient(to bottom, {c['panel']}, {c['bg']}); border-bottom: 1px solid {c['accent']}; }}
        .controlbar {{ background: {c['panel']}; border-top: 1px solid {c['accent']}; }}
        .logo {{ color: {c['accent']}; font-size: 30px; font-weight: 900; letter-spacing: 2px; text-shadow: 0 0 8px {c['accent']}; }}
        .filename {{ color: {c['accent2']}; font-size: 16px; font-weight: 700; }}
        .timecode {{ color: {c['accent2']}; font-weight: 700; }}
        .stats {{ color: {c['muted']}; background: {c['panel']}; padding: 8px; border: 1px solid {c['accent']}; border-radius: 8px; }}
        .panel {{ color: {c['text']}; background: {c['panel']}; border: 1px solid {c['accent']}; border-radius: 10px; padding: 8px; }}
        frame > label {{ color: {c['accent']}; font-weight: 900; font-size: 14px; }}
        button.nexusbtn, button {{ background: {c['panel']}; color: {c['text']}; border: 1px solid {c['accent']}; border-radius: 7px; padding: 4px 7px; font-weight: 800; font-size: 12px; }}
        button.nexusbtn:hover, button:hover {{ background: {c['accent']}; color: #000; box-shadow: 0 0 10px {c['accent']}; }}
        button.linkbtn {{ background: {c['panel']}; color: {c['text']}; border: 1px solid {c['muted']}; border-radius: 7px; padding: 7px 10px; font-weight: 900; text-decoration: none; }}
        button.linkbtn:hover {{ background: {c['muted']}; color: #000000; box-shadow: 0 0 10px {c['muted']}; }}
        scale slider {{ background: {c['accent2']}; border: 1px solid {c['accent']}; }}
        scale trough {{ background: #001a08; border: 1px solid {c['accent']}; min-height: 6px; }}
        scale highlight {{ background: {c['accent']}; }}
        combobox, combobox box, entry {{ background: #000; color: {c['accent2']}; border: 1px solid {c['accent']}; }}
        .hint {{ color: {c['muted']}; font-size: 12px; }}
        .tiny {{ color: {c['muted']}; font-size: 10px; }}
        .osdbanner {{ color: #ffffff; background: rgba(0,0,0,0.72); padding: 8px 18px; border: 1px solid {c['accent']}; border-radius: 18px; font-size: 17px; font-weight: 900; text-shadow: 0 0 8px {c['accent']}; }}
        .overlaycontrols {{ background: rgba(0,0,0,0.70); border: 1px solid {c['accent']}; border-radius: 12px; padding: 8px; }}
        .contextmenu {{ background: {c['panel']}; color: {c['text']}; border: 1px solid {c['accent']}; border-radius: 8px; padding: 4px; }}
        .contextheader {{ color: {c['accent']}; background: {c['bg']}; padding: 6px 8px; font-weight: 900; font-size: 12px; border-top: 1px solid {c['accent']}; }}
        button.contextbtn {{ background: {c['panel']}; color: {c['text']}; border: 0; border-radius: 4px; padding: 6px 9px; font-weight: 700; }}
        button.contextbtn:hover {{ background: {c['accent']}; color: #000000; }}
        .videowrap {{ background: #000000; border: 1px solid {c['accent']}; }}
        .videohost {{ background: #000000; }}
        .placeholder {{ background: rgba(0,0,0,0.58); padding: 18px; border: 1px solid {c['accent']}; border-radius: 16px; }}
        .placeholder_title {{ color: {c['accent']}; font-size: 24px; font-weight: 900; letter-spacing: 1px; text-shadow: 0 0 12px {c['accent']}; }}
        .placeholder_sub {{ color: {c['muted']}; font-size: 15px; }}
        .playlist_hint {{ color: {c['muted']}; font-size: 12px; }}
        treeview.playlistview, treeview.view {{ background: #000000; color: {c['text']}; border: 1px solid {c['accent']}; }}
        treeview.playlistview:selected, treeview.view:selected {{ background: {c['accent']}; color: #000000; }}
        drawingarea {{ background: #000; }}
        """
        provider = Gtk.CssProvider()
        provider.load_from_data(css.encode("utf-8"))
        Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    def show_message(self, message, msg_type):
        dialog = Gtk.MessageDialog(transient_for=self.window, flags=0, message_type=msg_type, buttons=Gtk.ButtonsType.OK, text=message)
        dialog.run()
        dialog.destroy()

    def about_dialog(self):
        dialog = Gtk.AboutDialog(transient_for=self.window, modal=True)
        dialog.set_program_name(APP_NAME)
        dialog.set_version(VERSION)
        dialog.set_comments("A Govibe Nexus style video player powered by mpv.")
        dialog.set_website("https://govibe.org")
        dialog.run()
        dialog.destroy()


def main():
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    app = GovibeNexusVideoPlayer()
    return app.run(sys.argv)


if __name__ == "__main__":
    sys.exit(main())
