#!/usr/bin/env python3
# Govibe Nexus Audio Player native Linux UI
# Screenshot-matched GTK/Cairo layout, no Wine, no emulation.

import os
import warnings
import sys
import math
import random
import json
import urllib.parse
import subprocess
import shutil
import socket
import tempfile
import time
import webbrowser
from pathlib import Path

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

try:
    from mutagen import File as MutagenFile
except Exception:
    MutagenFile = None

try:
    import gi
    gi.require_version('Gtk', '3.0')
    gi.require_version('Gdk', '3.0')
    gi.require_version('Gst', '1.0')
    gi.require_version('PangoCairo', '1.0')
    from gi.repository import Gtk, Gdk, GLib, Gio, Gst, Pango, PangoCairo
    AppIndicator = None
    try:
        gi.require_version('AyatanaAppIndicator3', '0.1')
        from gi.repository import AyatanaAppIndicator3 as AppIndicator
    except Exception:
        try:
            gi.require_version('AppIndicator3', '0.1')
            from gi.repository import AppIndicator3 as AppIndicator
        except Exception:
            AppIndicator = None
except Exception as e:
    print('Govibe Nexus Audio Player requires GTK3, PyGObject, and GStreamer.')
    print('Install: sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-gstreamer-1.0 gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav')
    print(e)
    sys.exit(1)

Gst.init(None)
try:
    Gtk.Window.set_default_icon_name('govibe-nexus-audio-player')
except Exception:
    pass
APP_NAME = 'Govibe Nexus Audio Player'
APP_ID = 'org.govibe.NexusAudioPlayer'
CFG_DIR = Path.home() / '.config' / 'govibe-nexus-audio-player'
CFG_FILE = CFG_DIR / 'settings.json'
PLAYLIST_FILE = CFG_DIR / 'playlist.m3u'

NEON = (0.21, 1.0, 0.34)
NEON2 = (0.70, 1.0, 0.22)
DARK = (0.01, 0.03, 0.02)
PANEL = (0.02, 0.08, 0.04)
PANEL2 = (0.03, 0.11, 0.06)
BORDER = (0.24, 0.55, 0.36)
TEXT = (0.66, 1.0, 0.75)
DIM = (0.22, 0.45, 0.32)
YELLOW = (0.92, 1.0, 0.10)

AUDIO_EXTS = {'.mp3','.wav','.ogg','.flac','.m4a','.aac','.wma','.mid','.midi','.rmi'}
TETRIS_ROWS = 20
TETRIS_COLS = 10

THEMES = {
    'Govibe Nexus Green': {
        'NEON': (0.21, 1.0, 0.34), 'NEON2': (0.70, 1.0, 0.22), 'DARK': (0.01, 0.03, 0.02),
        'PANEL': (0.02, 0.08, 0.04), 'PANEL2': (0.03, 0.11, 0.06), 'BORDER': (0.24, 0.55, 0.36),
        'TEXT': (0.66, 1.0, 0.75), 'DIM': (0.22, 0.45, 0.32), 'YELLOW': (0.92, 1.0, 0.10)
    },
    'Classic Winamp Green': {
        'NEON': (0.10, 0.95, 0.18), 'NEON2': (0.75, 1.0, 0.10), 'DARK': (0.00, 0.00, 0.00),
        'PANEL': (0.01, 0.04, 0.02), 'PANEL2': (0.01, 0.06, 0.03), 'BORDER': (0.18, 0.45, 0.25),
        'TEXT': (0.65, 1.0, 0.68), 'DIM': (0.15, 0.32, 0.22), 'YELLOW': (0.95, 1.0, 0.10)
    },
    'Govibe Cyan': {
        'NEON': (0.05, 0.95, 1.0), 'NEON2': (0.30, 1.0, 0.95), 'DARK': (0.00, 0.02, 0.03),
        'PANEL': (0.01, 0.06, 0.08), 'PANEL2': (0.02, 0.10, 0.12), 'BORDER': (0.12, 0.55, 0.60),
        'TEXT': (0.62, 1.0, 1.0), 'DIM': (0.18, 0.42, 0.45), 'YELLOW': (0.92, 1.0, 0.12)
    },
    'Govibe Amber': {
        'NEON': (1.0, 0.70, 0.12), 'NEON2': (1.0, 0.95, 0.20), 'DARK': (0.04, 0.02, 0.00),
        'PANEL': (0.10, 0.05, 0.01), 'PANEL2': (0.13, 0.07, 0.02), 'BORDER': (0.62, 0.40, 0.12),
        'TEXT': (1.0, 0.88, 0.55), 'DIM': (0.48, 0.34, 0.18), 'YELLOW': (1.0, 1.0, 0.25)
    },
    'Winamp Classic Blue': {
        'NEON': (0.25, 0.62, 1.0), 'NEON2': (0.85, 1.0, 0.25), 'DARK': (0.00, 0.01, 0.05),
        'PANEL': (0.02, 0.04, 0.12), 'PANEL2': (0.04, 0.07, 0.18), 'BORDER': (0.18, 0.38, 0.72),
        'TEXT': (0.70, 0.88, 1.0), 'DIM': (0.25, 0.38, 0.60), 'YELLOW': (0.95, 1.0, 0.16)
    },
    'Winamp Silver': {
        'NEON': (0.72, 0.85, 0.92), 'NEON2': (0.92, 1.0, 0.55), 'DARK': (0.015, 0.018, 0.020),
        'PANEL': (0.08, 0.09, 0.10), 'PANEL2': (0.12, 0.13, 0.14), 'BORDER': (0.42, 0.48, 0.52),
        'TEXT': (0.88, 0.96, 1.0), 'DIM': (0.42, 0.50, 0.52), 'YELLOW': (1.0, 0.95, 0.30)
    },
    'Nexus Purple': {
        'NEON': (0.78, 0.35, 1.0), 'NEON2': (0.25, 0.95, 1.0), 'DARK': (0.03, 0.00, 0.05),
        'PANEL': (0.08, 0.02, 0.12), 'PANEL2': (0.12, 0.04, 0.18), 'BORDER': (0.46, 0.22, 0.68),
        'TEXT': (0.94, 0.72, 1.0), 'DIM': (0.46, 0.28, 0.55), 'YELLOW': (1.0, 0.88, 0.20)
    },
    'Red Alert': {
        'NEON': (1.0, 0.18, 0.18), 'NEON2': (1.0, 0.68, 0.18), 'DARK': (0.04, 0.00, 0.00),
        'PANEL': (0.10, 0.01, 0.01), 'PANEL2': (0.16, 0.03, 0.03), 'BORDER': (0.62, 0.18, 0.18),
        'TEXT': (1.0, 0.75, 0.75), 'DIM': (0.52, 0.20, 0.20), 'YELLOW': (1.0, 0.95, 0.20)
    },
    'Electric Lime': {
        'NEON': (0.55, 1.0, 0.05), 'NEON2': (0.10, 1.0, 0.60), 'DARK': (0.01, 0.025, 0.00),
        'PANEL': (0.04, 0.08, 0.01), 'PANEL2': (0.06, 0.13, 0.02), 'BORDER': (0.35, 0.70, 0.12),
        'TEXT': (0.78, 1.0, 0.55), 'DIM': (0.32, 0.50, 0.18), 'YELLOW': (1.0, 1.0, 0.12)
    }
}

THEMES.update({
    'Matrix Deep Green': {
        'NEON': (0.00, 1.0, 0.18), 'NEON2': (0.60, 1.0, 0.08), 'DARK': (0.00, 0.01, 0.00),
        'PANEL': (0.00, 0.045, 0.015), 'PANEL2': (0.00, 0.075, 0.025), 'BORDER': (0.08, 0.42, 0.16),
        'TEXT': (0.58, 1.0, 0.62), 'DIM': (0.10, 0.30, 0.15), 'YELLOW': (0.75, 1.0, 0.08)
    },
    'Neon Blue Arcade': {
        'NEON': (0.05, 0.35, 1.0), 'NEON2': (0.05, 1.0, 1.0), 'DARK': (0.00, 0.00, 0.04),
        'PANEL': (0.00, 0.02, 0.10), 'PANEL2': (0.02, 0.05, 0.18), 'BORDER': (0.12, 0.30, 0.72),
        'TEXT': (0.65, 0.85, 1.0), 'DIM': (0.18, 0.30, 0.52), 'YELLOW': (0.92, 1.0, 0.15)
    },
    'Cyber Pink': {
        'NEON': (1.0, 0.12, 0.72), 'NEON2': (0.20, 0.95, 1.0), 'DARK': (0.035, 0.00, 0.035),
        'PANEL': (0.095, 0.00, 0.07), 'PANEL2': (0.14, 0.02, 0.12), 'BORDER': (0.70, 0.14, 0.52),
        'TEXT': (1.0, 0.70, 0.92), 'DIM': (0.50, 0.18, 0.42), 'YELLOW': (1.0, 0.95, 0.20)
    },
    'Orange Plasma': {
        'NEON': (1.0, 0.38, 0.05), 'NEON2': (1.0, 0.95, 0.05), 'DARK': (0.045, 0.012, 0.00),
        'PANEL': (0.12, 0.035, 0.00), 'PANEL2': (0.18, 0.06, 0.00), 'BORDER': (0.72, 0.25, 0.04),
        'TEXT': (1.0, 0.75, 0.48), 'DIM': (0.52, 0.26, 0.10), 'YELLOW': (1.0, 1.0, 0.10)
    },
    'Ice White': {
        'NEON': (0.75, 0.96, 1.0), 'NEON2': (1.0, 1.0, 1.0), 'DARK': (0.015, 0.025, 0.030),
        'PANEL': (0.06, 0.08, 0.09), 'PANEL2': (0.10, 0.13, 0.15), 'BORDER': (0.55, 0.72, 0.78),
        'TEXT': (0.88, 0.98, 1.0), 'DIM': (0.45, 0.58, 0.62), 'YELLOW': (1.0, 0.92, 0.30)
    },
    'Gold Metal': {
        'NEON': (1.0, 0.78, 0.18), 'NEON2': (1.0, 1.0, 0.45), 'DARK': (0.035, 0.025, 0.005),
        'PANEL': (0.09, 0.07, 0.015), 'PANEL2': (0.14, 0.11, 0.025), 'BORDER': (0.62, 0.48, 0.13),
        'TEXT': (1.0, 0.88, 0.55), 'DIM': (0.48, 0.38, 0.16), 'YELLOW': (1.0, 1.0, 0.12)
    },
    'Retro LCD': {
        'NEON': (0.62, 0.95, 0.42), 'NEON2': (0.95, 1.0, 0.45), 'DARK': (0.02, 0.035, 0.015),
        'PANEL': (0.08, 0.12, 0.04), 'PANEL2': (0.12, 0.17, 0.06), 'BORDER': (0.42, 0.58, 0.24),
        'TEXT': (0.78, 1.0, 0.62), 'DIM': (0.34, 0.46, 0.22), 'YELLOW': (1.0, 1.0, 0.20)
    },
    'Deep Red Winamp': {
        'NEON': (1.0, 0.06, 0.10), 'NEON2': (1.0, 0.58, 0.08), 'DARK': (0.025, 0.00, 0.00),
        'PANEL': (0.085, 0.00, 0.00), 'PANEL2': (0.13, 0.015, 0.015), 'BORDER': (0.58, 0.10, 0.10),
        'TEXT': (1.0, 0.68, 0.68), 'DIM': (0.48, 0.16, 0.16), 'YELLOW': (1.0, 0.95, 0.12)
    }
})


THEMES.update({
    'Void Black Gold': {
        'NEON': (1.0, 0.68, 0.06), 'NEON2': (0.96, 0.95, 0.72), 'DARK': (0.0, 0.0, 0.0),
        'PANEL': (0.035, 0.027, 0.006), 'PANEL2': (0.070, 0.052, 0.012), 'BORDER': (0.58, 0.42, 0.08),
        'TEXT': (1.0, 0.86, 0.42), 'DIM': (0.42, 0.32, 0.12), 'YELLOW': (1.0, 1.0, 0.25)
    },
    'Ocean Depth': {
        'NEON': (0.00, 0.72, 1.0), 'NEON2': (0.00, 1.0, 0.72), 'DARK': (0.00, 0.015, 0.035),
        'PANEL': (0.00, 0.045, 0.080), 'PANEL2': (0.00, 0.075, 0.125), 'BORDER': (0.06, 0.42, 0.62),
        'TEXT': (0.62, 0.92, 1.0), 'DIM': (0.12, 0.35, 0.48), 'YELLOW': (0.96, 1.0, 0.25)
    },
    'Radioactive Yellow': {
        'NEON': (0.96, 1.0, 0.00), 'NEON2': (0.20, 1.0, 0.10), 'DARK': (0.02, 0.025, 0.00),
        'PANEL': (0.075, 0.085, 0.00), 'PANEL2': (0.115, 0.130, 0.00), 'BORDER': (0.62, 0.70, 0.04),
        'TEXT': (0.95, 1.0, 0.46), 'DIM': (0.42, 0.48, 0.12), 'YELLOW': (1.0, 1.0, 0.05)
    },
    'Crimson Night': {
        'NEON': (1.0, 0.00, 0.32), 'NEON2': (1.0, 0.42, 0.10), 'DARK': (0.025, 0.00, 0.012),
        'PANEL': (0.075, 0.00, 0.035), 'PANEL2': (0.120, 0.012, 0.055), 'BORDER': (0.62, 0.07, 0.25),
        'TEXT': (1.0, 0.68, 0.78), 'DIM': (0.45, 0.16, 0.25), 'YELLOW': (1.0, 0.96, 0.20)
    },
    'Terminal Amber CRT': {
        'NEON': (1.0, 0.55, 0.04), 'NEON2': (0.82, 1.0, 0.20), 'DARK': (0.020, 0.010, 0.000),
        'PANEL': (0.060, 0.030, 0.000), 'PANEL2': (0.095, 0.048, 0.000), 'BORDER': (0.50, 0.28, 0.06),
        'TEXT': (1.0, 0.72, 0.38), 'DIM': (0.40, 0.24, 0.10), 'YELLOW': (1.0, 0.90, 0.16)
    },
    'Alien Toxic': {
        'NEON': (0.42, 1.0, 0.00), 'NEON2': (0.00, 1.0, 0.42), 'DARK': (0.002, 0.020, 0.000),
        'PANEL': (0.025, 0.075, 0.000), 'PANEL2': (0.040, 0.125, 0.012), 'BORDER': (0.28, 0.68, 0.04),
        'TEXT': (0.70, 1.0, 0.46), 'DIM': (0.25, 0.48, 0.12), 'YELLOW': (0.95, 1.0, 0.05)
    },
    'Royal Violet': {
        'NEON': (0.55, 0.18, 1.0), 'NEON2': (1.0, 0.86, 0.18), 'DARK': (0.018, 0.000, 0.035),
        'PANEL': (0.055, 0.010, 0.105), 'PANEL2': (0.085, 0.020, 0.165), 'BORDER': (0.42, 0.16, 0.72),
        'TEXT': (0.84, 0.68, 1.0), 'DIM': (0.34, 0.22, 0.52), 'YELLOW': (1.0, 0.92, 0.20)
    },
    'Steel Blue Gray': {
        'NEON': (0.50, 0.72, 0.90), 'NEON2': (0.78, 0.92, 1.0), 'DARK': (0.012, 0.016, 0.020),
        'PANEL': (0.060, 0.070, 0.080), 'PANEL2': (0.095, 0.110, 0.125), 'BORDER': (0.36, 0.46, 0.56),
        'TEXT': (0.76, 0.88, 1.0), 'DIM': (0.32, 0.40, 0.48), 'YELLOW': (1.0, 0.95, 0.30)
    }
})


def apply_theme_values(name):
    global NEON, NEON2, DARK, PANEL, PANEL2, BORDER, TEXT, DIM, YELLOW
    data = THEMES.get(name, THEMES['Govibe Nexus Green'])
    NEON = data['NEON']; NEON2 = data['NEON2']; DARK = data['DARK']
    PANEL = data['PANEL']; PANEL2 = data['PANEL2']; BORDER = data['BORDER']
    TEXT = data['TEXT']; DIM = data['DIM']; YELLOW = data['YELLOW']


def rgba(c, a=1.0):
    return (c[0], c[1], c[2], a)


def set_source(cr, c, a=1.0):
    cr.set_source_rgba(c[0], c[1], c[2], a)


def rounded(cr, x, y, w, h, r=4):
    r = min(r, w/2, h/2)
    cr.new_sub_path()
    cr.arc(x+w-r, y+r, r, -math.pi/2, 0)
    cr.arc(x+w-r, y+h-r, r, 0, math.pi/2)
    cr.arc(x+r, y+h-r, r, math.pi/2, math.pi)
    cr.arc(x+r, y+r, r, math.pi, 3*math.pi/2)
    cr.close_path()


def draw_text(cr, text, x, y, size=10, color=TEXT, bold=False, align='left', width=None):
    layout = PangoCairo.create_layout(cr)
    desc = Pango.FontDescription('DejaVu Sans')
    desc.set_size(int(size * Pango.SCALE))
    desc.set_weight(Pango.Weight.BOLD if bold else Pango.Weight.NORMAL)
    layout.set_font_description(desc)
    layout.set_text(str(text), -1)
    layout.set_single_paragraph_mode(True)
    if width:
        layout.set_width(int(width * Pango.SCALE))
        layout.set_ellipsize(Pango.EllipsizeMode.END)
        layout.set_wrap(Pango.WrapMode.CHAR)
        if align == 'center':
            layout.set_alignment(Pango.Alignment.CENTER)
        elif align == 'right':
            layout.set_alignment(Pango.Alignment.RIGHT)
    else:
        if align != 'left':
            tw, th = layout.get_pixel_size()
            if align == 'center':
                x -= tw/2
            elif align == 'right':
                x -= tw
    set_source(cr, color)
    cr.move_to(x, y)
    PangoCairo.show_layout(cr, layout)


def draw_marquee_text(cr, text, x, y, box_w, size=9.2, color=NEON, bold=True, gap=64):
    text = str(text)
    layout = PangoCairo.create_layout(cr)
    desc = Pango.FontDescription('DejaVu Sans')
    desc.set_size(int(size * Pango.SCALE))
    desc.set_weight(Pango.Weight.BOLD if bold else Pango.Weight.NORMAL)
    layout.set_font_description(desc)
    layout.set_text(text, -1)
    tw, th = layout.get_pixel_size()
    cr.save()
    cr.rectangle(x, y-2, box_w, max(18, th+4))
    cr.clip()
    set_source(cr, color)
    if tw <= box_w:
        cr.move_to(x + (box_w - tw) / 2.0, y)
        PangoCairo.show_layout(cr, layout)
    else:
        speed = 34.0
        cycle = tw + gap
        off = (time.time() * speed) % cycle
        start = x + box_w - off
        cr.move_to(start, y)
        PangoCairo.show_layout(cr, layout)
        cr.move_to(start + tw + gap, y)
        PangoCairo.show_layout(cr, layout)
    cr.restore()

def draw_line(cr, x1, y1, x2, y2, c=NEON, width=2, a=1.0):
    set_source(cr, c, a)
    cr.set_line_width(width)
    cr.move_to(x1, y1)
    cr.line_to(x2, y2)
    cr.stroke()


class Button:
    def __init__(self, key, text, x, y, w, h, kind='dark'):
        self.key = key
        self.text = text
        self.x = x
        self.y = y
        self.w = w
        self.h = h
        self.kind = kind
        self.down = False
    def contains(self, x, y):
        return self.x <= x <= self.x+self.w and self.y <= y <= self.y+self.h


class NexusSurface(Gtk.DrawingArea):
    def __init__(self, app):
        super().__init__()
        self.app = app
        self.set_size_request(820, 632)
        self.set_can_focus(True)
        self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK | Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.KEY_PRESS_MASK)
        self.connect('draw', self.on_draw)
        self.connect('button-press-event', self.on_button_press)
        self.connect('button-release-event', self.on_button_release)
        self.connect('motion-notify-event', self.on_motion)
        self.connect('scroll-event', self.on_scroll)
        self.connect('key-press-event', self.on_key)
        self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
        self.drag_dest_add_uri_targets()
        self.connect('drag-data-received', self.on_drop)
        self.hover = None
        self.active_button = None
        self.drag = None
        self.eq_drag = None
        self.buttons = []
        self.playlist_scroll = 0
        self.playlist_drag_start = None
        self.build_buttons()

    def build_buttons(self):
        self.buttons = [
            Button('open','OPEN',24,108,58,29),
            Button('prev','<<',98,108,50,29),
            Button('play','PLAY',156,108,52,29),
            Button('pause','PAUSE',215,108,60,29),
            Button('stop','STOP',282,108,52,29),
            Button('next','>>',342,108,50,29),
            Button('vis','VIS ON',400,108,48,29,'green'),
            Button('fx','FX',452,108,36,29),
            Button('eq','EQ ON',492,108,44,29,'green'),
            Button('list','LIST ON',540,108,56,29,'green'),
            Button('theme','THEME',608,108,77,29),
            Button('default','DEFAULT',692,108,94,29,'green'),
            Button('presets','PRESETS',672,296,118,29),
            Button('reset_eq','RESET EQ',672,335,118,28),
            Button('add','ADD',24,600,58,27),
            Button('remove','REMOVE',88,600,72,27),
            Button('clear','CLEAR',166,600,62,27),
            Button('shuffle','SHUF OFF',246,600,80,27),
            Button('repeat','REP OFF',334,600,80,27),
            Button('trans','TRANS NONE',422,600,136,27),
            Button('save','SAVE',650,600,66,27),
            Button('load','LOAD',724,600,66,27),
        ]

    def layout_positions(self):
        cur = 153
        vis_y = None
        eq_y = None
        list_y = None
        if self.app.show_vis:
            vis_y = cur
            cur += 125
        if self.app.show_eq:
            eq_y = cur
            cur += 181
        if self.app.show_list:
            list_y = cur
            cur += 173
        return {'vis_y': vis_y, 'eq_y': eq_y, 'list_y': list_y, 'height': max(153, cur)}

    def update_dynamic_button_positions(self):
        pos = self.layout_positions()
        eq_y = pos.get('eq_y')
        list_y = pos.get('list_y')
        # EQ board buttons follow the EQ panel when visualizer is hidden.
        self.buttons[12].y = (eq_y + 18) if eq_y is not None else -1000
        self.buttons[13].y = (eq_y + 57) if eq_y is not None else -1000
        # Playlist buttons follow the playlist panel when VIS/EQ are hidden.
        for b in self.buttons[14:]:
            b.y = (list_y + 141) if list_y is not None else -1000

    def design_h(self):
        return self.layout_positions()['height']
    def sx(self):
        return self.get_allocated_width() / 820.0
    def sy(self):
        return self.get_allocated_height() / float(self.design_h())
    def to_design(self, x, y):
        return x / self.sx(), y / self.sy()

    def panel(self, cr, x, y, w, h, title=None):
        set_source(cr, PANEL, 0.98)
        rounded(cr, x, y, w, h, 5)
        cr.fill_preserve()
        set_source(cr, BORDER, 0.8)
        cr.set_line_width(1)
        cr.stroke()
        if title:
            draw_text(cr, title, x+12, y+10, 10, NEON, True)
            draw_line(cr, x+8, y+30, x+w-8, y+30, NEON, 2.2)
            draw_line(cr, x+8, y+31, x+270, y+31, NEON2, 1)

    def button(self, cr, b):
        color = NEON if b.kind == 'green' else PANEL2
        border = NEON if b.kind == 'green' else BORDER
        textc = (0,0.10,0.05) if b.kind == 'green' else TEXT
        if self.hover == b.key:
            color = (min(color[0]+0.08,1), min(color[1]+0.08,1), min(color[2]+0.08,1))
        set_source(cr, color, 0.98)
        rounded(cr, b.x, b.y, b.w, b.h, 4)
        cr.fill_preserve()
        set_source(cr, border, 1)
        cr.set_line_width(1)
        cr.stroke()
        draw_text(cr, b.text, b.x+b.w/2, b.y+8, 9, textc, True, 'center')

    def draw_top(self, cr):
        self.panel(cr, 6, 6, 808, 139, None)
        draw_text(cr, APP_NAME.upper(), 24, 17, 10, NEON, True)
        draw_line(cr, 14, 35, 806, 35, NEON, 2.0)
        draw_line(cr, 14, 36, 760, 36, NEON2, 1.1)
        draw_text(cr, 'NEXUS', 24, 49, 22, NEON, True)
        draw_text(cr, 'Govibe audio core', 25, 75, 7, TEXT, False)
        # window controls drawn for visual match only
        for i, txt in enumerate(['_', 'x']):
            x = 756 + i*26
            rounded(cr, x, 18, 20, 22, 3)
            set_source(cr, DARK, .95); cr.fill_preserve(); set_source(cr, BORDER, .9); cr.stroke()
            draw_text(cr, txt, x+10, 22, 9, TEXT, True, 'center')
        # display bars
        rounded(cr, 166, 42, 412, 28, 3)
        set_source(cr, PANEL2, 1); cr.fill_preserve(); set_source(cr, BORDER, .85); cr.stroke()
        title = self.app.current_title() or 'No track loaded'
        draw_marquee_text(cr, title, 181, 50, 386, 9.2, NEON, True)
        rounded(cr, 166, 72, 412, 16, 2)
        set_source(cr, PANEL2, .9); cr.fill_preserve(); set_source(cr, BORDER, .75); cr.stroke()
        draw_text(cr, f'{self.app.time_text()}    Bitrate: {self.app.bitrate_text()}    Hz: {self.app.hz_text()}', 169, 75, 7.2, YELLOW, False)
        # seek
        draw_line(cr, 166, 95, 578, 95, BORDER, 5)
        draw_line(cr, 166, 95, 166 + 412*self.app.progress_ratio(), 95, NEON2, 5)
        xk = 166 + 412*self.app.progress_ratio()
        rounded(cr, xk-4, 89, 8, 12, 2); set_source(cr, NEON2, 1); cr.fill()
        # volume box
        rounded(cr, 600, 42, 186, 32, 3)
        set_source(cr, DARK, 1); cr.fill_preserve(); set_source(cr, BORDER, .85); cr.stroke()
        for i in range(34):
            c = NEON2 if i % 2 else NEON
            draw_line(cr, 614+i*4.5, 65, 616+i*4.5, 65, c, 2)
        draw_text(cr, f'VOL {int(self.app.volume*100)}%', 692, 68, 7, YELLOW, False, 'center')
        draw_line(cr, 615, 88, 780, 88, NEON, 4)
        draw_line(cr, 615, 89, 780, 89, NEON2, 2)
        vx = 615 + 165*self.app.volume
        rounded(cr, vx-4, 82, 8, 14, 2); set_source(cr, NEON2, 1); cr.fill()
        self.buttons[6].text = 'VIS ON' if self.app.show_vis else 'VIS OFF'
        self.buttons[8].text = 'EQ ON' if self.app.show_eq else 'EQ OFF'
        self.buttons[9].text = 'LIST ON' if self.app.show_list else 'LIST OFF'
        for b in self.buttons[:12]:
            self.button(cr, b)

    def draw_visualizer(self, cr):
        if not self.app.show_vis:
            return
        y = self.layout_positions()['vis_y']
        self.panel(cr, 6, y, 808, 118, 'OSCILLOSCOPE + SPECTRUM ANALYZER')
        # The main section is ONLY the oscilloscope + spectrum analyzer.
        # Real FX and games live in the separate FX window.
        self.draw_fx_spectrum(cr, y)

    def fx_box(self, cr, x, y, w, h, label):
        rounded(cr, x, y, w, h, 4)
        set_source(cr, (0.01,0.08,0.04), 1); cr.fill_preserve(); set_source(cr, BORDER, .7); cr.stroke()
        draw_text(cr, label, x+10, y+10, 8, DIM, True)

    def draw_fx_spectrum(self, cr, panel_y):
        box_y = panel_y + 38
        self.fx_box(cr, 24, box_y, 380, 59, 'OSCILLOSCOPE')
        for gx in range(0, 5):
            draw_line(cr, 34+gx*72, box_y+8, 34+gx*72, box_y+53, DIM, .6, .45)
        for gy in [box_y+30, box_y+41]:
            draw_line(cr, 34, gy, 397, gy, DIM, .6, .45)
        t = self.app.anim
        set_source(cr, NEON, .95)
        cr.set_line_width(2.2)
        for i in range(0, 360):
            amp = 1 + 6*self.app.level
            yy = box_y+30 + math.sin((i+t*12)/18.0)*amp*0.6 + math.sin((i+t*10)/7.0)*amp*0.25
            xx = 36 + i
            if i == 0: cr.move_to(xx, yy)
            else: cr.line_to(xx, yy)
        cr.stroke()
        self.fx_box(cr, 424, box_y, 333, 59, '00 BAND THEME SPECTRUM')
        draw_line(cr, 432, box_y+39, 748, box_y+39, DIM, .6, .45)
        n = 96
        for i in range(n):
            h = 2 + (math.sin(i*.19+t*2.2)+1)*4*self.app.level + random.random()*3*self.app.level
            c = NEON2 if i % 4 == 0 else NEON
            draw_line(cr, 432+i*3.0, box_y+48, 432+i*3.0, box_y+48-h, c, 1.5, .9)

    def draw_fx_oscilloscope(self, cr, x, y, w, h):
        self.fx_box(cr, x, y, w, h, 'OSCILLOSCOPE FULL')
        draw_line(cr, x+10, y+h/2, x+w-10, y+h/2, DIM, .8, .5)
        t = self.app.anim
        set_source(cr, NEON, .95); cr.set_line_width(2.4)
        for i in range(int(w-20)):
            xx = x+10+i
            yy = y+h/2 + math.sin(i/10.0+t*7)*14*self.app.level + math.sin(i/4.0+t*4)*5*self.app.level
            if i == 0: cr.move_to(xx, yy)
            else: cr.line_to(xx, yy)
        cr.stroke()

    def draw_fx_waveform(self, cr, x, y, w, h):
        self.fx_box(cr, x, y, w, h, 'WAVEFORM')
        t = self.app.anim
        for row, off in enumerate([0, 18]):
            set_source(cr, NEON if row == 0 else NEON2, .90)
            cr.set_line_width(1.8)
            base = y + 31 + off
            for i in range(int(w-20)):
                xx = x+10+i
                yy = base + math.sin(i/8.0+t*5+row)*7*self.app.level + math.sin(i/21.0+t*2)*4
                if i == 0: cr.move_to(xx, yy)
                else: cr.line_to(xx, yy)
            cr.stroke()

    def draw_fx_circular(self, cr, x, y, w, h):
        self.fx_box(cr, x, y, w, h, 'CIRCULAR SPECTRUM')
        cx, cy = x+w/2, y+h/2+6
        base = 20 + 18*self.app.level
        for i in range(120):
            a = i/120.0*math.tau + self.app.anim*.15
            amp = base + (math.sin(i*.34+self.app.anim*5)+1)*9*self.app.level
            x1 = cx + math.cos(a)*base
            y1 = cy + math.sin(a)*base
            x2 = cx + math.cos(a)*(base+amp*.55)
            y2 = cy + math.sin(a)*(base+amp*.55)
            draw_line(cr, x1, y1, x2, y2, NEON2 if i%5==0 else NEON, 1.4, .9)
        rounded(cr, cx-8, cy-8, 16, 16, 8); set_source(cr, NEON, .8); cr.fill()

    def draw_fx_aurora(self, cr, x, y, w, h):
        self.fx_box(cr, x, y, w, h, 'AURORA FLOW')
        cx, cy = x+w/2, y+h/2+5
        t = self.app.anim
        for row in range(5):
            set_source(cr, NEON2 if row % 2 else NEON, .28)
            cr.set_line_width(1.6 + self.app.level * 2.2)
            for i in range(int(w-24)):
                xx = x+12+i
                yy = y+20+row*16 + math.sin(i*.035+t*(2.2+self.app.level*3)+row)*12*self.app.level
                if i == 0: cr.move_to(xx, yy)
                else: cr.line_to(xx, yy)
            cr.stroke()
        for i in range(45):
            a = i*.63 + t*(1+self.app.level*2)
            r = 14 + (i%9)*8 + self.app.level*18
            set_source(cr, YELLOW if i%12==0 else NEON, .45)
            cr.arc(cx+math.cos(a)*r*2.2, cy+math.sin(a)*r*.7, 1.6+self.app.level*3, 0, math.tau)
            cr.fill()

    def draw_fx_tetris(self, cr, x, y, w, h):
        self.fx_box(cr, x, y, w, h, 'TETRIS BEAT FX  ARROWS MOVE  UP ROTATE  SPACE DROP')
        grid = self.app.tetris_grid
        cell = 5
        gx, gy = x+20, y+18
        rows, cols = 10, 20
        for r in range(rows):
            for c in range(cols):
                val = grid[r][c]
                if val:
                    set_source(cr, NEON2 if val == 1 else NEON, .95)
                    cr.rectangle(gx+c*cell, gy+r*cell, cell-1, cell-1); cr.fill()
                else:
                    set_source(cr, DIM, .25)
                    cr.rectangle(gx+c*cell, gy+r*cell, cell-1, cell-1); cr.stroke()
        for c, r in self.app.tetris_cells():
            if 0 <= c < cols and 0 <= r < rows:
                set_source(cr, NEON, 1)
                cr.rectangle(gx+c*cell, gy+r*cell, cell-1, cell-1); cr.fill()
        draw_text(cr, f'SCORE {self.app.tetris_score}', x+150, y+28, 10, NEON2, True)
        draw_text(cr, f'LEVEL {self.app.tetris_level}', x+150, y+47, 10, TEXT, True)
        for i in range(60):
            hbar = 2 + random.random()*18*self.app.level
            draw_line(cr, x+300+i*6, y+62, x+300+i*6, y+62-hbar, NEON if i%3 else NEON2, 2, .8)

    def draw_fx_starship(self, cr, x, y, w, h):
        self.fx_box(cr, x, y, w, h, 'VOID STARSHIP VOYAGE  ARROWS STEER  SPACE BOOST')
        cx, cy = x+w/2, y+h/2+4
        for sx, sy, sz in self.app.stars:
            px = cx + sx / max(sz, .1) * 70
            py = cy + sy / max(sz, .1) * 28
            px2 = cx + sx / max(sz+.08, .1) * 70
            py2 = cy + sy / max(sz+.08, .1) * 28
            draw_line(cr, px2, py2, px, py, NEON2 if sz < .5 else NEON, 1.2, .9)
        shipx = cx + self.app.ship_x*80
        shipy = cy + self.app.ship_y*26
        set_source(cr, NEON, .95)
        cr.move_to(shipx, shipy-10); cr.line_to(shipx-14, shipy+10); cr.line_to(shipx, shipy+5); cr.line_to(shipx+14, shipy+10); cr.close_path(); cr.stroke()

    def draw_eq(self, cr):
        if not self.app.show_eq:
            return
        self.update_dynamic_button_positions()
        y0 = self.layout_positions()['eq_y']
        self.panel(cr, 6, y0, 808, 176, 'WINAMP STYLE EQUALIZER BOARD')
        draw_text(cr, '+24', 30, y0+81, 7, DIM, True)
        draw_text(cr, '0', 34, y0+107, 8, TEXT, True)
        draw_text(cr, '-24', 30, y0+136, 7, DIM, True)
        labels = ['PRE','60','170','310','600','1K','3K','6K','12K','14K','16K']
        x0 = 64
        for i, lab in enumerate(labels):
            x = x0 + i*49
            draw_text(cr, f'{self.app.eq[i]*12:+.0f}' if i else f'{self.app.eq[i]*6:+.0f}', x+12, y0+61, 7, TEXT, True, 'center')
            rounded(cr, x, y0+76, 26, 72, 3)
            set_source(cr, DARK, 1); cr.fill_preserve(); set_source(cr, BORDER, .85); cr.stroke()
            draw_line(cr, x+13, y0+82, x+13, y0+142, DIM, 1)
            val = self.app.eq[i]
            knob_y = y0+112 - val*30
            rounded(cr, x+4, knob_y-5, 18, 10, 2)
            set_source(cr, NEON, 1); cr.fill()
            draw_text(cr, lab, x+13, y0+156, 8, DIM if lab!='PRE' else TEXT, True, 'center')
        self.button(cr, self.buttons[12]); self.button(cr, self.buttons[13])
        draw_text(cr, 'Real DSP EQ', 670, y0+90, 7, DIM, True)
        draw_text(cr, 'audio filter', 670, y0+101, 7, DIM, True)
        draw_text(cr, 'audio EQ core', 670, y0+112, 7, DIM, True)
        draw_text(cr, 'preset ready', 670, y0+123, 7, DIM, True)

    def draw_playlist(self, cr):
        if not self.app.show_list:
            return
        self.update_dynamic_button_positions()
        y0 = self.layout_positions()['list_y']
        self.panel(cr, 6, y0, 808, 173, 'PLAYLIST')
        rounded(cr, 24, y0+35, 771, 22, 2)
        set_source(cr, DARK, 1); cr.fill_preserve(); set_source(cr, BORDER, .75); cr.stroke()
        draw_text(cr, '#', 34, y0+41, 8, TEXT, True)
        draw_text(cr, 'TITLE', 82, y0+41, 8, TEXT, True)
        draw_text(cr, 'BITRATE / HZ', 526, y0+41, 8, TEXT, True)
        draw_text(cr, f'{len(self.app.playlist)} tracks', 756, y0+41, 8, TEXT, False)
        rounded(cr, 24, y0+57, 771, 74, 2)
        set_source(cr, (0,0,0), .96); cr.fill_preserve(); set_source(cr, BORDER, .75); cr.stroke()
        if not self.app.playlist:
            draw_text(cr, 'Drag and drop audio files here', 410, y0+89, 9, DIM, True, 'center')
        else:
            start = self.playlist_scroll
            yy = y0+62
            for idx in range(start, min(len(self.app.playlist), start+5)):
                item = self.app.playlist[idx]
                selected = (idx == self.app.selected_index) or (idx in getattr(self.app, 'selected_indices', set()))
                playing = (idx == self.app.current_index and self.app.playing)
                if playing:
                    set_source(cr, (0.02,0.23,0.08), .94)
                    cr.rectangle(26, yy-1, 767, 13); cr.fill()
                if selected:
                    set_source(cr, NEON2, .95)
                    cr.set_line_width(.8)
                    cr.rectangle(26.5, yy-1.5, 766, 14); cr.stroke()
                col = NEON2 if playing else (NEON if selected else TEXT)
                draw_text(cr, f'{idx+1}.', 34, yy, 8, col, True)
                draw_text(cr, Path(item).name, 82, yy, 8, col, True)
                br, hz = self.app.get_audio_info(item)
                draw_text(cr, f'{br} / {hz}', 526, yy, 8, DIM, False)
                yy += 13
        for b in self.buttons[14:]:
            self.button(cr, b)

    def on_draw(self, widget, cr):
        w, h = self.get_allocated_width(), self.get_allocated_height()
        cr.save()
        cr.scale(w/820.0, h/float(self.design_h()))
        # background
        set_source(cr, (0.005,0.008,0.006), 1)
        cr.rectangle(0,0,820,self.design_h()); cr.fill()
        # subtle outer neon line
        set_source(cr, NEON, .35)
        cr.rectangle(0.5,0.5,819,self.design_h()-1); cr.set_line_width(1); cr.stroke()
        self.update_dynamic_button_positions()
        self.draw_top(cr)
        self.draw_visualizer(cr)
        self.draw_eq(cr)
        self.draw_playlist(cr)
        cr.restore()
        return False

    def hit_button(self, x, y):
        self.update_dynamic_button_positions()
        for b in self.buttons:
            if b.contains(x, y): return b
        return None

    def on_button_press(self, widget, ev):
        self.grab_focus()
        x, y = self.to_design(ev.x, ev.y)
        if ev.button == 3:
            pos = self.layout_positions()
            ly = pos.get('list_y')
            if self.app.show_list and ly is not None and ly <= y <= ly + 173:
                self.app.show_playlist_context(ev)
            else:
                self.app.show_context(ev)
            return True
        # Screenshot-matched mini controls inside the player skin.
        # They now work like the real window buttons: _ minimizes, X exits and stops MPV.
        if 18 <= y <= 40:
            if 756 <= x <= 776:
                self.app.minimize_window()
                return True
            if 782 <= x <= 802:
                self.app.quit_app()
                return True
        b = self.hit_button(x, y)
        if b:
            self.active_button = b
            b.down = True
            self.queue_draw()
            return True
        # seek
        if 166 <= x <= 578 and 88 <= y <= 102:
            self.drag = 'seek'
            self.app.seek_ratio((x-166)/412.0)
            self.queue_draw()
            return True
        # volume
        if 615 <= x <= 780 and 80 <= y <= 98:
            self.drag = 'volume'
            self.app.set_volume((x-615)/165.0)
            self.queue_draw()
            return True
        # eq sliders
        pos = self.layout_positions()
        eq_y = pos.get('eq_y')
        list_y = pos.get('list_y')
        if self.app.show_eq and eq_y is not None and eq_y+76 <= y <= eq_y+148:
            x0 = 64
            for i in range(11):
                sx = x0 + i*49
                if sx <= x <= sx+26:
                    self.eq_drag = i
                    self.update_eq(y)
                    return True
        # playlist: one click selects for cleanup, CTRL+click toggles multi-select,
        # click-drag over rows selects a range while the current song keeps playing.
        if ev.type == Gdk.EventType._2BUTTON_PRESS and self.app.show_list and list_y is not None and list_y <= y <= list_y+34:
            self.app.detach_playlist()
            return True
        if self.app.show_list and list_y is not None and list_y+57 <= y <= list_y+131:
            row = int((y-(list_y+62))//13) + self.playlist_scroll
            if 0 <= row < len(self.app.playlist):
                ctrl = bool(ev.state & Gdk.ModifierType.CONTROL_MASK)
                if ev.type == Gdk.EventType._2BUTTON_PRESS:
                    self.app.select_playlist_index(row)
                    self.app.play_index(row)
                elif ctrl:
                    self.app.toggle_playlist_index(row)
                else:
                    self.app.select_playlist_index(row)
                    self.playlist_drag_start = row
                    self.drag = 'playlist_range'
                return True
        return True

    def update_eq(self, y):
        eq_y = self.layout_positions().get('eq_y') or 278
        val = max(-1.0, min(1.0, ((eq_y+112)-y)/30.0))
        if self.eq_drag is not None:
            self.app.eq[self.eq_drag] = val
            self.app.apply_eq_to_audio()
            self.app.save_settings()
            self.queue_draw()

    def on_button_release(self, widget, ev):
        x, y = self.to_design(ev.x, ev.y)
        if self.active_button:
            b = self.active_button
            self.active_button = None
            b.down = False
            if b.contains(x, y):
                self.app.handle_button(b.key)
            self.queue_draw()
        self.drag = None
        self.eq_drag = None
        self.playlist_drag_start = None
        return True

    def on_motion(self, widget, ev):
        x, y = self.to_design(ev.x, ev.y)
        if self.drag == 'seek':
            self.app.seek_ratio((x-166)/412.0)
        elif self.drag == 'volume':
            self.app.set_volume((x-615)/165.0)
        elif self.eq_drag is not None:
            self.update_eq(y)
        elif self.drag == 'playlist_range':
            pos = self.layout_positions()
            list_y = pos.get('list_y')
            if self.app.show_list and list_y is not None and list_y+57 <= y <= list_y+131 and self.playlist_drag_start is not None:
                row = int((y-(list_y+62))//13) + self.playlist_scroll
                if 0 <= row < len(self.app.playlist):
                    self.app.select_playlist_range(self.playlist_drag_start, row)
                    self.queue_draw()
        else:
            b = self.hit_button(x, y)
            key = b.key if b else None
            if key != self.hover:
                self.hover = key
                self.queue_draw()
        return True

    def on_scroll(self, widget, ev):
        if self.app.show_list:
            if ev.direction == Gdk.ScrollDirection.DOWN:
                self.playlist_scroll = min(max(0,len(self.app.playlist)-5), self.playlist_scroll+1)
            elif ev.direction == Gdk.ScrollDirection.UP:
                self.playlist_scroll = max(0, self.playlist_scroll-1)
            self.queue_draw()
        return True

    def on_key(self, widget, ev):
        key = Gdk.keyval_name(ev.keyval)
        if self.app.handle_game_key(key):
            self.queue_draw()
            return True
        if key == 'space': self.app.toggle_play()
        elif key == 'Left': self.app.seek_delta(-5)
        elif key == 'Right': self.app.seek_delta(5)
        elif key == 'Up': self.app.set_volume(self.app.volume + .05)
        elif key == 'Down': self.app.set_volume(self.app.volume - .05)
        elif key in ('n','N'): self.app.next_track()
        elif key in ('b','B'): self.app.prev_track()
        elif key in ('s','S'): self.app.stop()
        elif key in ('l','L'): self.app.show_list = not self.app.show_list; self.queue_draw()
        return True

    def on_drop(self, widget, context, x, y, data, info, time):
        uris = data.get_uris()
        files = []
        for uri in uris:
            p = urllib.parse.urlparse(uri)
            if p.scheme == 'file':
                path = urllib.parse.unquote(p.path)
                if os.path.isdir(path):
                    for root, dirs, names in os.walk(path):
                        for n in names:
                            if Path(n).suffix.lower() in AUDIO_EXTS:
                                files.append(os.path.join(root,n))
                elif Path(path).suffix.lower() in AUDIO_EXTS:
                    files.append(path)
        if files:
            self.app.add_files(files)
        Gtk.drag_finish(context, True, False, time)



class FXSurface(Gtk.DrawingArea):
    def __init__(self, app):
        super().__init__()
        self.app = app
        self.set_size_request(960, 880)
        self.set_can_focus(True)
        self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.KEY_PRESS_MASK)
        self.connect('draw', self.on_draw)
        self.connect('button-press-event', self.on_button)
        self.connect('key-press-event', self.on_key)
        self.buttons = [
            ('spectrum','SPECTRUM',18,808,82,32),
            ('oscilloscope','SCOPE',106,808,70,32),
            ('waveform','WAVE',182,808,70,32),
            ('circular','CIRCLE',258,808,76,32),
            ('checkers','CHECKERS',340,808,92,32),
            ('chess','CHESS',438,808,72,32),
            ('tetris','TETRIS',516,808,72,32),
            ('pacman','PACMAN',594,808,80,32),
            ('starship','STARSHIP',680,808,90,32),
            ('close','CLOSE',776,808,70,32),
        ]
    def draw_btn(self, cr, key, text, x, y, w, h):
        active = (self.app.fx_mode == key)
        set_source(cr, NEON if active else PANEL2, .98)
        rounded(cr, x, y, w, h, 4)
        cr.fill_preserve()
        set_source(cr, NEON if active else BORDER, 1)
        cr.set_line_width(1)
        cr.stroke()
        draw_text(cr, text, x+w/2, y+9, 8.2, (0,0.10,0.05) if active else TEXT, True, 'center')

    def draw_match_mode_buttons(self, cr, game, x, y):
        vs_ai = self.app.checkers_vs_ai if game == 'checkers' else self.app.chess_vs_ai
        draw_text(cr, 'PLAYER MODE', x, y-22, 10, TEXT, True)
        for key, label, bx in [('human', '1 VS 1 HUMAN', x), ('ai', '1 VS AI', x+126)]:
            active = (vs_ai and key == 'ai') or ((not vs_ai) and key == 'human')
            set_source(cr, NEON if active else PANEL2, .97)
            rounded(cr, bx, y, 118, 28, 4)
            cr.fill_preserve()
            set_source(cr, NEON2 if active else BORDER, 1)
            cr.set_line_width(1.2)
            cr.stroke()
            draw_text(cr, label, bx+59, y+8, 7.6, (0,0.10,0.05) if active else TEXT, True, 'center')

    def draw_game_reset_button(self, cr, x, y):
        set_source(cr, PANEL2, .97)
        rounded(cr, x, y, 244, 28, 4)
        cr.fill_preserve()
        set_source(cr, YELLOW, 1)
        cr.set_line_width(1.2)
        cr.stroke()
        draw_text(cr, 'RESET GAME', x+122, y+8, 8.2, YELLOW, True, 'center')
    def on_draw(self, widget, cr):
        w, h = self.get_allocated_width(), self.get_allocated_height()
        sx, sy = w/960.0, h/880.0
        cr.save(); cr.scale(sx, sy)
        set_source(cr, (0,0,0), 1); cr.rectangle(0,0,960,880); cr.fill()
        rounded(cr, 8, 8, 944, 785, 5)
        set_source(cr, DARK, 1); cr.fill_preserve(); set_source(cr, NEON, .9); cr.set_line_width(1.4); cr.stroke()
        draw_text(cr, 'GOVIBE NEXUS FX WINDOW', 26, 20, 13, NEON, True)
        draw_text(cr, 'Separate full FX and game window like the Windows player', 26, 42, 8, TEXT, False)
        draw_line(cr, 18, 66, 942, 66, NEON, 2.5)
        mode = self.app.fx_mode
        if mode == 'tetris':
            self.draw_tetris(cr, 20, 82, 920, 700)
        elif mode == 'pacman':
            self.draw_pacman(cr, 20, 82, 920, 700)
        elif mode == 'starship':
            self.draw_starship(cr, 20, 82, 920, 700)
        elif mode == 'checkers':
            self.draw_checkers(cr, 20, 82, 920, 700)
        elif mode == 'chess':
            self.draw_chess(cr, 20, 82, 920, 700)
        elif mode == 'oscilloscope':
            self.draw_scope(cr, 20, 82, 920, 700)
        elif mode == 'waveform':
            self.draw_waveform(cr, 20, 82, 920, 700)
        elif mode == 'circular':
            self.draw_circular(cr, 20, 82, 920, 700)
        elif mode in ('aurora','vortex','milkdrop'):
            self.app.set_fx_mode('checkers')
            self.draw_checkers(cr, 20, 82, 920, 700)
        else:
            self.draw_spectrum(cr, 20, 82, 920, 700)
        for b in self.buttons:
            self.draw_btn(cr, *b)
        cr.restore()
        return False
    def frame(self, cr, x, y, w, h, title):
        rounded(cr, x, y, w, h, 5)
        set_source(cr, PANEL, .98); cr.fill_preserve(); set_source(cr, BORDER, .9); cr.stroke()
        draw_text(cr, title, x+14, y+10, 12, NEON, True)
        draw_line(cr, x+12, y+34, x+w-12, y+34, NEON, 2)
    def draw_spectrum(self, cr, x, y, w, h):
        self.frame(cr,x,y,w,h,'90 BAND THEME SPECTRUM')
        base = y+h-36
        for i in range(120):
            hh = 6 + (math.sin(i*.18+self.app.anim*4)+1)*70*self.app.level + random.random()*22*self.app.level
            draw_line(cr, x+22+i*6.4, base, x+22+i*6.4, base-hh, NEON2 if i%5==0 else NEON, 3, .95)
        draw_text(cr, self.app.current_title() or 'No track loaded', x+22, y+h-22, 10, TEXT, True, width=w-44)
    def draw_scope(self, cr, x, y, w, h):
        self.frame(cr,x,y,w,h,'OSCILLOSCOPE FX')
        for gy in range(6): draw_line(cr, x+20, y+60+gy*42, x+w-20, y+60+gy*42, DIM, .7, .45)
        for gx in range(8): draw_line(cr, x+30+gx*95, y+46, x+30+gx*95, y+h-28, DIM, .7, .45)
        set_source(cr, NEON, .98); cr.set_line_width(3)
        for i in range(int(w-60)):
            xx=x+30+i; yy=y+h/2+math.sin(i/15+self.app.anim*6)*95*self.app.level+math.sin(i/5+self.app.anim*9)*25*self.app.level
            if i==0: cr.move_to(xx,yy)
            else: cr.line_to(xx,yy)
        cr.stroke()
    def draw_waveform(self, cr, x, y, w, h):
        self.frame(cr,x,y,w,h,'DUAL CHANNEL WAVEFORM')
        for row, off in enumerate([-55,55]):
            set_source(cr, NEON if row==0 else NEON2, .95); cr.set_line_width(2.2)
            for i in range(int(w-60)):
                xx=x+30+i; yy=y+h/2+off+math.sin(i/10+self.app.anim*5+row)*42*self.app.level+math.sin(i/26+self.app.anim*2)*12
                if i==0: cr.move_to(xx,yy)
                else: cr.line_to(xx,yy)
            cr.stroke()
    def draw_circular(self, cr, x, y, w, h):
        self.frame(cr,x,y,w,h,'CIRCULAR SPECTRUM FX')
        cx, cy=x+w/2, y+h/2+15; base=55+40*self.app.level
        for i in range(220):
            a=i/220*math.tau+self.app.anim*.4; amp=(math.sin(i*.22+self.app.anim*5)+1)*55*self.app.level+15
            draw_line(cr,cx+math.cos(a)*base,cy+math.sin(a)*base,cx+math.cos(a)*(base+amp),cy+math.sin(a)*(base+amp),NEON2 if i%6==0 else NEON,1.6,.9)
        set_source(cr, NEON, .25); cr.arc(cx,cy,base*.65,0,math.tau); cr.fill()
    def draw_aurora(self, cr, x, y, w, h):
        self.frame(cr,x,y,w,h,'AURORA FLOW FX')
        cx, cy = x + w / 2, y + h / 2 + 10
        t = self.app.anim
        beat = max(0.06, self.app.level)
        # Soft background glow clouds
        for i in range(18):
            phase = t * (0.45 + beat) + i * 0.72
            px = x + 70 + (i * 49 + math.sin(phase) * 40) % (w - 140)
            py = y + 105 + (math.sin(phase * 0.8 + i) + 1) * (h - 250) / 2
            radius = 35 + 85 * beat + (i % 5) * 10
            set_source(cr, NEON2 if i % 3 == 0 else NEON, 0.035 + beat * 0.055)
            cr.arc(px, py, radius, 0, math.tau)
            cr.fill()
        # Aurora curtain waves
        bands = 8
        for band in range(bands):
            base_y = y + 105 + band * 55
            amp = 20 + beat * (55 + band * 4)
            speed = t * (1.15 + beat * 2.6) + band * 0.65
            set_source(cr, NEON2 if band % 2 == 0 else NEON, 0.28 + beat * 0.45)
            cr.set_line_width(2.0 + beat * 3.5)
            for i in range(int(w - 80)):
                xx = x + 40 + i
                yy = base_y + math.sin(i * 0.018 + speed) * amp + math.sin(i * 0.052 - speed * 1.7) * amp * 0.25
                yy += math.sin(t * 4.5 + i * 0.006 + band) * 12 * beat
                if i == 0:
                    cr.move_to(xx, yy)
                else:
                    cr.line_to(xx, yy)
            cr.stroke()
            set_source(cr, NEON if band % 2 == 0 else NEON2, 0.08 + beat * 0.18)
            cr.set_line_width(9 + beat * 12)
            for i in range(0, int(w - 80), 4):
                xx = x + 40 + i
                yy = base_y + math.sin(i * 0.018 + speed) * amp
                if i == 0:
                    cr.move_to(xx, yy)
                else:
                    cr.line_to(xx, yy)
            cr.stroke()
        # Orbiting light beads
        for i in range(96):
            a = i * 0.392 + t * (0.75 + beat * 3.0)
            rx = 115 + (i % 9) * 30 + beat * 50
            ry = 48 + (i % 7) * 18 + beat * 28
            px = cx + math.cos(a) * rx
            py = cy + math.sin(a * 1.25) * ry
            size = 1.3 + (math.sin(t * 5 + i) + 1) * 1.5 + beat * 3.0
            set_source(cr, YELLOW if i % 13 == 0 else (NEON2 if i % 2 == 0 else NEON), 0.45 + beat * 0.35)
            cr.arc(px, py, size, 0, math.tau)
            cr.fill()
        # Bass pulse horizon
        horizon = y + h - 105
        set_source(cr, NEON, 0.55)
        cr.set_line_width(2.2 + beat * 5)
        for i in range(int(w - 80)):
            xx = x + 40 + i
            yy = horizon + math.sin(i * .025 + t * 6) * (8 + beat * 28)
            if i == 0:
                cr.move_to(xx, yy)
            else:
                cr.line_to(xx, yy)
        cr.stroke()
        draw_text(cr, 'AURORA FLOW  MUSIC REACTIVE LIGHT WAVES', x+28, y+h-38, 13, NEON2, True)
        draw_text(cr, 'moving curtains, orbit lights, and bass pulse glide with the sound', x+28, y+h-18, 9, TEXT, False)
    def draw_checkers(self, cr, x, y, w, h):
        self.frame(cr, x, y, w, h, 'CHECKERS 10 x 10 MUSIC FX GAME')
        draw_text(cr, 'CLICK A PIECE, THEN CLICK A DARK SQUARE TO MOVE OR CAPTURE', x+390, y+12, 9.5, TEXT, True)
        beat = max(.05, self.app.level)
        cell = 56
        gx = x + 42
        gy = y + 58
        board = self.app.checkers_board
        # Game specific FX 1: diagonal beat beams across the board
        for i in range(15):
            off = (self.app.anim * (80 + beat*180) + i * 78) % (cell*10 + 180) - 130
            draw_line(cr, gx + off, gy, gx + off + 260, gy + cell*10, NEON2 if i % 2 else NEON, 1.1 + beat*2.2, .12 + beat*.18)
        # Game specific FX 2: corner crown pulse rings
        for px, py in [(gx+cell*.5, gy+cell*.5), (gx+cell*9.5, gy+cell*.5), (gx+cell*.5, gy+cell*9.5), (gx+cell*9.5, gy+cell*9.5)]:
            set_source(cr, YELLOW, .06 + beat*.14)
            cr.arc(px, py, 18 + beat*44 + abs(math.sin(self.app.anim*5))*18, 0, math.tau)
            cr.stroke()
        rounded(cr, gx-12, gy-12, cell*10+24, cell*10+24, 8)
        set_source(cr, (0,0,0), .92); cr.fill_preserve(); set_source(cr, NEON, .75 + beat*.22); cr.set_line_width(1.5 + beat*2.0); cr.stroke()
        for r in range(10):
            for c in range(10):
                px, py = gx + c*cell, gy + r*cell
                dark = (r+c) % 2 == 1
                set_source(cr, (0.012,0.04,0.018) if dark else (0.035,0.105,0.045), 1)
                cr.rectangle(px, py, cell-1, cell-1); cr.fill()
                if self.app.checkers_sel == (c, r):
                    set_source(cr, YELLOW, .85)
                    cr.set_line_width(3)
                    cr.rectangle(px+3, py+3, cell-7, cell-7); cr.stroke()
                piece = board[r][c]
                if piece:
                    owner = piece[0]
                    king = len(piece) > 1 and piece[1] == 'K'
                    color = NEON2 if owner == 'w' else (1.0, .22, .18)
                    set_source(cr, color, .96)
                    cr.arc(px+cell/2, py+cell/2, cell*.36*(1+beat*.08), 0, math.tau); cr.fill()
                    set_source(cr, (1,1,1), .22)
                    cr.arc(px+cell/2-5, py+cell/2-6, cell*.15, 0, math.tau); cr.fill()
                    set_source(cr, (0,0,0), .55)
                    cr.arc(px+cell/2, py+cell/2, cell*.25, 0, math.tau); cr.stroke()
                    if king:
                        draw_text(cr, 'K', px+cell/2, py+cell/2-10, 15, YELLOW, True, 'center')
        info_x = x + 642
        draw_text(cr, '10 x 10 CHECKERS', info_x, y+82, 19, NEON2, True)
        draw_text(cr, f'TURN  {"GREEN" if self.app.checkers_turn == "w" else "RED"}', info_x, y+130, 14, TEXT, True)
        draw_text(cr, f'SCORE GREEN {self.app.checkers_score_w}', info_x, y+175, 11, NEON2, True)
        draw_text(cr, f'SCORE RED   {self.app.checkers_score_b}', info_x, y+198, 11, (1.0,.42,.35), True)
        self.draw_match_mode_buttons(cr, 'checkers', info_x, y+232)
        self.draw_game_reset_button(cr, info_x, y+268)
        draw_text(cr, 'FX A: diagonal beat beams', info_x, y+326, 9, DIM, True)
        draw_text(cr, 'FX B: crown pulse rings', info_x, y+346, 9, DIM, True)
        draw_text(cr, 'Music rhythm brightens pieces', info_x, y+368, 9, DIM, True)
        for i in range(42):
            hh=(math.sin(i*.42+self.app.anim*5)+1)*50*beat + random.random()*68*beat+6
            draw_line(cr,info_x+i*5.2,y+625,info_x+i*5.2,y+625-hh,NEON2 if i%5==0 else NEON,2.2,.8)

    def draw_chess(self, cr, x, y, w, h):
        self.frame(cr, x, y, w, h, 'REAL CHESS MUSIC FX GAME')
        draw_text(cr, 'CLICK A PIECE, THEN CLICK A SQUARE TO MOVE.  STANDARD PIECES, TURNS, CAPTURES.', x+390, y+12, 9.5, TEXT, True)
        beat = max(.05, self.app.level)
        cell = 68
        gx = x + 42
        gy = y + 74
        board = self.app.chess_board
        # Game specific FX 1: royal aura behind the board
        cx, cy = gx + cell*4, gy + cell*4
        for i in range(9):
            set_source(cr, NEON2 if i%2 else NEON, .035 + beat*.035)
            cr.arc(cx, cy, 90 + i*32 + beat*28*math.sin(self.app.anim*3+i), 0, math.tau)
            cr.stroke()
        # Game specific FX 2: lightning grid pulse
        for i in range(9):
            a = .10 + beat*.18
            draw_line(cr, gx+i*cell, gy, gx+i*cell, gy+cell*8, NEON if i%2 else NEON2, .8 + beat*1.6, a)
            draw_line(cr, gx, gy+i*cell, gx+cell*8, gy+i*cell, NEON2 if i%2 else NEON, .8 + beat*1.6, a)
        rounded(cr, gx-12, gy-12, cell*8+24, cell*8+24, 8)
        set_source(cr, (0,0,0), .90); cr.fill_preserve(); set_source(cr, NEON, .72 + beat*.25); cr.set_line_width(1.5 + beat*2.0); cr.stroke()
        piece_map = {
            'wK':'♔','wQ':'♕','wR':'♖','wB':'♗','wN':'♘','wP':'♙',
            'bK':'♚','bQ':'♛','bR':'♜','bB':'♝','bN':'♞','bP':'♟'
        }
        for r in range(8):
            for c in range(8):
                px, py = gx + c*cell, gy + r*cell
                light = (r+c) % 2 == 0
                set_source(cr, (0.045,0.115,0.052) if light else (0.006,0.030,0.014), 1)
                cr.rectangle(px, py, cell-1, cell-1); cr.fill()
                if self.app.chess_sel == (c, r):
                    set_source(cr, YELLOW, .92); cr.set_line_width(3)
                    cr.rectangle(px+3, py+3, cell-7, cell-7); cr.stroke()
                piece = board[r][c]
                if piece:
                    color = NEON2 if piece[0] == 'w' else (1.0,.36,.36)
                    set_source(cr, color, .18 + beat*.12)
                    cr.arc(px+cell/2, py+cell/2, cell*.40, 0, math.tau); cr.fill()
                    draw_text(cr, piece_map.get(piece, '?'), px+cell/2, py+9, 34, color, True, 'center')
        info_x = x + 648
        draw_text(cr, 'CHESS BOARD', info_x, y+92, 20, NEON2, True)
        draw_text(cr, f'TURN  {"WHITE" if self.app.chess_turn == "w" else "RED"}', info_x, y+142, 14, TEXT, True)
        draw_text(cr, f'CAPTURES WHITE {self.app.chess_score_w}', info_x, y+185, 10, NEON2, True)
        draw_text(cr, f'CAPTURES RED   {self.app.chess_score_b}', info_x, y+208, 10, (1.0,.42,.35), True)
        self.draw_match_mode_buttons(cr, 'chess', info_x, y+232)
        self.draw_game_reset_button(cr, info_x, y+268)
        draw_text(cr, 'FX A: royal aura rings', info_x, y+326, 9, DIM, True)
        draw_text(cr, 'FX B: lightning grid pulse', info_x, y+346, 9, DIM, True)
        draw_text(cr, 'Music rhythm glows the pieces', info_x, y+368, 9, DIM, True)
        for i in range(42):
            hh=(math.cos(i*.38+self.app.anim*4.6)+1)*52*beat + random.random()*72*beat+6
            draw_line(cr,info_x+i*5.2,y+625,info_x+i*5.2,y+625-hh,YELLOW if i%7==0 else (NEON2 if i%3==0 else NEON),2.2,.8)

    def draw_tetris(self, cr, x, y, w, h):
        self.frame(cr,x,y,w,h,'TETRIS BEAT FX GAME')
        draw_text(cr,'ARROWS MOVE  UP ROTATE  SPACE DROP',x+465,y+12,10,TEXT,True)
        pulse = 1.0 + self.app.level * 0.18
        cell=29
        gx=x+50
        gy=y+48
        board_w=TETRIS_COLS*cell
        board_h=TETRIS_ROWS*cell
        rounded(cr, gx-10, gy-10, board_w+20, board_h+20, 4)
        set_source(cr, (0,0,0), .95); cr.fill_preserve(); set_source(cr, NEON, .62 + .30*self.app.level); cr.set_line_width(1.5 + 2.0*self.app.level); cr.stroke()
        set_source(cr, NEON2, .08 + .12*self.app.level)
        cr.rectangle(gx-7, gy-7, board_w+14, board_h+14); cr.fill()
        for r in range(TETRIS_ROWS):
            for c in range(TETRIS_COLS):
                val=self.app.tetris_grid[r][c]
                if val:
                    set_source(cr, NEON2 if val == 2 else NEON, .98)
                    cr.rectangle(gx+c*cell+1, gy+r*cell+1, cell-2, cell-2); cr.fill()
                    set_source(cr, (1,1,1), .18); cr.rectangle(gx+c*cell+2, gy+r*cell+2, cell-6, 2); cr.fill()
                else:
                    set_source(cr, DIM, .22)
                    cr.rectangle(gx+c*cell, gy+r*cell, cell-2, cell-2); cr.stroke()
        for c,r in self.app.tetris_cells():
            if 0<=c<TETRIS_COLS and 0<=r<TETRIS_ROWS:
                set_source(cr, NEON, 1); cr.rectangle(gx+c*cell,gy+r*cell,cell-2,cell-2); cr.fill()
                set_source(cr, (1,1,1), .22); cr.rectangle(gx+c*cell+2,gy+r*cell+2,cell-6,2); cr.fill()
        draw_text(cr,'REAL TETRIS 10 x 20 BOARD',x+388,y+82,15,NEON2,True)
        draw_text(cr,f'SCORE {self.app.tetris_score}',x+390,y+136,19,NEON2,True)
        draw_text(cr,f'LEVEL {self.app.tetris_level}',x+390,y+182,19,TEXT,True)
        draw_text(cr,'NEXT',x+390,y+242,14,TEXT,True)
        nx, ny = x+390, y+274
        for pc, pr in self.app.tetris_shape_cells(self.app.tetris_next, 0):
            set_source(cr, NEON2, .95)
            cr.rectangle(nx+pc*30, ny+pr*30, 28, 28); cr.fill()
        for i in range(64):
            hh=(math.sin(i*.31+self.app.anim*6)+1)*80*self.app.level + random.random()*140*self.app.level+8
            draw_line(cr,x+585+i*5,y+620,x+585+i*5,y+620-hh,NEON2 if i%4==0 else NEON,3,.85)
        draw_text(cr,'MUSIC RHYTHM SPEED + BEAT PULSE',x+390,y+365,11,DIM,True)

    def draw_pacman(self, cr, x, y, w, h):
        self.frame(cr,x,y,w,h,'PACMAN BEAT FX GAME')
        draw_text(cr,'ARROWS MOVE  POWER COINS MAKE PACMAN THE PREDATOR  SIDE DOORS ARE PORTALS',x+335,y+12,9.5,TEXT,True)
        cell = 34
        gx = x + 16
        gy = y + 70
        maze = self.app.pac_maze
        pulse = 1.0 + 0.16 * self.app.level * abs(math.sin(self.app.anim * 9))
        frightened = self.app.pac_power_time > 0
        portal_y = self.app.pac_portal_y
        # portal door glow on both sides
        for side_x in [gx-16, gx+len(maze[0])*cell+4]:
            rounded(cr, side_x, gy+portal_y*cell+5, 12, cell-10, 6)
            set_source(cr, NEON2 if frightened else NEON, .75 + .20*self.app.level)
            cr.fill()
        for r, row in enumerate(maze):
            for c, ch in enumerate(row):
                px, py = gx+c*cell, gy+r*cell
                if ch == '#':
                    set_source(cr, (0.02,0.18,0.07), 1)
                    rounded(cr, px, py, cell-2, cell-2, 4); cr.fill_preserve(); set_source(cr, NEON, .62 + .25*self.app.level); cr.stroke()
                else:
                    set_source(cr, (0,0,0), .94)
                    cr.rectangle(px, py, cell-2, cell-2); cr.fill()
                    if (c,r) in self.app.pac_dots:
                        set_source(cr, YELLOW, .95)
                        cr.arc(px+cell/2, py+cell/2, 3.0 + self.app.level*2.2, 0, math.tau); cr.fill()
                    if (c,r) in self.app.pac_power_pellets:
                        flash = .55 + .45*abs(math.sin(self.app.anim*7))
                        set_source(cr, NEON2, flash)
                        cr.arc(px+cell/2, py+cell/2, 8.5 + self.app.level*4.0, 0, math.tau); cr.fill()
                        set_source(cr, YELLOW, .95)
                        cr.arc(px+cell/2, py+cell/2, 4.2 + self.app.level*2.0, 0, math.tau); cr.fill()
        # Pacman
        pcx, pcy = self.app.pac_x, self.app.pac_y
        mouth = 0.30 + 0.34*abs(math.sin(self.app.anim*11 + self.app.level*4))
        angles = {'Right':(mouth, math.tau-mouth), 'Left':(math.pi+mouth, math.pi-mouth), 'Up':(-math.pi/2+mouth, -math.pi/2-mouth), 'Down':(math.pi/2+mouth, math.pi/2-mouth)}
        a1,a2 = angles.get(self.app.pac_dir, angles['Right'])
        cx = gx+pcx*cell+cell/2; cy = gy+pcy*cell+cell/2
        set_source(cr, YELLOW if not frightened else NEON2, 1)
        cr.move_to(cx, cy); cr.arc(cx, cy, cell*.47*pulse, a1, a2); cr.close_path(); cr.fill()
        # ghosts
        ghost_cols = [NEON, NEON2, TEXT, (1.0,0.35,0.35)]
        for i,(gc,gr) in enumerate(self.app.ghosts):
            px = gx+gc*cell+cell/2; py = gy+gr*cell+cell/2
            if frightened:
                ghost_color = (0.16,0.45,1.0) if int(self.app.pac_power_time*4)%2 else (0.8,0.9,1.0)
            else:
                ghost_color = ghost_cols[i % len(ghost_cols)]
            set_source(cr, ghost_color, .95)
            cr.arc(px, py-4, cell*.39*pulse, math.pi, 0); cr.line_to(px+cell*.39, py+cell*.36); cr.line_to(px-cell*.39, py+cell*.36); cr.close_path(); cr.fill()
            set_source(cr, (0,0,0), .9)
            cr.arc(px-5, py-5, 2.3, 0, math.tau); cr.fill(); cr.arc(px+5, py-5, 2.3, 0, math.tau); cr.fill()
        info_x = x + 750
        draw_text(cr, f'SCORE {self.app.pac_score}', info_x, y+90, 15, NEON2, True)
        draw_text(cr, f'DOTS {len(self.app.pac_dots)}', info_x, y+130, 12, TEXT, True)
        draw_text(cr, f'POWER {self.app.pac_power_time:0.1f}s' if frightened else 'POWER READY', info_x, y+162, 10, YELLOW if frightened else DIM, True)
        draw_text(cr, 'PACMAN BEAT FX', info_x, y+220, 10, DIM, True)
        draw_text(cr, 'Bigger maze', info_x, y+243, 8, DIM, True)
        draw_text(cr, 'Power coins', info_x, y+260, 8, DIM, True)
        draw_text(cr, 'Side portals', info_x, y+277, 8, DIM, True)
        draw_text(cr, 'Music pulse', info_x, y+294, 8, DIM, True)
        for i in range(30):
            hh=(math.sin(i*.35+self.app.anim*6)+1)*58*self.app.level + random.random()*78*self.app.level+8
            draw_line(cr,info_x+i*5.2,y+620,info_x+i*5.2,y+620-hh,NEON2 if i%5==0 else NEON,2.4,.8)

    def draw_starship(self, cr, x, y, w, h):
        self.frame(cr,x,y,w,h,'VOID STARSHIP VOYAGE GAME')
        draw_text(cr,'ARROWS STEER  SPACE BOOST',x+560,y+12,9,TEXT,True)
        cx,cy=x+w/2,y+h/2+16
        for sx,sy,sz in self.app.stars:
            px=cx+sx/max(sz,.1)*360; py=cy+sy/max(sz,.1)*150
            px2=cx+sx/max(sz+.10,.1)*360; py2=cy+sy/max(sz+.10,.1)*150
            draw_line(cr,px2,py2,px,py,NEON2 if sz<.5 else NEON,1.7,.9)
        shipx=cx+self.app.ship_x*260; shipy=cy+self.app.ship_y*120
        set_source(cr,NEON,1); cr.set_line_width(3)
        cr.move_to(shipx,shipy-26); cr.line_to(shipx-34,shipy+28); cr.line_to(shipx,shipy+13); cr.line_to(shipx+34,shipy+28); cr.close_path(); cr.stroke()
    def on_button(self, widget, ev):
        self.grab_focus(); x=ev.x/(self.get_allocated_width()/960.0); y=ev.y/(self.get_allocated_height()/880.0)
        if ev.button==3:
            self.app.show_context(ev); return True
        if self.app.fx_mode in ('checkers', 'chess') and y < 790:
            if self.app.handle_fx_click(self.app.fx_mode, x, y):
                self.queue_draw(); return True
        for key,text,bx,by,bw,bh in self.buttons:
            if bx<=x<=bx+bw and by<=y<=by+bh:
                if key=='close':
                    if self.app.fx_window: self.app.fx_window.destroy()
                else:
                    self.app.set_fx_mode(key)
                self.queue_draw(); return True
        return True
    def on_key(self, widget, ev):
        key=Gdk.keyval_name(ev.keyval)
        if self.app.handle_game_key(key):
            self.queue_draw(); return True
        return False

class FXWindow(Gtk.Window):
    def __init__(self, app):
        super().__init__(title='Govibe Nexus FX Window')
        self.app = app
        self.set_default_size(1060, 980)
        self.set_position(Gtk.WindowPosition.CENTER)
        self.surface = FXSurface(app)
        self.add(self.surface)
        self.connect('delete-event', self.on_delete)
        self.connect('destroy', self.on_destroy)
        self.connect('key-press-event', self.surface.on_key)
    def queue_refresh(self):
        self.surface.queue_draw()
    def on_delete(self, *a):
        self.app.fx_window = None
        return False
    def on_destroy(self, *a):
        self.app.fx_window = None


class DetachedPlaylistSurface(Gtk.DrawingArea):
    def __init__(self, app):
        super().__init__()
        self.app = app
        self.set_size_request(900, 330)
        self.set_can_focus(True)
        self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK | Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.SCROLL_MASK | Gdk.EventMask.KEY_PRESS_MASK)
        self.connect('draw', self.on_draw)
        self.connect('button-press-event', self.on_button_press)
        self.connect('button-release-event', self.on_button_release)
        self.connect('motion-notify-event', self.on_motion)
        self.connect('scroll-event', self.on_scroll)
        self.connect('key-press-event', self.on_key)
        self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
        self.drag_dest_add_uri_targets()
        self.connect('drag-data-received', self.on_drop)
        self.scroll = 0
        self.drag_start = None
        self.dragging_rows = False
        self.buttons = [
            ('add','ADD',18,292,58,28),
            ('remove','REMOVE',84,292,82,28),
            ('clear','CLEAR',174,292,72,28),
            ('save','SAVE',254,292,62,28),
            ('load','LOAD',324,292,62,28),
            ('hide_embedded','HIDE EMBEDDED',396,292,138,28),
            ('close','CLOSE',812,292,64,28),
        ]

    def sx(self):
        return self.get_allocated_width() / 900.0

    def sy(self):
        return self.get_allocated_height() / 330.0

    def to_design(self, x, y):
        return x / self.sx(), y / self.sy()

    def visible_rows(self):
        return max(1, int((270 - 64) // 18))

    def row_at(self, y):
        row_y = 64
        row_h = 18
        if row_y <= y <= 270:
            idx = int((y - row_y) // row_h) + self.scroll
            if 0 <= idx < len(self.app.playlist):
                return idx
        return None

    def hit_button(self, x, y):
        for key, text, bx, by, bw, bh in self.buttons:
            if key == 'hide_embedded':
                text = 'HIDE EMBEDDED' if self.app.show_list else 'SHOW EMBEDDED'
            if bx <= x <= bx + bw and by <= y <= by + bh:
                return key
        return None

    def draw_button(self, cr, key, text, x, y, w, h):
        if key == 'hide_embedded':
            text = 'HIDE EMBEDDED' if self.app.show_list else 'SHOW EMBEDDED'
        set_source(cr, PANEL2, .98)
        rounded(cr, x, y, w, h, 4)
        cr.fill_preserve()
        set_source(cr, NEON, .95)
        cr.set_line_width(1)
        cr.stroke()
        draw_text(cr, text, x + w/2, y + 8, 8.5, TEXT, True, 'center')

    def on_draw(self, widget, cr):
        w, h = self.get_allocated_width(), self.get_allocated_height()
        cr.save()
        cr.scale(w/900.0, h/330.0)
        set_source(cr, (0.005,0.008,0.006), 1)
        cr.rectangle(0, 0, 900, 330)
        cr.fill()
        set_source(cr, NEON, .45)
        cr.rectangle(.5, .5, 899, 329)
        cr.set_line_width(1)
        cr.stroke()
        draw_text(cr, 'GOVIBE NEXUS AUDIO PLAYER  DETACHED PLAYLIST', 14, 14, 10, NEON, True)
        draw_line(cr, 12, 34, 888, 34, NEON, 2)
        draw_line(cr, 12, 35, 300, 35, NEON2, 1)
        # Header
        rounded(cr, 18, 42, 864, 20, 2)
        set_source(cr, PANEL2, .92)
        cr.fill_preserve()
        set_source(cr, BORDER, .8)
        cr.stroke()
        draw_text(cr, '#', 30, 47, 8, TEXT, True)
        draw_text(cr, 'TITLE', 82, 47, 8, TEXT, True)
        draw_text(cr, 'BITRATE / HZ', 650, 47, 8, TEXT, True)
        draw_text(cr, f'{len(self.app.playlist)} tracks', 870, 47, 8, TEXT, False, 'right')
        # List area
        rounded(cr, 18, 64, 864, 208, 2)
        set_source(cr, (0,0,0), .96)
        cr.fill_preserve()
        set_source(cr, BORDER, .75)
        cr.stroke()
        if not self.app.playlist:
            draw_text(cr, 'Drag and drop audio files here', 450, 158, 10, DIM, True, 'center')
        else:
            max_start = max(0, len(self.app.playlist) - self.visible_rows())
            self.scroll = max(0, min(self.scroll, max_start))
            y = 68
            for idx in range(self.scroll, min(len(self.app.playlist), self.scroll + self.visible_rows())):
                path = self.app.playlist[idx]
                selected = idx in getattr(self.app, 'selected_indices', set()) or idx == self.app.selected_index
                playing = idx == self.app.current_index and self.app.playing
                if playing:
                    set_source(cr, (0.02,0.22,0.08), .95)
                    cr.rectangle(22, y - 2, 856, 18)
                    cr.fill()
                if selected:
                    set_source(cr, NEON2, .95)
                    cr.set_line_width(1)
                    cr.rectangle(22.5, y - 2.5, 855, 19)
                    cr.stroke()
                col = NEON2 if playing else (NEON if selected else TEXT)
                draw_text(cr, f'{idx+1}.', 30, y, 9, col, True)
                draw_text(cr, Path(path).name, 82, y, 9, col, True, width=540)
                br, hz = self.app.get_audio_info(path)
                draw_text(cr, f'{br} / {hz}', 650, y, 9, DIM, False)
                y += 18
        for key, text, x, y, bw, bh in self.buttons:
            self.draw_button(cr, key, text, x, y, bw, bh)
        cr.restore()
        return False

    def on_button_press(self, widget, ev):
        self.grab_focus()
        x, y = self.to_design(ev.x, ev.y)
        if ev.button == 3:
            self.app.show_playlist_context(ev)
            return True
        key = self.hit_button(x, y)
        if key:
            if key == 'add':
                self.app.choose_files()
            elif key == 'remove':
                self.app.remove_selected()
            elif key == 'clear':
                self.app.clear_playlist()
            elif key == 'save':
                self.app.save_playlist()
            elif key == 'load':
                self.app.load_playlist()
            elif key == 'hide_embedded':
                self.app.toggle_embedded_playlist()
            elif key == 'close' and self.app.playlist_window:
                self.app.playlist_window.destroy()
            self.queue_draw()
            return True
        idx = self.row_at(y)
        if idx is not None:
            ctrl = bool(ev.state & Gdk.ModifierType.CONTROL_MASK)
            shift = bool(ev.state & Gdk.ModifierType.SHIFT_MASK)
            if ev.type == Gdk.EventType._2BUTTON_PRESS:
                self.app.select_playlist_index(idx, redraw=False)
                self.app.play_index(idx)
            elif ctrl:
                self.app.toggle_playlist_index(idx)
            elif shift and getattr(self.app, 'selected_index', -1) >= 0:
                self.app.select_playlist_range(self.app.selected_index, idx)
            else:
                self.app.select_playlist_index(idx, redraw=False)
                self.drag_start = idx
                self.dragging_rows = True
                if self.app.surface:
                    self.app.surface.queue_draw()
                self.queue_draw()
            return True
        return True

    def on_motion(self, widget, ev):
        if self.dragging_rows and self.drag_start is not None:
            x, y = self.to_design(ev.x, ev.y)
            idx = self.row_at(y)
            if idx is not None:
                self.app.select_playlist_range(self.drag_start, idx)
                if self.app.surface:
                    self.app.surface.queue_draw()
                self.queue_draw()
        return True

    def on_button_release(self, widget, ev):
        self.dragging_rows = False
        self.drag_start = None
        return True

    def on_scroll(self, widget, ev):
        max_start = max(0, len(self.app.playlist) - self.visible_rows())
        if ev.direction == Gdk.ScrollDirection.DOWN:
            self.scroll = min(max_start, self.scroll + 1)
        elif ev.direction == Gdk.ScrollDirection.UP:
            self.scroll = max(0, self.scroll - 1)
        self.queue_draw()
        return True

    def on_key(self, widget, ev):
        key = Gdk.keyval_name(ev.keyval)
        if key == 'Delete':
            self.app.remove_selected()
            return True
        if key in ('Return', 'KP_Enter') and 0 <= self.app.selected_index < len(self.app.playlist):
            self.app.play_index(self.app.selected_index)
            return True
        if key == 'a' and ev.state & Gdk.ModifierType.CONTROL_MASK:
            self.app.selected_indices = set(range(len(self.app.playlist)))
            self.app.selected_index = len(self.app.playlist) - 1 if self.app.playlist else -1
            if self.app.surface:
                self.app.surface.queue_draw()
            self.queue_draw()
            return True
        return False

    def on_drop(self, widget, context, x, y, data, info, time):
        uris = data.get_uris()
        files = []
        for uri in uris:
            p = urllib.parse.urlparse(uri)
            if p.scheme == 'file':
                path = urllib.parse.unquote(p.path)
                if os.path.isdir(path):
                    for root, dirs, names in os.walk(path):
                        for n in sorted(names):
                            if Path(n).suffix.lower() in AUDIO_EXTS:
                                files.append(os.path.join(root, n))
                elif Path(path).suffix.lower() in AUDIO_EXTS:
                    files.append(path)
        if files:
            old_len = len(self.app.playlist)
            self.app.add_files(files)
            self.app.selected_indices = set(range(old_len, len(self.app.playlist)))
            self.app.selected_index = len(self.app.playlist) - 1 if len(self.app.playlist) > old_len else self.app.selected_index
            if self.app.surface:
                self.app.surface.queue_draw()
            self.queue_draw()
        Gtk.drag_finish(context, True, False, time)

class PlaylistWindow(Gtk.Window):
    def __init__(self, app):
        super().__init__(title='Govibe Nexus Playlist')
        self.app = app
        self.set_default_size(920, 360)
        self.set_position(Gtk.WindowPosition.CENTER)
        self.surface = DetachedPlaylistSurface(app)
        self.add(self.surface)
        self.connect('delete-event', self.on_delete)
        self.connect('destroy', self.on_destroy)
        self.connect('key-press-event', self.surface.on_key)
    def refresh(self):
        if self.surface:
            self.surface.queue_draw()
    def refresh_controls(self):
        if self.surface:
            self.surface.queue_draw()
    def on_delete(self, *a):
        self.app.playlist_window = None
        return False
    def on_destroy(self, *a):
        self.app.playlist_window = None


class NexusPlayer(Gtk.Application):
    def __init__(self):
        super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
        self.window = None
        self.surface = None
        self.playlist_window = None
        self.fx_window = None
        self.playlist = []
        self.info_cache = {}
        self.current_index = -1
        self.selected_index = -1
        self.selected_indices = set()
        self.player = None
        self.mpv_proc = None
        self.mpv_socket = None
        self.mpv_started = False
        self.playing = False
        self.volume = .80
        self.show_vis = True
        self.show_eq = True
        self.show_list = True
        self.eq = [0.0]*11
        self.fx_mode = 'spectrum'
        self.theme_name = 'Govibe Nexus Green'
        self.shuffle = False
        self.repeat = False
        self.anim = 0.0
        self.stars = [(random.uniform(-1,1), random.uniform(-1,1), random.uniform(.15,1.2)) for _ in range(120)]
        self.ship_x = 0.0
        self.ship_y = 0.0
        self.tetris_grid = [[0 for _ in range(TETRIS_COLS)] for _ in range(TETRIS_ROWS)]
        self.tetris_piece = 'I'
        self.tetris_x = 3
        self.tetris_y = 0
        self.tetris_rot = 0
        self.tetris_next = 'T'
        self.tetris_score = 0
        self.tetris_level = 1
        self.tetris_drop_acc = 0.0
        self.pac_maze = self.make_pac_maze()
        self.pac_x = 1
        self.pac_y = 1
        self.pac_dir = 'Right'
        self.pac_next_dir = 'Right'
        self.pac_dots = set()
        self.pac_power_pellets = set()
        self.pac_power_time = 0.0
        self.pac_portal_y = 8
        self.ghosts = [(19,1),(19,15),(10,8),(1,15)]
        self.ghost_tick = 0.0
        self.pac_score = 0
        self.reset_pacman()
        self.checkers_board = self.make_checkers_board()
        self.checkers_turn = 'w'
        self.checkers_sel = None
        self.checkers_score_w = 0
        self.checkers_score_b = 0
        self.checkers_vs_ai = False
        self.checkers_ai_pending = False
        self.chess_board = self.make_chess_board()
        self.chess_turn = 'w'
        self.chess_sel = None
        self.chess_score_w = 0
        self.chess_score_b = 0
        self.chess_vs_ai = False
        self.chess_ai_pending = False
        self.level = .08
        self.duration_ns = 0
        self.position_ns = 0
        self.bitrate = 'unknown'
        self.hz = 'unknown'
        self.status_icon = None
        self.load_settings()

    def do_activate(self):
        if self.window:
            self.window.present()
            return
        self.window = Gtk.ApplicationWindow(application=self, title=APP_NAME)
        self.window.set_default_size(820, 632)
        self.window.set_resizable(True)
        self.window.set_position(Gtk.WindowPosition.CENTER)
        self.window.connect('delete-event', self.on_close)
        self.window.connect('key-press-event', self.on_key)
        self.surface = NexusSurface(self)
        self.window.add(self.surface)
        self.apply_layout_size()
        self.init_player()
        self.setup_tray()
        self.window.show_all()
        GLib.timeout_add(33, self.tick)

    def do_command_line(self, command_line):
        self.activate()
        args = list(command_line.get_arguments())[1:]
        if args:
            old_len = len(self.playlist)
            self.add_files(args)
            if len(self.playlist) > old_len:
                self.select_playlist_index(old_len)
                self.play_index(old_len)
        return 0

    def init_player(self):
        # MPV is the primary native Linux playback backend because it is very reliable
        # on Ubuntu/PipeWire/PulseAudio and supports MP3, M4A, FLAC, OGG, WAV, WMA, MIDI fallback, etc.
        self.player = Gst.ElementFactory.make('playbin', 'player')
        if self.player:
            sink = Gst.ElementFactory.make('autoaudiosink', 'audio-output')
            if sink:
                self.player.set_property('audio-sink', sink)
            self.player.set_property('volume', self.volume)
            bus = self.player.get_bus()
            bus.add_signal_watch()
            bus.connect('message', self.on_bus_message)
        self.start_mpv_backend()

    def start_mpv_backend(self, file_path=None):
        # Native audio playback path: launch mpv directly with the selected file.
        # This avoids silent IPC-only failures and makes MP3 playback work immediately.
        if shutil.which('mpv') is None:
            self.mpv_started = False
            return False
        if file_path is None and self.mpv_proc and self.mpv_proc.poll() is None:
            return True
        sock = os.path.join(tempfile.gettempdir(), f'govibe-nexus-mpv-{os.getpid()}.sock')
        try:
            if os.path.exists(sock):
                os.unlink(sock)
        except Exception:
            pass
        self.mpv_socket = sock
        cmd = ['mpv', '--no-video', '--really-quiet', '--force-window=no', '--input-terminal=no',
               '--audio-display=no', '--keep-open=no', f'--input-ipc-server={sock}',
               f'--volume={int(self.volume*100)}']
        af = self.mpv_af_arg()
        if af:
            cmd.append('--af=' + af)
        if file_path is not None:
            cmd.append(str(file_path))
        else:
            cmd.insert(1, '--idle=yes')
        try:
            if file_path is not None and self.mpv_proc and self.mpv_proc.poll() is None:
                try:
                    self.mpv_proc.terminate()
                    self.mpv_proc.wait(timeout=0.5)
                except Exception:
                    try: self.mpv_proc.kill()
                    except Exception: pass
            self.mpv_proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
            self.mpv_started = True
            # Socket may appear after playback starts. Do not treat missing socket as playback failure.
            for _ in range(40):
                if os.path.exists(sock):
                    break
                if self.mpv_proc.poll() is not None:
                    break
                time.sleep(0.025)
            return self.mpv_proc.poll() is None
        except Exception as e:
            self.mpv_started = False
            print('MPV backend unavailable:', e)
        return False

    def mpv_cmd(self, command, expect=False):
        if not self.start_mpv_backend() or not self.mpv_socket:
            return None
        payload = json.dumps({'command': command}) + '\n'
        try:
            if not self.mpv_socket or not os.path.exists(self.mpv_socket):
                return None
            with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
                s.settimeout(0.35)
                s.connect(self.mpv_socket)
                s.sendall(payload.encode('utf-8'))
                if expect:
                    data = s.recv(4096).decode('utf-8', 'ignore')
                    if data.strip():
                        return json.loads(data.splitlines()[0])
                return {'error':'success'}
        except Exception:
            return None

    def mpv_get(self, prop):
        res = self.mpv_cmd(['get_property', prop], expect=True)
        if isinstance(res, dict) and res.get('error') == 'success':
            return res.get('data')
        return None

    def on_bus_message(self, bus, msg):
        t = msg.type
        if t == Gst.MessageType.EOS:
            if self.repeat:
                self.seek_ratio(0)
                self.play()
            else:
                self.next_track()
        elif t == Gst.MessageType.ERROR:
            err, dbg = msg.parse_error()
            self.playing = False
            self.notify_simple('Playback error', str(err))
            self.surface.queue_draw()

    def load_settings(self):
        CFG_DIR.mkdir(parents=True, exist_ok=True)
        if CFG_FILE.exists():
            try:
                data = json.loads(CFG_FILE.read_text())
                self.volume = float(data.get('volume', self.volume))
                self.show_vis = bool(data.get('show_vis', True))
                self.show_eq = bool(data.get('show_eq', True))
                self.show_list = bool(data.get('show_list', True))
                self.eq = list(data.get('eq', self.eq))[:11]
                while len(self.eq)<11: self.eq.append(0.0)
                self.fx_mode = data.get('fx_mode', self.fx_mode)
                if self.fx_mode in ('milkdrop', 'vortex', 'aurora'):
                    self.fx_mode = 'checkers'
                self.theme_name = data.get('theme_name', self.theme_name)
                self.checkers_vs_ai = bool(data.get('checkers_vs_ai', self.checkers_vs_ai))
                self.chess_vs_ai = bool(data.get('chess_vs_ai', self.chess_vs_ai))
            except Exception:
                pass
        apply_theme_values(self.theme_name)
        if PLAYLIST_FILE.exists():
            try:
                self.playlist = [x.strip() for x in PLAYLIST_FILE.read_text(errors='ignore').splitlines() if x.strip() and not x.startswith('#')]
            except Exception:
                pass
    def save_settings(self):
        CFG_DIR.mkdir(parents=True, exist_ok=True)
        CFG_FILE.write_text(json.dumps({'volume': self.volume, 'show_vis': self.show_vis, 'show_eq': self.show_eq, 'show_list': self.show_list, 'eq': self.eq, 'fx_mode': self.fx_mode, 'theme_name': self.theme_name, 'checkers_vs_ai': self.checkers_vs_ai, 'chess_vs_ai': self.chess_vs_ai}, indent=2))
        self.save_playlist(str(PLAYLIST_FILE), silent=True)

    def setup_tray(self):
        self.indicator = None
        if AppIndicator is not None:
            try:
                self.indicator = AppIndicator.Indicator.new(APP_ID, 'govibe-nexus-audio-player', AppIndicator.IndicatorCategory.APPLICATION_STATUS)
                self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
                tray_menu = self.make_menu(tray=True)
                tray_menu.show_all()
                self.indicator.set_menu(tray_menu)
                return
            except Exception:
                self.indicator = None
        # Fedora/GNOME: avoid deprecated Gtk.StatusIcon fallback because it causes terminal warnings.
        self.status_icon = None
    def tray_activate(self, icon):
        if self.window.get_visible(): self.window.hide()
        else:
            self.window.show_all(); self.window.present()
    def tray_popup(self, icon, button, activate_time):
        menu = self.make_menu(tray=True)
        menu.show_all()
        menu.popup(None, None, Gtk.StatusIcon.position_menu, icon, button, activate_time)

    def on_close(self, *a):
        self.quit_app()
        return True
    def on_key(self, win, ev):
        return self.surface.on_key(self.surface, ev)

    def tick(self):
        self.anim += .033
        # query position from MPV first, GStreamer fallback
        if self.mpv_started and self.mpv_proc and self.mpv_proc.poll() is None:
            pos = self.mpv_get('time-pos')
            dur = self.mpv_get('duration')
            paused = self.mpv_get('pause')
            if isinstance(pos, (int, float)):
                self.position_ns = int(float(pos) * 1000000000)
            if isinstance(dur, (int, float)) and dur > 0:
                self.duration_ns = int(float(dur) * 1000000000)
            if isinstance(paused, bool):
                self.playing = not paused and bool(self.current_title())
        elif self.player:
            ok, pos = self.player.query_position(Gst.Format.TIME)
            if ok: self.position_ns = pos
            ok, dur = self.player.query_duration(Gst.Format.TIME)
            if ok and dur > 0: self.duration_ns = dur
        if (self.bitrate == 'unknown' or self.hz == 'unknown') and self.playing:
            sr = self.mpv_get('audio-params/samplerate') if self.mpv_started else None
            br = self.mpv_get('audio-bitrate') if self.mpv_started else None
            if isinstance(sr, (int, float)) and sr > 0:
                self.hz = str(int(sr))
            if isinstance(br, (int, float)) and br > 0:
                self.bitrate = f'{int(br/1000)} kbps'
        # Beat/rhythm driver for FX windows. This follows playback position when a song is playing,
        # so visualizers and games pulse with the music timeline instead of staying static.
        pos_sec = self.position_ns / 1000000000.0 if self.position_ns else self.anim
        if self.playing:
            beat = abs(math.sin(pos_sec * math.tau * 1.35))
            bass = abs(math.sin(pos_sec * math.tau * 0.68 + 0.9))
            hats = abs(math.sin(pos_sec * math.tau * 4.8 + 1.4)) * 0.35
            self.level = max(.10, min(1.0, .18 + beat*.38 + bass*.22 + hats + random.random()*.06))
        else:
            self.level = max(.05, min(.18, .08 + .04*math.sin(self.anim*2.0)))
        self.update_starship()
        self.update_tetris(.033)
        self.update_pacman(.033)
        self.update_checkers_fx(.033)
        self.update_chess_fx(.033)
        if self.surface: self.surface.queue_draw()
        if self.fx_window: self.fx_window.queue_refresh()
        return True

    def update_starship(self):
        speed = .010 + .035*self.level
        if self.fx_mode == 'starship':
            speed *= 1.8
        new = []
        for sx, sy, sz in self.stars:
            sz -= speed
            if sz <= .08:
                sx, sy, sz = random.uniform(-1,1), random.uniform(-1,1), random.uniform(.85,1.3)
            new.append((sx, sy, sz))
        self.stars = new

    def tetris_shape_cells(self, piece, rot):
        shapes = {
            'I': [[(0,1),(1,1),(2,1),(3,1)], [(2,0),(2,1),(2,2),(2,3)]],
            'O': [[(1,0),(2,0),(1,1),(2,1)]],
            'T': [[(1,0),(0,1),(1,1),(2,1)], [(1,0),(1,1),(2,1),(1,2)], [(0,1),(1,1),(2,1),(1,2)], [(1,0),(0,1),(1,1),(1,2)]],
            'L': [[(0,0),(0,1),(1,1),(2,1)], [(1,0),(2,0),(1,1),(1,2)], [(0,1),(1,1),(2,1),(2,2)], [(1,0),(1,1),(0,2),(1,2)]],
            'S': [[(1,0),(2,0),(0,1),(1,1)], [(1,0),(1,1),(2,1),(2,2)]]
        }
        arr = shapes.get(piece, shapes['I'])
        return arr[rot % len(arr)]

    def tetris_cells(self):
        return [(self.tetris_x+c, self.tetris_y+r) for c, r in self.tetris_shape_cells(self.tetris_piece, self.tetris_rot)]

    def tetris_collide(self, dx=0, dy=0, drot=0):
        oldrot = self.tetris_rot + drot
        for c, r in [(self.tetris_x+c+dx, self.tetris_y+r+dy) for c, r in self.tetris_shape_cells(self.tetris_piece, oldrot)]:
            if c < 0 or c >= TETRIS_COLS or r >= TETRIS_ROWS:
                return True
            if r >= 0 and self.tetris_grid[r][c]:
                return True
        return False

    def tetris_spawn(self):
        self.tetris_piece = getattr(self, 'tetris_next', random.choice(['I','O','T','L','S']))
        self.tetris_next = random.choice(['I','O','T','L','S'])
        self.tetris_x = 3
        self.tetris_y = 0
        self.tetris_rot = 0
        if self.tetris_collide():
            self.tetris_grid = [[0 for _ in range(TETRIS_COLS)] for _ in range(TETRIS_ROWS)]
            self.tetris_score = 0
            self.tetris_level = 1

    def tetris_lock(self):
        for c, r in self.tetris_cells():
            if 0 <= c < TETRIS_COLS and 0 <= r < TETRIS_ROWS:
                self.tetris_grid[r][c] = 1
        newgrid = [row for row in self.tetris_grid if not all(row)]
        cleared = TETRIS_ROWS - len(newgrid)
        while len(newgrid) < TETRIS_ROWS:
            newgrid.insert(0, [0 for _ in range(TETRIS_COLS)])
        self.tetris_grid = newgrid
        if cleared:
            self.tetris_score += cleared * 100
            self.tetris_level = 1 + self.tetris_score // 700
        self.tetris_spawn()

    def update_tetris(self, dt):
        if self.fx_mode != 'tetris':
            return
        self.tetris_drop_acc += dt * (1 + self.level * 1.75)
        delay = max(.18, .82 - self.tetris_level*.025 - self.level*.10)
        if self.tetris_drop_acc >= delay:
            self.tetris_drop_acc = 0
            if not self.tetris_collide(dy=1):
                self.tetris_y += 1
            else:
                self.tetris_lock()

    def handle_game_key(self, key):
        if self.fx_mode == 'tetris':
            if key == 'Left' and not self.tetris_collide(dx=-1): self.tetris_x -= 1; return True
            if key == 'Right' and not self.tetris_collide(dx=1): self.tetris_x += 1; return True
            if key == 'Down' and not self.tetris_collide(dy=1): self.tetris_y += 1; return True
            if key == 'Up' and not self.tetris_collide(drot=1): self.tetris_rot += 1; return True
            if key == 'space':
                while not self.tetris_collide(dy=1): self.tetris_y += 1
                self.tetris_lock(); return True
        if self.fx_mode == 'pacman':
            if key in ('Left','Right','Up','Down'):
                self.pac_next_dir = key
                return True
        if self.fx_mode == 'checkers':
            if key in ('r','R'):
                self.reset_checkers_game(); return True
        if self.fx_mode == 'chess':
            if key in ('r','R'):
                self.reset_chess_game(); return True
        if self.fx_mode == 'starship':
            if key == 'Left': self.ship_x = max(-1, self.ship_x-.08); return True
            if key == 'Right': self.ship_x = min(1, self.ship_x+.08); return True
            if key == 'Up': self.ship_y = max(-1, self.ship_y-.10); return True
            if key == 'Down': self.ship_y = min(1, self.ship_y+.10); return True
            if key == 'space':
                self.level = min(1, self.level+.35); return True
        return False

    def make_checkers_board(self):
        board = [[None for _ in range(10)] for _ in range(10)]
        for r in range(4):
            for c in range(10):
                if (r+c) % 2 == 1:
                    board[r][c] = 'b'
        for r in range(6,10):
            for c in range(10):
                if (r+c) % 2 == 1:
                    board[r][c] = 'w'
        return board

    def make_chess_board(self):
        return [
            ['bR','bN','bB','bQ','bK','bB','bN','bR'],
            ['bP','bP','bP','bP','bP','bP','bP','bP'],
            [None]*8,
            [None]*8,
            [None]*8,
            [None]*8,
            ['wP','wP','wP','wP','wP','wP','wP','wP'],
            ['wR','wN','wB','wQ','wK','wB','wN','wR'],
        ]

    def reset_checkers_game(self):
        self.checkers_board = self.make_checkers_board()
        self.checkers_turn = 'w'
        self.checkers_sel = None
        self.checkers_score_w = 0
        self.checkers_score_b = 0
        self.checkers_ai_pending = False

    def reset_chess_game(self):
        self.chess_board = self.make_chess_board()
        self.chess_turn = 'w'
        self.chess_sel = None
        self.chess_score_w = 0
        self.chess_score_b = 0
        self.chess_ai_pending = False

    def handle_fx_click(self, mode, x, y):
        # Shared 1v1 / 1vAI buttons drawn on the right side of Checkers and Chess.
        if mode in ('checkers', 'chess'):
            bx = 20 + 642
            by = 82 + 232
            reset_y = 82 + 268
            if by <= y <= by + 28:
                if bx <= x <= bx + 118:
                    if mode == 'checkers':
                        self.checkers_vs_ai = False; self.checkers_ai_pending = False
                    else:
                        self.chess_vs_ai = False; self.chess_ai_pending = False
                    self.save_settings(); return True
                if bx + 126 <= x <= bx + 244:
                    if mode == 'checkers':
                        self.checkers_vs_ai = True
                        self.save_settings()
                        if self.checkers_turn == 'b': self.schedule_checkers_ai()
                    else:
                        self.chess_vs_ai = True
                        self.save_settings()
                        if self.chess_turn == 'b': self.schedule_chess_ai()
                    return True
            if reset_y <= y <= reset_y + 28 and bx <= x <= bx + 244:
                if mode == 'checkers':
                    self.reset_checkers_game()
                else:
                    self.reset_chess_game()
                return True
        if mode == 'checkers':
            cell = 56; gx = 20 + 42; gy = 82 + 58
            c = int((x - gx) // cell); r = int((y - gy) // cell)
            if 0 <= c < 10 and 0 <= r < 10:
                return self.checkers_click(c, r)
        if mode == 'chess':
            cell = 68; gx = 20 + 42; gy = 82 + 74
            c = int((x - gx) // cell); r = int((y - gy) // cell)
            if 0 <= c < 8 and 0 <= r < 8:
                return self.chess_click(c, r)
        return False

    def update_checkers_fx(self, dt):
        return

    def update_chess_fx(self, dt):
        return

    def schedule_checkers_ai(self):
        if not self.checkers_vs_ai or self.checkers_turn != 'b' or self.checkers_ai_pending:
            return
        self.checkers_ai_pending = True
        GLib.timeout_add(420, self.run_checkers_ai)

    def run_checkers_ai(self):
        self.checkers_ai_pending = False
        if not self.checkers_vs_ai or self.checkers_turn != 'b':
            return False
        moves = self.checkers_legal_moves('b')
        if not moves:
            return False
        captures = [m for m in moves if m[4] is not None]
        sc, sr, c, r, captured = random.choice(captures or moves)
        self.checkers_apply_move(sc, sr, c, r, captured)
        if self.fx_window:
            self.fx_window.queue_refresh()
        if self.surface:
            self.surface.queue_draw()
        return False

    def checkers_legal_moves(self, color):
        moves = []
        for sr in range(10):
            for sc in range(10):
                piece = self.checkers_board[sr][sc]
                if not piece or piece[0] != color:
                    continue
                for dx, dy in self.checkers_dirs(piece):
                    c, r = sc + dx, sr + dy
                    if 0 <= c < 10 and 0 <= r < 10 and self.checkers_board[r][c] is None and (r+c) % 2 == 1:
                        moves.append((sc, sr, c, r, None))
                    c2, r2 = sc + dx*2, sr + dy*2
                    if 0 <= c2 < 10 and 0 <= r2 < 10 and self.checkers_board[r2][c2] is None and (r2+c2) % 2 == 1:
                        mid = (sc+dx, sr+dy)
                        mp = self.checkers_board[mid[1]][mid[0]]
                        if mp and mp[0] != color:
                            moves.append((sc, sr, c2, r2, mid))
        return moves

    def checkers_apply_move(self, sc, sr, c, r, captured=None):
        sel_piece = self.checkers_board[sr][sc]
        if not sel_piece:
            return False
        self.checkers_board[sr][sc] = None
        if captured:
            self.checkers_board[captured[1]][captured[0]] = None
            if sel_piece[0] == 'w': self.checkers_score_w += 1
            else: self.checkers_score_b += 1
        if (sel_piece == 'w' and r == 0) or (sel_piece == 'b' and r == 9):
            sel_piece = sel_piece[0] + 'K'
        self.checkers_board[r][c] = sel_piece
        self.checkers_turn = 'b' if self.checkers_turn == 'w' else 'w'
        return True

    def schedule_chess_ai(self):
        if not self.chess_vs_ai or self.chess_turn != 'b' or self.chess_ai_pending:
            return
        self.chess_ai_pending = True
        GLib.timeout_add(520, self.run_chess_ai)

    def run_chess_ai(self):
        self.chess_ai_pending = False
        if not self.chess_vs_ai or self.chess_turn != 'b':
            return False
        moves = self.chess_legal_moves('b')
        if not moves:
            return False
        captures = [m for m in moves if self.chess_board[m[3]][m[2]] is not None]
        # Prefer captures, then central moves, to feel less random.
        pool = captures or moves
        pool.sort(key=lambda m: -(4-abs(3.5-m[2]) + 4-abs(3.5-m[3]) + (10 if self.chess_board[m[3]][m[2]] else 0)))
        top = pool[:max(1, min(6, len(pool)))]
        sc, sr, c, r = random.choice(top)
        self.chess_apply_move(sc, sr, c, r)
        if self.fx_window:
            self.fx_window.queue_refresh()
        if self.surface:
            self.surface.queue_draw()
        return False

    def chess_legal_moves(self, color):
        moves = []
        for sr in range(8):
            for sc in range(8):
                piece = self.chess_board[sr][sc]
                if not piece or piece[0] != color:
                    continue
                for r in range(8):
                    for c in range(8):
                        if self.chess_legal_move(sc, sr, c, r, piece):
                            moves.append((sc, sr, c, r))
        return moves

    def chess_apply_move(self, sc, sr, c, r):
        sel_piece = self.chess_board[sr][sc]
        if not sel_piece or not self.chess_legal_move(sc, sr, c, r, sel_piece):
            return False
        target = self.chess_board[r][c]
        if target:
            if sel_piece[0] == 'w': self.chess_score_w += 1
            else: self.chess_score_b += 1
        if sel_piece[1] == 'P' and ((sel_piece[0] == 'w' and r == 0) or (sel_piece[0] == 'b' and r == 7)):
            sel_piece = sel_piece[0] + 'Q'
        self.chess_board[r][c] = sel_piece
        self.chess_board[sr][sc] = None
        self.chess_turn = 'b' if self.chess_turn == 'w' else 'w'
        return True

    def checkers_dirs(self, piece):
        if not piece:
            return []
        if len(piece) > 1 and piece[1] == 'K':
            return [(-1,-1),(1,-1),(-1,1),(1,1)]
        return [(-1,-1),(1,-1)] if piece[0] == 'w' else [(-1,1),(1,1)]

    def checkers_click(self, c, r):
        piece = self.checkers_board[r][c]
        if self.checkers_sel is None:
            if piece and piece[0] == self.checkers_turn:
                self.checkers_sel = (c, r)
                return True
            return False
        sc, sr = self.checkers_sel
        sel_piece = self.checkers_board[sr][sc]
        if piece and piece[0] == self.checkers_turn:
            self.checkers_sel = (c, r)
            return True
        if not sel_piece or piece is not None or (r+c) % 2 == 0:
            self.checkers_sel = None
            return True
        dc, dr = c-sc, r-sr
        valid = False; captured = None
        for dx, dy in self.checkers_dirs(sel_piece):
            if (dc, dr) == (dx, dy):
                valid = True
            if (dc, dr) == (dx*2, dy*2):
                mid = (sc+dx, sr+dy)
                mp = self.checkers_board[mid[1]][mid[0]]
                if mp and mp[0] != sel_piece[0]:
                    valid = True; captured = mid
        if valid:
            self.checkers_apply_move(sc, sr, c, r, captured)
            if self.checkers_vs_ai and self.checkers_turn == 'b':
                self.schedule_checkers_ai()
        self.checkers_sel = None
        return True

    def chess_click(self, c, r):
        piece = self.chess_board[r][c]
        if self.chess_sel is None:
            if piece and piece[0] == self.chess_turn:
                self.chess_sel = (c, r)
                return True
            return False
        sc, sr = self.chess_sel
        sel_piece = self.chess_board[sr][sc]
        if piece and piece[0] == self.chess_turn:
            self.chess_sel = (c, r)
            return True
        if not sel_piece:
            self.chess_sel = None
            return True
        if self.chess_legal_move(sc, sr, c, r, sel_piece):
            self.chess_apply_move(sc, sr, c, r)
            if self.chess_vs_ai and self.chess_turn == 'b':
                self.schedule_chess_ai()
        self.chess_sel = None
        return True

    def chess_clear_path(self, sc, sr, c, r):
        dc = (c > sc) - (c < sc)
        dr = (r > sr) - (r < sr)
        x, y = sc + dc, sr + dr
        while (x, y) != (c, r):
            if self.chess_board[y][x]:
                return False
            x += dc; y += dr
        return True

    def chess_legal_move(self, sc, sr, c, r, piece):
        if not (0 <= c < 8 and 0 <= r < 8) or (sc == c and sr == r):
            return False
        target = self.chess_board[r][c]
        if target and target[0] == piece[0]:
            return False
        kind = piece[1]; dc = c - sc; dr = r - sr
        adx, ady = abs(dc), abs(dr)
        direction = -1 if piece[0] == 'w' else 1
        if kind == 'P':
            start_row = 6 if piece[0] == 'w' else 1
            if dc == 0 and dr == direction and target is None:
                return True
            if dc == 0 and dr == direction*2 and sr == start_row and target is None and self.chess_board[sr+direction][sc] is None:
                return True
            if adx == 1 and dr == direction and target and target[0] != piece[0]:
                return True
            return False
        if kind == 'N':
            return (adx, ady) in ((1,2),(2,1))
        if kind == 'B':
            return adx == ady and self.chess_clear_path(sc, sr, c, r)
        if kind == 'R':
            return (dc == 0 or dr == 0) and self.chess_clear_path(sc, sr, c, r)
        if kind == 'Q':
            return (adx == ady or dc == 0 or dr == 0) and self.chess_clear_path(sc, sr, c, r)
        if kind == 'K':
            return adx <= 1 and ady <= 1
        return False

    def make_pac_maze(self):
        return [
            '#####################',
            '#.........#.........#',
            '#.###.###.#.###.###.#',
            '#o###.###.#.###.###o#',
            '#...................#',
            '#.###.#.#####.#.###.#',
            '#.....#...#...#.....#',
            '#####.###.#.###.#####',
            '......#.......#......',
            '#####.#.#####.#.#####',
            '#.........#.........#',
            '#.###.###.#.###.###.#',
            '#o..#.....#.....#..o#',
            '###.#.#.#####.#.#.###',
            '#.....#...#...#.....#',
            '#.........#.........#',
            '#####################',
        ]

    def reset_pacman(self, reset_score=True):
        self.pac_dots = set()
        self.pac_power_pellets = set()
        for r, row in enumerate(self.pac_maze):
            for c, ch in enumerate(row):
                if ch == '.':
                    self.pac_dots.add((c,r))
                elif ch == 'o':
                    self.pac_power_pellets.add((c,r))
        self.reset_pac_positions()
        if reset_score:
            self.pac_score = 0
        self.ghost_tick = 0.0
        self.pac_power_time = 0.0

    def reset_pac_positions(self):
        self.pac_x = 1; self.pac_y = 1
        self.pac_dir = 'Right'; self.pac_next_dir = 'Right'
        self.ghosts = [(19,1),(19,15),(10,8),(1,15)]

    def pac_can_move(self, x, y):
        if y == self.pac_portal_y and (x < 0 or x >= len(self.pac_maze[0])):
            return True
        if y < 0 or y >= len(self.pac_maze) or x < 0 or x >= len(self.pac_maze[0]):
            return False
        return self.pac_maze[y][x] != '#'

    def pac_wrap(self, x, y):
        cols = len(self.pac_maze[0])
        if y == self.pac_portal_y and x < 0:
            return cols - 1, y
        if y == self.pac_portal_y and x >= cols:
            return 0, y
        return x, y

    def pac_delta(self, d):
        return {'Left':(-1,0),'Right':(1,0),'Up':(0,-1),'Down':(0,1)}.get(d, (0,0))

    def update_pacman(self, dt):
        if self.fx_mode != 'pacman':
            if self.pac_power_time > 0:
                self.pac_power_time = max(0.0, self.pac_power_time - dt)
            return
        self.ghost_tick += dt
        if self.pac_power_time > 0:
            self.pac_power_time = max(0.0, self.pac_power_time - dt)
        # move pacman and ghosts on a music-reactive beat timer
        move_delay = max(.105, .245 - self.level*.065)
        if self.ghost_tick >= move_delay:
            dx,dy = self.pac_delta(self.pac_next_dir)
            nx, ny = self.pac_wrap(self.pac_x+dx, self.pac_y+dy)
            if self.pac_can_move(self.pac_x+dx, self.pac_y+dy):
                self.pac_dir = self.pac_next_dir
            dx,dy = self.pac_delta(self.pac_dir)
            tx, ty = self.pac_x+dx, self.pac_y+dy
            nx, ny = self.pac_wrap(tx, ty)
            if self.pac_can_move(tx, ty):
                self.pac_x, self.pac_y = nx, ny
            if (self.pac_x,self.pac_y) in self.pac_dots:
                self.pac_dots.remove((self.pac_x,self.pac_y))
                self.pac_score += 10
            if (self.pac_x,self.pac_y) in self.pac_power_pellets:
                self.pac_power_pellets.remove((self.pac_x,self.pac_y))
                self.pac_power_time = 6.0 + self.level * 2.0
                self.pac_score += 50
            if not self.pac_dots and not self.pac_power_pellets:
                old_score = self.pac_score + 500
                self.reset_pacman(reset_score=False)
                self.pac_score = old_score
            newghosts=[]
            frightened = self.pac_power_time > 0
            for gx,gy in self.ghosts:
                choices=[]
                for dd in ['Left','Right','Up','Down']:
                    dx,dy=self.pac_delta(dd)
                    tx,ty=gx+dx,gy+dy
                    nx,ny=self.pac_wrap(tx,ty)
                    if self.pac_can_move(tx, ty):
                        choices.append((nx,ny))
                if choices:
                    if frightened:
                        # power coin active: ghosts run away from pacman
                        choices.sort(key=lambda p: abs(p[0]-self.pac_x)+abs(p[1]-self.pac_y), reverse=True)
                        ng=choices[0] if random.random() < .78 else random.choice(choices)
                    elif random.random() < .64 + self.level*.16:
                        choices.sort(key=lambda p: abs(p[0]-self.pac_x)+abs(p[1]-self.pac_y))
                        ng=choices[0]
                    else:
                        ng=random.choice(choices)
                    newghosts.append(ng)
                else:
                    newghosts.append((gx,gy))
            self.ghosts = newghosts
            for i,(gx,gy) in enumerate(list(self.ghosts)):
                if (gx,gy)==(self.pac_x,self.pac_y):
                    if self.pac_power_time > 0:
                        self.pac_score += 200
                        self.ghosts[i] = (7,6)
                    else:
                        self.reset_pac_positions()
                        break
            self.ghost_tick = 0.0

    def current_title(self):
        if 0 <= self.current_index < len(self.playlist):
            return Path(self.playlist[self.current_index]).name
        return ''
    def progress_ratio(self):
        if self.duration_ns > 0:
            return max(0.0, min(1.0, self.position_ns / self.duration_ns))
        return 0.0
    def format_ns(self, ns):
        sec = max(0, int(ns / 1000000000))
        return f'{sec//60:02d}:{sec%60:02d}'
    def time_text(self):
        return f'{self.format_ns(self.position_ns)} / {self.format_ns(self.duration_ns)}'
    def bitrate_text(self): return self.bitrate
    def hz_text(self): return self.hz

    def notify_simple(self, title, body):
        print(f'{title}: {body}')

    def uri_for_path(self, path):
        return Path(path).expanduser().resolve().as_uri()
    def normalize_input_path(self, f):
        if not f:
            return None
        f = str(f)
        if f.startswith('file://'):
            parsed = urllib.parse.urlparse(f)
            return urllib.parse.unquote(parsed.path)
        return f

    def add_files(self, files):
        added = []
        for f in files:
            f = self.normalize_input_path(f)
            if not f:
                continue
            p = Path(f).expanduser()
            if p.is_dir():
                for root, dirs, names in os.walk(str(p)):
                    for n in sorted(names):
                        full = os.path.join(root, n)
                        if Path(full).suffix.lower() in AUDIO_EXTS:
                            added.append(full)
            elif p.exists() and p.suffix.lower() in AUDIO_EXTS:
                added.append(str(p))
        self.playlist.extend(added)
        if self.current_index < 0 and self.playlist:
            self.current_index = 0
        if self.selected_index < 0 and self.playlist:
            self.selected_index = self.current_index if self.current_index >= 0 else 0
        self.save_settings()
        if self.playlist_window: self.playlist_window.refresh()
        if self.surface: self.surface.queue_draw()
    def choose_files(self):
        dlg = Gtk.FileChooserDialog(title='Open audio files', parent=self.window, action=Gtk.FileChooserAction.OPEN)
        dlg.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
        dlg.set_select_multiple(True)
        filt = Gtk.FileFilter(); filt.set_name('Audio files')
        for e in AUDIO_EXTS: filt.add_pattern('*'+e); filt.add_pattern('*'+e.upper())
        dlg.add_filter(filt)
        res = dlg.run()
        if res == Gtk.ResponseType.OK:
            old_len = len(self.playlist)
            self.add_files(dlg.get_filenames())
            if len(self.playlist) > old_len:
                self.play_index(old_len)
        dlg.destroy()
    def choose_folder(self):
        dlg = Gtk.FileChooserDialog(title='Add audio folder', parent=self.window, action=Gtk.FileChooserAction.SELECT_FOLDER)
        dlg.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
        res = dlg.run()
        if res == Gtk.ResponseType.OK:
            self.add_files([dlg.get_filename()])
        dlg.destroy()

    def get_audio_info(self, path):
        path = str(path)
        if path in self.info_cache:
            return self.info_cache[path]
        br = 'unknown'
        hz = 'unknown'
        if MutagenFile is not None:
            try:
                audio = MutagenFile(path)
                info = getattr(audio, 'info', None)
                bitrate = getattr(info, 'bitrate', None)
                sample_rate = getattr(info, 'sample_rate', None)
                if isinstance(bitrate, (int, float)) and bitrate > 0:
                    br = f'{int(bitrate/1000)} kbps'
                if isinstance(sample_rate, (int, float)) and sample_rate > 0:
                    hz = str(int(sample_rate))
            except Exception:
                pass
        self.info_cache[path] = (br, hz)
        return br, hz

    def apply_layout_size(self):
        if self.surface:
            h = self.surface.design_h()
            self.surface.set_size_request(820, h)
        else:
            h = 632 if self.show_list else 153
        if self.window:
            try:
                self.window.resize(820, h)
                self.window.set_default_size(820, h)
            except Exception:
                pass

    def eq_filter_graph(self):
        # Map the Winamp style EQ sliders to an FFmpeg equalizer graph used by MPV.
        # PRE is a gentle volume preamp, the other sliders are real frequency bands.
        filters = []
        if self.eq:
            pre_db = max(-6.0, min(6.0, float(self.eq[0]) * 6.0))
            if abs(pre_db) > 0.15:
                amp = pow(10.0, pre_db / 20.0)
                filters.append(f'volume={amp:.3f}')
        freqs = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000]
        for freq, val in zip(freqs, self.eq[1:11]):
            gain = max(-12.0, min(12.0, float(val) * 12.0))
            if abs(gain) > 0.15:
                filters.append(f'equalizer=f={freq}:t=q:w=1:g={gain:.2f}')
        return ','.join(filters)

    def mpv_af_arg(self):
        graph = self.eq_filter_graph()
        return f'lavfi=[{graph}]' if graph else ''

    def apply_eq_to_audio(self):
        # Apply the EQ immediately to MPV while a song is playing.
        # If MPV rejects a live filter change, the next play_index launch still uses the same EQ.
        if self.mpv_proc and self.mpv_proc.poll() is None:
            af = self.mpv_af_arg()
            if af:
                if self.mpv_cmd(['af', 'set', af]) is None:
                    self.mpv_cmd(['set_property', 'af', af])
            else:
                if self.mpv_cmd(['af', 'clr']) is None:
                    self.mpv_cmd(['set_property', 'af', []])
        # GStreamer fallback keeps volume only. The real EQ is handled by MPV.

    def play_index(self, idx):
        if not (0 <= idx < len(self.playlist)):
            return
        self.current_index = idx
        self.selected_index = idx
        self.duration_ns = 0
        self.position_ns = 0
        path = str(Path(self.playlist[idx]).expanduser())
        self.bitrate, self.hz = self.get_audio_info(path)
        played = False
        # Primary: direct mpv process with the file path. This is the most reliable for MP3 on Ubuntu.
        if self.start_mpv_backend(path):
            self.playing = True
            played = True
        # Fallback: GStreamer playbin.
        if not played and self.player:
            try:
                self.player.set_state(Gst.State.NULL)
                self.player.set_property('uri', self.uri_for_path(path))
                self.player.set_property('volume', self.volume)
                self.player.set_state(Gst.State.PLAYING)
                self.playing = True
                played = True
            except Exception as e:
                print('GStreamer playback failed:', e)
        if not played:
            self.playing = False
            self.notify_simple('Playback failed', 'Install mpv or GStreamer plugins and try again.')
        self.save_settings()
        if self.surface:
            self.surface.queue_draw()

    def play_selected(self):
        if 0 <= self.selected_index < len(self.playlist):
            self.play_index(self.selected_index)
        else:
            self.play()

    def play(self):
        if not self.playlist:
            self.choose_files()
            if not self.playlist:
                return
        if self.current_index < 0:
            self.current_index = 0
        # If mpv is already loaded and paused, unpause. Otherwise start the current file.
        if self.mpv_proc and self.mpv_proc.poll() is None and self.mpv_socket:
            if self.mpv_cmd(['set_property', 'pause', False]) is not None:
                self.playing = True
                if self.surface: self.surface.queue_draw()
                return
        self.play_index(self.current_index)

    def pause(self):
        paused = False
        if self.mpv_proc and self.mpv_proc.poll() is None:
            self.mpv_cmd(['set_property', 'pause', True])
            paused = True
        if self.player:
            try:
                self.player.set_state(Gst.State.PAUSED)
                paused = True
            except Exception:
                pass
        if paused:
            self.playing = False
        if self.surface:
            self.surface.queue_draw()

    def stop(self):
        proc = self.mpv_proc
        if proc and proc.poll() is None:
            try:
                self.mpv_cmd(['quit'])
                proc.wait(timeout=0.8)
            except Exception:
                try:
                    proc.terminate()
                    proc.wait(timeout=0.8)
                except Exception:
                    try:
                        proc.kill()
                        proc.wait(timeout=0.8)
                    except Exception:
                        pass
        if self.mpv_socket:
            try:
                if os.path.exists(self.mpv_socket):
                    os.unlink(self.mpv_socket)
            except Exception:
                pass
        self.mpv_proc = None
        self.mpv_socket = None
        self.mpv_started = False
        if self.player:
            try:
                self.player.set_state(Gst.State.NULL)
            except Exception:
                pass
        self.playing = False
        self.position_ns = 0
        if self.surface:
            self.surface.queue_draw()

    def toggle_play(self):
        if self.playing: self.pause()
        else: self.play()
    def next_track(self):
        if not self.playlist: return
        if self.shuffle:
            idx = random.randrange(len(self.playlist))
        else:
            idx = (self.current_index + 1) % len(self.playlist)
        self.play_index(idx)
    def prev_track(self):
        if not self.playlist: return
        self.play_index((self.current_index - 1) % len(self.playlist))
    def seek_ratio(self, r):
        r = max(0.0, min(1.0, r))
        if self.duration_ns > 0:
            seconds = (self.duration_ns*r) / 1000000000.0
            if self.mpv_started:
                self.mpv_cmd(['set_property', 'time-pos', seconds])
            elif self.player:
                self.player.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, int(self.duration_ns*r))
        self.position_ns = int(self.duration_ns*r)
    def seek_delta(self, sec):
        if self.duration_ns > 0:
            self.seek_ratio((self.position_ns + sec*1000000000)/self.duration_ns)
    def set_volume(self, v):
        self.volume = max(0.0, min(1.0, v))
        if self.mpv_started: self.mpv_cmd(['set_property', 'volume', int(self.volume*100)])
        if self.player: self.player.set_property('volume', self.volume)
        self.save_settings(); self.surface.queue_draw()

    def save_playlist(self, filename=None, silent=False):
        if not filename:
            dlg = Gtk.FileChooserDialog(title='Save playlist', parent=self.window, action=Gtk.FileChooserAction.SAVE)
            dlg.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK)
            dlg.set_current_name('playlist.m3u')
            res = dlg.run(); filename = dlg.get_filename() if res == Gtk.ResponseType.OK else None; dlg.destroy()
        if filename:
            Path(filename).write_text('#EXTM3U\n' + '\n'.join(self.playlist) + '\n')
            if not silent: self.notify_simple('Playlist saved', filename)
    def load_playlist(self):
        dlg = Gtk.FileChooserDialog(title='Load playlist', parent=self.window, action=Gtk.FileChooserAction.OPEN)
        dlg.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
        res = dlg.run()
        if res == Gtk.ResponseType.OK:
            path = dlg.get_filename()
            self.playlist = [x.strip() for x in Path(path).read_text(errors='ignore').splitlines() if x.strip() and not x.startswith('#')]
            self.current_index = 0 if self.playlist else -1
            self.save_settings()
            if self.playlist_window: self.playlist_window.refresh()
            self.surface.queue_draw()
        dlg.destroy()
    def select_playlist_index(self, idx, redraw=True, additive=False):
        if 0 <= idx < len(self.playlist):
            self.selected_index = idx
            if additive:
                self.selected_indices.add(idx)
            else:
                self.selected_indices = {idx}
            if redraw and self.surface:
                self.surface.queue_draw()
            if redraw and self.playlist_window:
                self.playlist_window.refresh()

    def toggle_playlist_index(self, idx):
        if not (0 <= idx < len(self.playlist)):
            return
        if idx in self.selected_indices:
            self.selected_indices.remove(idx)
        else:
            self.selected_indices.add(idx)
        if self.selected_indices:
            self.selected_index = sorted(self.selected_indices)[-1]
        else:
            self.selected_index = -1
        if self.surface:
            self.surface.queue_draw()
        if self.playlist_window:
            self.playlist_window.refresh()

    def select_playlist_range(self, a, b):
        if not self.playlist:
            return
        lo, hi = sorted((max(0, a), min(len(self.playlist)-1, b)))
        self.selected_indices = set(range(lo, hi + 1))
        self.selected_index = b if 0 <= b < len(self.playlist) else hi
        if self.playlist_window:
            self.playlist_window.refresh()

    def remove_selected(self):
        if not self.playlist:
            return
        targets = sorted(i for i in self.selected_indices if 0 <= i < len(self.playlist))
        if not targets:
            idx = self.selected_index if 0 <= self.selected_index < len(self.playlist) else self.current_index
            if 0 <= idx < len(self.playlist):
                targets = [idx]
        if not targets:
            return
        target_set = set(targets)
        removing_playing = self.current_index in target_set
        if removing_playing:
            self.stop()
        old_current = self.current_index
        self.playlist = [p for i, p in enumerate(self.playlist) if i not in target_set]
        if not self.playlist:
            self.current_index = -1
            self.selected_index = -1
            self.selected_indices = set()
        else:
            first = min(targets)
            if removing_playing:
                self.current_index = -1
            elif old_current >= 0:
                self.current_index = old_current - sum(1 for i in targets if i < old_current)
            self.selected_index = min(first, len(self.playlist)-1)
            self.selected_indices = {self.selected_index}
        self.save_settings()
        if self.playlist_window: self.playlist_window.refresh()
        if self.surface: self.surface.queue_draw()

    def remove_current(self):
        self.remove_selected()
    def clear_playlist(self):
        self.stop(); self.playlist=[]; self.current_index=-1; self.selected_index=-1; self.selected_indices=set(); self.save_settings(); self.surface.queue_draw()
        if self.playlist_window: self.playlist_window.refresh()
    def toggle_embedded_playlist(self):
        self.show_list = not self.show_list
        self.apply_layout_size()
        self.save_settings()
        if self.surface: self.surface.queue_draw()
        if self.playlist_window: self.playlist_window.refresh_controls()

    def detach_playlist(self):
        if self.playlist_window:
            self.playlist_window.present(); return
        self.playlist_window = PlaylistWindow(self)
        self.playlist_window.show_all()

    def set_default_audio_player(self):
        desktop_id = 'govibe-nexus-audio-player.desktop'
        mime_types = [
            'audio/mpeg','audio/x-mpeg','audio/mp3','audio/wav','audio/x-wav','audio/ogg','audio/flac',
            'audio/mp4','audio/aac','audio/x-ms-wma','audio/midi','audio/x-midi','audio/x-matroska','audio/webm'
        ]
        ok = True
        for mt in mime_types:
            try:
                subprocess.run(['xdg-mime', 'default', desktop_id, mt], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            except Exception:
                ok = False
        self.notify_simple('Default audio player', 'Govibe Nexus Audio Player was set as the default for supported audio types.' if ok else 'Tried to set Govibe as default. Install xdg-utils or set it manually in Default Apps if needed.')

    def handle_button(self, key):
        if key == 'open': self.choose_files()
        elif key == 'prev': self.prev_track()
        elif key == 'play': self.play()
        elif key == 'pause': self.pause()
        elif key == 'stop': self.stop()
        elif key == 'next': self.next_track()
        elif key == 'vis': self.show_vis = not self.show_vis; self.apply_layout_size(); self.save_settings()
        elif key == 'eq': self.show_eq = not self.show_eq; self.apply_layout_size(); self.save_settings()
        elif key == 'list': self.toggle_embedded_playlist()
        elif key == 'fx': self.open_fx_window()
        elif key == 'theme': self.show_theme_menu()
        elif key == 'default': self.eq = [0.0]*11; self.apply_eq_to_audio(); self.set_volume(.80)
        elif key == 'presets': self.show_eq_presets()
        elif key == 'reset_eq': self.eq = [0.0]*11; self.apply_eq_to_audio(); self.save_settings()
        elif key == 'add': self.choose_files()
        elif key == 'remove': self.remove_selected()
        elif key == 'clear': self.clear_playlist()
        elif key == 'shuffle': self.shuffle = not self.shuffle; self.buttons[17].text = 'SHUF ON' if self.shuffle else 'SHUF OFF'
        elif key == 'repeat': self.repeat = not self.repeat; self.buttons[18].text = 'REP ON' if self.repeat else 'REP OFF'
        elif key == 'trans': self.notify_simple('Transition', 'TRANS NONE')
        elif key == 'save': self.save_playlist()
        elif key == 'load': self.load_playlist()
        self.surface.queue_draw()

    def menu_item(self, label, cb):
        item = Gtk.MenuItem(label=label)
        item.connect('activate', lambda *_: cb())
        return item

    def submenu_item(self, label, submenu):
        item = Gtk.MenuItem(label=label)
        item.set_submenu(submenu)
        return item

    def fx_menu(self):
        menu = Gtk.Menu()
        menu.append(self.menu_item('Open FX Window', self.open_fx_window))
        menu.append(Gtk.SeparatorMenuItem())
        modes = [
            ('Normal Spectrum Bars', 'spectrum'),
            ('Oscilloscope', 'oscilloscope'),
            ('Waveform', 'waveform'),
            ('Circular Spectrum', 'circular'),
            ('Checkers 10 x 10 FX Game', 'checkers'),
            ('Chess FX Game', 'chess'),
            ('Tetris Beat FX', 'tetris'),
            ('Pacman Beat FX', 'pacman'),
            ('Void Starship Voyage', 'starship'),
        ]
        for label, mode in modes:
            menu.append(self.menu_item(label, lambda m=mode: self.set_fx_mode(m, open_window=True)))
        return menu

    def theme_menu(self):
        menu = Gtk.Menu()
        for name in THEMES.keys():
            menu.append(self.menu_item(name, lambda n=name: self.set_theme(n)))
        return menu


    def open_govibe_site(self):
        try:
            Gio.AppInfo.launch_default_for_uri('https://govibe.org', None)
        except Exception:
            try:
                webbrowser.open('https://govibe.org')
            except Exception:
                self.notify_simple('Govibe', 'https://govibe.org')

    def make_menu(self, tray=False):
        menu = Gtk.Menu()
        menu.append(self.menu_item('Open Govibe Nexus Audio Player', lambda: (self.window.show_all(), self.window.present())))
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(self.menu_item('Open File', self.choose_files))
        menu.append(self.menu_item('Open Folder', self.choose_folder))
        menu.append(self.menu_item('Set as Default Audio Player', self.set_default_audio_player))
        menu.append(self.menu_item('About Govibe.org', self.open_govibe_site))
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(self.menu_item('Play / Pause', self.toggle_play))
        menu.append(self.menu_item('Stop', self.stop))
        menu.append(self.menu_item('Previous Track', self.prev_track))
        menu.append(self.menu_item('Next Track', self.next_track))
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(self.submenu_item('FX and Games', self.fx_menu()))
        menu.append(self.submenu_item('Theme', self.theme_menu()))
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(self.menu_item('Toggle Visualizer', lambda: self.handle_button('vis')))
        menu.append(self.menu_item('Toggle Equalizer', lambda: self.handle_button('eq')))
        menu.append(self.menu_item('Show / Hide Embedded Playlist', self.toggle_embedded_playlist))
        menu.append(self.menu_item('Detach Playlist Window', self.detach_playlist))
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(self.menu_item('Hide Window', lambda: self.window.hide()))
        menu.append(self.menu_item('Exit', self.quit_app))
        return menu

    def minimize_window(self):
        if self.window:
            try:
                self.window.iconify()
            except Exception:
                try:
                    self.window.hide()
                except Exception:
                    pass

    def quit_app(self):
        self.save_settings()
        self.stop()
        if self.fx_window:
            self.fx_window.destroy()
        if self.playlist_window:
            self.playlist_window.destroy()
        self.quit()

    def show_context(self, ev):
        menu = self.make_menu(False)
        menu.show_all()
        menu.popup_at_pointer(ev)

    def show_playlist_context(self, ev):
        menu = Gtk.Menu()
        menu.append(self.menu_item('Play Selected', self.play_selected))
        menu.append(self.menu_item('Detach Playlist Window', self.detach_playlist))
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(self.menu_item('Add Files', self.choose_files))
        menu.append(self.menu_item('Add Folder', self.choose_folder))
        menu.append(self.menu_item('Set as Default Audio Player', self.set_default_audio_player))
        menu.append(self.menu_item('About Govibe.org', self.open_govibe_site))
        menu.append(self.menu_item('Remove Selected', self.remove_selected))
        menu.append(self.menu_item('Clear Playlist', self.clear_playlist))
        menu.append(Gtk.SeparatorMenuItem())
        menu.append(self.submenu_item('FX and Games', self.fx_menu()))
        menu.append(self.submenu_item('Theme', self.theme_menu()))
        menu.show_all()
        menu.popup_at_pointer(ev)

    def tray_popup_direct(self, ev):
        menu = self.make_menu(True); menu.show_all(); menu.popup_at_pointer(ev)

    def show_fx_menu(self):
        menu = self.fx_menu()
        menu.show_all()
        menu.popup_at_pointer(None)

    def show_theme_menu(self):
        menu = self.theme_menu()
        menu.show_all()
        menu.popup_at_pointer(None)

    def open_fx_window(self):
        if self.fx_window:
            self.fx_window.present()
            return
        self.fx_window = FXWindow(self)
        self.fx_window.show_all()

    def set_fx_mode(self, mode, open_window=False):
        self.fx_mode = mode
        if mode == 'tetris' and not any(any(row) for row in self.tetris_grid):
            self.tetris_spawn()
        if mode == 'pacman' and not self.pac_dots:
            self.reset_pacman()
        self.save_settings()
        if self.surface: self.surface.queue_draw()
        if self.fx_window:
            self.fx_window.queue_refresh()
        if open_window:
            self.open_fx_window()

    def set_theme(self, name):
        self.theme_name = name
        apply_theme_values(name)
        self.save_settings()
        if self.surface: self.surface.queue_draw()

    def show_eq_presets(self):
        presets = {
            'Flat':[0,0,0,0,0,0,0,0,0,0,0],
            'Rock':[.25,.42,.25,.10,-.05,-.02,.12,.28,.42,.38,.30],
            'Hard Rock':[.20,.55,.38,.18,-.12,-.08,.18,.36,.55,.50,.42],
            'Bass Boost':[.20,.85,.72,.48,.24,.08,0,-.04,-.08,-.10,-.10],
            'Deep Bass':[.15,1.0,.82,.55,.22,.02,-.08,-.15,-.20,-.22,-.22],
            'Subwoofer Punch':[.28,1.0,.90,.62,.32,.08,-.08,-.16,-.22,-.24,-.24],
            'Treble Boost':[0,-.08,-.05,0,.05,.14,.28,.45,.70,.78,.82],
            'Bright Headphones':[.02,-.05,0,.06,.12,.24,.38,.55,.72,.78,.70],
            'Warm Headphones':[.12,.35,.28,.18,.10,.06,.08,.12,.10,.06,.02],
            'Vocal':[.05,-.18,-.08,.16,.38,.55,.42,.20,.05,-.05,-.10],
            'Voice Clear':[.08,-.30,-.20,.05,.30,.55,.50,.22,.04,-.08,-.12],
            'Podcast Clear':[.02,-.35,-.24,.02,.34,.66,.58,.24,-.04,-.14,-.18],
            'Dance':[.18,.72,.55,.30,.05,-.03,.10,.30,.48,.45,.34],
            'Club':[.20,.78,.62,.36,.12,0,.10,.28,.46,.42,.32],
            'House':[.18,.66,.52,.34,.12,.02,.18,.34,.46,.42,.30],
            'Techno':[.15,.62,.48,.22,0,.06,.24,.45,.62,.56,.44],
            'Electronic':[.12,.56,.42,.20,.00,.14,.32,.54,.72,.64,.48],
            'Drum And Bass':[.20,.95,.78,.45,.12,-.08,.02,.22,.44,.50,.42],
            'Hip Hop':[.22,.90,.72,.42,.16,.02,.08,.18,.26,.24,.16],
            'Reggae':[.18,.62,.44,.22,.08,.00,.10,.18,.22,.18,.10],
            'Pop':[.10,.34,.22,.10,.05,.12,.22,.30,.38,.34,.24],
            'Classical':[.05,.18,.16,.10,.05,.04,.08,.16,.24,.22,.18],
            'Jazz':[.06,.22,.16,.08,.10,.22,.26,.18,.08,.02,0],
            'Acoustic':[.05,.18,.20,.18,.12,.10,.12,.18,.22,.18,.10],
            'Metal':[.22,.60,.38,.10,-.18,-.12,.18,.44,.66,.62,.50],
            'Extreme Metal':[.18,.68,.48,.16,-.26,-.20,.20,.52,.78,.74,.60],
            'Live Hall':[.10,.28,.22,.12,.05,.10,.24,.38,.48,.42,.30],
            'Studio Monitor':[0,.08,.06,.02,0,0,.03,.06,.08,.06,.04],
            'Small Speakers':[.12,.38,.30,.18,.10,.12,.16,.24,.30,.22,.12],
            'Phone Speaker':[.05,-.55,-.38,-.10,.18,.46,.42,.18,-.10,-.24,-.30],
            'Night Soft':[-.08,-.20,-.18,-.10,-.02,.04,.08,.10,.08,.04,0],
            'Late Night Bass Safe':[-.12,-.30,-.24,-.12,0,.06,.12,.16,.14,.10,.04],
            'Vinyl Warmth':[.12,.30,.26,.18,.10,.02,.04,.08,.06,.02,-.04],
            'Radio Voice':[.04,-.45,-.28,.08,.42,.70,.55,.18,-.08,-.20,-.24],
        }
        menu = Gtk.Menu()
        for name, vals in presets.items():
            menu.append(self.menu_item(name, lambda v=vals: self.set_eq(v)))
        menu.show_all(); menu.popup_at_pointer(None)
    def set_eq(self, vals):
        self.eq = list(vals)[:11]
        while len(self.eq) < 11:
            self.eq.append(0.0)
        self.apply_eq_to_audio()
        self.save_settings(); self.surface.queue_draw()


    def do_shutdown(self):
        try:
            self.stop()
            self.save_settings()
        except Exception:
            pass
        Gtk.Application.do_shutdown(self)

def main():
    app = NexusPlayer()
    return app.run(sys.argv)

if __name__ == '__main__':
    main()
