#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Ondine 2.0 — Amberol-inspired music player for GNOME."""

import sys, os, math, json, hashlib, sqlite3, tempfile, gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
gi.require_version('Gdk', '4.0')
try:
    gi.require_version('Gst', '1.0')
    gi.require_version('GstPbutils', '1.0')
    from gi.repository import Gst, GstPbutils
    Gst.init(None)
    HAS_GST = True
except (ValueError, Exception):
    HAS_GST = False

from gi.repository import Gtk, Adw, Gdk, Gio, GLib, GObject, GdkPixbuf
import cairo
import colorsys
import random
import threading

APP_ID = 'info.debuck.Ondine'

# --- MPRIS2 D-Bus Integration ---
MPRIS_IFACE = 'org.mpris.MediaPlayer2'
MPRIS_PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player'
MPRIS_PATH = '/org/mpris/MediaPlayer2'

MPRIS_INTROSPECTION = '''
<node>
  <interface name="org.mpris.MediaPlayer2">
    <method name="Raise"/>
    <method name="Quit"/>
    <property name="CanQuit" type="b" access="read"/>
    <property name="CanRaise" type="b" access="read"/>
    <property name="Identity" type="s" access="read"/>
    <property name="DesktopEntry" type="s" access="read"/>
  </interface>
  <interface name="org.mpris.MediaPlayer2.Player">
    <method name="Next"/>
    <method name="Previous"/>
    <method name="Pause"/>
    <method name="PlayPause"/>
    <method name="Stop"/>
    <method name="Play"/>
    <method name="Seek">
      <arg direction="in" name="Offset" type="x"/>
    </method>
    <method name="SetPosition">
      <arg direction="in" name="TrackId" type="o"/>
      <arg direction="in" name="Position" type="x"/>
    </method>
    <property name="PlaybackStatus" type="s" access="read"/>
    <property name="Metadata" type="a{sv}" access="read"/>
    <property name="Volume" type="d" access="readwrite"/>
    <property name="Position" type="x" access="read"/>
    <property name="CanGoNext" type="b" access="read"/>
    <property name="CanGoPrevious" type="b" access="read"/>
    <property name="CanPlay" type="b" access="read"/>
    <property name="CanPause" type="b" access="read"/>
    <property name="CanSeek" type="b" access="read"/>
    <signal name="Seeked">
      <arg name="Position" type="x"/>
    </signal>
  </interface>
</node>
'''


class MprisService:
    """MPRIS2 D-Bus service for Ondine."""

    def __init__(self, window):
        self._window = window
        self._connection = None
        self._reg_ids = []
        self._node_info = Gio.DBusNodeInfo.new_for_xml(MPRIS_INTROSPECTION)
        self._bus_id = Gio.bus_own_name(
            Gio.BusType.SESSION,
            'org.mpris.MediaPlayer2.ondine',
            Gio.BusNameOwnerFlags.NONE,
            self._on_bus_acquired,
            None, None)

    def _on_bus_acquired(self, connection, name):
        self._connection = connection
        for iface in self._node_info.interfaces:
            reg_id = connection.register_object(
                MPRIS_PATH, iface,
                self._on_method_call,
                self._on_get_property,
                self._on_set_property)
            self._reg_ids.append(reg_id)

    def _on_method_call(self, connection, sender, path, iface, method, params, invocation):
        w = self._window
        if iface == MPRIS_IFACE:
            if method == 'Raise':
                w.present()
            elif method == 'Quit':
                w.get_application().quit()
            invocation.return_value(None)
        elif iface == MPRIS_PLAYER_IFACE:
            if method == 'Play':
                if not w.playing:
                    w._on_play_pause(None)
            elif method == 'Pause':
                if w.playing:
                    w._on_play_pause(None)
            elif method == 'PlayPause':
                w._on_play_pause(None)
            elif method == 'Stop':
                if w.playing:
                    w._on_play_pause(None)
                if HAS_GST and w.player:
                    w.player.set_state(Gst.State.NULL)
            elif method == 'Next':
                w._on_next(None)
            elif method == 'Previous':
                w._on_prev(None)
            elif method == 'Seek':
                offset_us = params.unpack()[0]  # microseconds
                if HAS_GST and w.player:
                    ok, pos = w.player.query_position(Gst.Format.TIME)
                    if ok:
                        new_pos = max(0, pos + offset_us * 1000)  # us to ns
                        w.player.seek_simple(Gst.Format.TIME,
                                             Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, new_pos)
            elif method == 'SetPosition':
                _, position_us = params.unpack()
                if HAS_GST and w.player:
                    w.player.seek_simple(Gst.Format.TIME,
                                         Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
                                         position_us * 1000)
            invocation.return_value(None)

    def _on_get_property(self, connection, sender, path, iface, prop_name):
        w = self._window
        if iface == MPRIS_IFACE:
            if prop_name == 'CanQuit':
                return GLib.Variant('b', True)
            elif prop_name == 'CanRaise':
                return GLib.Variant('b', True)
            elif prop_name == 'Identity':
                return GLib.Variant('s', 'Ondine')
            elif prop_name == 'DesktopEntry':
                return GLib.Variant('s', 'info.debuck.Ondine')
        elif iface == MPRIS_PLAYER_IFACE:
            if prop_name == 'PlaybackStatus':
                if w.playing:
                    return GLib.Variant('s', 'Playing')
                elif w.tracks and w.current_idx >= 0:
                    return GLib.Variant('s', 'Paused')
                else:
                    return GLib.Variant('s', 'Stopped')
            elif prop_name == 'Metadata':
                meta = {}
                if 0 <= w.current_idx < len(w.tracks):
                    t = w.tracks[w.current_idx]
                    meta['xesam:title'] = GLib.Variant('s', t.title)
                    meta['xesam:artist'] = GLib.Variant('as', [t.artist])
                    meta['xesam:album'] = GLib.Variant('s', t.album)
                    if t.duration > 0:
                        meta['mpris:length'] = GLib.Variant('x', int(t.duration * 1e6))
                    meta['mpris:trackid'] = GLib.Variant('o', f'/org/mpris/MediaPlayer2/Track/{w.current_idx}')
                    art_uri = w._get_art_uri(t)
                    if art_uri:
                        meta['mpris:artUrl'] = GLib.Variant('s', art_uri)
                return GLib.Variant('a{sv}', meta)
            elif prop_name == 'Volume':
                return GLib.Variant('d', w.volume_scale.get_value() / 100.0)
            elif prop_name == 'Position':
                pos_us = int(w.position * 1e6)
                return GLib.Variant('x', pos_us)
            elif prop_name in ('CanGoNext', 'CanGoPrevious', 'CanPlay', 'CanPause', 'CanSeek'):
                return GLib.Variant('b', bool(w.tracks))
        return None

    def _on_set_property(self, connection, sender, path, iface, prop_name, value):
        w = self._window
        if iface == MPRIS_PLAYER_IFACE and prop_name == 'Volume':
            vol = value.unpack() * 100.0
            w.volume_scale.set_value(vol)
            return True
        return False

    def update(self):
        """Emit PropertiesChanged for playback state and metadata."""
        if not self._connection:
            return
        w = self._window
        changed = {}
        if w.playing:
            changed['PlaybackStatus'] = GLib.Variant('s', 'Playing')
        elif w.tracks and w.current_idx >= 0:
            changed['PlaybackStatus'] = GLib.Variant('s', 'Paused')
        else:
            changed['PlaybackStatus'] = GLib.Variant('s', 'Stopped')
        # Metadata
        meta = {}
        if 0 <= w.current_idx < len(w.tracks):
            t = w.tracks[w.current_idx]
            meta['xesam:title'] = GLib.Variant('s', t.title)
            meta['xesam:artist'] = GLib.Variant('as', [t.artist])
            meta['xesam:album'] = GLib.Variant('s', t.album)
            if t.duration > 0:
                meta['mpris:length'] = GLib.Variant('x', int(t.duration * 1e6))
            meta['mpris:trackid'] = GLib.Variant('o', f'/org/mpris/MediaPlayer2/Track/{w.current_idx}')
            art_uri = w._get_art_uri(t)
            if art_uri:
                meta['mpris:artUrl'] = GLib.Variant('s', art_uri)
        changed['Metadata'] = GLib.Variant('a{sv}', meta)
        try:
            self._connection.emit_signal(
                None, MPRIS_PATH,
                'org.freedesktop.DBus.Properties',
                'PropertiesChanged',
                GLib.Variant('(sa{sv}as)', (MPRIS_PLAYER_IFACE, changed, [])))
        except Exception:
            pass
AUDIO_EXTS = ('.mp3', '.flac', '.ogg', '.wav', '.m4a', '.opus', '.aac', '.wma')

REPEAT_NONE = 0
REPEAT_ALL = 1
REPEAT_ONE = 2

LIKED_PATH = os.path.join(GLib.get_user_data_dir(), 'ondine', 'liked.json')
DB_DIR = os.path.join(GLib.get_user_data_dir(), 'ondine')
DB_PATH = os.path.join(DB_DIR, 'ondine.db')


def _get_db():
    """Get a connection to the SQLite database, creating tables if needed."""
    os.makedirs(DB_DIR, exist_ok=True)
    conn = sqlite3.connect(DB_PATH)
    conn.execute('PRAGMA journal_mode=WAL')
    conn.execute('''CREATE TABLE IF NOT EXISTS tracks (
        id INTEGER PRIMARY KEY,
        path TEXT UNIQUE,
        title TEXT,
        artist TEXT,
        album TEXT,
        duration REAL,
        position INTEGER
    )''')
    conn.execute('''CREATE TABLE IF NOT EXISTS liked (
        path TEXT PRIMARY KEY
    )''')
    conn.execute('''CREATE TABLE IF NOT EXISTS settings (
        key TEXT PRIMARY KEY,
        value TEXT
    )''')
    conn.commit()
    # Migrate liked.json if it exists
    if os.path.exists(LIKED_PATH):
        try:
            with open(LIKED_PATH, 'r') as f:
                old_liked = json.load(f)
            for p in old_liked:
                conn.execute('INSERT OR IGNORE INTO liked (path) VALUES (?)', (p,))
            conn.commit()
            os.rename(LIKED_PATH, LIKED_PATH + '.migrated')
        except Exception:
            pass
    return conn


def _fmt_time(seconds):
    """Format seconds as m:ss or h:mm:ss."""
    seconds = max(0, int(seconds))
    if seconds >= 3600:
        h = seconds // 3600
        m = (seconds % 3600) // 60
        s = seconds % 60
        return f'{h}:{m:02d}:{s:02d}'
    return f'{seconds // 60}:{seconds % 60:02d}'


def _fmt_time_remaining(seconds):
    """Format remaining time as −m:ss."""
    seconds = max(0, int(seconds))
    if seconds >= 3600:
        h = seconds // 3600
        m = (seconds % 3600) // 60
        s = seconds % 60
        return f'−{h}:{m:02d}:{s:02d}'
    return f'−{seconds // 60}:{seconds % 60:02d}'


def _load_liked():
    try:
        conn = _get_db()
        rows = conn.execute('SELECT path FROM liked').fetchall()
        conn.close()
        return set(r[0] for r in rows)
    except Exception:
        return set()


def _save_liked(liked_set):
    try:
        conn = _get_db()
        conn.execute('DELETE FROM liked')
        for p in liked_set:
            conn.execute('INSERT OR IGNORE INTO liked (path) VALUES (?)', (p,))
        conn.commit()
        conn.close()
    except Exception:
        pass


def _generate_waveform(path, num_bars=70):
    """Generate deterministic pseudo-waveform heights from file path hash."""
    seed = int(hashlib.md5(path.encode()).hexdigest(), 16) % (2**32)
    rng = random.Random(seed)
    bars = []
    for i in range(num_bars):
        base = rng.random() * 0.6 + 0.2
        pos = i / num_bars
        envelope = 0.5 + 0.5 * math.sin(pos * math.pi)
        bars.append(base * envelope)
    return bars


def _generate_hero_wave(path, num_points=200):
    """Generate smooth wave data for the hero visualization (multiple layers)."""
    seed = int(hashlib.md5(path.encode()).hexdigest(), 16) % (2**32)
    rng = random.Random(seed)
    # Generate control points for smooth curves
    # Create 3-5 peaks/valleys with smooth interpolation
    num_peaks = rng.randint(3, 5)
    control_y = [rng.random() * 0.6 + 0.2 for _ in range(num_peaks + 2)]
    control_y[0] = 0.1 + rng.random() * 0.15
    control_y[-1] = 0.1 + rng.random() * 0.15

    # Interpolate smoothly using cubic
    points = []
    for i in range(num_points):
        t = i / (num_points - 1)
        # Map t to control point space
        ct = t * (len(control_y) - 1)
        idx = min(int(ct), len(control_y) - 2)
        frac = ct - idx

        # Smooth interpolation (cubic hermite-like)
        y0 = control_y[max(0, idx - 1)]
        y1 = control_y[idx]
        y2 = control_y[min(len(control_y) - 1, idx + 1)]
        y3 = control_y[min(len(control_y) - 1, idx + 2)]

        # Catmull-Rom spline
        a = -0.5 * y0 + 1.5 * y1 - 1.5 * y2 + 0.5 * y3
        b = y0 - 2.5 * y1 + 2.0 * y2 - 0.5 * y3
        c = -0.5 * y0 + 0.5 * y2
        d = y1
        val = a * frac**3 + b * frac**2 + c * frac + d
        points.append(max(0.05, min(1.0, val)))

    return points


def _extract_cover_art(path):
    """Extract cover art pixbuf from audio file tags via GStreamer discoverer."""
    if not HAS_GST:
        return None
    try:
        discoverer = GstPbutils.Discoverer.new(5 * Gst.SECOND)
        uri = Gst.filename_to_uri(path)
        info = discoverer.discover_uri(uri)
        tags = info.get_tags()
        if not tags:
            return None
        # Try image tag
        ok, sample = tags.get_sample('image')
        if not ok:
            ok, sample = tags.get_sample('preview-image')
        if ok and sample:
            buf = sample.get_buffer()
            ok2, mapinfo = buf.map(Gst.MapFlags.READ)
            if ok2:
                data = bytes(mapinfo.data)
                buf.unmap(mapinfo)
                loader = GdkPixbuf.PixbufLoader()
                loader.write(data)
                loader.close()
                return loader.get_pixbuf()
    except Exception:
        pass
    return None


class Track:
    def __init__(self, path, title='', artist='', album='', duration=0):
        self.path = path
        self.title = title or os.path.splitext(os.path.basename(path))[0]
        self.artist = artist or 'Unknown Artist'
        self.album = album or 'Unknown Album'
        self.duration = duration
        self.art_pixbuf = None  # Full-size cover art
        self.art_thumb = None   # 50x50 thumbnail
        self.waveform = _generate_waveform(path)
        self.hero_wave = _generate_hero_wave(path)

    def discover_metadata(self):
        """Read metadata from the file via GStreamer Discoverer."""
        if not HAS_GST or not os.path.exists(self.path):
            return
        try:
            discoverer = GstPbutils.Discoverer.new(5 * Gst.SECOND)
            uri = Gst.filename_to_uri(self.path)
            info = discoverer.discover_uri(uri)
            dur_ns = info.get_duration()
            if dur_ns > 0:
                self.duration = dur_ns / Gst.SECOND
            tags = info.get_tags()
            if tags:
                ok, val = tags.get_string('title')
                if ok and val:
                    self.title = val
                ok, val = tags.get_string('artist')
                if ok and val:
                    self.artist = val
                ok, val = tags.get_string('album')
                if ok and val:
                    self.album = val
                # Extract cover art
                sample = None
                ok, s = tags.get_sample('image')
                if ok:
                    sample = s
                else:
                    ok, s = tags.get_sample('preview-image')
                    if ok:
                        sample = s
                if sample:
                    buf = sample.get_buffer()
                    ok2, mapinfo = buf.map(Gst.MapFlags.READ)
                    if ok2:
                        data = bytes(mapinfo.data)
                        buf.unmap(mapinfo)
                        try:
                            loader = GdkPixbuf.PixbufLoader()
                            loader.write(data)
                            loader.close()
                            self.art_pixbuf = loader.get_pixbuf()
                            # Create thumbnail
                            if self.art_pixbuf:
                                self.art_thumb = self.art_pixbuf.scale_simple(
                                    50, 50, GdkPixbuf.InterpType.BILINEAR)
                        except Exception:
                            pass
        except Exception:
            pass


def _register_custom_icons():
    """Register custom SVG icons so they work regardless of system icon theme."""
    icon_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                            '..', 'data', 'icons', 'hicolor', 'scalable', 'actions')
    icon_dir = os.path.normpath(icon_dir)
    display = Gdk.Display.get_default()
    if not display:
        return
    theme = Gtk.IconTheme.get_for_display(display)
    # Add our icon directory to the search path
    theme.add_search_path(icon_dir)


class OndineWindow(Adw.ApplicationWindow):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        _register_custom_icons()
        self.set_title('Ondine')
        self.set_default_size(900, 620)
        self.set_size_request(320, 400)
        self.tracks = []
        self._db = _get_db()
        self._accent_mode = self._get_setting('accent_mode', 'cover')
        self.current_idx = -1
        self.playing = False
        self.shuffle = False
        self.repeat_mode = REPEAT_NONE
        self.position = 0
        self.duration = 0
        self._tick_id = None
        self._seeking = False
        self._liked = _load_liked()
        self._show_liked = False  # False = current playlist, True = liked songs
        self._search_active = False
        self._search_text = ''
        self._current_hue = 0.0
        self._accent_mode = 'cover'  # default, updated after DB init
        self._wave_accent_rgb = (0.95, 0.85, 0.20)  # default yellow until accent is resolved
        self._autoplay_on_startup = self._get_setting('autoplay_on_startup', '0') == '1'

        # CSS provider for dynamic background
        self._css_provider = Gtk.CssProvider()
        Gtk.StyleContext.add_provider_for_display(
            Gdk.Display.get_default(), self._css_provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        self._update_background_css(0.0, has_track=False)

        # React to system theme changes
        style_mgr = Adw.StyleManager.get_default()
        style_mgr.connect('notify::dark', self._on_theme_changed)

        # GStreamer pipeline with spectrum element for frequency-band visualization
        if HAS_GST:
            self.player = Gst.ElementFactory.make('playbin', 'player')
            # Add audio spectrum analysis (gives real frequency band magnitudes)
            self._level = Gst.ElementFactory.make('spectrum', 'spectrum')
            if self._level:
                self._level.set_property('bands', 24)          # 24 fixed frequency bands
                self._level.set_property('interval', 10000000)  # 10ms ~100 updates/sec
                self._level.set_property('post-messages', True)
                self._level.set_property('message-magnitude', True)
                audiobin = Gst.Bin.new('audiobin')
                convert = Gst.ElementFactory.make('audioconvert', 'convert')
                sink = Gst.ElementFactory.make('autoaudiosink', 'sink')
                audiobin.add(convert)
                audiobin.add(self._level)
                audiobin.add(sink)
                convert.link(self._level)
                self._level.link(sink)
                pad = convert.get_static_pad('sink')
                audiobin.add_pad(Gst.GhostPad.new('sink', pad))
                self.player.set_property('audio-sink', audiobin)
            bus = self.player.get_bus()
            bus.add_signal_watch()
            bus.connect('message::eos', self._on_eos)
            bus.connect('message::error', self._on_gst_error)
            bus.connect('message::element', self._on_level_message)
        else:
            self.player = None

        self._toast = Adw.ToastOverlay()
        self.set_content(self._toast)

        self.split = Adw.OverlaySplitView()
        self._toast.set_child(self.split)

        # === SIDEBAR ===
        sidebar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        sidebar_box.add_css_class('sidebar-pane')

        sidebar_header = Adw.HeaderBar()
        sidebar_header.add_css_class('flat')

        # Folder button — opens native file dialog (select files AND/OR folders)
        self.folder_btn = Gtk.Button(icon_name='folder-open-symbolic',
                                      tooltip_text='Open Music (Ctrl+O)')
        self.folder_btn.add_css_class('flat')
        self.folder_btn.connect('clicked', lambda *_: self._on_open_native())
        sidebar_header.pack_start(self.folder_btn)

        # Playlist/Liked toggle
        self.playlist_toggle = Gtk.Box(spacing=0)
        self.playlist_toggle.add_css_class('linked')
        self.btn_playlist = Gtk.ToggleButton(label='Playlist')
        self.btn_playlist.set_active(True)
        self.btn_playlist.connect('toggled', self._on_playlist_toggled)
        self.btn_liked = Gtk.ToggleButton(label='♥ Liked')
        self.btn_liked.set_active(False)
        self.btn_liked.connect('toggled', self._on_liked_toggled)
        self.playlist_toggle.append(self.btn_playlist)
        self.playlist_toggle.append(self.btn_liked)
        sidebar_header.set_title_widget(self.playlist_toggle)

        sidebar_box.append(sidebar_header)

        # Search bar
        self.search_bar = Gtk.SearchBar()
        self.search_entry = Gtk.SearchEntry()
        self.search_entry.set_hexpand(True)
        self.search_entry.connect('search-changed', self._on_search_changed)
        self.search_bar.set_child(self.search_entry)
        self.search_bar.connect_entry(self.search_entry)
        sidebar_box.append(self.search_bar)

        # Track list
        list_scroll = Gtk.ScrolledWindow(vexpand=True)
        self.listbox = Gtk.ListBox()
        self.listbox.set_selection_mode(Gtk.SelectionMode.MULTIPLE)
        self.listbox.add_css_class('boxed-list')
        self.listbox.set_placeholder(self._create_empty_placeholder())
        self.listbox.set_filter_func(self._filter_func)
        self.listbox.set_activate_on_single_click(False)
        list_scroll.set_child(self.listbox)
        sidebar_box.append(list_scroll)

        self.split.set_sidebar(sidebar_box)

        # === CONTENT (Now Playing) ===
        content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        content_box.add_css_class('now-playing-pane')

        content_header = Adw.HeaderBar()
        content_header.add_css_class('flat')

        # Sidebar show/hide toggle (always visible) — custom bundled icons
        self.sidebar_toggle = Gtk.Button(icon_name='sidebar-show-symbolic',
                                          tooltip_text='Toggle Playlist (Ctrl+B)')
        self.sidebar_toggle.add_css_class('flat')
        self.sidebar_toggle.connect('clicked', self._on_sidebar_toggle)
        content_header.pack_start(self.sidebar_toggle)

        menu_btn = Gtk.MenuButton(icon_name='open-menu-symbolic')
        menu_btn.add_css_class('flat')
        menu = Gio.Menu()
        # Appearance submenu
        appearance_section = Gio.Menu()
        appearance_section.append('System Accent', 'win.accent-system')
        appearance_section.append('Cover Art Accent', 'win.accent-cover')
        menu.append_submenu('Appearance', appearance_section)
        # Playback submenu
        playback_section = Gio.Menu()
        playback_section.append('Autoplay on Startup', 'win.autoplay-toggle')
        menu.append_submenu('Playback', playback_section)
        menu.append('Wave Intensity…', 'win.wave-intensity')
        menu.append('Keyboard Shortcuts…', 'win.shortcuts')
        menu.append('About Ondine', 'app.about')
        menu_btn.set_menu_model(menu)
        content_header.pack_end(menu_btn)

        # Wave intensity preference (default 1.0, range 0.5 to 3.0)
        self._wave_exaggeration_step = 0.70
        wave_action = Gio.SimpleAction.new('wave-intensity', None)
        wave_action.connect('activate', self._on_wave_intensity)
        self.add_action(wave_action)

        shortcuts_action = Gio.SimpleAction.new('shortcuts', None)
        shortcuts_action.connect('activate', self._on_show_shortcuts)
        self.add_action(shortcuts_action)

        # Accent preference actions
        accent_system = Gio.SimpleAction.new('accent-system', None)
        accent_system.connect('activate', lambda *_: self._set_accent_mode('system'))
        self.add_action(accent_system)
        accent_cover = Gio.SimpleAction.new('accent-cover', None)
        accent_cover.connect('activate', lambda *_: self._set_accent_mode('cover'))
        self.add_action(accent_cover)

        # Autoplay toggle action
        autoplay_action = Gio.SimpleAction.new_stateful(
            'autoplay-toggle',
            None,
            GLib.Variant.new_boolean(self._autoplay_on_startup)
        )
        autoplay_action.connect('change-state', self._on_autoplay_toggle)
        self.add_action(autoplay_action)

        search_btn = Gtk.ToggleButton(icon_name='system-search-symbolic',
                                        tooltip_text='Search (Ctrl+F)')
        search_btn.add_css_class('flat')
        search_btn.connect('toggled', self._on_search_toggled)
        content_header.pack_end(search_btn)
        self._search_btn = search_btn

        # Loading indicator (inline in header)
        self._header_loading_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
        self._header_loading_box.set_visible(False)
        self._header_loading_box.set_valign(Gtk.Align.CENTER)
        self._header_loading_spinner = Gtk.Spinner()
        self._header_loading_spinner.set_size_request(16, 16)
        self._header_loading_box.append(self._header_loading_spinner)
        self._header_loading_label = Gtk.Label(label='')
        self._header_loading_label.add_css_class('dim-label')
        self._header_loading_label.add_css_class('caption')
        self._header_loading_box.append(self._header_loading_label)
        content_header.pack_end(self._header_loading_box)

        content_box.append(content_header)

        # Now Playing panel
        now_playing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12,
                               halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
                               vexpand=True,
                               margin_top=24, margin_bottom=20, margin_start=32, margin_end=32)
        content_box.append(now_playing)

        # Art / Live Visualizer flip container
        self._art_flipped = False
        self._live_levels = [0.0] * 24  # 24 fixed frequency bands (8 per low/mid/high)
        self._wave_offset = 0.0  # Animation offset for sine wave
        self._viz_tick_id = None  # Animation ticker for waveform

        self.art_stack = Gtk.Stack()
        self.art_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
        self.art_stack.set_transition_duration(500)
        self.art_stack.set_hexpand(True)
        now_playing.append(self.art_stack)

        # Page 1: Album art
        self.art_area = Gtk.DrawingArea()
        self.art_area.set_size_request(220, 220)
        self.art_area.set_halign(Gtk.Align.CENTER)
        self.art_area.set_draw_func(self._draw_art)
        art_click = Gtk.GestureClick()
        art_click.connect('released', self._on_art_clicked)
        self.art_area.add_controller(art_click)
        self.art_stack.add_named(self.art_area, 'art')

        # Page 2: Live waveform visualizer (full width of now-playing pane)
        self.live_viz = Gtk.DrawingArea()
        self.live_viz.set_size_request(-1, 220)  # Full width, same height as cover
        self.live_viz.set_hexpand(True)
        self.live_viz.set_draw_func(self._draw_live_viz)
        viz_click = Gtk.GestureClick()
        viz_click.connect('released', self._on_art_clicked)
        self.live_viz.add_controller(viz_click)
        self.art_stack.add_named(self.live_viz, 'viz')

        self.art_stack.set_visible_child_name('art')

        # Song info
        info_labels = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
                               halign=Gtk.Align.CENTER)
        now_playing.append(info_labels)

        # Title centered, heart positioned to its left via overlay
        title_overlay = Gtk.Overlay()
        title_overlay.set_halign(Gtk.Align.CENTER)
        info_labels.append(title_overlay)

        # Title is the centered child
        self.title_label = Gtk.Label(label='Ondine')
        self.title_label.add_css_class('title-2')
        self.title_label.set_ellipsize(3)
        self.title_label.set_max_width_chars(30)
        self.title_label.set_margin_start(32)  # space for the heart on the left
        self.title_label.set_margin_end(32)
        title_overlay.set_child(self.title_label)

        # Heart floats to the left of the title
        self.heart_btn = Gtk.Button(icon_name='emblem-favorite-symbolic')
        self.heart_btn.add_css_class('flat')
        self.heart_btn.add_css_class('circular')
        self.heart_btn.set_halign(Gtk.Align.START)
        self.heart_btn.set_valign(Gtk.Align.CENTER)
        self.heart_btn.set_tooltip_text('Like this song')
        self.heart_btn.connect('clicked', self._on_heart_clicked)
        title_overlay.add_overlay(self.heart_btn)

        self.artist_label = Gtk.Label(label='Open files or a folder to start')
        self.artist_label.add_css_class('dim-label')
        info_labels.append(self.artist_label)

        self.album_label = Gtk.Label(label='')
        self.album_label.add_css_class('dim-label')
        self.album_label.add_css_class('caption')
        info_labels.append(self.album_label)

        # Waveform visualization area
        waveform_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        waveform_box.set_size_request(360, -1)
        now_playing.append(waveform_box)

        # Time labels row
        time_row = Gtk.Box(spacing=8)
        self.time_current = Gtk.Label(label='0:00')
        self.time_current.add_css_class('caption')
        self.time_current.add_css_class('time-label')
        self.time_current.set_xalign(0)
        time_row.append(self.time_current)
        spacer = Gtk.Box(hexpand=True)
        time_row.append(spacer)
        self.time_remaining = Gtk.Label(label='−0:00')
        self.time_remaining.add_css_class('caption')
        self.time_remaining.add_css_class('time-label')
        self.time_remaining.set_xalign(1)
        # Click to toggle between remaining time and total duration
        self._show_total_time = False
        time_rem_click = Gtk.GestureClick()
        time_rem_click.connect('released', self._on_time_remaining_click)
        self.time_remaining.add_controller(time_rem_click)
        self.time_remaining.set_cursor(Gdk.Cursor.new_from_name('pointer'))
        time_row.append(self.time_remaining)
        waveform_box.append(time_row)

        # Waveform DrawingArea (clickable for seeking)
        self.waveform_area = Gtk.DrawingArea()
        self.waveform_area.set_size_request(360, 48)
        self.waveform_area.set_draw_func(self._draw_waveform)
        # Click to seek on waveform
        waveform_click = Gtk.GestureClick()
        waveform_click.connect('released', self._on_waveform_click)
        self.waveform_area.add_controller(waveform_click)
        waveform_box.append(self.waveform_area)

        # Transport controls
        transport = Gtk.Box(spacing=16, halign=Gtk.Align.CENTER, margin_top=8)
        now_playing.append(transport)

        self.shuffle_btn = Gtk.Button(icon_name='media-playlist-shuffle-symbolic')
        self.shuffle_btn.add_css_class('flat')
        self.shuffle_btn.add_css_class('circular')
        self.shuffle_btn.connect('clicked', self._on_shuffle_toggle)
        transport.append(self.shuffle_btn)

        prev_btn = Gtk.Button(icon_name='media-skip-backward-symbolic')
        prev_btn.add_css_class('flat')
        prev_btn.add_css_class('circular')
        prev_btn.set_size_request(44, 44)
        prev_btn.connect('clicked', self._on_prev)
        transport.append(prev_btn)

        self.play_btn = Gtk.Button(icon_name='media-playback-start-symbolic')
        self.play_btn.add_css_class('suggested-action')
        self.play_btn.add_css_class('circular')
        self.play_btn.set_size_request(56, 56)
        self.play_btn.connect('clicked', self._on_play_pause)
        transport.append(self.play_btn)

        next_btn = Gtk.Button(icon_name='media-skip-forward-symbolic')
        next_btn.add_css_class('flat')
        next_btn.add_css_class('circular')
        next_btn.set_size_request(44, 44)
        next_btn.connect('clicked', self._on_next)
        transport.append(next_btn)

        self.repeat_btn = Gtk.Button(icon_name='media-playlist-repeat-symbolic')
        self.repeat_btn.add_css_class('flat')
        self.repeat_btn.add_css_class('circular')
        self.repeat_btn.set_tooltip_text('Repeat: Off')
        self.repeat_btn.connect('clicked', self._on_repeat_toggle)
        transport.append(self.repeat_btn)

        # Volume
        vol_box = Gtk.Box(spacing=8, halign=Gtk.Align.CENTER, margin_top=4)
        now_playing.append(vol_box)
        vol_icon = Gtk.Image.new_from_icon_name('audio-volume-medium-symbolic')
        vol_box.append(vol_icon)
        self.volume_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 5)
        self.volume_scale.set_value(70)
        self.volume_scale.set_draw_value(False)
        self.volume_scale.set_size_request(160, -1)
        self.volume_scale.connect('value-changed', self._on_volume)
        vol_box.append(self.volume_scale)

        self.split.set_content(content_box)

        self.split.set_min_sidebar_width(240)
        self.split.set_max_sidebar_width(340)
        self.split.set_collapsed(False)
        self.split.connect('notify::collapsed', self._on_collapsed_changed)
        self.connect('notify::default-width', self._on_size_changed)
        self.connect('notify::default-height', self._on_size_changed)
        # Also listen for actual surface size changes
        self.connect('notify::maximized', self._on_size_changed)
        self.connect('notify::fullscreened', self._on_size_changed)

        # Sidebar toggle: also hide OverlaySplitView's built-in show-sidebar button
        self.split.set_show_sidebar(True)

        # Drag and drop
        drop_target = Gtk.DropTarget.new(Gio.File, Gdk.DragAction.COPY)
        drop_target.set_gtypes([Gio.File])
        drop_target.connect('drop', self._on_drop)
        self.add_controller(drop_target)

        drop_target_multi = Gtk.DropTarget.new(Gdk.FileList, Gdk.DragAction.COPY)
        drop_target_multi.connect('drop', self._on_drop_multi)
        self.add_controller(drop_target_multi)

        # Keyboard shortcuts
        key_ctrl = Gtk.EventControllerKey()
        key_ctrl.connect('key-pressed', self._on_key_pressed)
        self.add_controller(key_ctrl)

        # Double-click on playlist row → play; single click only selects
        self.listbox.connect('row-activated', self._on_row_activated)

        # MPRIS D-Bus service
        self._mpris = MprisService(self)

        # Save reference to list scroll for lazy art loading
        self._list_scroll = list_scroll
        self._art_load_timeout = None
        adj = self._list_scroll.get_vadjustment()
        adj.connect('value-changed', self._on_list_scrolled)
        adj.connect('notify::page-size', lambda *_: self._on_list_scrolled(adj))

        # Load tracks from DB (instant — just SQLite reads)
        self._load_tracks_from_db()

        # Restore last session state
        self._restore_session()

        # Save state on window close
        self.connect('close-request', self._on_close_request)

    # --- Settings helpers ---

    def _get_setting(self, key, default=None):
        try:
            row = self._db.execute('SELECT value FROM settings WHERE key = ?', (key,)).fetchone()
            return row[0] if row else default
        except Exception:
            return default

    def _set_setting(self, key, value):
        try:
            self._db.execute('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', (key, value))
            self._db.commit()
        except Exception:
            pass

    def _on_show_shortcuts(self, action, param):
        """Show a keyboard shortcuts reference dialog."""
        dialog = Adw.Window(transient_for=self, modal=True,
                            default_width=480, default_height=600,
                            title='Keyboard Shortcuts')

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        header = Adw.HeaderBar()
        header.add_css_class('flat')
        outer.append(header)

        scroll = Gtk.ScrolledWindow(vexpand=True, hexpand=True)
        scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)

        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24,
                          margin_top=20, margin_bottom=24,
                          margin_start=28, margin_end=28)

        # --- Sections: (title, [(shortcut_label, description), ...]) ---
        sections = [
            ('Playback', [
                ('Space / P', 'Play / Pause'),
                ('N  /  Ctrl+→', 'Next track'),
                ('B  /  Ctrl+←', 'Previous track'),
                ('Shift+→', 'Seek forward 5 s'),
                ('Shift+←', 'Seek back 5 s'),
                ('Shift+S', 'Toggle shuffle'),
                ('Shift+R', 'Cycle repeat mode'),
            ]),
            ('Volume', [
                ('Ctrl++', 'Volume up 5 %'),
                ('Ctrl+−', 'Volume down 5 %'),
            ]),
            ('Playlist', [
                ('Ctrl+O', 'Open files / folder'),
                ('Delete', 'Remove selected tracks'),
                ('Ctrl+J', 'Scroll to current track'),
                ('Ctrl+F', 'Toggle search bar'),
                ('Ctrl+B', 'Toggle sidebar'),
            ]),
        ]

        for section_title, shortcuts in sections:
            # Section heading
            heading = Gtk.Label(label=section_title)
            heading.add_css_class('heading')
            heading.set_xalign(0)
            heading.set_margin_bottom(4)
            content.append(heading)

            # Card-style list
            list_box = Gtk.ListBox()
            list_box.set_selection_mode(Gtk.SelectionMode.NONE)
            list_box.add_css_class('boxed-list')

            for keys, description in shortcuts:
                row = Gtk.ListBoxRow()
                row.set_activatable(False)
                row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
                                  spacing=12, margin_top=10, margin_bottom=10,
                                  margin_start=12, margin_end=12)

                # Description label (left, expands)
                desc_label = Gtk.Label(label=description)
                desc_label.set_xalign(0)
                desc_label.set_hexpand(True)
                row_box.append(desc_label)

                # Keyboard shortcut chips (right)
                keys_box = Gtk.Box(spacing=4, halign=Gtk.Align.END)
                for part in keys.split('/'):
                    part = part.strip()
                    chip = Gtk.Label(label=part)
                    chip.add_css_class('caption')
                    chip.add_css_class('dim-label')
                    chip.add_css_class('card')
                    chip.set_margin_start(2)
                    chip.set_margin_end(2)
                    chip.set_margin_top(2)
                    chip.set_margin_bottom(2)
                    # Style as a key cap
                    chip_box = Gtk.Box(margin_start=6, margin_end=6,
                                       margin_top=3, margin_bottom=3)
                    chip_lbl = Gtk.Label(label=part)
                    chip_lbl.add_css_class('caption')
                    chip_lbl.add_css_class('monospace')
                    chip_frame = Gtk.Frame()
                    chip_frame.set_child(chip_lbl)
                    chip_frame.add_css_class('card')
                    keys_box.append(chip_frame)

                row_box.append(keys_box)
                row.set_child(row_box)
                list_box.append(row)

            content.append(list_box)

        scroll.set_child(content)
        outer.append(scroll)
        dialog.set_content(outer)
        dialog.present()

    def _on_wave_intensity(self, action, param):
        """Show a dialog with a slider to adjust wave amplitude spread."""
        dialog = Adw.Window(transient_for=self, modal=True,
                            default_width=350, default_height=180,
                            title='Wave Intensity')
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16,
                       margin_top=24, margin_bottom=24, margin_start=24, margin_end=24)
        header = Adw.HeaderBar()
        header.add_css_class('flat')
        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        vbox.append(header)

        label = Gtk.Label(label='Amplitude spread between wave layers')
        label.add_css_class('dim-label')
        box.append(label)

        val_label = Gtk.Label(label=f'{self._wave_exaggeration_step:.2f}')
        val_label.add_css_class('title-3')

        scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.10, 3.00, 0.05)
        scale.set_value(self._wave_exaggeration_step)
        scale.set_draw_value(False)
        scale.set_hexpand(True)

        def on_value_changed(s):
            self._wave_exaggeration_step = round(s.get_value(), 2)
            val_label.set_text(f'{self._wave_exaggeration_step:.2f}')
            if hasattr(self, 'live_viz'):
                self.live_viz.queue_draw()

        scale.connect('value-changed', on_value_changed)
        box.append(scale)
        box.append(val_label)

        vbox.append(box)
        dialog.set_content(vbox)
        dialog.present()

    def _set_accent_mode(self, mode):
        self._accent_mode = mode
        self._set_setting('accent_mode', mode)
        # Re-apply current track's appearance
        if 0 <= self.current_idx < len(self.tracks):
            track = self.tracks[self.current_idx]
            if track.art_pixbuf:
                colors = self._extract_art_colors(track.art_pixbuf)
                self._update_background_css(colors=colors)
            else:
                hue = (self.current_idx * 0.618033988) % 1.0
                self._update_background_css(hue=hue)
        self.art_area.queue_draw()

    def _on_autoplay_toggle(self, action, value):
        """Toggle autoplay on startup preference."""
        self._autoplay_on_startup = value.get_boolean()
        self._set_setting('autoplay_on_startup', '1' if self._autoplay_on_startup else '0')
        action.set_state(value)

    # --- Session save/restore ---

    def _on_close_request(self, window):
        """Save session state before closing."""
        # Clean up animation ticker
        if self._viz_tick_id:
            GLib.source_remove(self._viz_tick_id)
            self._viz_tick_id = None
        self._save_session()
        return False  # Allow close to proceed

    def _save_session(self):
        """Persist current playback state to settings."""
        # Current track path
        if 0 <= self.current_idx < len(self.tracks):
            self._set_setting('session_track_path', self.tracks[self.current_idx].path)
        else:
            self._set_setting('session_track_path', '')

        # Position in seconds
        self._set_setting('session_position', str(self.position))

        # Volume
        self._set_setting('session_volume', str(self.volume_scale.get_value()))

        # Playlist view (all or liked)
        self._set_setting('session_show_liked', '1' if self._show_liked else '0')

    def _restore_session(self):
        """Restore previous session state after tracks are loaded."""
        if not self.tracks:
            return

        # Restore volume
        vol = self._get_setting('session_volume')
        if vol is not None:
            try:
                self.volume_scale.set_value(float(vol))
            except (ValueError, TypeError):
                pass

        # Restore playlist view
        show_liked = self._get_setting('session_show_liked', '0')
        if show_liked == '1':
            self._show_liked = True
            self.btn_liked.set_active(True)
            self.btn_playlist.set_active(False)

        # Restore current track
        track_path = self._get_setting('session_track_path', '')
        if track_path:
            for i, track in enumerate(self.tracks):
                if track.path == track_path:
                    self.current_idx = i
                    self._update_now_playing()
                    row = self.listbox.get_row_at_index(i)
                    if row:
                        self.listbox.select_row(row)

                    # Restore position
                    pos_str = self._get_setting('session_position', '0')
                    try:
                        saved_pos = float(pos_str)
                    except (ValueError, TypeError):
                        saved_pos = 0

                    if saved_pos > 0:
                        self._saved_restore_pos = saved_pos
                        # Display the saved position immediately (visual feedback)
                        self.position = saved_pos
                        self.time_current.set_text(_fmt_time(self.position))
                        # Show remaining time if duration is known
                        if self.duration > 0:
                            remaining = self.duration - self.position
                            self.time_remaining.set_text(_fmt_time_remaining(remaining))
                        self.waveform_area.queue_draw()
                    
                    # If autoplay is enabled, start playing
                    if self._autoplay_on_startup:
                        GLib.idle_add(self._on_play_pause, None)
                    break

    # --- SQLite persistence ---

    def _load_tracks_from_db(self):
        """Load all tracks from SQLite instantly."""
        try:
            rows = self._db.execute(
                'SELECT path, title, artist, album, duration, position FROM tracks ORDER BY position'
            ).fetchall()
        except Exception:
            return

        needs_discovery = []
        for path, title, artist, album, duration, position in rows:
            if not os.path.exists(path):
                self._db.execute('DELETE FROM tracks WHERE path = ?', (path,))
                continue
            t = Track(path, title=title or '', artist=artist or '',
                      album=album or '', duration=duration or 0)
            self.tracks.append(t)
            if not title or title == os.path.splitext(os.path.basename(path))[0]:
                needs_discovery.append(t)

        self._db.commit()
        if self.tracks:
            self._refresh_list()

        # Queue background metadata discovery for tracks that need it
        if needs_discovery:
            self._discovery_queue = needs_discovery
            self._discovery_idx = 0
            GLib.idle_add(self._discover_metadata_batch)

    def _discover_metadata_batch(self):
        """Background metadata discovery for tracks loaded from DB."""
        if self._discovery_idx >= len(self._discovery_queue):
            del self._discovery_queue
            return False
        t = self._discovery_queue[self._discovery_idx]
        self._discovery_idx += 1
        t.discover_metadata()
        self._db.execute(
            'UPDATE tracks SET title=?, artist=?, album=?, duration=? WHERE path=?',
            (t.title, t.artist, t.album, t.duration, t.path))
        if self._discovery_idx % 5 == 0 or self._discovery_idx >= len(self._discovery_queue):
            self._db.commit()
            self._refresh_list()
        return True

    def _ensure_track_art(self, track):
        """Lazy-load cover art asynchronously. Updates UI when done."""
        if track.art_pixbuf is not None:
            return  # Already loaded
        if getattr(track, '_art_loading', False):
            return  # Already loading
        track._art_loading = True

        def _load_in_thread():
            self._load_track_art(track)
            track._art_loading = False
            GLib.idle_add(self._on_art_loaded, track)

        threading.Thread(target=_load_in_thread, daemon=True).start()

    def _on_art_loaded(self, track):
        """Called on main thread when cover art finishes loading."""
        # Only update if this track is still the current one
        if (0 <= self.current_idx < len(self.tracks) and
            self.tracks[self.current_idx] is track):
            if track.art_pixbuf:
                colors = self._extract_art_colors(track.art_pixbuf)
                self._update_background_css(colors=colors)
            self.art_area.queue_draw()
            self.waveform_area.queue_draw()

        # Also update the sidebar thumbnail if visible
        i = 0
        while True:
            row = self.listbox.get_row_at_index(i)
            if row is None:
                break
            if hasattr(row, '_track_idx') and row._track_idx < len(self.tracks):
                if self.tracks[row._track_idx] is track and track.art_thumb:
                    if hasattr(row, '_thumb_widget'):
                        row._thumb_widget.set_from_pixbuf(track.art_thumb)
                        row._thumb_widget.set_pixel_size(50)
                    break
            i += 1

        if hasattr(self, '_mpris'):
            self._mpris.update()

        return False  # Don't repeat

    def _get_art_uri(self, track):
        """Save track art to temp file and return file:// URI for MPRIS."""
        if not track.art_pixbuf:
            return None
        cache_dir = os.path.join(GLib.get_user_cache_dir(), 'ondine', 'art')
        os.makedirs(cache_dir, exist_ok=True)
        art_hash = hashlib.md5(track.path.encode()).hexdigest()
        art_path = os.path.join(cache_dir, f'{art_hash}.png')
        if not os.path.exists(art_path):
            track.art_pixbuf.savev(art_path, 'png', [], [])
        return f'file://{art_path}'

    def _load_track_art(self, track):
        """Load just cover art for a track (when metadata already in DB)."""
        if not HAS_GST or not os.path.exists(track.path):
            return
        try:
            discoverer = GstPbutils.Discoverer.new(5 * Gst.SECOND)
            uri = Gst.filename_to_uri(track.path)
            info = discoverer.discover_uri(uri)
            tags = info.get_tags()
            if tags:
                ok, s = tags.get_sample('image')
                if not ok:
                    ok, s = tags.get_sample('preview-image')
                if ok and s:
                    buf = s.get_buffer()
                    ok2, mapinfo = buf.map(Gst.MapFlags.READ)
                    if ok2:
                        data = bytes(mapinfo.data)
                        buf.unmap(mapinfo)
                        loader = GdkPixbuf.PixbufLoader()
                        loader.write(data)
                        loader.close()
                        track.art_pixbuf = loader.get_pixbuf()
                        if track.art_pixbuf:
                            track.art_thumb = track.art_pixbuf.scale_simple(
                                50, 50, GdkPixbuf.InterpType.BILINEAR)
        except Exception:
            pass

    def _db_save_tracks(self):
        """Save all tracks to SQLite."""
        try:
            self._db.execute('DELETE FROM tracks')
            for i, t in enumerate(self.tracks):
                self._db.execute(
                    'INSERT OR REPLACE INTO tracks (path, title, artist, album, duration, position) VALUES (?, ?, ?, ?, ?, ?)',
                    (t.path, t.title, t.artist, t.album, t.duration, i))
            self._db.commit()
        except Exception:
            pass

    def _db_add_track(self, track, position):
        """Insert a single track into DB."""
        try:
            self._db.execute(
                'INSERT OR REPLACE INTO tracks (path, title, artist, album, duration, position) VALUES (?, ?, ?, ?, ?, ?)',
                (track.path, track.title, track.artist, track.album, track.duration, position))
            self._db.commit()
        except Exception:
            pass

    def _db_remove_tracks(self, paths):
        """Remove tracks from DB by path."""
        try:
            for p in paths:
                self._db.execute('DELETE FROM tracks WHERE path = ?', (p,))
            self._db.commit()
        except Exception:
            pass

    # --- CSS / Gradient ---

    def _update_background_css(self, hue=0.0, has_track=True, colors=None):
        """Generate and apply CSS for gradient background.
        
        If colors is provided (list of 4 rgb tuples), use those directly.
        Otherwise generate from hue.
        When has_track is False, show a neutral dark background.
        """
        self._current_hue = hue

        if not has_track:
            # Detect system theme (dark or light)
            style_mgr = Adw.StyleManager.get_default()
            is_dark = style_mgr.get_dark()

            if is_dark:
                bg = 'rgb(30, 30, 34)'
                text_color = 'rgba(255, 255, 255, 0.9)'
                text_dim = 'rgba(255, 255, 255, 0.55)'
                sidebar_bg = 'rgba(0, 0, 0, 0.15)'
            else:
                bg = 'rgb(240, 240, 244)'
                text_color = 'rgba(0, 0, 0, 0.87)'
                text_dim = 'rgba(0, 0, 0, 0.5)'
                sidebar_bg = 'rgba(0, 0, 0, 0.06)'

            searchentry_bg = 'rgba(0, 0, 0, 0.06)' if not is_dark else 'rgba(255, 255, 255, 0.08)'
            css = f"""
            window.background {{
                transition: background 1.2s ease-in-out;
                background: {bg};
                color: {text_color};
            }}
            .sidebar-pane {{
                background-color: {sidebar_bg};
            }}
            .now-playing-pane {{
                background-color: transparent;
            }}
            .boxed-list {{
                background-color: transparent;
                border: none;
                box-shadow: none;
            }}
            .boxed-list row {{
                border-bottom: none;
                border: none;
                box-shadow: none;
            }}
            headerbar {{
                background-color: transparent;
                box-shadow: none;
                border: none;
                color: {text_color};
            }}
            searchbar, searchbar > revealer > box {{
                background-color: transparent;
                box-shadow: none;
                border: none;
            }}
            searchentry {{
                background-color: {searchentry_bg};
                color: {text_color};
            }}
            searchentry text {{
                color: {text_color};
                caret-color: {text_color};
            }}
            searchentry image {{
                color: {text_dim};
            }}
            .dim-label {{
                color: {text_dim};
            }}
            .title-2, .title-4 {{
                color: {text_color};
            }}
            .time-label {{
                color: rgba(255, 255, 255, 0.95);
            }}
            """
            self._css_provider.load_from_string(css)
            return

        if colors and len(colors) >= 4:
            # Use actual colors from cover art
            (r1, g1, b1), (r2, g2, b2), (r3, g3, b3), (r4, g4, b4) = colors[:4]
            # Extract hue for waveform accent color
            avg_r = (r1 + r2 + r3 + r4) / 4
            avg_g = (g1 + g2 + g3 + g4) / 4
            avg_b = (b1 + b2 + b3 + b4) / 4
            h, s, v = colorsys.rgb_to_hsv(avg_r, avg_g, avg_b)
            self._current_hue = h
        else:
            # Generate from hue (golden ratio)
            r1, g1, b1 = colorsys.hsv_to_rgb(hue, 0.3, 0.10)
            r2, g2, b2 = colorsys.hsv_to_rgb((hue + 0.03) % 1.0, 0.35, 0.18)
            r3, g3, b3 = colorsys.hsv_to_rgb((hue + 0.08) % 1.0, 0.25, 0.14)
            r4, g4, b4 = colorsys.hsv_to_rgb((hue + 0.12) % 1.0, 0.3, 0.08)
            self._current_hue = hue

        avg_r = (r1 + r2 + r3 + r4) / 4
        avg_g = (g1 + g2 + g3 + g4) / 4
        avg_b = (b1 + b2 + b3 + b4) / 4
        luminance = 0.299 * avg_r + 0.587 * avg_g + 0.114 * avg_b

        # If background is light (luminance > 0.45), use dark text; otherwise light text
        if luminance > 0.45:
            text_color = 'rgba(0, 0, 0, 0.87)'
            text_dim = 'rgba(0, 0, 0, 0.55)'
            sel_bg = 'rgba(0, 0, 0, 0.1)'
            indicator_color = 'rgba(0, 0, 0, 0.7)'
        else:
            text_color = 'rgba(255, 255, 255, 0.9)'
            text_dim = 'rgba(255, 255, 255, 0.6)'
            sel_bg = 'rgba(255, 255, 255, 0.1)'
            indicator_color = 'rgba(255, 255, 255, 0.8)'

        def c(v): return int(v * 255)

        searchentry_bg = 'rgba(0, 0, 0, 0.06)' if luminance > 0.45 else 'rgba(255, 255, 255, 0.08)'
        css = f"""
        window.background {{
            transition: background 1.2s ease-in-out;
            background: linear-gradient(135deg,
                rgb({c(r1)},{c(g1)},{c(b1)}) 0%,
                rgb({c(r2)},{c(g2)},{c(b2)}) 33%,
                rgb({c(r3)},{c(g3)},{c(b3)}) 66%,
                rgb({c(r4)},{c(g4)},{c(b4)}) 100%);
            color: {text_color};
        }}
        .sidebar-pane {{
            background-color: rgba(0, 0, 0, 0.12);
        }}
        .now-playing-pane {{
            background-color: transparent;
        }}
        .boxed-list {{
            background-color: transparent;
            border: none;
            box-shadow: none;
        }}
        .boxed-list row {{
            background-color: transparent;
            color: {text_color};
            border-bottom: none;
            border: none;
            box-shadow: none;
        }}
        .boxed-list row:selected {{
            background-color: {sel_bg};
        }}
        headerbar {{
            background-color: transparent;
            box-shadow: none;
            border: none;
            color: {text_color};
        }}
        searchbar, searchbar > revealer > box {{
            background-color: transparent;
            box-shadow: none;
            border: none;
        }}
        searchbar {{
            background-color: transparent;
        }}
        searchbar > revealer > box {{
            background-color: transparent;
            border: none;
            box-shadow: none;
        }}
        .sidebar-pane searchentry {{
            background-color: {searchentry_bg};
            color: {text_color};
            border: 1px solid {text_dim};
            min-height: 28px;
        }}
        .sidebar-pane searchentry text {{
            color: {text_color};
            caret-color: {text_color};
        }}
        .sidebar-pane searchentry image {{
            color: {text_color};
        }}
        .sidebar-pane searchentry placeholder {{
            color: {text_dim};
        }}
        .sidebar-pane searchentry > * {{
            color: {text_color};
        }}
        .now-playing-indicator {{
            color: {indicator_color};
        }}
        .dim-label {{
            color: {text_dim};
        }}
        .title-2, .title-4 {{
            color: {text_color};
        }}
        .time-label {{
            color: rgba(255, 255, 255, 0.95);
        }}
        """
        # Derive accent color for UI elements — use the waveform accent color
        if hasattr(self, '_accent_mode') and self._accent_mode == 'cover':
            ar, ag, ab = colorsys.hsv_to_rgb(self._current_hue, 0.6, 0.9)
            self._wave_accent_rgb = (ar, ag, ab)
            av = 0.9
            accent_css = f"""
            .suggested-action {{
                background-color: rgb({c(ar)},{c(ag)},{c(ab)});
                color: {'black' if av > 0.6 else 'white'};
            }}
            .accent {{
                color: rgb({c(ar)},{c(ag)},{c(ab)});
            }}
            scale trough highlight {{
                background-color: rgb({c(ar)},{c(ag)},{c(ab)});
            }}
            """
            css += accent_css
        else:
            # System accent mode: read Adwaita accent color
            try:
                style_mgr = Adw.StyleManager.get_default()
                adw_accent = style_mgr.get_accent_color()
                rgba = adw_accent.to_rgba()
                self._wave_accent_rgb = (rgba.red, rgba.green, rgba.blue)
            except Exception:
                self._wave_accent_rgb = (0.95, 0.85, 0.20)
        self._css_provider.load_from_string(css)

    def _on_theme_changed(self, style_mgr, pspec):
        """Re-apply CSS when system theme switches."""
        has_track = self.tracks and 0 <= self.current_idx < len(self.tracks)
        if has_track and self.tracks[self.current_idx].art_pixbuf:
            colors = self._extract_art_colors(self.tracks[self.current_idx].art_pixbuf)
            self._update_background_css(colors=colors)
        else:
            self._update_background_css(self._current_hue, has_track=has_track)

    # --- Helpers ---

    def _create_empty_placeholder(self):
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12,
                       halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
                       margin_top=48, margin_bottom=48, margin_start=24, margin_end=24)
        icon = Gtk.Image.new_from_icon_name('folder-music-symbolic')
        icon.set_pixel_size(48)
        icon.add_css_class('dim-label')
        box.append(icon)
        label = Gtk.Label(label='No tracks loaded')
        label.add_css_class('dim-label')
        label.add_css_class('title-4')
        box.append(label)
        hint = Gtk.Label(label='Open a folder or add files\nto start listening')
        hint.add_css_class('dim-label')
        hint.add_css_class('caption')
        hint.set_justify(Gtk.Justification.CENTER)
        box.append(hint)
        return box

    def _filter_func(self, row):
        """Filter rows by search text and liked mode."""
        if not hasattr(row, '_track_idx'):
            return True
        idx = row._track_idx
        if idx >= len(self.tracks):
            return False
        track = self.tracks[idx]
        # Liked filter
        if self._show_liked and track.path not in self._liked:
            return False
        # Search filter
        if self._search_text:
            q = self._search_text.lower()
            if (q not in track.title.lower() and
                q not in track.artist.lower() and
                q not in track.album.lower()):
                return False
        return True

    # --- Sidebar events ---

    def _on_sidebar_toggle(self, btn=None):
        showing = self.split.get_show_sidebar()
        self.split.set_show_sidebar(not showing)

    def _on_collapsed_changed(self, split, pspec):
        pass  # sidebar toggle always visible now

    def _on_size_changed(self, win, pspec):
        width = self.get_width()
        if width > 0 and width < 550:
            if self.split.get_show_sidebar():
                self.split.set_show_sidebar(False)
        # Don't auto-show — let user control it

        # Reload visible art on resize (more rows may now be visible)
        if self._art_load_timeout:
            GLib.source_remove(self._art_load_timeout)
        self._art_load_timeout = GLib.timeout_add(200, self._load_visible_art)

    def _on_search_toggled(self, btn):
        self._search_active = btn.get_active()
        self.search_bar.set_search_mode(self._search_active)
        if self._search_active:
            self.search_entry.grab_focus()
        else:
            self.search_entry.set_text('')
            self._search_text = ''
            self.listbox.invalidate_filter()

    def _on_search_changed(self, entry):
        self._search_text = entry.get_text()
        self.listbox.invalidate_filter()

    def _on_playlist_toggled(self, btn):
        if btn.get_active():
            self.btn_liked.set_active(False)
            self._show_liked = False
            self.listbox.invalidate_filter()

    def _on_liked_toggled(self, btn):
        if btn.get_active():
            self.btn_playlist.set_active(False)
            self._show_liked = True
            self.listbox.invalidate_filter()

    # --- Drawing ---

    def _draw_art(self, area, cr, w, h):
        """Draw album art or gradient placeholder.
        
        - Shadow only when a file is playing (with room so it's not cropped)
        - Music note icon only when nothing is playing
        """
        radius = 8
        shadow_offset = 10
        has_track = 0 <= self.current_idx < len(self.tracks)
        has_art = has_track and self.tracks[self.current_idx].art_pixbuf

        # Art is inset to leave room for shadow
        art_size = min(w, h) - shadow_offset * 2
        ax, ay = (w - art_size) / 2, (h - art_size) / 2 - shadow_offset / 2

        # Simple subtle shadow
        if has_track:
            for offset, alpha in [(3, 0.08), (6, 0.05), (10, 0.03)]:
                cr.save()
                sx, sy = ax - offset/2, ay + offset/2
                sw, sh = art_size + offset, art_size + offset
                r = radius + offset/2
                cr.new_sub_path()
                cr.arc(sx + sw - r, sy + r, r, -math.pi / 2, 0)
                cr.arc(sx + sw - r, sy + sh - r, r, 0, math.pi / 2)
                cr.arc(sx + r, sy + sh - r, r, math.pi / 2, math.pi)
                cr.arc(sx + r, sy + r, r, math.pi, 3 * math.pi / 2)
                cr.close_path()
                cr.set_source_rgba(0, 0, 0, alpha)
                cr.fill()
                cr.restore()

        # Clip to rounded rectangle for art
        cr.save()
        cr.new_sub_path()
        cr.arc(ax + art_size - radius, ay + radius, radius, -math.pi / 2, 0)
        cr.arc(ax + art_size - radius, ay + art_size - radius, radius, 0, math.pi / 2)
        cr.arc(ax + radius, ay + art_size - radius, radius, math.pi / 2, math.pi)
        cr.arc(ax + radius, ay + radius, radius, math.pi, 3 * math.pi / 2)
        cr.close_path()
        cr.clip()

        if has_art:
            pixbuf = self.tracks[self.current_idx].art_pixbuf
            scaled = pixbuf.scale_simple(int(art_size), int(art_size), GdkPixbuf.InterpType.BILINEAR)
            Gdk.cairo_set_source_pixbuf(cr, scaled, ax, ay)
            cr.paint()
        else:
            # Gradient placeholder
            idx = max(0, self.current_idx)
            hue = (idx * 0.618033988) % 1.0
            r, g, b = colorsys.hsv_to_rgb(hue, 0.3, 0.25)
            r2, g2, b2 = colorsys.hsv_to_rgb(hue, 0.5, 0.4)
            pat = cairo.LinearGradient(ax, ay, ax + art_size, ay + art_size)
            pat.add_color_stop_rgb(0, r, g, b)
            pat.add_color_stop_rgb(1, r2, g2, b2)
            cr.set_source(pat)
            cr.paint()

            # Show music icon ONLY when nothing is playing
            if not has_track:
                self._draw_icon_on_cairo(cr, 'audio-x-generic-symbolic',
                                          64, ax, ay, art_size, art_size, 0.25)
        cr.restore()

    def _draw_icon_on_cairo(self, cr, icon_name, size, area_x, area_y, area_w, area_h, alpha=1.0):
        """Render a GTK symbolic icon centered in an area on a cairo context."""
        display = Gdk.Display.get_default()
        if not display:
            return
        theme = Gtk.IconTheme.get_for_display(display)
        icon_paintable = theme.lookup_icon(icon_name, None, size, 1,
                                            Gtk.TextDirection.NONE, Gtk.IconLookupFlags(0))
        if not icon_paintable:
            return
        # Render via GdkPixbuf fallback — load the icon file
        try:
            icon_file = icon_paintable.get_file()
            if icon_file:
                pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(icon_file.get_path(), size, size)
                if pixbuf:
                    x = area_x + (area_w - size) / 2
                    y = area_y + (area_h - size) / 2
                    Gdk.cairo_set_source_pixbuf(cr, pixbuf, x, y)
                    cr.paint_with_alpha(alpha)
        except Exception:
            pass

    def _on_art_clicked(self, gesture, n_press, x, y):
        """Flip between cover art and live waveform visualizer."""
        self._art_flipped = not self._art_flipped
        if self._art_flipped:
            # Start animation ticker - ~100 fps for highly responsive visualization
            if not self._viz_tick_id:
                self._viz_tick_id = GLib.timeout_add(10, self._animate_waveform)  # ~100 fps
            self.art_stack.set_visible_child_name('viz')
        else:
            # Stop animation ticker and reset offset
            if self._viz_tick_id:
                GLib.source_remove(self._viz_tick_id)
                self._viz_tick_id = None
            self._wave_offset = 0.0
            self.art_stack.set_visible_child_name('art')

    def _animate_waveform(self):
        """Redraw waveform for live visualization - decay to 0 when paused."""
        if not self.playing:
            for i in range(len(self._live_levels)):
                self._live_levels[i] *= 0.98
                if self._live_levels[i] < 0.001:
                    self._live_levels[i] = 0.0

        if self._art_flipped:
            self.live_viz.queue_draw()
        return True

    def _on_level_message(self, bus, msg):
        """Receive frequency spectrum data from GStreamer spectrum element."""
        if not self.playing:
            return  # Don’t update levels when paused — let the decay run cleanly
        structure = msg.get_structure()
        if structure.get_name() != 'spectrum':
            return
        try:
            import re
            text = structure.to_string()
            m = re.search(r'magnitude=[^{]*\{([^}]+)\}', text)
            if m:
                floats = [float(x) for x in re.findall(r'[-\d.]+', m.group(1))]
                raw_bands = len(floats)
                num_out = len(self._live_levels)

                for i in range(num_out):
                    # Logarithmic mapping: t^2 gives bass more bars
                    # ~50% of output bars map to the lower 25% of frequencies
                    t = i / num_out
                    band_idx = int((t ** 2.0) * (raw_bands - 1))
                    band_idx = min(band_idx, raw_bands - 1)
                    # Average a small neighborhood (±1 band) for smoother look
                    lo = max(0, band_idx - 1)
                    hi = min(raw_bands, band_idx + 2)
                    db = sum(floats[lo:hi]) / (hi - lo)

                    linear = max(0.0, min(1.0, (db + 70) / 60))
                    # Moderate treble boost (1.0→2.5× gain) so highs still react
                    gain = 1.0 + t * 1.5
                    linear = min(1.0, linear * gain)

                    # Exponential smoothing: fast attack (0.4), moderate release (0.25)
                    prev = self._live_levels[i]
                    if linear > prev:
                        smooth = 0.4   # attack — bars jump up quickly on beats
                    else:
                        smooth = 0.25  # release — fade down moderately
                    self._live_levels[i] = prev + smooth * (linear - prev)

                if self._art_flipped:
                    GLib.idle_add(self.live_viz.queue_draw)
        except Exception:
            pass

    def _draw_live_viz(self, area, cr, w, h):
        """Draw layered aurora-style frequency visualization.
        
        Uses live GStreamer levels when playing, falls back to the track's
        deterministic hero_wave data for a beautiful static display.
        """
        cr.set_source_rgba(0, 0, 0, 0)
        cr.paint()

        center_y = h / 2

        # Use live levels when there's a signal, flat line only when all zeroed out
        has_signal = any(v > 0.01 for v in self._live_levels)
        if has_signal:
            levels = list(self._live_levels)
        else:
            levels = [0.0] * 24

        # Anchor the last (highest freq) band to the mean — highs tend to spike artificially
        levels[-1] = sum(levels) / len(levels)

        num_points = len(levels)
        if num_points < 2:
            return

        # Build base coordinates with uniform linear x-spacing across all 24 bands
        base_coords = []
        for i, amp in enumerate(levels):
            x = (i / (num_points - 1)) * w
            base_coords.append((x, max(0.0, min(1.0, amp))))

        # Layers: 0=back (white, wide, flat), 7=front (accent color, thin, exaggerated)
        # Color interpolates from accent (front) → white (back)
        ar, ag, ab = self._wave_accent_rgb
        num_layers = 8
        layers = []
        for li in range(num_layers):
            t = li / (num_layers - 1)  # 0=back(accent), 1=front(white)
            r = ar + (1.0 - ar) * t
            g = ag + (1.0 - ag) * t
            b = ab + (1.0 - ab) * t
            opacity = 0.25 + t * 0.70
            lw = 4.6 - t * 3.2  # back=4.6, front=1.4
            layers.append((li, r, g, b, opacity, lw))

        # All waves rise UP from this shared bottom baseline
        baseline_y = h * 0.92
        max_amplitude = h * 0.82

        # Pre-smooth the amplitude data with a gaussian-like moving average
        # This rounds off sharp peaks: 3→7→2 becomes a smooth hill
        raw_amps = [a for _, a in base_coords]
        smoothed = list(raw_amps)
        for _pass in range(2):  # 2 passes of smoothing
            prev = list(smoothed)
            for i in range(len(smoothed)):
                left = prev[max(0, i - 1)]
                center = prev[i]
                right = prev[min(len(prev) - 1, i + 1)]
                smoothed[i] = left * 0.2 + center * 0.6 + right * 0.2

        # Rebuild base_coords with smoothed values
        smooth_coords = [(base_coords[i][0], smoothed[i]) for i in range(len(base_coords))]

        # Catmull-Rom subdivision: insert smooth intermediate points
        # This gives the spline more points to work with → rounder curves
        upsampled = []
        n = len(smooth_coords)
        for i in range(n):
            x1, a1 = smooth_coords[i]
            upsampled.append((x1, a1))
            if i < n - 1:
                x0, a0 = smooth_coords[max(0, i - 1)]
                x2, a2 = smooth_coords[i + 1]
                x3, a3 = smooth_coords[min(n - 1, i + 2)]
                # Insert 7 Catmull-Rom interpolated points between i and i+1
                for k in range(1, 8):
                    t = k / 8.0
                    t2 = t * t
                    t3 = t2 * t
                    mx = 0.5 * ((2*x1) + (-x0+x2)*t + (2*x0-5*x1+4*x2-x3)*t2 + (-x0+3*x1-3*x2+x3)*t3)
                    ma = 0.5 * ((2*a1) + (-a0+a2)*t + (2*a0-5*a1+4*a2-a3)*t2 + (-a0+3*a1-3*a2+a3)*t3)
                    upsampled.append((mx, max(0.0, ma)))

        # Average amplitude of the base signal — drives per-point exaggeration
        base_avg = sum(a for _, a in upsampled) / max(1, len(upsampled))

        for layer_idx, r, g, b, opacity, line_w in layers:
            cr.set_line_width(line_w)
            cr.set_line_cap(cairo.LINE_CAP_ROUND)
            cr.set_line_join(cairo.LINE_JOIN_ROUND)

            # Exponential amplitude scale per layer:
            # Front/yellow (layer_idx=7): most exaggerated — dominant visual element.
            # Back (layer_idx=0): nearly flat — subtle ghost behind yellow.
            amplitude_scale = 0.30 * math.exp(layer_idx * self._wave_exaggeration_step * 0.38)

            # Per-point exaggeration: scale deviations from average
            scaled = []
            for x, amp in upsampled:
                deviation = amp - base_avg
                exaggerated = base_avg + deviation * amplitude_scale
                scaled.append((x, max(0.0, exaggerated)))

            # Center this wave: average sits at vertical middle
            avg_amp = sum(a for _, a in scaled) / len(scaled)
            center_y_wave = h / 2.0
            # Fade wave height to zero as signal approaches silence:
            # at avg_amp=0.10 → full height; below 0.05 → nearly flat
            signal_gain = min(1.0, avg_amp / 0.08)
            wave_scale = h * 0.32 * signal_gain

            coords = []
            for x, a in scaled:
                y = center_y_wave - (a - avg_amp) * wave_scale / max(0.01, avg_amp)
                coords.append((x, y))

            # Catmull-Rom spline — low tension (2.0) for maximum roundness
            cr.move_to(coords[0][0], coords[0][1])
            tension = 2.0
            for i in range(len(coords) - 1):
                p0 = coords[i - 1] if i > 0 else coords[i]
                p1 = coords[i]
                p2 = coords[i + 1]
                p3 = coords[i + 2] if (i + 2) < len(coords) else coords[i + 1]

                cp1x = p1[0] + (p2[0] - p0[0]) / tension
                cp1y = p1[1] + (p2[1] - p0[1]) / tension
                cp2x = p2[0] - (p3[0] - p1[0]) / tension
                cp2y = p2[1] - (p3[1] - p1[1]) / tension
                cr.curve_to(cp1x, cp1y, cp2x, cp2y, p2[0], p2[1])

            cr.set_source_rgba(r, g, b, opacity)
            cr.stroke()

    def _draw_smooth_curve(self, cr, coords):
        """Draw a smooth Catmull-Rom curve through coords [(x, y), ...]."""
        if len(coords) < 2:
            return
        cr.move_to(coords[0][0], coords[0][1])
        for i in range(len(coords) - 1):
            p0 = coords[i - 1] if i > 0 else coords[i]
            p1 = coords[i]
            p2 = coords[i + 1]
            p3 = coords[i + 2] if (i + 2) < len(coords) else coords[i + 1]
            cp1x = p1[0] + (p2[0] - p0[0]) / 6.0
            cp1y = p1[1] + (p2[1] - p0[1]) / 6.0
            cp2x = p2[0] - (p3[0] - p1[0]) / 6.0
            cp2y = p2[1] - (p3[1] - p1[1]) / 6.0
            cr.curve_to(cp1x, cp1y, cp2x, cp2y, p2[0], p2[1])

    def _draw_waveform(self, area, cr, w, h):
        """Draw smooth waveform scrubber — played portion colored, unplayed dim.
        
        Inspired by the reference: smooth filled curves, pink for played, gray for unplayed,
        with a thin progress line through the center.
        """
        is_dark = Adw.StyleManager.get_default().get_dark()
        center_y = h / 2

        if not self.tracks or self.current_idx < 0 or self.current_idx >= len(self.tracks):
            # Empty state — flat line
            if is_dark:
                cr.set_source_rgba(1, 1, 1, 0.15)
            else:
                cr.set_source_rgba(0, 0, 0, 0.12)
            cr.set_line_width(1)
            cr.move_to(0, center_y)
            cr.line_to(w, center_y)
            cr.stroke()
            return

        track = self.tracks[self.current_idx]
        bars = track.waveform
        num_bars = len(bars)

        progress = 0
        if self.duration > 0:
            progress = min(1.0, self.position / self.duration)

        hue = self._current_hue
        split_x = progress * w

        # Build smooth wave coords (mirrored: top half + bottom half)
        top_coords = []
        bot_coords = []
        for i, val in enumerate(bars):
            x = (i / (num_bars - 1)) * w
            amp = max(0.05, val) * (h * 0.42)
            top_coords.append((x, center_y - amp))
            bot_coords.append((x, center_y + amp))

        # Draw played portion (colored, filled)
        if split_x > 0:
            cr.save()
            cr.rectangle(0, 0, split_x, h)
            cr.clip()

            # Top curve down to center, bottom curve up to center → filled shape
            self._draw_smooth_curve(cr, top_coords)
            # Line to rightmost bottom, then draw bottom backwards
            cr.line_to(top_coords[-1][0], center_y)
            for x, y in reversed(bot_coords):
                cr.line_to(x, y)
            cr.line_to(top_coords[0][0], center_y)
            cr.close_path()

            r, g, b = colorsys.hsv_to_rgb(hue, 0.5, 0.85)
            cr.set_source_rgba(r, g, b, 0.8)
            cr.fill()
            cr.restore()

        # Draw unplayed portion (dim)
        if split_x < w:
            cr.save()
            cr.rectangle(split_x, 0, w - split_x, h)
            cr.clip()

            self._draw_smooth_curve(cr, top_coords)
            cr.line_to(top_coords[-1][0], center_y)
            for x, y in reversed(bot_coords):
                cr.line_to(x, y)
            cr.line_to(top_coords[0][0], center_y)
            cr.close_path()

            if is_dark:
                cr.set_source_rgba(1, 1, 1, 0.15)
            else:
                cr.set_source_rgba(0, 0, 0, 0.12)
            cr.fill()
            cr.restore()

        # Progress line (thin vertical playhead)
        if progress > 0 and progress < 1:
            cr.set_source_rgba(1, 1, 1, 0.7) if is_dark else cr.set_source_rgba(0, 0, 0, 0.5)
            cr.set_line_width(1.5)
            cr.move_to(split_x, 2)
            cr.line_to(split_x, h - 2)
            cr.stroke()

        # Thin center line
        if is_dark:
            cr.set_source_rgba(1, 1, 1, 0.08)
        else:
            cr.set_source_rgba(0, 0, 0, 0.06)
        cr.set_line_width(0.5)
        cr.move_to(0, center_y)
        cr.line_to(w, center_y)
        cr.stroke()

    def _on_waveform_click(self, gesture, n_press, x, y):
        """Seek by clicking on waveform."""
        if self.duration <= 0 or not HAS_GST or not self.player:
            return
        w = self.waveform_area.get_width()
        if w <= 0:
            return
        fraction = max(0, min(1.0, x / w))
        target_ns = int(fraction * self.duration * Gst.SECOND)
        self.player.seek_simple(Gst.Format.TIME,
                                Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, target_ns)
        self.position = fraction * self.duration
        self.time_current.set_text(_fmt_time(self.position))
        remaining = self.duration - self.position
        self.time_remaining.set_text(_fmt_time_remaining(remaining))
        self.waveform_area.queue_draw()

    # --- Track list ---

    def _refresh_list(self):
        while row := self.listbox.get_row_at_index(0):
            self.listbox.remove(row)

        for i, track in enumerate(self.tracks):
            row = Adw.ActionRow(title=GLib.markup_escape_text(track.title),
                                subtitle=GLib.markup_escape_text(track.artist))
            row._track_idx = i

            # Cover art thumbnail (prefix)
            if track.art_thumb:
                thumb = Gtk.Image.new_from_pixbuf(track.art_thumb)
                thumb.set_pixel_size(50)
                thumb.set_size_request(50, 50)
            else:
                thumb = Gtk.Image.new_from_icon_name('audio-x-generic-symbolic')
                thumb.set_pixel_size(32)
                thumb.set_size_request(50, 50)
            row.add_prefix(thumb)
            row._thumb_widget = thumb

            # Heart button for like/unlike on playlist rows
            is_liked = track.path in self._liked
            heart = Gtk.Button(
                icon_name='heart-filled-symbolic' if is_liked else 'emblem-favorite-symbolic')
            heart.add_css_class('flat')
            heart.add_css_class('circular')
            heart.set_valign(Gtk.Align.CENTER)
            heart.set_tooltip_text('Unlike' if is_liked else 'Like')
            if is_liked:
                heart.add_css_class('accent')
                heart.set_opacity(1.0)
            else:
                heart.set_opacity(0.0)  # Hidden unless hovered
                heart.add_css_class('playlist-heart')
            heart._track_idx = i
            heart.connect('clicked', self._on_playlist_heart_clicked)
            row.add_suffix(heart)
            row._heart_btn = heart

            # Now-playing indicator (left of duration, hidden by default)
            playing_icon = Gtk.Image.new_from_icon_name('audio-volume-high-symbolic')
            playing_icon.add_css_class('now-playing-indicator')
            playing_icon.set_valign(Gtk.Align.CENTER)
            playing_icon.set_visible(i == self.current_idx and self.playing)
            row.add_suffix(playing_icon)
            row._playing_icon = playing_icon

            # Duration label (rightmost)
            dur_label = Gtk.Label(label=_fmt_time(track.duration) if track.duration > 0 else '--:--')
            dur_label.add_css_class('dim-label')
            dur_label.add_css_class('caption')
            dur_label.set_valign(Gtk.Align.CENTER)
            row.add_suffix(dur_label)
            row._dur_label = dur_label

            # Hover controller to show/hide heart on non-liked songs
            hover = Gtk.EventControllerMotion()
            hover.connect('enter', self._on_row_hover_enter, heart, track)
            hover.connect('leave', self._on_row_hover_leave, heart, track)
            row.add_controller(hover)

            # Double-click to play
            dblclick = Gtk.GestureClick()
            dblclick.set_button(1)
            dblclick.connect('released', lambda g, n, x, y, r=row: self._play_track_at_row(r) if n == 2 else None)
            row.add_controller(dblclick)

            # Drag-and-drop reordering
            drag_source = Gtk.DragSource()
            drag_source.set_actions(Gdk.DragAction.MOVE)
            drag_source.connect('prepare', self._on_row_drag_prepare, i)
            drag_source.connect('drag-begin', self._on_row_drag_begin, row)
            row.add_controller(drag_source)

            drop_target = Gtk.DropTarget.new(GObject.TYPE_INT, Gdk.DragAction.MOVE)
            drop_target.connect('drop', self._on_row_drop, i)
            drop_target.connect('motion', self._on_row_drag_motion, i)
            drop_target.connect('leave', self._on_row_drag_leave, row)
            row.add_controller(drop_target)

            self.listbox.append(row)

        # Trigger lazy art load for visible rows
        GLib.idle_add(lambda: self._load_visible_art() or False)

    def _on_list_scrolled(self, adj):
        """Debounced scroll handler to load art for newly visible rows."""
        if self._art_load_timeout:
            GLib.source_remove(self._art_load_timeout)
        self._art_load_timeout = GLib.timeout_add(100, self._load_visible_art)

    def _load_visible_art(self):
        """Load cover art for currently visible rows in the listbox."""
        self._art_load_timeout = None
        adj = self._list_scroll.get_vadjustment()
        page = adj.get_page_size()
        val = adj.get_value()

        # Measure actual row height from first row if possible
        row0 = self.listbox.get_row_at_index(0)
        row_height = row0.get_allocated_height() if row0 and row0.get_allocated_height() > 0 else 64

        # Generous buffer: 4 rows above and 6 below to avoid missing bottom rows
        first_visible = max(0, int(val / row_height) - 4)
        last_visible = int((val + page) / row_height) + 6

        rows_to_load = []
        for i in range(first_visible, last_visible + 1):
            row = self.listbox.get_row_at_index(i)
            if row is None:
                break
            if hasattr(row, '_track_idx'):
                idx = row._track_idx
                if idx < len(self.tracks) and self.tracks[idx].art_thumb is None:
                    rows_to_load.append((i, idx))

        if rows_to_load:
            self._art_queue = rows_to_load
            self._art_queue_idx = 0
            GLib.idle_add(self._load_art_for_row)

        return False

    def _load_art_for_row(self):
        """Load art for one queued row per idle tick — in background thread."""
        if self._art_queue_idx >= len(self._art_queue):
            return False

        row_index, track_idx = self._art_queue[self._art_queue_idx]
        self._art_queue_idx += 1

        if track_idx < len(self.tracks):
            track = self.tracks[track_idx]
            if track.art_thumb is None and not getattr(track, '_art_loading', False):
                track._art_loading = True
                ri = row_index  # capture for closure
                def _load():
                    self._load_track_art(track)
                    track._art_loading = False
                    GLib.idle_add(self._on_row_art_loaded, track, ri)
                threading.Thread(target=_load, daemon=True).start()

        return self._art_queue_idx < len(self._art_queue)

    def _on_row_art_loaded(self, track, row_index):
        """Update row thumbnail after background art load."""
        row = self.listbox.get_row_at_index(row_index)
        if row and hasattr(row, '_thumb_widget') and track.art_thumb:
            row._thumb_widget.set_from_pixbuf(track.art_thumb)
            row._thumb_widget.set_pixel_size(50)

        # Also update main art if this is current track
        if (0 <= self.current_idx < len(self.tracks) and
            self.tracks[self.current_idx] is track and track.art_pixbuf):
            colors = self._extract_art_colors(track.art_pixbuf)
            self._update_background_css(colors=colors)
            self.art_area.queue_draw()

        return False

    def _on_row_hover_enter(self, ctrl, x, y, heart, track):
        """Show heart outline on hover for non-liked songs."""
        if track.path not in self._liked:
            heart.set_opacity(0.5)

    def _on_row_hover_leave(self, ctrl, heart, track):
        """Hide heart on leave for non-liked songs."""
        if track.path not in self._liked:
            heart.set_opacity(0.0)

    # --- Drag-and-drop reordering ---

    def _on_row_drag_prepare(self, source, x, y, idx):
        """Prepare drag data — just the track index as int."""
        self._drag_source_idx = idx
        return Gdk.ContentProvider.new_for_value(idx)

    def _on_row_drag_begin(self, source, drag, row):
        """Visual feedback: snapshot the row as drag icon, dim the original."""
        paintable = Gtk.WidgetPaintable.new(row)
        icon = Gtk.DragIcon.get_for_drag(drag)
        pic = Gtk.Picture.new_for_paintable(paintable)
        pic.set_size_request(row.get_allocated_width(), row.get_allocated_height())
        icon.set_child(pic)
        drag.set_hotspot(row.get_allocated_width() // 2, row.get_allocated_height() // 2)

        row.set_opacity(0.3)
        drag.connect('dnd-finished', lambda d: row.set_opacity(1.0))
        drag.connect('cancel', lambda d, reason: row.set_opacity(1.0))

    def _on_row_drag_motion(self, target, x, y, dest_idx):
        """Show drop indicator: move the source row live as we hover over rows."""
        src_idx = getattr(self, '_drag_source_idx', -1)
        if src_idx < 0 or src_idx == dest_idx:
            return Gdk.DragAction.MOVE

        # Live reorder: move the track in the list and refresh
        if 0 <= src_idx < len(self.tracks) and 0 <= dest_idx < len(self.tracks):
            track = self.tracks.pop(src_idx)
            self.tracks.insert(dest_idx, track)

            # Update current_idx to follow the playing track
            if self.current_idx == src_idx:
                self.current_idx = dest_idx
            elif src_idx < self.current_idx <= dest_idx:
                self.current_idx -= 1
            elif dest_idx <= self.current_idx < src_idx:
                self.current_idx += 1

            self._drag_source_idx = dest_idx
            self._refresh_list()
            # Dim the row at new position
            new_row = self.listbox.get_row_at_index(dest_idx)
            if new_row:
                new_row.set_opacity(0.3)

        return Gdk.DragAction.MOVE

    def _on_row_drag_leave(self, target, row):
        """Clear drop indicator styling."""
        pass  # opacity is managed by drag_begin/finish

    def _on_row_drop(self, target, value, x, y, dest_idx):
        """Finalize reorder: persist positions to DB and restore opacity."""
        self._drag_source_idx = -1

        # Persist positions to DB
        try:
            for i, t in enumerate(self.tracks):
                self._db.execute('UPDATE tracks SET position=? WHERE path=?', (i, t.path))
            self._db.commit()
        except Exception:
            pass

        self._refresh_list()
        # Re-select current track
        if 0 <= self.current_idx < len(self.tracks):
            row = self.listbox.get_row_at_index(self.current_idx)
            if row:
                self.listbox.select_row(row)
        return True

    def _on_playlist_heart_clicked(self, btn):
        """Toggle like from playlist row — applies to ALL selected rows."""
        selected_rows = self.listbox.get_selected_rows()
        indices = []
        if selected_rows:
            for row in selected_rows:
                if hasattr(row, '_track_idx') and 0 <= row._track_idx < len(self.tracks):
                    indices.append(row._track_idx)

        # If the clicked row isn't in selection, just use it alone
        if not indices:
            idx = btn._track_idx
            if 0 <= idx < len(self.tracks):
                indices = [idx]

        if not indices:
            return

        # If any selected is not liked → like all; else unlike all
        all_liked = all(self.tracks[i].path in self._liked for i in indices)
        for i in indices:
            path = self.tracks[i].path
            if all_liked:
                self._liked.discard(path)
            else:
                self._liked.add(path)

        _save_liked(self._liked)
        count = len(indices)
        if all_liked:
            self._toast.add_toast(Adw.Toast(title=f'Removed {count} song{"s" if count > 1 else ""} from liked', timeout=2))
        else:
            self._toast.add_toast(Adw.Toast(title=f'Added {count} song{"s" if count > 1 else ""} to liked', timeout=2))

        self._update_heart_state()
        # Update heart icons in-place (no full refresh — keeps selection, no freeze)
        self._update_row_hearts(indices)
        if self._show_liked:
            self.listbox.invalidate_filter()

    def _update_row_hearts(self, indices=None):
        """Update heart icon appearance on specific rows (or all if indices is None)."""
        i = 0
        while True:
            row = self.listbox.get_row_at_index(i)
            if row is None:
                break
            if hasattr(row, '_heart_btn') and hasattr(row, '_track_idx'):
                if indices is None or row._track_idx in indices:
                    track = self.tracks[row._track_idx]
                    heart = row._heart_btn
                    if track.path in self._liked:
                        heart.set_icon_name('heart-filled-symbolic')
                        heart.add_css_class('accent')
                        heart.set_opacity(1.0)
                    else:
                        heart.set_icon_name('emblem-favorite-symbolic')
                        heart.remove_css_class('accent')
                        heart.set_opacity(0.5)
            i += 1

    def _update_playing_indicators(self):
        """Update the speaker icon visibility on all rows."""
        i = 0
        while True:
            row = self.listbox.get_row_at_index(i)
            if row is None:
                break
            if hasattr(row, '_playing_icon'):
                row._playing_icon.set_visible(
                    i == self.current_idx and self.playing)
            i += 1

    def _update_now_playing(self):
        if not self.tracks or self.current_idx < 0 or self.current_idx >= len(self.tracks):
            self.title_label.set_text('Ondine')
            self.artist_label.set_text('Open files or a folder to start')
            self.album_label.set_text('')
            self.time_current.set_text('0:00')
            self.time_remaining.set_text('−0:00')
            self.position = 0
            self.art_area.queue_draw()
            self.waveform_area.queue_draw()
            self._update_background_css(0.0, has_track=False)
            self._update_heart_state()
            return

        track = self.tracks[self.current_idx]
        self._ensure_track_art(track)
        self.title_label.set_text(track.title)
        self.artist_label.set_text(track.artist)
        self.album_label.set_text(track.album)
        self.duration = track.duration
        self.position = 0
        self.time_current.set_text('0:00')
        remaining = track.duration if track.duration > 0 else 0
        self.time_remaining.set_text(_fmt_time_remaining(remaining))

        # Update background from cover art colors or golden ratio hue
        if track.art_pixbuf:
            colors = self._extract_art_colors(track.art_pixbuf)
            self._update_background_css(colors=colors)
        else:
            hue = (self.current_idx * 0.618033988) % 1.0
            self._update_background_css(hue=hue)

        self.art_area.queue_draw()
        self.waveform_area.queue_draw()
        self._update_playing_indicators()
        self._update_heart_state()
        if hasattr(self, '_mpris'):
            self._mpris.update()

    def _extract_art_colors(self, pixbuf):
        """Extract 4 vibrant colors from cover art for gradient background.
        
        Samples a 4x4 grid of pixels, picks the brightest/most vibrant ones,
        and darkens them moderately to keep readability while staying colorful.
        Returns list of 4 (r,g,b) tuples in 0-1 range.
        """
        try:
            small = pixbuf.scale_simple(16, 16, GdkPixbuf.InterpType.BILINEAR)
            pixels = small.get_pixels()
            nc = small.get_n_channels()
            sw, sh = 16, 16

            # Sample a 4x4 grid (16 points)
            samples = []
            for gy in range(4):
                for gx in range(4):
                    x = 2 + gx * 4
                    y = 2 + gy * 4
                    off = (y * sw + x) * nc
                    r, g, b = pixels[off] / 255.0, pixels[off+1] / 255.0, pixels[off+2] / 255.0
                    samples.append((r, g, b))

            # Sort by luminance (brightness), pick top brightest that aren't pure white
            def luminance(rgb):
                return 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]

            # Filter out near-white and near-black
            filtered = [c for c in samples if 0.08 < luminance(c) < 0.92]
            if len(filtered) < 4:
                filtered = samples  # fallback to all

            # Sort by a combo of luminance + saturation (prefer vibrant bright colors)
            def vibrancy(rgb):
                h, s, v = colorsys.rgb_to_hsv(*rgb)
                return luminance(rgb) * 0.6 + s * 0.4

            filtered.sort(key=vibrancy, reverse=True)
            top4 = filtered[:4]

            # Darken moderately — keep more color alive
            darkened = []
            for r, g, b in top4:
                h, s, v = colorsys.rgb_to_hsv(r, g, b)
                v = min(v * 0.5, 0.4)  # Brighter than before
                s = min(s * 1.1, 0.85)  # Boost saturation slightly
                dr, dg, db = colorsys.hsv_to_rgb(h, s, v)
                darkened.append((dr, dg, db))

            return darkened
        except Exception:
            pass
        # Fallback: generate from golden ratio hue
        hue = (self.current_idx * 0.618033988) % 1.0
        return [
            colorsys.hsv_to_rgb(hue, 0.3, 0.10),
            colorsys.hsv_to_rgb((hue + 0.03) % 1.0, 0.35, 0.18),
            colorsys.hsv_to_rgb((hue + 0.08) % 1.0, 0.25, 0.14),
            colorsys.hsv_to_rgb((hue + 0.12) % 1.0, 0.3, 0.08),
        ]

    def _update_heart_state(self):
        """Update heart button: filled+accent if liked, dim outline if not."""
        if 0 <= self.current_idx < len(self.tracks):
            path = self.tracks[self.current_idx].path
            if path in self._liked:
                self.heart_btn.set_icon_name('heart-filled-symbolic')
                self.heart_btn.add_css_class('accent')
                self.heart_btn.set_tooltip_text('Unlike this song')
            else:
                self.heart_btn.set_icon_name('emblem-favorite-symbolic')
                self.heart_btn.remove_css_class('accent')
                self.heart_btn.set_tooltip_text('Like this song')
                self.heart_btn.set_opacity(0.5)
            # Reset opacity for liked
            if path in self._liked:
                self.heart_btn.set_opacity(1.0)
            return
        self.heart_btn.set_icon_name('emblem-favorite-symbolic')
        self.heart_btn.remove_css_class('accent')
        self.heart_btn.set_opacity(0.5)

    # --- Playback ---

    def _play_current(self):
        if not HAS_GST or self.current_idx < 0 or self.current_idx >= len(self.tracks):
            return
        track = self.tracks[self.current_idx]
        if os.path.exists(track.path):
            self.player.set_state(Gst.State.NULL)
            self.player.set_property('uri', Gst.filename_to_uri(track.path))
            self.player.set_property('volume', self.volume_scale.get_value() / 100)
            self.player.set_state(Gst.State.PLAYING)
            GLib.timeout_add(300, self._query_duration)
            # Restore saved position if any
            if hasattr(self, '_saved_restore_pos') and self._saved_restore_pos > 0:
                restore_pos = self._saved_restore_pos
                self._saved_restore_pos = 0
                GLib.timeout_add(500, self._seek_to_position, restore_pos)

    def _seek_to_position(self, seconds):
        """Seek to a specific position in seconds (used for session restore)."""
        if HAS_GST and self.player:
            target_ns = int(seconds * Gst.SECOND)
            self.player.seek_simple(Gst.Format.TIME,
                                    Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, target_ns)
            self.position = seconds
            self.time_current.set_text(_fmt_time(self.position))
            if self.duration > 0:
                remaining = self.duration - self.position
                self.time_remaining.set_text(_fmt_time_remaining(remaining))
            self.waveform_area.queue_draw()
        return False

    def _query_duration(self):
        if not HAS_GST or not self.player:
            return False
        ok, dur = self.player.query_duration(Gst.Format.TIME)
        if ok and dur > 0:
            self.duration = dur / Gst.SECOND
            if 0 <= self.current_idx < len(self.tracks):
                self.tracks[self.current_idx].duration = self.duration
            self._refresh_time_remaining_label()
            # Update just the duration label on the current row (don't rebuild entire list)
            row = self.listbox.get_row_at_index(self.current_idx)
            if row and hasattr(row, '_dur_label'):
                row._dur_label.set_text(_fmt_time(self.duration))
        return False

    def _on_play_pause(self, btn):
        if not self.tracks:
            return
        self.playing = not self.playing
        if self.playing:
            if self.current_idx < 0:
                self.current_idx = 0
                self._update_now_playing()
                row = self.listbox.get_row_at_index(0)
                if row:
                    self.listbox.unselect_all()
                    self.listbox.select_row(row)
            self.play_btn.set_icon_name('media-playback-pause-symbolic')
            if HAS_GST:
                state = self.player.get_state(0)[1]
                if state == Gst.State.PAUSED:
                    self.player.set_state(Gst.State.PLAYING)
                else:
                    self._play_current()
            if not self._tick_id:
                self._tick_id = GLib.timeout_add(250, self._update_position)
        else:
            self.play_btn.set_icon_name('media-playback-start-symbolic')
            if HAS_GST:
                self.player.set_state(Gst.State.PAUSED)
        self._update_playing_indicators()
        if hasattr(self, '_mpris'):
            self._mpris.update()

    def _update_position(self):
        if not self.playing:
            self._tick_id = None
            return False
        if HAS_GST and self.player and not self._seeking:
            ok, pos = self.player.query_position(Gst.Format.TIME)
            if ok:
                self.position = pos / Gst.SECOND
                self.time_current.set_text(_fmt_time(self.position))
                self._refresh_time_remaining_label()
                self.waveform_area.queue_draw()
        return True

    def _on_seek_change(self, scale, scroll_type, value):
        if self.duration > 0 and HAS_GST and self.player:
            seek_pos = max(0, min(value, 100))
            target_ns = int(seek_pos / 100 * self.duration * Gst.SECOND)
            self.player.seek_simple(Gst.Format.TIME,
                                    Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, target_ns)
            self.position = seek_pos / 100 * self.duration
            self.time_current.set_text(_fmt_time(self.position))
        return False

    def _on_prev(self, btn):
        if not self.tracks:
            return
        if self.position > 3:
            self.position = 0
            if HAS_GST and self.player:
                self.player.seek_simple(Gst.Format.TIME,
                                        Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, 0)
            self._update_now_playing()
            return
        self.current_idx = (self.current_idx - 1) % len(self.tracks)
        self._update_now_playing()
        row = self.listbox.get_row_at_index(self.current_idx)
        if row:
            self.listbox.unselect_all()
            self.listbox.select_row(row)
        if self.playing:
            self._play_current()
        elif HAS_GST and self.player:
            # Stop old track so play button starts the new one
            self.player.set_state(Gst.State.NULL)

    def _on_next(self, btn):
        if not self.tracks:
            return
        if self.repeat_mode == REPEAT_ONE:
            self._update_now_playing()
            if self.playing:
                self._play_current()
            return
        if self.shuffle:
            if len(self.tracks) > 1:
                new_idx = self.current_idx
                while new_idx == self.current_idx:
                    new_idx = random.randint(0, len(self.tracks) - 1)
                self.current_idx = new_idx
        else:
            next_idx = self.current_idx + 1
            if next_idx >= len(self.tracks):
                if self.repeat_mode == REPEAT_ALL:
                    self.current_idx = 0
                else:
                    self.playing = False
                    self.play_btn.set_icon_name('media-playback-start-symbolic')
                    if HAS_GST:
                        self.player.set_state(Gst.State.NULL)
                    self._update_playing_indicators()
                    return
            else:
                self.current_idx = next_idx
        self._update_now_playing()
        row = self.listbox.get_row_at_index(self.current_idx)
        if row:
            self.listbox.unselect_all()
            self.listbox.select_row(row)
        if self.playing:
            self._play_current()
        elif HAS_GST and self.player:
            # Stop old track so play button starts the new one
            self.player.set_state(Gst.State.NULL)

    def _on_time_remaining_click(self, gesture, n_press, x, y):
        """Toggle between remaining time and total duration display."""
        self._show_total_time = not self._show_total_time
        self._refresh_time_remaining_label()

    def _refresh_time_remaining_label(self):
        """Update the time-remaining label according to the current toggle state."""
        if self._show_total_time:
            if self.duration > 0:
                self.time_remaining.set_text(_fmt_time(self.duration))
            else:
                self.time_remaining.set_text('0:00')
        else:
            remaining = max(0, self.duration - self.position)
            self.time_remaining.set_text(_fmt_time_remaining(remaining))

    def _on_shuffle_toggle(self, btn):
        self.shuffle = not self.shuffle
        if self.shuffle:
            self.shuffle_btn.add_css_class('accent')
        else:
            self.shuffle_btn.remove_css_class('accent')

    def _on_repeat_toggle(self, btn):
        self.repeat_mode = (self.repeat_mode + 1) % 3
        if self.repeat_mode == REPEAT_NONE:
            self.repeat_btn.set_icon_name('media-playlist-repeat-symbolic')
            self.repeat_btn.remove_css_class('accent')
            self.repeat_btn.set_tooltip_text('Repeat: Off')
        elif self.repeat_mode == REPEAT_ALL:
            self.repeat_btn.set_icon_name('media-playlist-repeat-symbolic')
            self.repeat_btn.add_css_class('accent')
            self.repeat_btn.set_tooltip_text('Repeat: All')
        elif self.repeat_mode == REPEAT_ONE:
            self.repeat_btn.set_icon_name('media-playlist-repeat-song-symbolic')
            self.repeat_btn.add_css_class('accent')
            self.repeat_btn.set_tooltip_text('Repeat: One')

    def _on_volume(self, scale):
        if HAS_GST and self.player:
            self.player.set_property('volume', scale.get_value() / 100)

    # --- Heart/Like ---

    def _on_heart_clicked(self, btn):
        """Like/unlike: if multiple rows selected, apply to all of them."""
        selected_rows = self.listbox.get_selected_rows()
        indices = []
        if selected_rows:
            for row in selected_rows:
                if hasattr(row, '_track_idx') and 0 <= row._track_idx < len(self.tracks):
                    indices.append(row._track_idx)

        # Fall back to current playing track if nothing selected
        if not indices and 0 <= self.current_idx < len(self.tracks):
            indices = [self.current_idx]

        if not indices:
            return

        # Determine action: if ANY selected track is not liked, we like all; else unlike all
        all_liked = all(self.tracks[i].path in self._liked for i in indices)
        count = 0
        for i in indices:
            path = self.tracks[i].path
            if all_liked:
                self._liked.discard(path)
            else:
                self._liked.add(path)
            count += 1

        _save_liked(self._liked)

        if all_liked:
            self._toast.add_toast(Adw.Toast(title=f'Removed {count} song{"s" if count > 1 else ""} from liked', timeout=2))
        else:
            self._toast.add_toast(Adw.Toast(title=f'Added {count} song{"s" if count > 1 else ""} to liked', timeout=2))

        self._update_heart_state()
        self._update_row_hearts(indices)
        if self._show_liked:
            self.listbox.invalidate_filter()

    # --- File opening ---

    def _on_open_native(self):
        """Open a single native file chooser that accepts both files and folders."""
        dialog = Gtk.FileChooserNative.new(
            'Open Music', self, Gtk.FileChooserAction.OPEN, '_Open', '_Cancel')
        dialog.set_select_multiple(True)

        # Audio file filter (also allow directories via mime type)
        audio_filter = Gtk.FileFilter()
        audio_filter.set_name('Audio Files')
        for ext in AUDIO_EXTS:
            audio_filter.add_pattern(f'*{ext}')
            audio_filter.add_pattern(f'*{ext.upper()}')
        audio_filter.add_mime_type('inode/directory')
        dialog.add_filter(audio_filter)
        all_filter = Gtk.FileFilter()
        all_filter.set_name('All Files')
        all_filter.add_pattern('*')
        dialog.add_filter(all_filter)
        dialog.set_filter(audio_filter)

        dialog.connect('response', self._on_native_response)
        dialog.show()

    def _on_native_response(self, dialog, response):
        if response != Gtk.ResponseType.ACCEPT:
            return
        paths = []
        files = dialog.get_files()
        for i in range(files.get_n_items()):
            gfile = files.get_item(i)
            p = gfile.get_path()
            if p:
                paths.append(p)
        if paths:
            self._add_files_from_paths(paths)
            pass

    def _on_row_activated(self, listbox, row):
        """Enter key on a playlist row → play that track."""
        if row is None:
            return
        self._play_track_at_row(row)

    def _play_track_at_row(self, row):
        """Start playing the track at the given row."""
        self.current_idx = row._track_idx
        self._update_now_playing()
        self.playing = True
        self.play_btn.set_icon_name('media-playback-pause-symbolic')
        self._play_current()
        if not self._tick_id:
            self._tick_id = GLib.timeout_add(250, self._update_position)
        self._update_playing_indicators()

    def _on_key_pressed(self, controller, keyval, keycode, state):
        ctrl = state & Gdk.ModifierType.CONTROL_MASK
        shift = state & Gdk.ModifierType.SHIFT_MASK

        # Ctrl+J : scroll to current track
        if ctrl and keyval == Gdk.KEY_j:
            self._scroll_to_current_track()
            return True

        # Shift+R : next random/repeat mode
        if shift and keyval == Gdk.KEY_r:
            self._on_repeat_toggle(None)
            return True

        # Shift+S : toggle shuffle
        if shift and keyval == Gdk.KEY_s:
            self._on_shuffle_toggle(None)
            return True

        if keyval == Gdk.KEY_space or keyval == Gdk.KEY_p:
            # If a playlist row is selected and it's a different track, play it
            if keyval == Gdk.KEY_space:
                selected = self.listbox.get_selected_rows()
                if (selected and len(selected) == 1 and
                    hasattr(selected[0], '_track_idx') and
                    selected[0]._track_idx != self.current_idx):
                    self._play_track_at_row(selected[0])
                    return True
            self._on_play_pause(None)
            return True
        if keyval == Gdk.KEY_Delete:
            self._delete_selected_rows()
            return True
        if keyval == Gdk.KEY_n or (ctrl and keyval == Gdk.KEY_Right):
            self._on_next(None)
            return True
        if keyval == Gdk.KEY_b and not ctrl:
            self._on_prev(None)
            return True
        if ctrl and keyval == Gdk.KEY_Left:
            self._on_prev(None)
            return True
        if ctrl and (keyval == Gdk.KEY_plus or keyval == Gdk.KEY_equal):
            self.volume_scale.set_value(min(100, self.volume_scale.get_value() + 5))
            return True
        if ctrl and keyval == Gdk.KEY_minus:
            self.volume_scale.set_value(max(0, self.volume_scale.get_value() - 5))
            return True
        if shift and keyval == Gdk.KEY_Right:
            if HAS_GST and self.player:
                ok, pos = self.player.query_position(Gst.Format.TIME)
                if ok:
                    new_pos = max(0, pos + 5 * Gst.SECOND)
                    self.player.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, new_pos)
            return True
        if shift and keyval == Gdk.KEY_Left:
            if HAS_GST and self.player:
                ok, pos = self.player.query_position(Gst.Format.TIME)
                if ok:
                    new_pos = max(0, pos - 5 * Gst.SECOND)
                    self.player.seek_simple(Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, new_pos)
            return True
        if ctrl and keyval == Gdk.KEY_o:
            self._on_open_native()
            return True
        if ctrl and keyval == Gdk.KEY_f:
            self._search_btn.set_active(not self._search_btn.get_active())
            return True
        if ctrl and keyval == Gdk.KEY_b:
            self._on_sidebar_toggle()
            return True
        return False

    def _scroll_to_current_track(self):
        """Scrolls the playlist to center the currently playing track."""
        if not (0 <= self.current_idx < len(self.tracks)):
            return
        row = self.listbox.get_row_at_index(self.current_idx)
        if row is None:
            return
        adj = self._list_scroll.get_vadjustment()
        alloc = row.get_allocation()
        page = adj.get_page_size()
        target = alloc.y - (page / 2) + (alloc.height / 2)
        adj.set_value(max(adj.get_lower(), min(target, adj.get_upper() - page)))

    def _delete_selected_rows(self):
        """Remove selected rows from the playlist (or unlike from liked view)."""
        selected = self.listbox.get_selected_rows()
        if not selected:
            return

        # Gather indices to remove (sorted descending so removal doesn't shift indices)
        indices = sorted([r._track_idx for r in selected if hasattr(r, '_track_idx')], reverse=True)
        if not indices:
            return

        if self._show_liked:
            # In liked view: unlike those songs
            for idx in indices:
                if idx < len(self.tracks):
                    self._liked.discard(self.tracks[idx].path)
            _save_liked(self._liked)
            self.listbox.invalidate_filter()
            self._update_heart_state()
            self._toast.add_toast(Adw.Toast(title=f'Removed {len(indices)} from liked', timeout=2))
            return

        # In playlist view: remove tracks
        removed_paths = [self.tracks[idx].path for idx in indices if idx < len(self.tracks)]
        was_playing_idx = self.current_idx
        for idx in indices:
            if idx < len(self.tracks):
                self.tracks.pop(idx)
                # Adjust current_idx
                if idx < self.current_idx:
                    self.current_idx -= 1
                elif idx == self.current_idx:
                    # Currently playing track removed — stop
                    self.current_idx = min(idx, len(self.tracks) - 1)
                    if self.playing and HAS_GST:
                        self.player.set_state(Gst.State.NULL)
                    self.playing = False
                    self.play_btn.set_icon_name('media-playback-start-symbolic')

        self._db_remove_tracks(removed_paths)
        self._refresh_list()
        if self.tracks and 0 <= self.current_idx < len(self.tracks):
            row = self.listbox.get_row_at_index(self.current_idx)
            if row:
                self.listbox.unselect_all()
                self.listbox.select_row(row)
            self._update_now_playing()
        elif not self.tracks:
            self.current_idx = -1
            self._update_now_playing()
        self._update_playing_indicators()
        self._toast.add_toast(Adw.Toast(title=f'Removed {len(indices)} track{"s" if len(indices) > 1 else ""}', timeout=2))

    def _add_files_from_paths(self, paths):
        # Collect all file paths first
        file_list = []
        for path in paths:
            if os.path.isdir(path):
                for root, dirs, files in os.walk(path):
                    dirs.sort()
                    for f in sorted(files):
                        if f.lower().endswith(AUDIO_EXTS):
                            file_list.append(os.path.join(root, f))
            elif os.path.isfile(path) and path.lower().endswith(AUDIO_EXTS):
                file_list.append(path)

        if not file_list:
            return

        total = len(file_list)
        self._header_loading_label.set_text(f'0/{total}')
        self._header_loading_box.set_visible(True)
        self._header_loading_spinner.start()

        first_new = len(self.tracks)
        self._pending_files = file_list
        self._pending_idx = 0
        self._pending_first_new = first_new
        GLib.idle_add(self._process_file_batch)

    def _process_file_batch(self):
        """Process one file per idle tick for smooth UI."""
        total = len(self._pending_files)

        if self._pending_idx < total:
            fp = self._pending_files[self._pending_idx]
            self._pending_idx += 1
            # Skip duplicates
            if not any(t.path == fp for t in self.tracks):
                track = Track(fp)
                track.discover_metadata()
                self.tracks.append(track)
                self._db_add_track(track, len(self.tracks) - 1)
                self._refresh_list()

                # Auto-play first new track immediately
                if (self._pending_first_new == len(self.tracks) - 1
                        and not self.playing):
                    self.current_idx = self._pending_first_new
                    self._update_now_playing()
                    self.playing = True
                    self.play_btn.set_icon_name('media-playback-pause-symbolic')
                    self._play_current()
                    if not self._tick_id:
                        self._tick_id = GLib.timeout_add(250, self._update_position)
                    self._update_playing_indicators()

        self._header_loading_label.set_text(f'{self._pending_idx}/{total}')

        if self._pending_idx < total:
            return True  # Continue processing

        # Done
        self._header_loading_spinner.stop()
        self._header_loading_box.set_visible(False)

        count = len(self.tracks) - self._pending_first_new
        if count > 0:
            self._refresh_list()
            # If we haven't started playing yet (all were duplicates?), start now
            if not self.playing and self._pending_first_new < len(self.tracks):
                self.current_idx = self._pending_first_new
                self._update_now_playing()
                self.playing = True
                self.play_btn.set_icon_name('media-playback-pause-symbolic')
                self._play_current()
                if not self._tick_id:
                    self._tick_id = GLib.timeout_add(250, self._update_position)
                self._update_playing_indicators()
            self.play_btn.grab_focus()
            self._toast.add_toast(Adw.Toast(title=f'Added {count} track{"s" if count > 1 else ""}', timeout=2))
        return False

    def _on_drop(self, target, value, x, y):
        if isinstance(value, Gio.File):
            path = value.get_path()
            if path:
                self._add_files_from_paths([path])
                return True
        return False

    def _on_drop_multi(self, target, value, x, y):
        if isinstance(value, Gdk.FileList):
            paths = [f.get_path() for f in value.get_files() if f.get_path()]
            if paths:
                self._add_files_from_paths(paths)
                return True
        return False

    def _on_eos(self, bus, msg):
        GLib.idle_add(self._on_next, None)

    def _on_gst_error(self, bus, msg):
        err, debug = msg.parse_error()
        GLib.idle_add(lambda: self._toast.add_toast(
            Adw.Toast(title=f'Error: {err.message}', timeout=3)))


class OndineApp(Adw.Application):
    def __init__(self):
        super().__init__(application_id=APP_ID,
                         flags=Gio.ApplicationFlags.HANDLES_OPEN)
        self.create_action('about', self._on_about)
        self.create_action('quit', lambda *_: self.quit(), ['<primary>q'])

    def create_action(self, name, callback, shortcuts=None):
        action = Gio.SimpleAction.new(name, None)
        action.connect('activate', callback)
        self.add_action(action)
        if shortcuts:
            self.set_accels_for_action(f'app.{name}', shortcuts)

    def do_activate(self):
        win = self.props.active_window or OndineWindow(application=self)
        win.present()

    def do_open(self, files, n_files, hint):
        """Handle files passed via command line or 'Open With' from file manager."""
        self.do_activate()
        win = self.props.active_window
        if win:
            paths = []
            for f in files:
                p = f.get_path()
                if p:
                    paths.append(p)
            if paths:
                win._add_files_from_paths(paths)

    def _on_about(self, *args):
        Adw.AboutDialog(
            application_name='Ondine',
            application_icon=APP_ID,
            version='2.9.0',
            developer_name='De Buck Technologies SRL',
            developers=['De Buck Technologies SRL'],
            copyright='© 2026 De Buck Technologies SRL',
            license_type=Gtk.License.GPL_3_0,
            website='https://gitlab.debuck.info/dbt/ondine',
            issue_url='https://gitlab.debuck.info/dbt/ondine/-/issues',
            comments='Amberol-inspired music player — ultra simple, stunning.\n\n'
                     'Supported formats: MP3, FLAC, OGG, WAV, M4A, AAC, Opus, WMA.\n'
                     'Drag & drop files or folders to add music.'
        ).present(self.props.active_window)


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


if __name__ == '__main__':
    main()
