/*
 * Traditional (Media Player Classic-style) GUI shell for the Windows build.
 * See gui.h for the overview.
 */

#include <windows.h>
#include <commctrl.h>
#include <commdlg.h>
#include <dwmapi.h>
#include <uxtheme.h>
#include <shlwapi.h>
#include <shellapi.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <wchar.h>
#include <math.h>
#include <ctype.h>

#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif

#include "mpv_talloc.h"
#include "common/common.h"
#include "common/global.h"
#include "common/msg.h"
#include "input/input.h"
#include "input/cmd.h"
#include "misc/bstr.h"
#include "osdep/io.h"
#include "player/client.h"
#include "mpv/client.h"

#include "gui.h"

// Posted to the frame when the mpv client handle has events pending.
#define WM_APP_MPV (WM_APP + 1)

EXTERN_C IMAGE_DOS_HEADER __ImageBase;
#define HINST_THISCOMPONENT ((HINSTANCE)&__ImageBase)

#define FRAME_CLASS L"jmwgFrame"

// Layout metrics (in DIPs at 96 DPI; scaled by g->dpi_scale).
#define SEEK_H      20
#define BTN_W       36
#define BTN_H       30
#define BTN_GAP     3
#define BAR_PAD     8
#define ROW_GAP     7   // between the seek bar and the button row
#define GROUP_GAP   12  // between control groups (buttons | time | volume)
#define VOL_W       120
#define TIME_W      150

// Control IDs.
enum {
    ID_SEEK = 100,
    ID_VOLUME,
    ID_STATUS,
    ID_TIMELBL,
    ID_BTN_PREV,
    ID_BTN_BACK,
    ID_BTN_PLAY,
    ID_BTN_STOP,
    ID_BTN_FWD,
    ID_BTN_NEXT,
    ID_BTN_MUTE,

    // Menu command IDs.
    ID_FILE_OPEN = 200,
    ID_FILE_OPENURL,
    ID_FILE_MEDIAINFO,
    ID_FILE_EXIT,
    ID_PLAY_PAUSE,
    ID_PLAY_STOP,
    ID_PLAY_PREV,
    ID_PLAY_NEXT,
    ID_PLAY_STEPF,
    ID_PLAY_STEPB,
    ID_VIEW_FULLSCREEN,
    ID_VIEW_ONTOP,
    ID_HELP_ABOUT,

    ID_PLAY_PREVCH,
    ID_PLAY_NEXTCH,
    ID_OPT_MPVKEYS,
    ID_OPT_MPVOSC,
    ID_SHADER_LOAD,
    ID_SHADER_CLEAR,
    ID_AUDIO_VOLUP,
    ID_AUDIO_VOLDOWN,
    ID_AUDIO_MUTE,
    ID_SUB_ADD,
    ID_ONTOP_NEVER,
    ID_ONTOP_ALWAYS,
    ID_ONTOP_PLAYING,
    ID_RECENT_CLEAR,
    ID_VIDEO_SCREENSHOT,
};

// Always-on-top modes (GUI-managed; mpv has no "while playing" concept).
enum { ONTOP_NEVER, ONTOP_ALWAYS, ONTOP_PLAYING };

// Dynamic menu id ranges. The low byte encodes the track id / list index.
#define ID_AUDIO_BASE   0x3000
#define ID_SUB_BASE     0x3100
#define ID_VIDEO_BASE   0x3200
#define ID_SPEED_BASE   0x3400
#define ID_ASPECT_BASE  0x3500
#define ID_RECENT_BASE  0x3600
#define ID_CHAPTER_BASE 0x3700
#define ID_DYN_MASK     0xFF00
#define ID_DYN_INDEX    0x00FF

#define MAX_RECENT 10
#define MAX_CHAPTERS 256

// WM_TIMER ids on the frame.
#define FS_TIMER_ID    1   // fullscreen activity/auto-hide poll
#define AUTOSIZE_ID    2   // deferred resize-window-to-video (off the mpv path)

// Segoe MDL2 Assets glyphs (modern Windows transport icons).
#define GLYPH_PREV   L"\uE892"  // Previous
#define GLYPH_BACK   L"\uE76B"  // ChevronLeft (step back)
#define GLYPH_PLAY   L"\uE768"  // Play
#define GLYPH_PAUSE  L"\uE769"  // Pause
#define GLYPH_STOP   L"\uE71A"  // Stop
#define GLYPH_FWD    L"\uE76C"  // ChevronRight (step forward)
#define GLYPH_NEXT   L"\uE893"  // Next
#define GLYPH_VOLUME L"\uE767"  // Volume (speaker)
#define GLYPH_MUTE   L"\uE74F"  // Mute (speaker with X)

struct mp_w32_gui {
    struct mp_log *log;
    struct mpv_global *global;
    struct input_ctx *input_ctx;

    HWND frame;
    HWND video;

    HWND seek;
    HWND volume;
    HWND status;
    HWND timelbl;
    HWND btn_prev, btn_back, btn_play, btn_stop, btn_fwd, btn_next, btn_mute;

    // Submenus whose contents are rebuilt on demand (WM_INITMENUPOPUP).
    HMENU menu_audio, menu_sub, menu_video, menu_speed, menu_aspect, menu_ontop;
    HMENU menu_recent, menu_chapters, menu_view;

    // Optional opt-ins (default off): mpv's own key bindings and native OSC.
    bool mpv_keys;
    bool mpv_osc;

    // Most-recently-used file list ([0] = newest), persisted to disk.
    char *recent[MAX_RECENT];
    int recent_count;

    // Chapter start times (seconds) and titles, for the seek-bar markers and
    // the hover tooltip.
    double chap_time[MAX_CHAPTERS];
    char *chap_title[MAX_CHAPTERS];
    int chap_count;

    HFONT glyph_font; // Segoe MDL2 Assets, for the transport icons
    HFONT ui_font;    // Segoe UI, for the time label / status bar

    double dpi_scale;

    // Internal mpv client handle, used to observe playback state and (later)
    // query richer data for the menus.
    mpv_handle *client;

    double cur_time;
    double cur_duration;
    int64_t cur_chapter;  // observed; used by Prev/Next (no sync query on nav)
    char *cur_path;       // observed current file path (talloc'd on g)

    // Cached source info for the status "codec" pane, updated purely from
    // property observers. Must NOT be filled with synchronous mpv_get_property
    // from the window thread (that deadlocks against the VO during reconfig).
    int64_t info_w, info_h;
    char info_vfmt[64];
    char info_afmt[64];
    char info_container[24];

    bool paused;
    bool idle;
    bool eof;          // playback reached end of file (keep-open paused it)
    bool stopped;      // file loaded but stopped (rewound, awaiting Play)
    bool seeking;      // user is dragging the seek bar
    bool vol_dragging; // user is dragging the volume bar
    HWND tooltip;      // tracking tooltip shown while dragging the volume bar

    // Fullscreen auto-hide: a bottom overlay strip of controls that appears on
    // mouse movement into the lower screen and hides (with the cursor) after a
    // few seconds of inactivity. It is a SEPARATE top-level (owned popup) window,
    // not a child of the frame — a child sibling of mpv's D3D11 video window gets
    // painted over by the swapchain present, so the controls were invisible. The
    // transport widgets are re-parented into it while fullscreen.
    HWND fs_overlay;
    bool fs_controls_shown;
    bool cursor_hidden;
    POINT fs_last_pt;
    DWORD fs_idle_since;

    // Fullscreen / always-on-top state (frame-level; the embedded VO can't
    // apply these to itself).
    bool fullscreen;
    bool ontop;        // current topmost state
    int ontop_mode;    // ONTOP_NEVER / ONTOP_ALWAYS / ONTOP_PLAYING
    RECT saved_rect;
    LONG_PTR saved_style;
    HMENU saved_menu;

    // System-theme (light/dark) matching.
    bool dark;
    HBRUSH bg_brush;

    // Persisted across sessions in %APPDATA%\mpv\jmwg-state.txt: the live volume
    // and mute (cached from their observers), and the values loaded at startup to
    // restore on first show.
    double last_volume;     // mpv volume scale, cached for saving
    bool have_volume;       // last_volume has been observed at least once
    bool last_mute;
    double init_volume;     // loaded-from-disk values, applied at startup
    bool init_volume_set;
    bool init_mute;
    RECT init_rect;         // saved window placement (rcNormalPosition)
    bool init_rect_set;
    bool init_max;          // restore maximized

    // True while inside a modal move/size loop (title-bar drag, edge resize).
    // The per-frame seek/time updates are suppressed so they don't compete with
    // the OS move loop on this thread and make the window drag stutter.
    bool in_move_size;

    // Video DISPLAY size (aspect-corrected); the window auto-fits to it whenever
    // it changes (windowed only) so it tracks variable-resolution clips. A
    // one-shot timer (autosize_timer_on) throttles/defers the resize.
    int64_t vid_dw, vid_dh;
    bool autosize_timer_on;
};

static void set_fullscreen(struct mp_w32_gui *g, bool fs);
static void autosize_to_video(struct mp_w32_gui *g);
static void apply_theme(struct mp_w32_gui *g);
static void stop_playback(struct mp_w32_gui *g);
static void show_media_info(struct mp_w32_gui *g);
static void load_shader_dialog(struct mp_w32_gui *g);
static void load_shader(struct mp_w32_gui *g, const wchar_t *file);
static mpv_node *map_get(mpv_node *map, const char *key);
static const char *map_str(mpv_node *map, const char *key, const char *def);

#define DARK_BG   RGB(32, 32, 32)
#define DARK_TEXT RGB(240, 240, 240)

static const struct { const wchar_t *label; double val; } g_speeds[] = {
    { L"0.&5x", 0.5 }, { L"0.&75x", 0.75 }, { L"&Normal", 1.0 },
    { L"1.&25x", 1.25 }, { L"1.&5x", 1.5 }, { L"&2x", 2.0 },
};
static const struct { const wchar_t *label; const char *val; } g_aspects[] = {
    { L"&Auto", "-1" }, { L"&16:9", "1.7778" }, { L"&4:3", "1.3333" },
    { L"&2.35:1", "2.35" }, { L"1.&85:1", "1.85" },
};

static ATOM g_frame_class;

static int dpi(struct mp_w32_gui *g, int v)
{
    return (int)(v * g->dpi_scale + 0.5);
}

static void run_cmd(struct mp_w32_gui *g, const char *cmd)
{
    if (!g->input_ctx)
        return;
    struct mp_cmd *c = mp_input_parse_cmd(g->input_ctx, bstr0(cmd), "<gui>");
    if (c)
        mp_input_queue_cmd(g->input_ctx, c);
}

// Load a UTF-8 path/URL. Uses the client API's argument array, so there are
// no quoting/escaping concerns regardless of the characters in the path.
static void load_utf8(struct mp_w32_gui *g, const char *path)
{
    if (!path || !path[0])
        return;
    if (g->client) {
        const char *cmd[] = { "loadfile", path, NULL };
        mpv_command_async(g->client, 0, cmd);
    } else {
        char *c = talloc_asprintf(NULL, "loadfile \"%s\"", path);
        run_cmd(g, c);
        talloc_free(c);
    }
}

static void load_file(struct mp_w32_gui *g, const wchar_t *wpath)
{
    char *path = mp_to_utf8(NULL, wpath);
    load_utf8(g, path);
    talloc_free(path);
}

//--- recent files (MRU), persisted to %APPDATA%\mpv\recent.txt --------------

static bool get_recent_path(wchar_t *out, size_t n)
{
    const wchar_t *appdata = _wgetenv(L"APPDATA");
    if (!appdata || wcslen(appdata) + 24 > n)
        return false;
    wcscpy(out, appdata);
    wcscat(out, L"\\mpv");
    CreateDirectoryW(out, NULL);
    wcscat(out, L"\\recent.txt");
    return true;
}

static void save_recent(struct mp_w32_gui *g)
{
    wchar_t path[MAX_PATH];
    if (!get_recent_path(path, MP_ARRAY_SIZE(path)))
        return;
    FILE *f = _wfopen(path, L"wb");
    if (!f)
        return;
    for (int i = 0; i < g->recent_count; i++)
        fprintf(f, "%s\n", g->recent[i]);
    fclose(f);
}

static void load_recent(struct mp_w32_gui *g)
{
    wchar_t path[MAX_PATH];
    if (!get_recent_path(path, MP_ARRAY_SIZE(path)))
        return;
    FILE *f = _wfopen(path, L"rb");
    if (!f)
        return;
    char line[8192];
    while (g->recent_count < MAX_RECENT && fgets(line, sizeof(line), f)) {
        size_t len = strlen(line);
        while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r'))
            line[--len] = 0;
        if (len > 0)
            g->recent[g->recent_count++] = talloc_strdup(g, line);
    }
    fclose(f);
}

static void add_recent(struct mp_w32_gui *g, const char *path)
{
    if (!path || !path[0])
        return;
    // Drop any existing occurrence of this path.
    for (int i = 0; i < g->recent_count; i++) {
        if (strcmp(g->recent[i], path) == 0) {
            talloc_free(g->recent[i]);
            memmove(&g->recent[i], &g->recent[i + 1],
                    (g->recent_count - i - 1) * sizeof(g->recent[0]));
            g->recent_count--;
            break;
        }
    }
    if (g->recent_count == MAX_RECENT)
        talloc_free(g->recent[--g->recent_count]);
    memmove(&g->recent[1], &g->recent[0], g->recent_count * sizeof(g->recent[0]));
    g->recent[0] = talloc_strdup(g, path);
    g->recent_count++;
    save_recent(g);
}

//--- persisted settings: volume, mute, window placement --------------------

static bool get_state_path(wchar_t *out, size_t n)
{
    const wchar_t *appdata = _wgetenv(L"APPDATA");
    if (!appdata || wcslen(appdata) + 24 > n)
        return false;
    wcscpy(out, appdata);
    wcscat(out, L"\\mpv");
    CreateDirectoryW(out, NULL);
    wcscat(out, L"\\jmwg-state.txt");
    return true;
}

static void load_state(struct mp_w32_gui *g)
{
    wchar_t path[MAX_PATH];
    if (!get_state_path(path, MP_ARRAY_SIZE(path)))
        return;
    FILE *f = _wfopen(path, L"rb");
    if (!f)
        return;
    char line[256];
    long x = 0, y = 0, w = 0, h = 0, maxed = 0, got = 0;
    double dv;
    long lv;
    while (fgets(line, sizeof(line), f)) {
        if (sscanf(line, "volume=%lf", &dv) == 1) {
            g->init_volume = dv < 0 ? 0 : dv > 1000 ? 1000 : dv;
            g->init_volume_set = true;
        } else if (sscanf(line, "mute=%ld", &lv) == 1) {
            g->init_mute = lv != 0;
        } else if (sscanf(line, "win_x=%ld", &lv) == 1) { x = lv; got |= 1; }
        else if (sscanf(line, "win_y=%ld", &lv) == 1) { y = lv; got |= 2; }
        else if (sscanf(line, "win_w=%ld", &lv) == 1) { w = lv; got |= 4; }
        else if (sscanf(line, "win_h=%ld", &lv) == 1) { h = lv; got |= 8; }
        else if (sscanf(line, "win_max=%ld", &lv) == 1) { maxed = lv; }
    }
    fclose(f);
    // Only accept geometry that looks sane; SetWindowPlacement will pull an
    // off-screen rect back onto a visible monitor for us.
    if (got == 15 && w >= 200 && w <= 20000 && h >= 150 && h <= 20000) {
        g->init_rect.left = x;
        g->init_rect.top = y;
        g->init_rect.right = x + w;
        g->init_rect.bottom = y + h;
        g->init_rect_set = true;
        g->init_max = maxed != 0;
    }
}

static void save_state(struct mp_w32_gui *g)
{
    wchar_t path[MAX_PATH];
    if (!get_state_path(path, MP_ARRAY_SIZE(path)))
        return;

    // The restored (non-maximized, non-fullscreen) window rect + maximized flag,
    // in the workspace coordinates used by Get/SetWindowPlacement.
    RECT nr; int maxed = 0;
    if (g->fullscreen && g->init_rect_set) {
        nr = g->init_rect;             // keep the last known windowed placement
        maxed = g->init_max;
    } else {
        WINDOWPLACEMENT wp = { .length = sizeof(wp) };
        if (!g->fullscreen && GetWindowPlacement(g->frame, &wp)) {
            nr = wp.rcNormalPosition;
            maxed = (wp.showCmd == SW_SHOWMAXIMIZED);
        } else {
            GetWindowRect(g->frame, &nr);
        }
    }

    FILE *f = _wfopen(path, L"wb");
    if (!f)
        return;
    if (g->have_volume)
        fprintf(f, "volume=%.2f\nmute=%d\n", g->last_volume, g->last_mute ? 1 : 0);
    fprintf(f, "win_x=%ld\nwin_y=%ld\nwin_w=%ld\nwin_h=%ld\nwin_max=%d\n",
            (long)nr.left, (long)nr.top, (long)(nr.right - nr.left),
            (long)(nr.bottom - nr.top), maxed);
    fclose(f);
}

static void open_file_dialog(struct mp_w32_gui *g)
{
    wchar_t file[32768] = {0};
    OPENFILENAMEW ofn = {
        .lStructSize = sizeof(ofn),
        .hwndOwner = g->frame,
        .lpstrFile = file,
        .nMaxFile = MP_ARRAY_SIZE(file),
        .lpstrFilter = L"Media files\0*.mkv;*.mp4;*.avi;*.mov;*.webm;*.flv;"
                       L"*.ts;*.m2ts;*.wmv;*.mpg;*.mpeg;*.mp3;*.flac;*.wav;"
                       L"*.m4a;*.opus;*.ogg;*.aac;*.mka\0All files\0*.*\0\0",
        .nFilterIndex = 1,
        .Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY |
                 OFN_NOCHANGEDIR,
    };
    if (GetOpenFileNameW(&ofn))
        load_file(g, file);
    if (g->video)
        SetFocus(g->video);
}

static void run_set(struct mp_w32_gui *g, const char *prop, const char *val)
{
    char *cmd = talloc_asprintf(NULL, "set %s %s", prop, val);
    run_cmd(g, cmd);
    talloc_free(cmd);
}

// Compute and apply the frame's topmost state from the on-top mode and the
// current playback state. Called on mode change and on play/pause/stop.
static void apply_ontop(struct mp_w32_gui *g)
{
    bool top = g->ontop_mode == ONTOP_ALWAYS ||
               (g->ontop_mode == ONTOP_PLAYING && !g->paused && !g->idle);
    g->ontop = top;
    SetWindowPos(g->frame, top ? HWND_TOPMOST : HWND_NOTOPMOST,
                 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
}

//--- Prev/Next: chapter navigation, spilling over to sibling files ----------

static const wchar_t *const media_exts[] = {
    L"mkv", L"mp4", L"avi", L"mov", L"webm", L"flv", L"ts", L"m2ts", L"wmv",
    L"mpg", L"mpeg", L"m4v", L"3gp", L"ogv", L"mp3", L"flac", L"wav", L"m4a",
    L"opus", L"ogg", L"aac", L"mka", L"wma",
};

static bool has_media_ext(const wchar_t *name)
{
    const wchar_t *dot = wcsrchr(name, L'.');
    if (!dot)
        return false;
    for (size_t i = 0; i < MP_ARRAY_SIZE(media_exts); i++)
        if (_wcsicmp(dot + 1, media_exts[i]) == 0)
            return true;
    return false;
}

static int CALLBACK sort_names(void *a, void *b)
{
    return StrCmpLogicalW(*(wchar_t **)a, *(wchar_t **)b);
}

// Load the file before/after the current one (dir = -1 / +1) in the same
// directory, ordered the way Explorer would. No-op for URLs or at the ends.
static void open_sibling_file(struct mp_w32_gui *g, int dir)
{
    if (!g->client)
        return;
    // Cached observed path (no sync query — see nav_prev_next).
    if (!g->cur_path || !g->cur_path[0])
        return;
    wchar_t *wpath = mp_from_utf8(NULL, g->cur_path);

    if (wcsstr(wpath, L"://")) { // a URL, not a local directory
        talloc_free(wpath);
        return;
    }
    wchar_t *sep = wcsrchr(wpath, L'\\');
    wchar_t *fsep = wcsrchr(wpath, L'/');
    if (fsep > sep)
        sep = fsep;
    if (!sep) {
        talloc_free(wpath);
        return;
    }
    const wchar_t *curname = sep + 1;
    size_t dirlen = sep - wpath; // not counting the separator

    void *tmp = talloc_new(NULL);
    size_t pat_n = dirlen + 4;
    wchar_t *pattern = talloc_array(tmp, wchar_t, pat_n);
    swprintf(pattern, pat_n, L"%.*s\\*", (int)dirlen, wpath);

    WIN32_FIND_DATAW fd;
    HANDLE h = FindFirstFileW(pattern, &fd);
    if (h == INVALID_HANDLE_VALUE) {
        talloc_free(tmp);
        talloc_free(wpath);
        return;
    }
    wchar_t **names = NULL;
    int count = 0;
    do {
        if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
            continue;
        if (!has_media_ext(fd.cFileName))
            continue;
        wchar_t *dup = talloc_memdup(tmp, fd.cFileName,
                                     (wcslen(fd.cFileName) + 1) * sizeof(wchar_t));
        MP_TARRAY_APPEND(tmp, names, count, dup);
    } while (FindNextFileW(h, &fd));
    FindClose(h);

    if (count > 1)
        qsort(names, count, sizeof(names[0]), (void *)sort_names);

    int cur = -1;
    for (int i = 0; i < count; i++) {
        if (_wcsicmp(names[i], curname) == 0) {
            cur = i;
            break;
        }
    }
    int target = cur + dir;
    if (cur >= 0 && target >= 0 && target < count) {
        size_t fp_n = dirlen + 1 + wcslen(names[target]) + 1;
        wchar_t *full = talloc_array(tmp, wchar_t, fp_n);
        swprintf(full, fp_n, L"%.*s\\%ls", (int)dirlen, wpath, names[target]);
        load_file(g, full);
    }
    talloc_free(tmp);
    talloc_free(wpath);
}

// Prev/Next button, menu and hotkey: step chapters, and when already at the
// first/last chapter (or the file has none) move to the sibling file.
static void nav_prev_next(struct mp_w32_gui *g, int dir)
{
    if (!g->client)
        return;
    // Use observed/cached state — a synchronous mpv_get_property here would
    // block the window thread against the VO while it reconfigures (which can
    // hang under rapid file switching of variable-resolution clips).
    int chapters = g->chap_count;
    int64_t chapter = g->cur_chapter;
    if (chapters > 1) {
        if (dir > 0 && chapter < chapters - 1) {
            run_cmd(g, "add chapter 1");
            return;
        }
        if (dir < 0 && chapter > 0) {
            run_cmd(g, "add chapter -1");
            return;
        }
    }
    open_sibling_file(g, dir);
}

//--- Help > About -----------------------------------------------------------

// Open clicked hyperlinks in the default browser.
static HRESULT CALLBACK about_taskdlg_cb(HWND hwnd, UINT msg, WPARAM wp,
                                         LPARAM lp, LONG_PTR ref)
{
    (void)wp; (void)ref;
    if (msg == TDN_HYPERLINK_CLICKED && lp)
        ShellExecuteW(hwnd, L"open", (const wchar_t *)lp, NULL, NULL,
                      SW_SHOWNORMAL);
    return S_OK;
}

// The build's upstream mpv base (from version.h: v0.41.0-dev-g2bd9c3229-dirty;
// "-dirty" = the local jmwg modifications on top).
#define MPV_BASE_COMMIT L"2bd9c3229"

static void show_about(struct mp_w32_gui *g)
{
    char *mpvver = NULL, *ffver = NULL;
    if (g->client) {
        mpv_get_property(g->client, "mpv-version", MPV_FORMAT_STRING, &mpvver);
        mpv_get_property(g->client, "ffmpeg-version", MPV_FORMAT_STRING, &ffver);
    }
    wchar_t wmpv[160], wff[160];
    MultiByteToWideChar(CP_UTF8, 0, (mpvver && mpvver[0]) ? mpvver : "mpv",
                        -1, wmpv, MP_ARRAY_SIZE(wmpv));
    MultiByteToWideChar(CP_UTF8, 0, (ffver && ffver[0]) ? ffver : "unknown",
                        -1, wff, MP_ARRAY_SIZE(wff));
    mpv_free(mpvver);
    mpv_free(ffver);

    // Keep this reasonably short: TaskDialog clips very long content.
    wchar_t content[1600];
    _snwprintf(content, MP_ARRAY_SIZE(content),
        L"A Media Player Classic–style interface built directly into mpv's own "
        L"playback engine and renderer — not an external wrapper.\n\n"
        L"Engine:  %ls\nFFmpeg:  %ls\n\n"
        L"Source:  <a href=\"https://github.com/mpv-player/mpv\">"
        L"github.com/mpv-player/mpv</a>  ·  fork of commit "
        L"<a href=\"https://github.com/mpv-player/mpv/commit/" MPV_BASE_COMMIT
        L"\">" MPV_BASE_COMMIT L"</a>\n\n"
        L"License:  GNU GPL, version 3 or later.  jmwg is built into mpv and links "
        L"its GPL components (FFmpeg, x264, x265).  "
        L"<a href=\"https://www.gnu.org/licenses/gpl-3.0.html\">GPL v3 text</a>  ·  "
        L"<a href=\"https://github.com/mpv-player/mpv/blob/master/Copyright\">"
        L"component licenses</a>\n\n"
        L"Bundled libraries keep their own licenses (THIRD-PARTY-LICENSES.txt).\n"
        L"Includes MediaInfo by MediaArea (BSD-2-Clause).\n"
        L"© 2000–2026 mpv / MPlayer / mplayer2 projects.",
        wmpv, wff);

    TASKDIALOGCONFIG tc = {0};
    tc.cbSize = sizeof(tc);
    tc.hwndParent = g->frame;
    tc.hInstance = HINST_THISCOMPONENT;
    // Custom (app) icon + hyperlinks. A custom HICON is silent — only the
    // standard info/warning/error icons trigger the system "ding".
    tc.dwFlags = TDF_ENABLE_HYPERLINKS | TDF_USE_HICON_MAIN |
                 TDF_ALLOW_DIALOG_CANCELLATION;
    tc.dwCommonButtons = TDCBF_OK_BUTTON;
    tc.pszWindowTitle = L"About jmwg";
    tc.hMainIcon = LoadIconW(HINST_THISCOMPONENT, L"IDI_ICON1");
    tc.pszMainInstruction = L"jmwg 1.0.0 — Josh's mpv Win32 GUI";
    tc.pszContent = content;
    tc.pfCallback = about_taskdlg_cb;

    if (TaskDialogIndirect(&tc, NULL, NULL, NULL) != S_OK) {
        // Fallback if TaskDialog is unavailable: plain box, no icon (no "ding").
        MessageBoxW(g->frame,
            L"jmwg — Josh's mpv Win32 GUI.\n\n"
            L"Built into the mpv playback engine "
            L"(github.com/mpv-player/mpv), licensed under the GNU GPL v3 or later.",
            L"About jmwg", MB_OK);
    }
}

// Play/Pause that also handles the finished/stopped state: restart from the
// beginning rather than un-pausing onto the last (EOF) frame.
static void play_or_restart(struct mp_w32_gui *g)
{
    if (g->eof || g->stopped) {
        run_cmd(g, "no-osd seek 0 absolute");
        run_cmd(g, "set pause no");
    } else {
        run_cmd(g, "cycle pause");
    }
}

static void on_command(struct mp_w32_gui *g, int id)
{
    // Dynamic submenu ranges: track selection, speed and aspect presets.
    int hi = id & ID_DYN_MASK;
    int idx = id & ID_DYN_INDEX;
    if (hi == ID_AUDIO_BASE || hi == ID_SUB_BASE || hi == ID_VIDEO_BASE) {
        const char *prop = hi == ID_AUDIO_BASE ? "aid"
                         : hi == ID_SUB_BASE   ? "sid" : "vid";
        char num[16];
        if (idx == 0)
            snprintf(num, sizeof(num), "no");
        else
            snprintf(num, sizeof(num), "%d", idx);
        run_set(g, prop, num);
        if (g->video)
            SetFocus(g->video);
        return;
    }
    if (hi == ID_SPEED_BASE && idx < (int)MP_ARRAY_SIZE(g_speeds)) {
        char num[32];
        snprintf(num, sizeof(num), "%g", g_speeds[idx].val);
        run_set(g, "speed", num);
        if (g->video)
            SetFocus(g->video);
        return;
    }
    if (hi == ID_ASPECT_BASE && idx < (int)MP_ARRAY_SIZE(g_aspects)) {
        run_set(g, "video-aspect-override", g_aspects[idx].val);
        if (g->video)
            SetFocus(g->video);
        return;
    }
    if (hi == ID_RECENT_BASE) {
        if (idx >= 0 && idx < g->recent_count)
            load_utf8(g, g->recent[idx]);
        if (g->video)
            SetFocus(g->video);
        return;
    }
    if (hi == ID_CHAPTER_BASE) {
        char num[16];
        snprintf(num, sizeof(num), "%d", idx);
        run_set(g, "chapter", num);
        if (g->video)
            SetFocus(g->video);
        return;
    }

    switch (id) {
    case ID_FILE_OPEN:                       open_file_dialog(g); return;
    case ID_FILE_MEDIAINFO: show_media_info(g);                   return;
    case ID_FILE_EXIT:     run_cmd(g, "quit");                    break;

    case ID_RECENT_CLEAR:
        for (int i = 0; i < g->recent_count; i++)
            talloc_free(g->recent[i]);
        g->recent_count = 0;
        save_recent(g);
        break;

    case ID_BTN_PREV:
    case ID_PLAY_PREV:     nav_prev_next(g, -1);                  break;
    case ID_BTN_NEXT:
    case ID_PLAY_NEXT:     nav_prev_next(g, +1);                  break;
    case ID_BTN_PLAY:
    case ID_PLAY_PAUSE:    play_or_restart(g);                     break;
    case ID_BTN_STOP:
    case ID_PLAY_STOP:     stop_playback(g);                      break;
    case ID_BTN_BACK:
    case ID_PLAY_STEPB:    run_cmd(g, "frame-back-step");         break;
    case ID_BTN_FWD:
    case ID_PLAY_STEPF:    run_cmd(g, "frame-step");              break;

    case ID_PLAY_PREVCH:   run_cmd(g, "add chapter -1");          break;
    case ID_PLAY_NEXTCH:   run_cmd(g, "add chapter 1");           break;

    case ID_AUDIO_VOLUP:   run_cmd(g, "add volume 5");            break;
    case ID_AUDIO_VOLDOWN: run_cmd(g, "add volume -5");           break;
    case ID_BTN_MUTE:
    case ID_AUDIO_MUTE:    run_cmd(g, "cycle mute");              break;

    case ID_VIEW_FULLSCREEN: run_cmd(g, "cycle fullscreen");      break;

    case ID_OPT_MPVKEYS:
        g->mpv_keys = !g->mpv_keys;
        run_set(g, "input-default-bindings", g->mpv_keys ? "yes" : "no");
        break;
    case ID_OPT_MPVOSC:
        g->mpv_osc = !g->mpv_osc;
        run_set(g, "osc", g->mpv_osc ? "yes" : "no");
        break;

    case ID_VIDEO_SCREENSHOT: run_cmd(g, "screenshot");          break;
    case ID_SHADER_LOAD:   load_shader_dialog(g);               return;
    case ID_SHADER_CLEAR:
        if (g->client) {
            const char *c[] = { "set", "glsl-shaders", "", NULL };
            mpv_command_async(g->client, 0, c);
        }
        break;

    case ID_ONTOP_NEVER:   g->ontop_mode = ONTOP_NEVER;   apply_ontop(g); break;
    case ID_ONTOP_ALWAYS:  g->ontop_mode = ONTOP_ALWAYS;  apply_ontop(g); break;
    case ID_ONTOP_PLAYING: g->ontop_mode = ONTOP_PLAYING; apply_ontop(g); break;

    case ID_HELP_ABOUT:    show_about(g);                          break;
    default:
        break;
    }

    if (g->video)
        SetFocus(g->video);
}

//--- volume slider: amplitude-linear taper + drag tooltip ------------------

// The slider is linear in amplitude (0..100 %). mpv applies gain =
// (volume/100)^3, so convert between the slider's amplitude % and the mpv
// volume property with a cube / cube-root. This makes 50% actually -6 dB
// (clearly audible) instead of mpv's -18 dB.
static double slider_to_volume(int pct)
{
    return 100.0 * cbrt(pct / 100.0);
}

static int volume_to_slider(double vol)
{
    double f = vol / 100.0;
    int pct = (int)(100.0 * f * f * f + 0.5);
    return pct < 0 ? 0 : pct > 100 ? 100 : pct;
}

static void create_vol_tooltip(struct mp_w32_gui *g)
{
    g->tooltip = CreateWindowExW(WS_EX_TOPMOST, TOOLTIPS_CLASSW, NULL,
        WS_POPUP | TTS_NOPREFIX | TTS_ALWAYSTIP,
        CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
        g->frame, NULL, HINST_THISCOMPONENT, NULL);
    if (!g->tooltip)
        return;
    TOOLINFOW ti = {
        .cbSize = sizeof(ti),
        .uFlags = TTF_TRACK | TTF_ABSOLUTE,
        .hwnd = g->frame,
        .uId = 1,
        .lpszText = L"",
    };
    SendMessageW(g->tooltip, TTM_ADDTOOLW, 0, (LPARAM)&ti);
}

// Show the tracking tooltip with the given text, offset from the cursor.
static void show_tip_text(struct mp_w32_gui *g, const wchar_t *text, int dx, int dy)
{
    if (!g->tooltip)
        return;
    TOOLINFOW ti = { .cbSize = sizeof(ti), .hwnd = g->frame, .uId = 1,
                     .lpszText = (wchar_t *)text };
    SendMessageW(g->tooltip, TTM_UPDATETIPTEXTW, 0, (LPARAM)&ti);
    POINT pt;
    GetCursorPos(&pt);
    SendMessageW(g->tooltip, TTM_TRACKPOSITION, 0,
                 MAKELPARAM(pt.x + dx, pt.y + dy));
    SendMessageW(g->tooltip, TTM_TRACKACTIVATE, TRUE, (LPARAM)&ti);
}

static void show_vol_tip(struct mp_w32_gui *g, int pct)
{
    wchar_t text[16];
    _snwprintf(text, MP_ARRAY_SIZE(text), L"%d%%", pct);
    show_tip_text(g, text, 12, -24);
}

// Index of the chapter covering time t (the last one starting at or before t),
// or -1 if none.
static int chapter_at_time(struct mp_w32_gui *g, double t)
{
    int idx = -1;
    for (int i = 0; i < g->chap_count; i++) {
        if (g->chap_time[i] <= t + 1e-6)
            idx = i;
    }
    return idx;
}

static void hide_vol_tip(struct mp_w32_gui *g)
{
    if (!g->tooltip)
        return;
    TOOLINFOW ti = { .cbSize = sizeof(ti), .hwnd = g->frame, .uId = 1 };
    SendMessageW(g->tooltip, TTM_TRACKACTIVATE, FALSE, (LPARAM)&ti);
}

static void on_hscroll(struct mp_w32_gui *g, HWND ctrl, int code)
{
    if (ctrl == g->seek) {
        int pos = (int)SendMessageW(g->seek, TBM_GETPOS, 0, 0);
        g->seeking = (code != TB_ENDTRACK && code != SB_ENDSCROLL);
        // Fast keyframe seeks while dragging; a precise seek when released.
        const char *prec = g->seeking ? "keyframes" : "exact";
        char *cmd = talloc_asprintf(NULL, "no-osd seek %.4f absolute-percent+%s",
                                    pos / 100.0, prec);
        run_cmd(g, cmd);
        talloc_free(cmd);
    } else if (ctrl == g->volume) {
        int pos = (int)SendMessageW(g->volume, TBM_GETPOS, 0, 0);
        g->vol_dragging = (code != TB_ENDTRACK && code != SB_ENDSCROLL);
        char *cmd = talloc_asprintf(NULL, "no-osd set volume %.2f",
                                    slider_to_volume(pos));
        run_cmd(g, cmd);
        talloc_free(cmd);
        if (g->vol_dragging)
            show_vol_tip(g, pos);
        else
            hide_vol_tip(g);
    }
}

//--- live playback state from an internal mpv client handle -----------------

static void fmt_time(char *buf, size_t n, double t)
{
    if (!(t >= 0) || !isfinite(t))
        t = 0;
    int s = (int)(t + 0.5);
    snprintf(buf, n, "%d:%02d:%02d", s / 3600, (s / 60) % 60, s % 60);
}

static void update_time_label(struct mp_w32_gui *g)
{
    char a[32], b[32], buf[80];
    fmt_time(a, sizeof(a), g->cur_time);
    fmt_time(b, sizeof(b), g->cur_duration);
    snprintf(buf, sizeof(buf), "%s / %s", a, b);
    wchar_t *w = mp_from_utf8(NULL, buf);
    SetWindowTextW(g->timelbl, w);
    talloc_free(w);
}

static void update_status(struct mp_w32_gui *g)
{
    const wchar_t *s = (g->idle || g->stopped || g->eof) ? L"Stopped"
                     : g->paused                         ? L"Paused" : L"Playing";
    SendMessageW(g->status, SB_SETTEXTW, 0, (LPARAM)s);
    // Play button shows "pause" only while actually playing.
    bool playing = !g->paused && !g->idle;
    SetWindowTextW(g->btn_play, playing ? GLYPH_PAUSE : GLYPH_PLAY);
    // "On top while playing" depends on the play state.
    apply_ontop(g);
}

// Status pane 1: the file's name (not mpv's internal media-title, which still
// drives the window title bar).
static void set_filename_pane(struct mp_w32_gui *g, const char *name)
{
    if (name && name[0]) {
        wchar_t *w = mp_from_utf8(NULL, name);
        SendMessageW(g->status, SB_SETTEXTW, 1, (LPARAM)w);
        talloc_free(w);
    } else {
        SendMessageW(g->status, SB_SETTEXTW, 1, (LPARAM)L"");
    }
}

// Derive the container label (uppercased file extension) from the filename.
static void set_container_from_name(struct mp_w32_gui *g, const char *name)
{
    g->info_container[0] = 0;
    if (!name)
        return;
    const char *dot = strrchr(name, '.');
    if (dot && dot[1]) {
        int k = 0;
        for (const char *c = dot + 1;
             *c && k < (int)sizeof(g->info_container) - 1; c++)
            g->info_container[k++] = (char)toupper((unsigned char)*c);
        g->info_container[k] = 0;
    }
}

// Copy a STRING property's current value into a fixed buffer (clears on unset).
static void copy_str_prop(char *dst, size_t n, mpv_event_property *p)
{
    const char *s = (p->format == MPV_FORMAT_STRING && p->data)
                        ? *(char **)p->data : NULL;
    if (s)
        snprintf(dst, n, "%s", s);
    else
        dst[0] = 0;
}

// Status pane 2: a compact "1920x1080 | h264 | aac | MKV" technical summary,
// built only from cached, observer-supplied values (no synchronous queries).
static void update_codec_pane(struct mp_w32_gui *g)
{
    char buf[256];
    int p = 0;
    const char *sep = "";
    if (g->info_w > 0 && g->info_h > 0) {
        p += snprintf(buf + p, sizeof(buf) - p, "%lldx%lld",
                      (long long)g->info_w, (long long)g->info_h);
        sep = "  |  ";
    }
    if (g->info_vfmt[0]) {
        p += snprintf(buf + p, sizeof(buf) - p, "%s%s", sep, g->info_vfmt);
        sep = "  |  ";
    }
    if (g->info_afmt[0]) {
        p += snprintf(buf + p, sizeof(buf) - p, "%s%s", sep, g->info_afmt);
        sep = "  |  ";
    }
    if (g->info_container[0])
        p += snprintf(buf + p, sizeof(buf) - p, "%s%s", sep, g->info_container);
    if (p == 0)
        buf[0] = 0;

    wchar_t *wb = mp_from_utf8(NULL, buf);
    SendMessageW(g->status, SB_SETTEXTW, 2, (LPARAM)wb);
    talloc_free(wb);
}

// "Stop" in a desktop player keeps the file loaded but rewinds and pauses it,
// so Play resumes from the start. (mpv's own `stop` would unload the file.)
static void stop_playback(struct mp_w32_gui *g)
{
    if (g->idle)
        return;
    run_cmd(g, "set pause yes");
    run_cmd(g, "no-osd seek 0 absolute");
    g->stopped = true;
    update_status(g);
}

// Force an immediate, full repaint of the seek bar so the chapter-marker
// custom-draw re-runs. A plain async InvalidateRect isn't enough: during slow
// playback the thumb barely moves, so the channel is otherwise never redrawn.
static void invalidate_seek(struct mp_w32_gui *g)
{
    InvalidateRect(g->seek, NULL, TRUE);
    UpdateWindow(g->seek);
}

static void parse_chapters_node(struct mp_w32_gui *g, mpv_node *node)
{
    for (int i = 0; i < g->chap_count; i++) {
        talloc_free(g->chap_title[i]);
        g->chap_title[i] = NULL;
    }
    g->chap_count = 0;
    if (!node || node->format != MPV_FORMAT_NODE_ARRAY)
        return;
    for (int i = 0; i < node->u.list->num && g->chap_count < MAX_CHAPTERS; i++) {
        mpv_node *entry = &node->u.list->values[i];
        mpv_node *t = map_get(entry, "time");
        double time = 0;
        if (t && t->format == MPV_FORMAT_DOUBLE)
            time = t->u.double_;
        else if (t && t->format == MPV_FORMAT_INT64)
            time = (double)t->u.int64;
        const char *title = map_str(entry, "title", NULL);
        g->chap_time[g->chap_count] = time;
        g->chap_title[g->chap_count] = title ? talloc_strdup(g, title) : NULL;
        g->chap_count++;
    }
}

static void on_property(struct mp_w32_gui *g, mpv_event_property *p)
{
    if (!strcmp(p->name, "pause")) {
        g->paused = p->format == MPV_FORMAT_FLAG && *(int *)p->data;
        if (!g->paused)
            g->stopped = false; // actually playing again
        update_status(g);
    } else if (!strcmp(p->name, "idle-active")) {
        g->idle = p->format == MPV_FORMAT_FLAG && *(int *)p->data;
        if (g->idle) {
            g->info_w = g->info_h = 0;
            g->info_vfmt[0] = g->info_afmt[0] = g->info_container[0] = 0;
            SendMessageW(g->status, SB_SETTEXTW, 1, (LPARAM)L"");
            SendMessageW(g->status, SB_SETTEXTW, 2, (LPARAM)L"");
        }
        update_status(g);
    } else if (!strcmp(p->name, "eof-reached")) {
        // The file finished (keep-open pauses it at the end): just reflect the
        // "Stopped/finished" state. Do NOT seek back to 0 — that would defeat
        // Next (which would then chapter-step from chapter 0 instead of moving
        // to the next file) and yank the position around when a seek lands at
        // the end. Play restarts from the start instead (see ID_PLAY_PAUSE).
        g->eof = p->format == MPV_FORMAT_FLAG && *(int *)p->data;
        update_status(g);
    } else if (!strcmp(p->name, "path")) {
        if (p->format == MPV_FORMAT_STRING) {
            add_recent(g, *(char **)p->data);
            talloc_free(g->cur_path);
            g->cur_path = talloc_strdup(g, *(char **)p->data);
        }
    } else if (!strcmp(p->name, "chapter")) {
        g->cur_chapter = p->format == MPV_FORMAT_INT64 ? *(int64_t *)p->data : -1;
    } else if (!strcmp(p->name, "time-pos")) {
        g->cur_time = p->format == MPV_FORMAT_DOUBLE ? *(double *)p->data : 0;
        if (!g->in_move_size)
            update_time_label(g);
    } else if (!strcmp(p->name, "duration")) {
        g->cur_duration = p->format == MPV_FORMAT_DOUBLE ? *(double *)p->data : 0;
        update_time_label(g);
        invalidate_seek(g); // chapter markers depend on the duration
    } else if (!strcmp(p->name, "chapter-list")) {
        parse_chapters_node(g, p->format == MPV_FORMAT_NODE
                                   ? (mpv_node *)p->data : NULL);
        invalidate_seek(g);
    } else if (!strcmp(p->name, "percent-pos")) {
        if (!g->seeking && !g->in_move_size && p->format == MPV_FORMAT_DOUBLE) {
            int v = (int)(*(double *)p->data * 100.0 + 0.5);
            v = v < 0 ? 0 : v > 10000 ? 10000 : v;
            SendMessageW(g->seek, TBM_SETPOS, TRUE, v);
        }
    } else if (!strcmp(p->name, "volume")) {
        if (p->format == MPV_FORMAT_DOUBLE) {
            g->last_volume = *(double *)p->data;
            g->have_volume = true;
            if (!g->vol_dragging)
                SendMessageW(g->volume, TBM_SETPOS, TRUE,
                             volume_to_slider(*(double *)p->data));
        }
    } else if (!strcmp(p->name, "mute")) {
        bool muted = p->format == MPV_FORMAT_FLAG && *(int *)p->data;
        g->last_mute = muted;
        SetWindowTextW(g->btn_mute, muted ? GLYPH_MUTE : GLYPH_VOLUME);
    } else if (!strcmp(p->name, "filename")) {
        const char *t = (p->format == MPV_FORMAT_STRING && p->data)
                            ? *(char **)p->data : NULL;
        set_filename_pane(g, t);
        set_container_from_name(g, t);
        update_codec_pane(g);
    } else if (!strcmp(p->name, "video-format")) {
        copy_str_prop(g->info_vfmt, sizeof(g->info_vfmt), p);
        update_codec_pane(g);
    } else if (!strcmp(p->name, "audio-codec-name")) {
        copy_str_prop(g->info_afmt, sizeof(g->info_afmt), p);
        update_codec_pane(g);
    } else if (!strcmp(p->name, "width")) {
        g->info_w = p->format == MPV_FORMAT_INT64 ? *(int64_t *)p->data : 0;
        update_codec_pane(g);
    } else if (!strcmp(p->name, "height")) {
        g->info_h = p->format == MPV_FORMAT_INT64 ? *(int64_t *)p->data : 0;
        update_codec_pane(g);
    } else if (!strcmp(p->name, "video-params")) {
        // Fit the window to the video's display (aspect-corrected) size whenever
        // it changes — so the window tracks a clip whose resolution varies, and
        // resizes once for a normal (constant-size) file. video-params is a node
        // so dw/dh arrive together. The resize is deferred via a one-shot timer:
        // it runs off this mpv-event path (resizing the VO child synchronously
        // here can deadlock) and the timer throttles the rate for fast changes.
        if (p->format == MPV_FORMAT_NODE) {
            mpv_node *dw = map_get(p->data, "dw");
            mpv_node *dh = map_get(p->data, "dh");
            if (dw && dh && dw->format == MPV_FORMAT_INT64 &&
                dh->format == MPV_FORMAT_INT64 &&
                dw->u.int64 > 0 && dh->u.int64 > 0) {
                g->vid_dw = dw->u.int64;
                g->vid_dh = dh->u.int64;
                if (!g->autosize_timer_on) {
                    g->autosize_timer_on = true;
                    SetTimer(g->frame, AUTOSIZE_ID, 16, NULL);
                }
            }
        }
    } else if (!strcmp(p->name, "fullscreen")) {
        set_fullscreen(g, p->format == MPV_FORMAT_FLAG && *(int *)p->data);
    }
}

static void handle_mpv_events(struct mp_w32_gui *g)
{
    if (!g->client)
        return;
    while (1) {
        mpv_event *ev = mpv_wait_event(g->client, 0);
        if (ev->event_id == MPV_EVENT_NONE)
            break;
        // On quit the core sends SHUTDOWN and then blocks until every client
        // destroys its handle. We must do so here or the player never exits.
        if (ev->event_id == MPV_EVENT_SHUTDOWN) {
            mpv_destroy(g->client);
            g->client = NULL;
            return;
        }
        if (ev->event_id == MPV_EVENT_CLIENT_MESSAGE) {
            mpv_event_client_message *m = ev->data;
            if (m->num_args >= 1 && !strcmp(m->args[0], "open-file"))
                open_file_dialog(g);
            else if (m->num_args >= 1 && !strcmp(m->args[0], "prev"))
                nav_prev_next(g, -1);
            else if (m->num_args >= 1 && !strcmp(m->args[0], "next"))
                nav_prev_next(g, +1);
            else if (m->num_args >= 1 && !strcmp(m->args[0], "playpause"))
                play_or_restart(g);
            else if (m->num_args >= 1 && !strcmp(m->args[0], "click"))
                // Act instantly (play/pause, or restart from the start at EOF).
                // A double-click fires this twice (the two cycle-pauses cancel)
                // plus MBTN_LEFT_DBL -> cycle fullscreen, so a double-click ends
                // on the original play state with a brief pause flicker.
                play_or_restart(g);
            else if (m->num_args >= 2 && !strcmp(m->args[0], "load-shader")) {
                wchar_t *w = mp_from_utf8(NULL, m->args[1]);
                load_shader(g, w);
                talloc_free(w);
            }
        }
        if (ev->event_id == MPV_EVENT_FILE_LOADED) {
            // A freshly opened file always plays, regardless of the previous
            // stopped/paused state (pause is global, not per-file in mpv).
            g->stopped = false;
            g->eof = false;
            run_cmd(g, "set pause no");
            invalidate_seek(g); // redraw chapter markers for the new file
        }
        if (ev->event_id == MPV_EVENT_PROPERTY_CHANGE)
            on_property(g, ev->data);
    }
}

static void gui_wakeup(void *ctx)
{
    struct mp_w32_gui *g = ctx;
    PostMessageW(g->frame, WM_APP_MPV, 0, 0);
}

static void init_client(struct mp_w32_gui *g)
{
    if (!g->global || !g->global->client_api)
        return;
    g->client = mp_new_client(g->global->client_api, "gui");
    if (!g->client) {
        MP_WARN(g, "could not create GUI client handle\n");
        return;
    }
    mpv_observe_property(g->client, 0, "pause", MPV_FORMAT_FLAG);
    mpv_observe_property(g->client, 0, "idle-active", MPV_FORMAT_FLAG);
    mpv_observe_property(g->client, 0, "eof-reached", MPV_FORMAT_FLAG);
    mpv_observe_property(g->client, 0, "path", MPV_FORMAT_STRING);
    mpv_observe_property(g->client, 0, "time-pos", MPV_FORMAT_DOUBLE);
    mpv_observe_property(g->client, 0, "duration", MPV_FORMAT_DOUBLE);
    mpv_observe_property(g->client, 0, "chapter-list", MPV_FORMAT_NODE);
    mpv_observe_property(g->client, 0, "chapter", MPV_FORMAT_INT64);
    mpv_observe_property(g->client, 0, "percent-pos", MPV_FORMAT_DOUBLE);
    mpv_observe_property(g->client, 0, "volume", MPV_FORMAT_DOUBLE);
    mpv_observe_property(g->client, 0, "mute", MPV_FORMAT_FLAG);
    mpv_observe_property(g->client, 0, "filename", MPV_FORMAT_STRING);
    mpv_observe_property(g->client, 0, "video-format", MPV_FORMAT_STRING);
    mpv_observe_property(g->client, 0, "audio-codec-name", MPV_FORMAT_STRING);
    mpv_observe_property(g->client, 0, "width", MPV_FORMAT_INT64);
    mpv_observe_property(g->client, 0, "height", MPV_FORMAT_INT64);
    mpv_observe_property(g->client, 0, "video-params", MPV_FORMAT_NODE);
    mpv_observe_property(g->client, 0, "fullscreen", MPV_FORMAT_FLAG);

    // Conventional video-player key bindings (familiar from WMP/MPC/YouTube).
    // These override mpv's defaults. Ctrl+O is routed back to us so it can open
    // the native file dialog.
    static const char *const binds[][2] = {
        {"SPACE",        "script-message-to gui playpause"},
        // Single vs double click is disambiguated in the GUI by a timer, so a
        // double-click toggles fullscreen WITHOUT a pause flicker, and exiting
        // fullscreen never leaves playback paused (see WM_TIMER / client msgs).
        {"MBTN_LEFT",    "script-message-to gui click"},
        {"MBTN_LEFT_DBL", "cycle fullscreen"},
        // Right-click opens mpv's native context menu. The shell only forwards
        // the right button to mpv when native controls/OSC are enabled (otherwise
        // it shows its own menu), so this binding only fires in that mode.
        {"MBTN_RIGHT",   "script-binding select/context-menu"},
        {"PGUP",         "script-message-to gui prev"},
        {"PGDWN",        "script-message-to gui next"},
        {"UP",           "add volume 5"},
        {"DOWN",         "add volume -5"},
        {"WHEEL_UP",     "add volume 5"},
        {"WHEEL_DOWN",   "add volume -5"},
        {"LEFT",         "seek -5"},
        {"RIGHT",        "seek 5"},
        {"Shift+LEFT",   "seek -1 exact"},
        {"Shift+RIGHT",  "seek 1 exact"},
        {"m",            "cycle mute"},
        {"f",            "cycle fullscreen"},
        {"F11",          "cycle fullscreen"},
        {"Alt+ENTER",    "cycle fullscreen"},     // left Alt, and right Alt as plain Alt
        {"Ctrl+Alt+ENTER", "cycle fullscreen"},   // right Alt that still reports AltGr's Ctrl
        {"ESC",          "set fullscreen no"},
        {"HOME",         "seek 0 absolute"},
        {"ctrl+o",       "script-message-to gui open-file"},
        // Frame stepping (auto-pauses), playback speed, and chapter jumps.
        {".",            "frame-step"},
        {",",            "frame-back-step"},
        {"]",            "multiply speed 1.1"},
        {"[",            "multiply speed 1/1.1"},
        {"BS",           "set speed 1.0"},
        {"Ctrl+RIGHT",   "add chapter 1"},
        {"Ctrl+LEFT",    "add chapter -1"},
        // Screenshots (saved to the Desktop; see builtin.conf [builtin-gui]).
        {"s",            "screenshot"},
        {"S",            "screenshot video"},
        // Jump to 0%..90% of the file (MPC/YouTube-style).
        {"0",            "seek 0 absolute-percent"},
        {"1",            "seek 10 absolute-percent"},
        {"2",            "seek 20 absolute-percent"},
        {"3",            "seek 30 absolute-percent"},
        {"4",            "seek 40 absolute-percent"},
        {"5",            "seek 50 absolute-percent"},
        {"6",            "seek 60 absolute-percent"},
        {"7",            "seek 70 absolute-percent"},
        {"8",            "seek 80 absolute-percent"},
        {"9",            "seek 90 absolute-percent"},
    };
    // Must be async: init_client runs on the window thread during VO init, and
    // a synchronous command would deadlock against the core waiting on us.
    for (int i = 0; i < MP_ARRAY_SIZE(binds); i++) {
        const char *cmd[] = { "keybind", binds[i][0], binds[i][1], NULL };
        mpv_command_async(g->client, 0, cmd);
    }

    mpv_set_wakeup_callback(g->client, gui_wakeup, g);
}

static void layout(struct mp_w32_gui *g)
{
    if (!g->frame || !g->status)
        return;

    RECT rc;
    if (!GetClientRect(g->frame, &rc))
        return;
    int W = rc.right, H = rc.bottom;

    // In fullscreen the video fills the whole frame; chrome is hidden.
    if (g->fullscreen) {
        if (g->video)
            MoveWindow(g->video, 0, 0, W, H, TRUE);
        return;
    }

    // Status bar auto-docks to the bottom on WM_SIZE.
    SendMessageW(g->status, WM_SIZE, 0, 0);
    RECT sr;
    GetWindowRect(g->status, &sr);
    int sh = sr.bottom - sr.top;

    // Recompute the status panes for the current width: a fixed state pane on
    // the left, a fixed technical-info pane on the right, filename in between.
    int state_w = dpi(g, 90);
    int codec_w = dpi(g, 260);
    int mid = W - codec_w;
    if (mid < state_w + dpi(g, 60))
        mid = state_w + dpi(g, 60);
    int parts[] = { state_w, mid, -1 };
    SendMessageW(g->status, SB_SETPARTS, MP_ARRAY_SIZE(parts), (LPARAM)parts);

    int seek_h = dpi(g, SEEK_H);
    int btn_h  = dpi(g, BTN_H);
    int btn_w  = dpi(g, BTN_W);
    int gap    = dpi(g, BTN_GAP);
    int pad    = dpi(g, BAR_PAD);
    int rowgap = dpi(g, ROW_GAP);
    int grpgap = dpi(g, GROUP_GAP);
    int vol_w  = dpi(g, VOL_W);
    int time_w = dpi(g, TIME_W);

    int bar_h = pad + seek_h + rowgap + btn_h + pad;
    int bar_top = H - sh - bar_h;
    if (bar_top < 0)
        bar_top = 0;

    // Video fills everything above the control bar. Resize it ASYNC: the VO
    // owns this window on its own thread, so a synchronous MoveWindow would
    // block (and can deadlock against the VO mid-reconfigure, e.g. when an
    // auto-resize fires right as a new file is loading).
    if (g->video)
        SetWindowPos(g->video, NULL, 0, 0, W, bar_top,
                     SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);

    // Seek bar across the top of the control area.
    MoveWindow(g->seek, pad, bar_top + pad, W - pad * 2, seek_h, TRUE);

    // Transport buttons row.
    int by = bar_top + pad + seek_h + rowgap;
    int bx = pad;
    HWND btns[] = { g->btn_prev, g->btn_back, g->btn_play, g->btn_stop,
                    g->btn_fwd, g->btn_next };
    for (int i = 0; i < MP_ARRAY_SIZE(btns); i++) {
        MoveWindow(btns[i], bx, by, btn_w, btn_h, TRUE);
        bx += btn_w + gap;
    }

    // Mute + volume docked to the right.
    int vol_x = W - pad - vol_w;
    int mute_x = vol_x - gap - btn_w;
    MoveWindow(g->volume, vol_x, by, vol_w, btn_h, TRUE);
    MoveWindow(g->btn_mute, mute_x, by, btn_w, btn_h, TRUE);

    // Time label fills the space between the buttons and the volume group,
    // vertically centered (SS_CENTERIMAGE) over the full row height.
    int time_x = bx - gap + grpgap;
    int time_max = mute_x - grpgap - time_x;
    if (time_max < 0)
        time_max = 0;
    MoveWindow(g->timelbl, time_x, by, time_w < time_max ? time_w : time_max,
               btn_h, TRUE);
}

// Resize the window so the video region shows at its native display size (or
// scaled down to fit the monitor's work area), preserving aspect ratio. Only in
// plain windowed mode — never when fullscreen, maximized, or minimized. The
// window's top-left is kept (nudged back on-screen if needed).
static void autosize_to_video(struct mp_w32_gui *g)
{
    if (!g->frame || g->fullscreen || IsZoomed(g->frame) || IsIconic(g->frame))
        return;
    int vw = (int)g->vid_dw, vh = (int)g->vid_dh;
    if (vw <= 0 || vh <= 0)
        return;

    // Chrome that lives in the client area below the video.
    RECT sr;
    GetWindowRect(g->status, &sr);
    int chrome_h = dpi(g, BAR_PAD) * 2 + dpi(g, SEEK_H) + dpi(g, ROW_GAP) +
                   dpi(g, BTN_H) + (sr.bottom - sr.top);
    int min_w = dpi(g, 420);    // keep the transport row usable

    MONITORINFO mi = { .cbSize = sizeof(mi) };
    GetMonitorInfoW(MonitorFromWindow(g->frame, MONITOR_DEFAULTTONEAREST), &mi);

    LONG style = (LONG)GetWindowLongPtrW(g->frame, GWL_STYLE);
    LONG ex = (LONG)GetWindowLongPtrW(g->frame, GWL_EXSTYLE);
    RECT nc = { 0, 0, 1000, 1000 };
    AdjustWindowRectEx(&nc, style, GetMenu(g->frame) != NULL, ex);
    int nc_w = (nc.right - nc.left) - 1000;     // borders
    int nc_h = (nc.bottom - nc.top) - 1000;     // title + menu + borders

    // Scale the video down (never up) to fit the work area with all the chrome.
    double maxw = (mi.rcWork.right - mi.rcWork.left) - nc_w;
    double maxh = (mi.rcWork.bottom - mi.rcWork.top) - nc_h - chrome_h;
    double scale = 1.0;
    if (vw > maxw)            scale = maxw / vw;
    if (vh * scale > maxh)    scale = maxh / vh;
    if (scale > 1.0) scale = 1.0;
    if (scale < 0.05) scale = 0.05;
    int dvw = (int)(vw * scale + 0.5);
    int dvh = (int)(vh * scale + 0.5);

    RECT wr = { 0, 0, dvw < min_w ? min_w : dvw, dvh + chrome_h };
    AdjustWindowRectEx(&wr, style, GetMenu(g->frame) != NULL, ex);
    int win_w = wr.right - wr.left, win_h = wr.bottom - wr.top;

    RECT fr;
    GetWindowRect(g->frame, &fr);
    int x = fr.left, y = fr.top;
    if (x + win_w > mi.rcWork.right)  x = mi.rcWork.right - win_w;
    if (y + win_h > mi.rcWork.bottom) y = mi.rcWork.bottom - win_h;
    if (x < mi.rcWork.left) x = mi.rcWork.left;
    if (y < mi.rcWork.top)  y = mi.rcWork.top;

    SetWindowPos(g->frame, NULL, x, y, win_w, win_h,
                 SWP_NOZORDER | SWP_NOACTIVATE);
}

static void show_chrome(struct mp_w32_gui *g, bool show)
{
    int cmd = show ? SW_SHOW : SW_HIDE;
    HWND ws[] = { g->seek, g->volume, g->status, g->timelbl,
                  g->btn_prev, g->btn_back, g->btn_play, g->btn_stop,
                  g->btn_fwd, g->btn_next, g->btn_mute };
    for (int i = 0; i < MP_ARRAY_SIZE(ws); i++)
        ShowWindow(ws[i], cmd);
}

#define FS_POLL_MS  250
#define FS_HIDE_MS  5000

// The transport widgets that live in the fullscreen overlay (everything except
// the status bar). Re-parented between the frame and the overlay.
static int fs_controls(struct mp_w32_gui *g, HWND out[10])
{
    HWND c[] = { g->seek, g->btn_prev, g->btn_back, g->btn_play, g->btn_stop,
                 g->btn_fwd, g->btn_next, g->btn_mute, g->volume, g->timelbl };
    for (int i = 0; i < 10; i++)
        out[i] = c[i];
    return 10;
}

// Window proc for the fullscreen overlay: it owns the transport widgets while
// fullscreen, paints the bar background, and forwards their notifications to the
// usual handlers (which key off the control handle, not the parent).
static LRESULT CALLBACK fs_overlay_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
    struct mp_w32_gui *g = (void *)GetWindowLongPtrW(hwnd, GWLP_USERDATA);

    switch (msg) {
    case WM_CREATE: {
        CREATESTRUCTW *cs = (CREATESTRUCTW *)lp;
        SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR)cs->lpCreateParams);
        return 0;
    }
    case WM_HSCROLL:
        if (g)
            on_hscroll(g, (HWND)lp, LOWORD(wp));
        return 0;
    case WM_COMMAND:
        if (g)
            on_command(g, LOWORD(wp));
        return 0;
    case WM_ERASEBKGND: {
        RECT r;
        GetClientRect(hwnd, &r);
        HBRUSH b = (g && g->dark && g->bg_brush) ? g->bg_brush
                                                 : (HBRUSH)(COLOR_BTNFACE + 1);
        FillRect((HDC)wp, &r, b);
        return 1;
    }
    case WM_CTLCOLORSTATIC:
    case WM_CTLCOLORBTN:
        if (g && g->dark) {
            SetTextColor((HDC)wp, DARK_TEXT);
            SetBkColor((HDC)wp, DARK_BG);
            return (LRESULT)(g->bg_brush ? g->bg_brush
                                         : (HBRUSH)GetStockObject(BLACK_BRUSH));
        }
        break;
    }
    return DefWindowProcW(hwnd, msg, wp, lp);
}

static void ensure_fs_overlay(struct mp_w32_gui *g)
{
    if (g->fs_overlay)
        return;
    static ATOM cls;
    if (!cls) {
        WNDCLASSEXW wc = {
            .cbSize = sizeof(wc),
            .lpfnWndProc = fs_overlay_proc,
            .hInstance = HINST_THISCOMPONENT,
            .hCursor = LoadCursorW(NULL, IDC_ARROW),
            .hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1),
            .lpszClassName = L"jmwgFsBar",
        };
        cls = RegisterClassExW(&wc);
    }
    // Owned popup (WS_POPUP + owner) so DWM composites it ABOVE the frame and its
    // D3D11 video child. WS_EX_NOACTIVATE keeps the main window active on clicks.
    g->fs_overlay = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
        L"jmwgFsBar", L"", WS_POPUP | WS_CLIPCHILDREN,
        0, 0, 100, 50, g->frame, NULL, HINST_THISCOMPONENT, g);
}

static void set_cursor_hidden(struct mp_w32_gui *g, bool hide)
{
    if (hide == g->cursor_hidden)
        return;
    g->cursor_hidden = hide;
    ShowCursor(!hide);
}

// Height of the overlay bar.
static int fs_bar_h(struct mp_w32_gui *g)
{
    return dpi(g, BAR_PAD) * 2 + dpi(g, SEEK_H) + dpi(g, ROW_GAP) + dpi(g, BTN_H);
}

// Lay the transport widgets out inside the overlay window's client area.
static void fs_overlay_layout(struct mp_w32_gui *g)
{
    RECT rc;
    GetClientRect(g->fs_overlay, &rc);
    int W = rc.right;
    int seek_h = dpi(g, SEEK_H), btn_h = dpi(g, BTN_H), btn_w = dpi(g, BTN_W);
    int gap = dpi(g, BTN_GAP), pad = dpi(g, BAR_PAD), rowgap = dpi(g, ROW_GAP);
    int grpgap = dpi(g, GROUP_GAP), vol_w = dpi(g, VOL_W), time_w = dpi(g, TIME_W);

    MoveWindow(g->seek, pad, pad, W - pad * 2, seek_h, TRUE);

    int by = pad + seek_h + rowgap;
    int bx = pad;
    HWND btns[] = { g->btn_prev, g->btn_back, g->btn_play, g->btn_stop,
                    g->btn_fwd, g->btn_next };
    for (int i = 0; i < MP_ARRAY_SIZE(btns); i++) {
        MoveWindow(btns[i], bx, by, btn_w, btn_h, TRUE);
        bx += btn_w + gap;
    }
    int vol_x = W - pad - vol_w;
    int mute_x = vol_x - gap - btn_w;
    MoveWindow(g->volume, vol_x, by, vol_w, btn_h, TRUE);
    MoveWindow(g->btn_mute, mute_x, by, btn_w, btn_h, TRUE);
    int time_x = bx - gap + grpgap;
    int time_max = mute_x - grpgap - time_x;
    if (time_max < 0)
        time_max = 0;
    MoveWindow(g->timelbl, time_x, by, time_w < time_max ? time_w : time_max,
               btn_h, TRUE);
}

// Show/hide the fullscreen overlay window (its controls travel with it).
static void fs_show_controls(struct mp_w32_gui *g, bool show)
{
    if (show == g->fs_controls_shown || !g->fs_overlay)
        return;
    g->fs_controls_shown = show;
    if (show) {
        fs_overlay_layout(g);
        // Raise above the (owned) frame without stealing activation, and show.
        SetWindowPos(g->fs_overlay, HWND_TOP, 0, 0, 0, 0,
                     SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
    } else {
        ShowWindow(g->fs_overlay, SW_HIDE);
    }
}

// Polled (WM_TIMER) while fullscreen. The controls are shown only while BOTH:
// the pointer is in the bottom hot zone (over/near where the bar appears) AND
// the mouse has moved within the last FS_HIDE_MS. Leaving the zone hides them at
// once, even while still moving. The cursor itself is revealed on any movement
// and hidden after the same idle timeout.
static void fs_poll_activity(struct mp_w32_gui *g)
{
    POINT pt;
    GetCursorPos(&pt);

    if (pt.x != g->fs_last_pt.x || pt.y != g->fs_last_pt.y) {
        g->fs_last_pt = pt;
        g->fs_idle_since = GetTickCount();
        set_cursor_hidden(g, false);
    }

    bool active = GetTickCount() - g->fs_idle_since < FS_HIDE_MS;

    // Hot zone: the strip the overlay occupies plus one bar-height of grace above
    // it, so it's easy to reach without persisting up into the picture.
    RECT fr;
    GetWindowRect(g->frame, &fr);
    int bh = fs_bar_h(g);
    bool in_zone = pt.x >= fr.left && pt.x < fr.right &&
                   pt.y < fr.bottom && pt.y >= fr.bottom - 2 * bh;

    fs_show_controls(g, active && in_zone);

    if (!active)
        set_cursor_hidden(g, true);
}

static void set_fullscreen(struct mp_w32_gui *g, bool fs)
{
    if (fs == g->fullscreen)
        return;
    g->fullscreen = fs;

    if (fs) {
        g->saved_menu = GetMenu(g->frame);
        GetWindowRect(g->frame, &g->saved_rect);
        g->saved_style = GetWindowLongPtrW(g->frame, GWL_STYLE);

        SetMenu(g->frame, NULL);
        // Only the status bar belongs to the frame in fullscreen (and it's
        // hidden); the transport widgets move into the overlay below.
        ShowWindow(g->status, SW_HIDE);
        SetWindowLongPtrW(g->frame, GWL_STYLE,
                          (g->saved_style & ~WS_OVERLAPPEDWINDOW) | WS_POPUP);

        MONITORINFO mi = { .cbSize = sizeof(mi) };
        GetMonitorInfoW(MonitorFromWindow(g->frame, MONITOR_DEFAULTTONEAREST),
                        &mi);
        SetWindowPos(g->frame, HWND_TOP, mi.rcMonitor.left, mi.rcMonitor.top,
                     mi.rcMonitor.right - mi.rcMonitor.left,
                     mi.rcMonitor.bottom - mi.rcMonitor.top,
                     SWP_FRAMECHANGED | SWP_NOOWNERZORDER);

        // Re-parent the transport widgets into the overlay and size it to a bar
        // at the monitor's bottom edge (hidden until the pointer enters the
        // lower area).
        ensure_fs_overlay(g);
        HWND ctl[10];
        int n = fs_controls(g, ctl);
        for (int i = 0; i < n; i++) {
            SetParent(ctl[i], g->fs_overlay);
            ShowWindow(ctl[i], SW_SHOW);
        }
        int bh = fs_bar_h(g);
        SetWindowPos(g->fs_overlay, HWND_TOP, mi.rcMonitor.left,
                     mi.rcMonitor.bottom - bh,
                     mi.rcMonitor.right - mi.rcMonitor.left, bh,
                     SWP_NOACTIVATE | SWP_HIDEWINDOW);
        fs_overlay_layout(g);

        // Auto-hide controls/cursor: start polling for activity.
        g->fs_controls_shown = false;
        set_cursor_hidden(g, false);
        GetCursorPos(&g->fs_last_pt);
        g->fs_idle_since = GetTickCount();
        SetTimer(g->frame, FS_TIMER_ID, FS_POLL_MS, NULL);
    } else {
        KillTimer(g->frame, FS_TIMER_ID);
        set_cursor_hidden(g, false);
        g->fs_controls_shown = false;
        if (g->fs_overlay)
            ShowWindow(g->fs_overlay, SW_HIDE);
        // Move the transport widgets back into the frame.
        HWND ctl[10];
        int n = fs_controls(g, ctl);
        for (int i = 0; i < n; i++)
            SetParent(ctl[i], g->frame);

        SetMenu(g->frame, g->saved_menu);
        show_chrome(g, true);
        SetWindowLongPtrW(g->frame, GWL_STYLE, g->saved_style);
        SetWindowPos(g->frame, NULL,
                     g->saved_rect.left, g->saved_rect.top,
                     g->saved_rect.right - g->saved_rect.left,
                     g->saved_rect.bottom - g->saved_rect.top,
                     SWP_FRAMECHANGED | SWP_NOOWNERZORDER | SWP_NOZORDER);
    }

    layout(g);
    apply_ontop(g);
    if (g->video)
        SetFocus(g->video);
}

//--- dynamic menus: track lists, speed, aspect -----------------------------

static mpv_node *map_get(mpv_node *map, const char *key)
{
    if (!map || map->format != MPV_FORMAT_NODE_MAP)
        return NULL;
    mpv_node_list *l = map->u.list;
    for (int i = 0; i < l->num; i++) {
        if (strcmp(l->keys[i], key) == 0)
            return &l->values[i];
    }
    return NULL;
}

static const char *map_str(mpv_node *map, const char *key, const char *def)
{
    mpv_node *n = map_get(map, key);
    return (n && n->format == MPV_FORMAT_STRING) ? n->u.string : def;
}

static int64_t map_int(mpv_node *map, const char *key, int64_t def)
{
    mpv_node *n = map_get(map, key);
    return (n && n->format == MPV_FORMAT_INT64) ? n->u.int64 : def;
}

static bool map_flag(mpv_node *map, const char *key, bool def)
{
    mpv_node *n = map_get(map, key);
    return (n && n->format == MPV_FORMAT_FLAG) ? n->u.flag != 0 : def;
}

// Append the tracks of the given type to the menu (checking the active one).
// Returns true if a track of this type is currently selected.
static bool append_tracks(struct mp_w32_gui *g, HMENU m, const char *type,
                          int id_base)
{
    bool any_selected = false;
    if (!g->client)
        return false;

    mpv_node node;
    if (mpv_get_property(g->client, "track-list", MPV_FORMAT_NODE, &node) < 0)
        return false;

    if (node.format == MPV_FORMAT_NODE_ARRAY) {
        for (int i = 0; i < node.u.list->num; i++) {
            mpv_node *t = &node.u.list->values[i];
            if (strcmp(map_str(t, "type", ""), type) != 0)
                continue;
            int64_t id = map_int(t, "id", 0);
            const char *title = map_str(t, "title", NULL);
            const char *lang = map_str(t, "lang", NULL);
            bool sel = map_flag(t, "selected", false);

            char buf[256];
            if (title && lang)
                snprintf(buf, sizeof(buf), "%lld: %s [%s]", (long long)id, title, lang);
            else if (title)
                snprintf(buf, sizeof(buf), "%lld: %s", (long long)id, title);
            else if (lang)
                snprintf(buf, sizeof(buf), "%lld: [%s]", (long long)id, lang);
            else
                snprintf(buf, sizeof(buf), "Track %lld", (long long)id);

            wchar_t label[256];
            MultiByteToWideChar(CP_UTF8, 0, buf, -1, label, MP_ARRAY_SIZE(label));
            AppendMenuW(m, MF_STRING | (sel ? MF_CHECKED : 0),
                        id_base + (UINT)(id & 0xFF), label);
            any_selected |= sel;
        }
    }
    mpv_free_node_contents(&node);
    return any_selected;
}

static void clear_menu(HMENU m)
{
    // RemoveMenu (not DeleteMenu): DeleteMenu would destroy the attached
    // submenu handle (e.g. the shared Aspect Ratio popup), breaking reuse.
    while (GetMenuItemCount(m) > 0)
        RemoveMenu(m, 0, MF_BYPOSITION);
}

static void rebuild_audio_menu(struct mp_w32_gui *g)
{
    clear_menu(g->menu_audio);
    bool sel = append_tracks(g, g->menu_audio, "audio", ID_AUDIO_BASE);
    InsertMenuW(g->menu_audio, 0, MF_BYPOSITION | MF_STRING |
                (sel ? 0 : MF_CHECKED), ID_AUDIO_BASE + 0, L"Disabled");
    AppendMenuW(g->menu_audio, MF_SEPARATOR, 0, NULL);
    AppendMenuW(g->menu_audio, MF_STRING, ID_AUDIO_VOLUP, L"Increase Volume\tUp");
    AppendMenuW(g->menu_audio, MF_STRING, ID_AUDIO_VOLDOWN, L"Decrease Volume\tDown");
    AppendMenuW(g->menu_audio, MF_STRING, ID_AUDIO_MUTE, L"&Mute\tM");
}

static void rebuild_sub_menu(struct mp_w32_gui *g)
{
    clear_menu(g->menu_sub);
    bool sel = append_tracks(g, g->menu_sub, "sub", ID_SUB_BASE);
    InsertMenuW(g->menu_sub, 0, MF_BYPOSITION | MF_STRING |
                (sel ? 0 : MF_CHECKED), ID_SUB_BASE + 0, L"&None");
}

static void rebuild_video_menu(struct mp_w32_gui *g)
{
    clear_menu(g->menu_video);
    append_tracks(g, g->menu_video, "video", ID_VIDEO_BASE);
    // Divide the track list from the actions below only if any tracks were
    // listed; otherwise the menu would open with a stray leading separator.
    if (GetMenuItemCount(g->menu_video) > 0)
        AppendMenuW(g->menu_video, MF_SEPARATOR, 0, NULL);
    AppendMenuW(g->menu_video, MF_STRING, ID_VIDEO_SCREENSHOT,
                L"Take &Screenshot\tS");
    AppendMenuW(g->menu_video, MF_SEPARATOR, 0, NULL);
    AppendMenuW(g->menu_video, MF_POPUP, (UINT_PTR)g->menu_aspect,
                L"&Aspect Ratio");
    AppendMenuW(g->menu_video, MF_SEPARATOR, 0, NULL);
    AppendMenuW(g->menu_video, MF_STRING, ID_SHADER_LOAD, L"Load &Shader...");
    AppendMenuW(g->menu_video, MF_STRING, ID_SHADER_CLEAR, L"Clear Shaders");
}

static void update_speed_checks(struct mp_w32_gui *g)
{
    double sp = 1.0;
    if (g->client)
        mpv_get_property(g->client, "speed", MPV_FORMAT_DOUBLE, &sp);
    for (int i = 0; i < (int)MP_ARRAY_SIZE(g_speeds); i++) {
        bool on = fabs(sp - g_speeds[i].val) < 0.001;
        CheckMenuItem(g->menu_speed, ID_SPEED_BASE + i,
                      MF_BYCOMMAND | (on ? MF_CHECKED : MF_UNCHECKED));
    }
}

static void rebuild_recent_menu(struct mp_w32_gui *g)
{
    clear_menu(g->menu_recent);
    if (g->recent_count == 0) {
        AppendMenuW(g->menu_recent, MF_STRING | MF_GRAYED, 0, L"(empty)");
        return;
    }
    for (int i = 0; i < g->recent_count; i++) {
        wchar_t wpath[1024];
        MultiByteToWideChar(CP_UTF8, 0, g->recent[i], -1, wpath,
                            MP_ARRAY_SIZE(wpath));
        // Label: "&N  <path>", with N the access key (1..9, 0 for the 10th).
        wchar_t label[1100];
        int p = 0;
        label[p++] = L'&';
        label[p++] = (wchar_t)(L'0' + (i + 1) % 10);
        label[p++] = L' ';
        label[p++] = L' ';
        for (int k = 0; wpath[k] && p < (int)MP_ARRAY_SIZE(label) - 1; k++)
            label[p++] = wpath[k];
        label[p] = 0;
        AppendMenuW(g->menu_recent, MF_STRING, ID_RECENT_BASE + i, label);
    }
    AppendMenuW(g->menu_recent, MF_SEPARATOR, 0, NULL);
    AppendMenuW(g->menu_recent, MF_STRING, ID_RECENT_CLEAR, L"&Clear List");
}

static void rebuild_chapters_menu(struct mp_w32_gui *g)
{
    clear_menu(g->menu_chapters);
    int count = 0;
    if (g->client) {
        int64_t cur = -1;
        mpv_get_property(g->client, "chapter", MPV_FORMAT_INT64, &cur);
        mpv_node node;
        if (mpv_get_property(g->client, "chapter-list", MPV_FORMAT_NODE,
                             &node) >= 0) {
            if (node.format == MPV_FORMAT_NODE_ARRAY) {
                for (int i = 0; i < node.u.list->num; i++) {
                    const char *title =
                        map_str(&node.u.list->values[i], "title", NULL);
                    char buf[256];
                    if (title)
                        snprintf(buf, sizeof(buf), "%d.  %s", i + 1, title);
                    else
                        snprintf(buf, sizeof(buf), "Chapter %d", i + 1);
                    wchar_t label[256];
                    MultiByteToWideChar(CP_UTF8, 0, buf, -1, label,
                                        MP_ARRAY_SIZE(label));
                    AppendMenuW(g->menu_chapters,
                                MF_STRING | (i == (int)cur ? MF_CHECKED : 0),
                                ID_CHAPTER_BASE + i, label);
                    count++;
                }
            }
            mpv_free_node_contents(&node);
        }
    }
    if (count == 0)
        AppendMenuW(g->menu_chapters, MF_STRING | MF_GRAYED, 0, L"(no chapters)");
}

// The pixel range over which the thumb CENTER travels: the channel inset by the
// thumb's half-width at each end (pos 0 -> lo, pos max -> lo + travel). Notches,
// click-to-seek and the hover lookup all use this so they line up with the thumb.
// It must be POSITION-INDEPENDENT: deriving lo from the live thumb position (to
// chase the control's sub-pixel rounding) makes the notches jitter ~1px as the
// thumb moves, and since the control only repaints around the thumb, the old
// notch lines aren't erased — they ghost into "brackets".
static void track_geom(HWND hwnd, double *lo, double *travel)
{
    RECT chan, th;
    SendMessageW(hwnd, TBM_GETCHANNELRECT, 0, (LPARAM)&chan);
    SendMessageW(hwnd, TBM_GETTHUMBRECT, 0, (LPARAM)&th);
    double tw = (double)((chan.right - chan.left) - (th.right - th.left));
    if (tw < 1)
        tw = 1;
    *travel = tw;
    *lo = chan.left + (th.right - th.left) / 2.0;
}

// Draw chapter notches over the seek bar's channel. Done from the trackbar
// subclass's WM_PAINT (not NM_CUSTOMDRAW): the themed trackbar only re-runs
// channel custom-draw on live thumb-move repaints, so it would miss a forced
// full repaint. A subclass WM_PAINT runs on every repaint, reliably.
static void draw_chapter_marks(struct mp_w32_gui *g, HWND hwnd, HDC dc)
{
    if (g->chap_count <= 0 || g->cur_duration <= 0)
        return;
    RECT chan, cr, th;
    SendMessageW(hwnd, TBM_GETCHANNELRECT, 0, (LPARAM)&chan);
    SendMessageW(hwnd, TBM_GETTHUMBRECT, 0, (LPARAM)&th);
    GetClientRect(hwnd, &cr);
    // Place each notch at the EXACT pixel the control uses for the thumb at that
    // chapter's slider value — same integer arithmetic the trackbar uses
    // (chan.left + thumbW/2 + MulDiv(value, channelW - thumbW, max)) — so the
    // notch lines up to the pixel when the thumb sits on it, instead of differing
    // by 1px from float-vs-integer rounding. Depends only on the chapter (not the
    // current position), so notches never move and can't ghost on partial repaints.
    int range = (int)SendMessageW(hwnd, TBM_GETRANGEMAX, 0, 0);
    if (range < 1)
        range = 1;
    int thumb_w = th.right - th.left;
    int span = (chan.right - chan.left) - thumb_w;
    if (span < 0)
        span = 0;
    int top = cr.top + dpi(g, 1);
    int bot = cr.bottom - dpi(g, 1);
    int mw = dpi(g, 2);
    HBRUSH br = CreateSolidBrush(g->dark ? RGB(205, 205, 205)
                                         : RGB(70, 70, 70));
    for (int i = 0; i < g->chap_count; i++) {
        double f = g->chap_time[i] / g->cur_duration;
        f = f < 0 ? 0 : f > 1 ? 1 : f;
        int value = (int)(f * range + 0.5);
        int x = chan.left + thumb_w / 2 + MulDiv(value, span, range);
        if (x + mw / 2 >= th.left && x - mw / 2 <= th.right)
            continue; // leave the thumb un-obscured
        RECT m = { x - mw / 2, top, x - mw / 2 + mw, bot };
        FillRect(dc, &m, br);
    }
    DeleteObject(br);
}

//--- GLSL / slang shader overlays -------------------------------------------
//
// mpv natively loads its own user-shader format (--glsl-shaders, the //!HOOK
// blocks). It cannot read RetroArch .slang/.slangp directly, so a .slangp is
// converted on the fly: each pass becomes a HOOK on MAIN (source-scaled passes)
// or OUTPUT (viewport-scaled passes), with the RetroArch semantics mapped over
// (Source -> HOOKED, SourceSize -> vec4(HOOKED_size,HOOKED_pt), FrameCount ->
// frame, #pragma parameters folded to their preset literals).

static char *read_text_file(void *ctx, const wchar_t *wpath)
{
    FILE *f = _wfopen(wpath, L"rb");
    if (!f)
        return NULL;
    fseek(f, 0, SEEK_END);
    long n = ftell(f);
    fseek(f, 0, SEEK_SET);
    if (n < 0) {
        fclose(f);
        return NULL;
    }
    char *buf = talloc_size(ctx, n + 1);
    size_t rd = fread(buf, 1, n, f);
    fclose(f);
    buf[rd] = 0;
    return buf;
}

// Replace every occurrence of find with repl. Returns a fresh talloc string.
static char *str_replace(void *ctx, const char *s, const char *find,
                         const char *repl)
{
    size_t flen = strlen(find);
    char *out = talloc_strdup(ctx, "");
    const char *p = s, *q;
    while ((q = strstr(p, find))) {
        out = talloc_asprintf_append(out, "%.*s%s", (int)(q - p), p, repl);
        p = q + flen;
    }
    return talloc_asprintf_append(out, "%s", p);
}

static void trim(char *s)
{
    size_t n = strlen(s);
    while (n && (s[n-1]=='\r'||s[n-1]=='\n'||s[n-1]==' '||s[n-1]=='\t'||s[n-1]=='"'))
        s[--n] = 0;
    int i = 0;
    while (s[i]==' '||s[i]=='\t'||s[i]=='"')
        i++;
    if (i)
        memmove(s, s + i, strlen(s + i) + 1);
}

// Convert one slang pass's fragment stage into an mpv HOOK block.
static char *convert_pass(void *ctx, const char *slang, char **names,
                          char **vals, int nparam, const char *hookpoint,
                          int idx, const char *desc)
{
    const char *frag = strstr(slang, "#pragma stage fragment");
    if (!frag)
        return NULL;
    frag = strchr(frag, '\n');
    if (!frag)
        return NULL;
    char *body = talloc_strdup(ctx, frag + 1);

    body = str_replace(ctx, body, "layout(location = 0) in vec2 vTexCoord;", "");
    body = str_replace(ctx, body, "layout(location = 0) out vec4 FragColor;", "");

    // params already sorted longest-name-first to avoid prefix clashes.
    for (int i = 0; i < nparam; i++) {
        char *key = talloc_asprintf(ctx, "params.%s", names[i]);
        body = str_replace(ctx, body, key, vals[i]);
    }
    body = str_replace(ctx, body, "texture(Source,", "HOOKED_tex(");
    body = str_replace(ctx, body, "vTexCoord", "HOOKED_pos");
    body = str_replace(ctx, body, "void main()", "vec4 hook()");
    body = str_replace(ctx, body, "return;", "return FragColor;");

    // Declare a local FragColor right after the hook's opening brace...
    char *h = strstr(body, "vec4 hook()");
    char *brace = h ? strchr(h, '{') : NULL;
    if (brace) {
        size_t off = brace - body + 1;
        body = talloc_asprintf(ctx, "%.*s\n    vec4 FragColor = vec4(0.0);%s",
                               (int)off, body, body + off);
    }
    // ...and return it before the final closing brace.
    char *lb = strrchr(body, '}');
    if (lb) {
        size_t off = lb - body;
        body = talloc_asprintf(ctx, "%.*s    return FragColor;\n}%s",
                               (int)off, body, lb + 1);
    }
    return talloc_asprintf(ctx,
        "//!HOOK %s\n//!BIND HOOKED\n//!DESC %s pass %d\n%s\n",
        hookpoint, desc, idx, body);
}

// Add a param (name,value) to the arrays, replacing an existing entry.
static void param_set(void *ctx, char **names, char **vals, int *n, int max,
                      const char *name, const char *val)
{
    for (int i = 0; i < *n; i++) {
        if (strcmp(names[i], name) == 0) {
            vals[i] = talloc_strdup(ctx, val);
            return;
        }
    }
    if (*n < max) {
        names[*n] = talloc_strdup(ctx, name);
        vals[*n] = talloc_strdup(ctx, val);
        (*n)++;
    }
}

// Convert a full .slangp preset to an mpv user-shader string, or NULL.
static char *convert_slangp(void *ctx, const wchar_t *wpath)
{
    char *text = read_text_file(ctx, wpath);
    if (!text)
        return NULL;

    // Directory of the preset (slang files are relative to it).
    wchar_t wdir[4096];
    wcsncpy(wdir, wpath, MP_ARRAY_SIZE(wdir) - 1);
    wdir[MP_ARRAY_SIZE(wdir) - 1] = 0;
    wchar_t *s1 = wcsrchr(wdir, L'\\'), *s2 = wcsrchr(wdir, L'/');
    if (s2 > s1) s1 = s2;
    if (s1) *s1 = 0; else wdir[0] = 0;

    // Pass-independent preset parameter overrides.
    enum { MAXP = 96 };
    char *pnames[MAXP], *pvals[MAXP];
    int np = 0;
    char passfile[8][256] = {{0}};
    char scaletype[8][64] = {{0}};
    int npass = 0;

    for (char *line = strtok(text, "\n"); line; line = strtok(NULL, "\n")) {
        char buf[512];
        snprintf(buf, sizeof(buf), "%s", line);
        char *hash = strchr(buf, '#');
        if (hash) *hash = 0;
        char *eq = strchr(buf, '=');
        if (!eq) continue;
        *eq = 0;
        char key[256], val[256];
        snprintf(key, sizeof(key), "%s", buf);
        snprintf(val, sizeof(val), "%s", eq + 1);
        trim(key); trim(val);
        if (!key[0]) continue;

        if (strcmp(key, "shaders") == 0) {
            npass = atoi(val);
            if (npass > 8) npass = 8;
        } else if (strncmp(key, "shader", 6) == 0 && isdigit((unsigned char)key[6])) {
            int i = atoi(key + 6);
            if (i >= 0 && i < 8) snprintf(passfile[i], 256, "%s", val);
        } else if (strncmp(key, "scale_type", 10) == 0 &&
                   isdigit((unsigned char)key[10])) {
            int i = atoi(key + 10);
            if (i >= 0 && i < 8) snprintf(scaletype[i], 64, "%s", val);
        } else if (strncmp(key, "scale", 5) == 0 || strncmp(key, "filter", 6) == 0 ||
                   strncmp(key, "wrap", 4) == 0 || strncmp(key, "srgb", 4) == 0 ||
                   strncmp(key, "float_framebuffer", 17) == 0 ||
                   strncmp(key, "mipmap", 6) == 0 || strncmp(key, "alias", 5) == 0 ||
                   strncmp(key, "feedback", 8) == 0) {
            // structural directive: ignored (single-input post-process only)
        } else {
            param_set(ctx, pnames, pvals, &np, MAXP, key, val); // a preset value
        }
    }
    if (npass <= 0)
        return NULL;

    char *out = talloc_asprintf(ctx,
        "//!DESC slangp (converted from RetroArch preset)\n");

    for (int pi = 0; pi < npass; pi++) {
        if (!passfile[pi][0])
            continue;
        wchar_t wfile[4096];
        wchar_t wname[256];
        MultiByteToWideChar(CP_UTF8, 0, passfile[pi], -1, wname, MP_ARRAY_SIZE(wname));
        swprintf(wfile, MP_ARRAY_SIZE(wfile), L"%ls\\%ls", wdir, wname);
        char *slang = read_text_file(ctx, wfile);
        if (!slang)
            return NULL;

        // Per-pass parameter table: built-in size/frame aliases, the pass's own
        // #pragma parameter defaults, then any preset overrides.
        char *names[MAXP], *vals[MAXP];
        int n = 0;
        param_set(ctx, names, vals, &n, MAXP, "SourceSize",
                  "vec4(HOOKED_size, HOOKED_pt)");
        param_set(ctx, names, vals, &n, MAXP, "OriginalSize",
                  "vec4(HOOKED_size, HOOKED_pt)");
        param_set(ctx, names, vals, &n, MAXP, "OutputSize",
                  "vec4(target_size, 1.0/target_size)");
        param_set(ctx, names, vals, &n, MAXP, "FrameCount", "frame");

        // #pragma parameter NAME "desc" DEFAULT MIN MAX STEP
        for (const char *p = slang; (p = strstr(p, "#pragma parameter")); ) {
            char nm[128] = {0}, dflt[64] = {0};
            // skip "#pragma parameter"
            const char *c = p + 17;
            while (*c == ' ' || *c == '\t') c++;
            int k = 0;
            while (*c && *c != ' ' && *c != '\t' && k < 127) nm[k++] = *c++;
            nm[k] = 0;
            const char *quote = strchr(c, '"');           // skip the desc string
            if (quote) { quote++; quote = strchr(quote, '"'); }
            if (quote) {
                c = quote + 1;
                while (*c == ' ' || *c == '\t') c++;
                int j = 0;
                while (*c && *c != ' ' && *c != '\t' && *c != '\n' && j < 63)
                    dflt[j++] = *c++;
                dflt[j] = 0;
                if (nm[0] && dflt[0])
                    param_set(ctx, names, vals, &n, MAXP, nm, dflt);
            }
            p += 17;
        }
        // Apply preset overrides on top of the defaults.
        for (int i = 0; i < np; i++)
            param_set(ctx, names, vals, &n, MAXP, pnames[i], pvals[i]);

        // Sort longest name first so e.g. CRT_GLOW_RADIUS is replaced before CRT_GLOW.
        for (int a = 0; a < n; a++)
            for (int b = a + 1; b < n; b++)
                if (strlen(names[b]) > strlen(names[a])) {
                    char *tn = names[a]; names[a] = names[b]; names[b] = tn;
                    char *tv = vals[a]; vals[a] = vals[b]; vals[b] = tv;
                }

        const char *hp = (strcmp(scaletype[pi], "viewport") == 0 ||
                          pi == npass - 1) ? "OUTPUT" : "MAIN";
        char *block = convert_pass(ctx, slang, names, vals, n, hp, pi, "slangp");
        if (!block)
            return NULL;
        out = talloc_asprintf_append(out, "%s", block);
    }
    return out;
}

// Resolve %TEMP%\mpv-gui-shaders and return a writable .hook path for `base`.
static bool temp_hook_path(const char *base, wchar_t *out, size_t n)
{
    wchar_t tmp[MAX_PATH];
    if (!GetTempPathW(MP_ARRAY_SIZE(tmp), tmp))
        return false;
    wchar_t dir[MAX_PATH];
    swprintf(dir, MP_ARRAY_SIZE(dir), L"%lsmpv-gui-shaders", tmp);
    CreateDirectoryW(dir, NULL);
    wchar_t wbase[256];
    MultiByteToWideChar(CP_UTF8, 0, base, -1, wbase, MP_ARRAY_SIZE(wbase));
    swprintf(out, n, L"%ls\\%ls.hook", dir, wbase);
    return true;
}

static void apply_shader_path(struct mp_w32_gui *g, const wchar_t *wpath)
{
    if (!g->client)
        return;
    char *u8 = mp_to_utf8(NULL, wpath);
    const char *cmd[] = { "change-list", "glsl-shaders", "append", u8, NULL };
    mpv_command_async(g->client, 0, cmd);
    talloc_free(u8);
}

// Load a shader file (wide path): .slangp is converted to mpv's format first,
// .glsl/.hook are applied directly.
static void load_shader(struct mp_w32_gui *g, const wchar_t *file)
{
    const wchar_t *ext = wcsrchr(file, L'.');
    if (ext && _wcsicmp(ext, L".slangp") == 0) {
        void *tmp = talloc_new(NULL);
        char *hook = convert_slangp(tmp, file);
        if (!hook) {
            talloc_free(tmp);
            MessageBoxW(g->frame,
                L"Could not convert this .slangp preset. Only single-input "
                L"post-processing presets (like the bundled NTSC example) are "
                L"supported; complex multi-texture presets are not.",
                L"Shader", MB_OK | MB_ICONWARNING);
            return;
        }
        // base name without extension/path
        char *base8 = mp_to_utf8(tmp, file);
        char *b = strrchr(base8, '\\');
        char *b2 = strrchr(base8, '/');
        if (b2 > b) b = b2;
        b = b ? b + 1 : base8;
        char *dot = strrchr(b, '.');
        if (dot) *dot = 0;
        wchar_t outpath[MAX_PATH];
        if (temp_hook_path(b, outpath, MP_ARRAY_SIZE(outpath))) {
            FILE *f = _wfopen(outpath, L"wb");
            if (f) {
                fwrite(hook, 1, strlen(hook), f);
                fclose(f);
                apply_shader_path(g, outpath);
            }
        }
        talloc_free(tmp);
    } else {
        apply_shader_path(g, file);
    }
}

static void load_shader_dialog(struct mp_w32_gui *g)
{
    wchar_t file[4096] = {0};
    // Open the browser in the bundled shaders/ folder (next to the exe) when it
    // exists, so shipped presets are one click away; otherwise use the default.
    wchar_t shdir[4096] = {0}, exe[4096] = {0};
    if (GetModuleFileNameW(NULL, exe, MP_ARRAY_SIZE(exe))) {
        wchar_t *slash = wcsrchr(exe, L'\\');
        if (slash) {
            *slash = 0;
            _snwprintf(shdir, MP_ARRAY_SIZE(shdir), L"%ls\\shaders", exe);
            DWORD attr = GetFileAttributesW(shdir);
            if (attr == INVALID_FILE_ATTRIBUTES || !(attr & FILE_ATTRIBUTE_DIRECTORY))
                shdir[0] = 0;
        }
    }
    OPENFILENAMEW ofn = {
        .lStructSize = sizeof(ofn),
        .hwndOwner = g->frame,
        .lpstrFile = file,
        .nMaxFile = MP_ARRAY_SIZE(file),
        .lpstrFilter = L"Shaders (*.glsl;*.hook;*.slangp)\0*.glsl;*.hook;*.slangp\0"
                       L"All files\0*.*\0\0",
        .nFilterIndex = 1,
        .lpstrInitialDir = shdir[0] ? shdir : NULL,
        .Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY |
                 OFN_NOCHANGEDIR,
    };
    if (GetOpenFileNameW(&ofn))
        load_shader(g, file);
    if (g->video)
        SetFocus(g->video);
}

//--- MediaInfo dialog -------------------------------------------------------

// Built from MediaInfo.dll when it is shipped alongside mpv.exe; otherwise a
// compact report assembled from mpv's own properties.
typedef void *(WINAPI *MI_New)(void);
typedef void  (WINAPI *MI_Delete)(void *);
typedef size_t(WINAPI *MI_Open)(void *, const wchar_t *);
typedef void  (WINAPI *MI_Close)(void *);
typedef const wchar_t *(WINAPI *MI_Inform)(void *, size_t);
typedef const wchar_t *(WINAPI *MI_Option)(void *, const wchar_t *, const wchar_t *);

static wchar_t *mediainfo_report(const wchar_t *wpath)
{
    if (!wpath || wcsstr(wpath, L"://"))
        return NULL; // MediaInfo wants a local file
    HMODULE dll = LoadLibraryW(L"MediaInfo.dll");
    if (!dll)
        return NULL;
    MI_New    fNew    = (MI_New)   (void *)GetProcAddress(dll, "MediaInfo_New");
    MI_Open   fOpen   = (MI_Open)  (void *)GetProcAddress(dll, "MediaInfo_Open");
    MI_Inform fInform = (MI_Inform)(void *)GetProcAddress(dll, "MediaInfo_Inform");
    MI_Option fOption = (MI_Option)(void *)GetProcAddress(dll, "MediaInfo_Option");
    MI_Close  fClose  = (MI_Close) (void *)GetProcAddress(dll, "MediaInfo_Close");
    MI_Delete fDelete = (MI_Delete)(void *)GetProcAddress(dll, "MediaInfo_Delete");
    wchar_t *out = NULL;
    if (fNew && fOpen && fInform && fClose && fDelete) {
        void *h = fNew();
        if (h) {
            if (fOption)
                fOption(h, L"Complete", L"0");
            if (fOpen(h, wpath)) {
                const wchar_t *r = fInform(h, 0);
                if (r && r[0]) {
                    size_t n = wcslen(r);
                    out = talloc_memdup(NULL, (void *)r, (n + 1) * sizeof(wchar_t));
                }
            }
            fClose(h);
            fDelete(h);
        }
    }
    FreeLibrary(dll);
    return out;
}

static char *fallback_report(struct mp_w32_gui *g)
{
    char *s = talloc_strdup(NULL, "");
    if (!g->client)
        return s;
    char *v = NULL;
    if (mpv_get_property(g->client, "filename", MPV_FORMAT_STRING, &v) >= 0 && v) {
        s = talloc_asprintf_append(s, "File:     %s\r\n", v);
        mpv_free(v);
        v = NULL;
    }
    if (mpv_get_property(g->client, "file-format", MPV_FORMAT_STRING, &v) >= 0 && v) {
        s = talloc_asprintf_append(s, "Format:   %s\r\n", v);
        mpv_free(v);
        v = NULL;
    }
    int64_t sz = 0;
    if (mpv_get_property(g->client, "file-size", MPV_FORMAT_INT64, &sz) >= 0 && sz > 0)
        s = talloc_asprintf_append(s, "Size:     %.1f MiB\r\n", sz / 1048576.0);
    double dur = 0;
    if (mpv_get_property(g->client, "duration", MPV_FORMAT_DOUBLE, &dur) >= 0 && dur > 0) {
        char t[32];
        fmt_time(t, sizeof(t), dur);
        s = talloc_asprintf_append(s, "Duration: %s\r\n", t);
    }
    mpv_node node;
    if (mpv_get_property(g->client, "track-list", MPV_FORMAT_NODE, &node) >= 0) {
        if (node.format == MPV_FORMAT_NODE_ARRAY) {
            for (int i = 0; i < node.u.list->num; i++) {
                mpv_node *t = &node.u.list->values[i];
                const char *type = map_str(t, "type", "?");
                const char *codec = map_str(t, "codec", NULL);
                const char *title = map_str(t, "title", NULL);
                const char *lang = map_str(t, "lang", NULL);
                s = talloc_asprintf_append(s, "\r\n[%s #%lld]\r\n", type,
                                           (long long)map_int(t, "id", 0));
                if (codec)
                    s = talloc_asprintf_append(s, "  Codec:       %s\r\n", codec);
                if (!strcmp(type, "video")) {
                    int64_t w = map_int(t, "demux-w", 0), h = map_int(t, "demux-h", 0);
                    if (w && h)
                        s = talloc_asprintf_append(s, "  Resolution:  %lldx%lld\r\n",
                                                   (long long)w, (long long)h);
                    mpv_node *fps = map_get(t, "demux-fps");
                    if (fps && fps->format == MPV_FORMAT_DOUBLE)
                        s = talloc_asprintf_append(s, "  Frame rate:  %.3f fps\r\n",
                                                   fps->u.double_);
                } else if (!strcmp(type, "audio")) {
                    int64_t ch = map_int(t, "demux-channel-count", 0);
                    int64_t sr = map_int(t, "demux-samplerate", 0);
                    if (ch)
                        s = talloc_asprintf_append(s, "  Channels:    %lld\r\n",
                                                   (long long)ch);
                    if (sr)
                        s = talloc_asprintf_append(s, "  Sample rate: %lld Hz\r\n",
                                                   (long long)sr);
                }
                if (lang)
                    s = talloc_asprintf_append(s, "  Language:    %s\r\n", lang);
                if (title)
                    s = talloc_asprintf_append(s, "  Title:       %s\r\n", title);
            }
        }
        mpv_free_node_contents(&node);
    }
    if (!s[0])
        s = talloc_asprintf_append(s, "No file loaded.\r\n");
    return s;
}

static ATOM g_info_class;
static HFONT g_info_font;

static LRESULT CALLBACK info_proc(HWND h, UINT m, WPARAM w, LPARAM l)
{
    switch (m) {
    case WM_SIZE: {
        HWND e = GetWindow(h, GW_CHILD);
        if (e)
            MoveWindow(e, 0, 0, LOWORD(l), HIWORD(l), TRUE);
        return 0;
    }
    case WM_CLOSE:
        DestroyWindow(h);
        return 0;
    }
    return DefWindowProcW(h, m, w, l);
}

static void show_media_info(struct mp_w32_gui *g)
{
    char *path = NULL;
    wchar_t *wpath = NULL;
    if (g->client &&
        mpv_get_property(g->client, "path", MPV_FORMAT_STRING, &path) >= 0 && path) {
        wpath = mp_from_utf8(NULL, path);
        mpv_free(path);
    }
    wchar_t *text = wpath ? mediainfo_report(wpath) : NULL;
    if (!text) {
        char *rep = fallback_report(g);
        text = mp_from_utf8(NULL, rep);
        talloc_free(rep);
    }
    talloc_free(wpath);

    if (!g_info_class) {
        WNDCLASSEXW wc = {
            .cbSize = sizeof(wc),
            .lpfnWndProc = info_proc,
            .hInstance = HINST_THISCOMPONENT,
            .hIcon = LoadIconW(HINST_THISCOMPONENT, L"IDI_ICON1"),
            .hCursor = LoadCursorW(NULL, IDC_ARROW),
            .hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1),
            .lpszClassName = L"mpvInfoWnd",
        };
        g_info_class = RegisterClassExW(&wc);
    }
    if (!g_info_font)
        g_info_font = CreateFontW(-dpi(g, 13), 0, 0, 0, FW_NORMAL, FALSE, FALSE,
            FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
            CLEARTYPE_QUALITY, FIXED_PITCH, L"Consolas");

    int ww = dpi(g, 720), wh = dpi(g, 640);
    RECT fr;
    GetWindowRect(g->frame, &fr);
    int x = fr.left + ((fr.right - fr.left) - ww) / 2;
    int y = fr.top + ((fr.bottom - fr.top) - wh) / 2;
    HWND win = CreateWindowExW(0, L"mpvInfoWnd", L"MediaInfo",
        WS_OVERLAPPEDWINDOW, x, y, ww, wh, g->frame, NULL,
        HINST_THISCOMPONENT, NULL);
    if (!win) {
        talloc_free(text);
        return;
    }
    BOOL dark = g->dark;
    DwmSetWindowAttribute(win, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark));

    RECT cr;
    GetClientRect(win, &cr);
    HWND edit = CreateWindowExW(0, L"EDIT", text,
        WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE |
        ES_READONLY | ES_AUTOVSCROLL | ES_AUTOHSCROLL,
        0, 0, cr.right, cr.bottom, win, NULL, HINST_THISCOMPONENT, NULL);
    if (edit && g_info_font)
        SendMessageW(edit, WM_SETFONT, (WPARAM)g_info_font, TRUE);
    talloc_free(text);

    ShowWindow(win, SW_SHOW);
    UpdateWindow(win);
}

static LRESULT CALLBACK frame_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
    struct mp_w32_gui *g = (void *)GetWindowLongPtrW(hwnd, GWLP_USERDATA);

    switch (msg) {
    case WM_CREATE: {
        CREATESTRUCTW *cs = (CREATESTRUCTW *)lp;
        SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR)cs->lpCreateParams);
        return 0;
    }
    case WM_SIZE:
        layout(g);
        return 0;
    case WM_ENTERSIZEMOVE:
        if (g)
            g->in_move_size = true;
        return 0;
    case WM_EXITSIZEMOVE:
        if (g) {
            g->in_move_size = false;
            // Catch the transport widgets up to the current position.
            update_time_label(g);
            if (!g->seeking && g->cur_duration > 0) {
                int v = (int)(g->cur_time / g->cur_duration * 10000.0 + 0.5);
                v = v < 0 ? 0 : v > 10000 ? 10000 : v;
                SendMessageW(g->seek, TBM_SETPOS, TRUE, v);
            }
        }
        return 0;
    case WM_COMMAND:
        if (g)
            on_command(g, LOWORD(wp));
        return 0;
    case WM_INITMENUPOPUP:
        if (g) {
            HMENU sub = (HMENU)wp;
            if (sub == g->menu_audio)      rebuild_audio_menu(g);
            else if (sub == g->menu_sub)   rebuild_sub_menu(g);
            else if (sub == g->menu_video) rebuild_video_menu(g);
            else if (sub == g->menu_speed) update_speed_checks(g);
            else if (sub == g->menu_recent) rebuild_recent_menu(g);
            else if (sub == g->menu_chapters) rebuild_chapters_menu(g);
            else if (sub == g->menu_view) {
                CheckMenuItem(g->menu_view, ID_VIEW_FULLSCREEN, MF_BYCOMMAND |
                              (g->fullscreen ? MF_CHECKED : MF_UNCHECKED));
                CheckMenuItem(g->menu_view, ID_OPT_MPVOSC, MF_BYCOMMAND |
                              (g->mpv_osc ? MF_CHECKED : MF_UNCHECKED));
                CheckMenuItem(g->menu_view, ID_OPT_MPVKEYS, MF_BYCOMMAND |
                              (g->mpv_keys ? MF_CHECKED : MF_UNCHECKED));
            }
            else if (sub == g->menu_ontop)
                CheckMenuRadioItem(g->menu_ontop, ID_ONTOP_NEVER,
                                   ID_ONTOP_PLAYING,
                                   ID_ONTOP_NEVER + g->ontop_mode, MF_BYCOMMAND);
        }
        return 0;
    case WM_HSCROLL:
        if (g)
            on_hscroll(g, (HWND)lp, LOWORD(wp));
        return 0;
    case WM_APP_MPV:
        if (g)
            handle_mpv_events(g);
        return 0;
    case WM_TIMER:
        if (g && wp == FS_TIMER_ID && g->fullscreen)
            fs_poll_activity(g);
        else if (g && wp == AUTOSIZE_ID) {
            KillTimer(g->frame, AUTOSIZE_ID);
            g->autosize_timer_on = false;
            autosize_to_video(g);
        }
        return 0;
    case WM_SETFOCUS:
        if (g && g->video)
            SetFocus(g->video);
        return 0;
    case WM_ERASEBKGND:
        if (g && g->dark && g->bg_brush) {
            RECT r;
            GetClientRect(hwnd, &r);
            FillRect((HDC)wp, &r, g->bg_brush);
            return 1;
        }
        break;
    case WM_CTLCOLORSTATIC:
    case WM_CTLCOLORBTN:
        if (g && g->dark) {
            SetTextColor((HDC)wp, DARK_TEXT);
            SetBkColor((HDC)wp, DARK_BG);
            return (LRESULT)(g->bg_brush ? g->bg_brush
                                         : (HBRUSH)GetStockObject(BLACK_BRUSH));
        }
        break;
    case WM_SETTINGCHANGE:
        if (g && lp && !lstrcmpW((wchar_t *)lp, L"ImmersiveColorSet"))
            apply_theme(g);
        break;
    case WM_CLOSE:
        if (g)
            run_cmd(g, "quit");
        return 0;
    case WM_DESTROY:
        if (g)
            save_state(g);
        PostQuitMessage(0);
        return 0;
    }
    return DefWindowProcW(hwnd, msg, wp, lp);
}

static void ensure_class(void)
{
    static bool done;
    if (done)
        return;
    done = true;

    INITCOMMONCONTROLSEX icc = {
        .dwSize = sizeof(icc),
        .dwICC = ICC_BAR_CLASSES | ICC_STANDARD_CLASSES | ICC_WIN95_CLASSES,
    };
    InitCommonControlsEx(&icc);

    WNDCLASSEXW wc = {
        .cbSize = sizeof(wc),
        .style = CS_HREDRAW | CS_VREDRAW,
        .lpfnWndProc = frame_proc,
        .hInstance = HINST_THISCOMPONENT,
        .hIcon = LoadIconW(HINST_THISCOMPONENT, L"IDI_ICON1"),
        .hCursor = LoadCursorW(NULL, IDC_ARROW),
        .hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1),
        .lpszClassName = FRAME_CLASS,
    };
    g_frame_class = RegisterClassExW(&wc);
}

static void build_menu(struct mp_w32_gui *g)
{
    HMENU bar = CreateMenu();

    // File
    HMENU file = CreatePopupMenu();
    AppendMenuW(file, MF_STRING, ID_FILE_OPEN, L"&Open File...\tCtrl+O");
    g->menu_recent = CreatePopupMenu();
    AppendMenuW(g->menu_recent, MF_STRING | MF_GRAYED, 0, L"(empty)");
    AppendMenuW(file, MF_POPUP, (UINT_PTR)g->menu_recent, L"&Recent Files");
    AppendMenuW(file, MF_SEPARATOR, 0, NULL);
    AppendMenuW(file, MF_STRING, ID_FILE_MEDIAINFO, L"&MediaInfo...");
    AppendMenuW(file, MF_SEPARATOR, 0, NULL);
    AppendMenuW(file, MF_STRING, ID_FILE_EXIT, L"E&xit");
    AppendMenuW(bar, MF_POPUP, (UINT_PTR)file, L"&File");

    // Static preset submenus reused by Play / Video.
    g->menu_speed = CreatePopupMenu();
    for (int i = 0; i < (int)MP_ARRAY_SIZE(g_speeds); i++)
        AppendMenuW(g->menu_speed, MF_STRING, ID_SPEED_BASE + i, g_speeds[i].label);
    g->menu_aspect = CreatePopupMenu();
    for (int i = 0; i < (int)MP_ARRAY_SIZE(g_aspects); i++)
        AppendMenuW(g->menu_aspect, MF_STRING, ID_ASPECT_BASE + i, g_aspects[i].label);

    // Play
    HMENU play = CreatePopupMenu();
    AppendMenuW(play, MF_STRING, ID_PLAY_PAUSE, L"&Play/Pause\tSpace");
    AppendMenuW(play, MF_STRING, ID_PLAY_STOP, L"&Stop");
    AppendMenuW(play, MF_SEPARATOR, 0, NULL);
    AppendMenuW(play, MF_POPUP, (UINT_PTR)g->menu_speed, L"S&peed");
    AppendMenuW(play, MF_SEPARATOR, 0, NULL);
    AppendMenuW(play, MF_STRING, ID_PLAY_PREV, L"P&revious\tPgUp");
    AppendMenuW(play, MF_STRING, ID_PLAY_NEXT, L"&Next\tPgDn");
    AppendMenuW(play, MF_SEPARATOR, 0, NULL);
    AppendMenuW(play, MF_STRING, ID_PLAY_PREVCH, L"Previous &Chapter");
    AppendMenuW(play, MF_STRING, ID_PLAY_NEXTCH, L"Next C&hapter");
    g->menu_chapters = CreatePopupMenu();
    AppendMenuW(g->menu_chapters, MF_STRING | MF_GRAYED, 0, L"(no chapters)");
    AppendMenuW(play, MF_POPUP, (UINT_PTR)g->menu_chapters, L"Chap&ters");
    AppendMenuW(play, MF_SEPARATOR, 0, NULL);
    AppendMenuW(play, MF_STRING, ID_PLAY_STEPB, L"Step &Back");
    AppendMenuW(play, MF_STRING, ID_PLAY_STEPF, L"Step &Forward");
    AppendMenuW(bar, MF_POPUP, (UINT_PTR)play, L"&Play");

    // Audio / Subtitle / Video: contents rebuilt on open (WM_INITMENUPOPUP).
    g->menu_audio = CreatePopupMenu();
    AppendMenuW(g->menu_audio, MF_STRING | MF_GRAYED, 0, L"(no file)");
    AppendMenuW(bar, MF_POPUP, (UINT_PTR)g->menu_audio, L"&Audio");

    g->menu_sub = CreatePopupMenu();
    AppendMenuW(g->menu_sub, MF_STRING | MF_GRAYED, 0, L"(no file)");
    AppendMenuW(bar, MF_POPUP, (UINT_PTR)g->menu_sub, L"&Subtitle");

    g->menu_video = CreatePopupMenu();
    AppendMenuW(g->menu_video, MF_STRING | MF_GRAYED, 0, L"(no file)");
    AppendMenuW(bar, MF_POPUP, (UINT_PTR)g->menu_video, L"&Video");

    // View
    HMENU view = CreatePopupMenu();
    g->menu_view = view;
    AppendMenuW(view, MF_STRING, ID_VIEW_FULLSCREEN, L"&Fullscreen\tF");
    g->menu_ontop = CreatePopupMenu();
    AppendMenuW(g->menu_ontop, MF_STRING, ID_ONTOP_NEVER, L"&Never");
    AppendMenuW(g->menu_ontop, MF_STRING, ID_ONTOP_ALWAYS, L"&Always");
    AppendMenuW(g->menu_ontop, MF_STRING, ID_ONTOP_PLAYING, L"While &Playing");
    AppendMenuW(view, MF_POPUP, (UINT_PTR)g->menu_ontop, L"On &Top");
    AppendMenuW(view, MF_SEPARATOR, 0, NULL);
    AppendMenuW(view, MF_STRING, ID_OPT_MPVOSC, L"Native mpv &Controls (OSC)");
    AppendMenuW(view, MF_STRING, ID_OPT_MPVKEYS, L"mpv &Keyboard Shortcuts");
    AppendMenuW(bar, MF_POPUP, (UINT_PTR)view, L"Vi&ew");

    // Help
    HMENU help = CreatePopupMenu();
    AppendMenuW(help, MF_STRING, ID_HELP_ABOUT, L"&About jmwg");
    AppendMenuW(bar, MF_POPUP, (UINT_PTR)help, L"&Help");

    SetMenu(g->frame, bar);
}

static HWND mkbutton(struct mp_w32_gui *g, int id, const wchar_t *glyph)
{
    HWND b = CreateWindowExW(0, L"BUTTON", glyph,
        WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON | BS_CENTER | BS_VCENTER,
        0, 0, 10, 10, g->frame, (HMENU)(INT_PTR)id, HINST_THISCOMPONENT, NULL);
    if (g->glyph_font)
        SendMessageW(b, WM_SETFONT, (WPARAM)g->glyph_font, TRUE);
    return b;
}

// Map a client x to a trackbar value and apply it, notifying the parent with
// the given scroll code. Uses the thumb-center travel range (channel inset by
// the thumb half-width) so the thumb lands exactly under the cursor and a click
// on a chapter notch seeks precisely to that chapter.
static void track_set_from_x(HWND hwnd, int x, int code)
{
    int mn = (int)SendMessageW(hwnd, TBM_GETRANGEMIN, 0, 0);
    int mx = (int)SendMessageW(hwnd, TBM_GETRANGEMAX, 0, 0);
    double lo, travel;
    track_geom(hwnd, &lo, &travel);
    int val;
    if (x <= lo)
        val = mn;
    else if (x >= lo + travel)
        val = mx;
    else
        val = mn + (int)((x - lo) / travel * (mx - mn) + 0.5);
    SendMessageW(hwnd, TBM_SETPOS, TRUE, val);
    SendMessageW(GetParent(hwnd), WM_HSCROLL, MAKEWPARAM(code, 0), (LPARAM)hwnd);
}

// Subclass for the seek/volume trackbars. Click-to-seek and drag are handled
// entirely here with mouse capture, so the control's own channel "paging" never
// runs. (That paging added ~one page-step beyond the click, which near the end
// clamped the seek straight to EOF; dragging was unaffected, hence the asymmetry.)
static LRESULT CALLBACK trackbar_subproc(HWND hwnd, UINT msg, WPARAM wp,
                                         LPARAM lp, UINT_PTR id, DWORD_PTR ref)
{
    struct mp_w32_gui *g = (struct mp_w32_gui *)ref;

    switch (msg) {
    case WM_LBUTTONDOWN:
        SetCapture(hwnd);
        track_set_from_x(hwnd, (short)LOWORD(lp), TB_THUMBTRACK);
        return 0;
    case WM_MOUSEMOVE:
        if ((wp & MK_LBUTTON) && GetCapture() == hwnd) {
            // Dragging: keep seeking/setting to the cursor position.
            track_set_from_x(hwnd, (short)LOWORD(lp), TB_THUMBTRACK);
            return 0;
        }
        // Hovering the seek bar: show the target time under the cursor, plus the
        // chapter title when the file has chapters.
        if (g && hwnd == g->seek && g->cur_duration > 0) {
            double lo, travel;
            track_geom(hwnd, &lo, &travel);
            int x = (short)LOWORD(lp);
            double f = (x - lo) / travel;
            f = f < 0 ? 0 : f > 1 ? 1 : f;
            double t = f * g->cur_duration;
            char tb[32];
            fmt_time(tb, sizeof(tb), t);
            wchar_t buf[320];
            int ci = chapter_at_time(g, t);
            if (ci >= 0 && g->chap_title[ci] && g->chap_title[ci][0]) {
                wchar_t *wt = mp_from_utf8(NULL, g->chap_title[ci]);
                _snwprintf(buf, MP_ARRAY_SIZE(buf), L"%hs  -  %d. %ls",
                           tb, ci + 1, wt);
                talloc_free(wt);
            } else if (ci >= 0) {
                _snwprintf(buf, MP_ARRAY_SIZE(buf), L"%hs  -  Chapter %d",
                           tb, ci + 1);
            } else {
                _snwprintf(buf, MP_ARRAY_SIZE(buf), L"%hs", tb);
            }
            show_tip_text(g, buf, 0, -30);
            TRACKMOUSEEVENT tme = { sizeof(tme), TME_LEAVE, hwnd, 0 };
            TrackMouseEvent(&tme);
        }
        break; // let the control hot-track the thumb on hover
    case WM_LBUTTONUP:
        if (GetCapture() == hwnd) {
            ReleaseCapture();
            track_set_from_x(hwnd, (short)LOWORD(lp), TB_ENDTRACK); // precise seek
        }
        return 0;
    case WM_MOUSELEAVE:
        if (g && hwnd == g->seek)
            hide_vol_tip(g);
        break;
    case WM_PAINT:
        if (g && hwnd == g->seek) {
            LRESULT res = DefSubclassProc(hwnd, msg, wp, lp);
            HDC dc = GetDC(hwnd);
            if (dc) {
                draw_chapter_marks(g, hwnd, dc);
                ReleaseDC(hwnd, dc);
            }
            return res;
        }
        break;
    }
    return DefSubclassProc(hwnd, msg, wp, lp);
}

static void create_controls(struct mp_w32_gui *g)
{
    g->dpi_scale = GetDpiForWindow(g->frame) / 96.0;
    if (g->dpi_scale <= 0)
        g->dpi_scale = 1.0;

    g->glyph_font = CreateFontW(-dpi(g, 16), 0, 0, 0, FW_NORMAL, FALSE, FALSE,
        FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
        CLEARTYPE_QUALITY, DEFAULT_PITCH, L"Segoe MDL2 Assets");
    g->ui_font = CreateFontW(-dpi(g, 13), 0, 0, 0, FW_NORMAL, FALSE, FALSE,
        FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
        CLEARTYPE_QUALITY, DEFAULT_PITCH, L"Segoe UI");

    g->seek = CreateWindowExW(0, TRACKBAR_CLASSW, L"",
        WS_CHILD | WS_VISIBLE | TBS_HORZ | TBS_NOTICKS,
        0, 0, 10, 10, g->frame, (HMENU)(INT_PTR)ID_SEEK,
        HINST_THISCOMPONENT, NULL);
    // Fine range (hundredths of a percent) for smooth, precise seeking.
    SendMessageW(g->seek, TBM_SETRANGEMIN, FALSE, 0);
    SendMessageW(g->seek, TBM_SETRANGEMAX, TRUE, 10000);
    SendMessageW(g->seek, TBM_SETPOS, TRUE, 0);
    SendMessageW(g->seek, TBM_SETPAGESIZE, 0, 500);
    SendMessageW(g->seek, TBM_SETLINESIZE, 0, 50);
    SetWindowSubclass(g->seek, trackbar_subproc, 0, (DWORD_PTR)g);

    g->btn_prev = mkbutton(g, ID_BTN_PREV, GLYPH_PREV);
    g->btn_back = mkbutton(g, ID_BTN_BACK, GLYPH_BACK);
    g->btn_play = mkbutton(g, ID_BTN_PLAY, GLYPH_PLAY);
    g->btn_stop = mkbutton(g, ID_BTN_STOP, GLYPH_STOP);
    g->btn_fwd  = mkbutton(g, ID_BTN_FWD,  GLYPH_FWD);
    g->btn_next = mkbutton(g, ID_BTN_NEXT, GLYPH_NEXT);
    g->btn_mute = mkbutton(g, ID_BTN_MUTE, GLYPH_VOLUME);

    g->timelbl = CreateWindowExW(0, L"STATIC", L"00:00:00 / 00:00:00",
        WS_CHILD | WS_VISIBLE | SS_CENTERIMAGE | SS_LEFT,
        0, 0, 10, 10, g->frame, (HMENU)(INT_PTR)ID_TIMELBL,
        HINST_THISCOMPONENT, NULL);
    SendMessageW(g->timelbl, WM_SETFONT, (WPARAM)g->ui_font, TRUE);

    g->volume = CreateWindowExW(0, TRACKBAR_CLASSW, L"",
        WS_CHILD | WS_VISIBLE | TBS_HORZ | TBS_NOTICKS,
        0, 0, 10, 10, g->frame, (HMENU)(INT_PTR)ID_VOLUME,
        HINST_THISCOMPONENT, NULL);
    SendMessageW(g->volume, TBM_SETRANGE, TRUE, MAKELONG(0, 100));
    SendMessageW(g->volume, TBM_SETPOS, TRUE, 100);
    SetWindowSubclass(g->volume, trackbar_subproc, 0, (DWORD_PTR)g);

    // (The fullscreen overlay is a separate top-level window, created on demand
    // in ensure_fs_overlay when first entering fullscreen.)

    g->status = CreateWindowExW(0, STATUSCLASSNAMEW, L"",
        WS_CHILD | WS_VISIBLE | SBARS_SIZEGRIP,
        0, 0, 0, 0, g->frame, (HMENU)(INT_PTR)ID_STATUS,
        HINST_THISCOMPONENT, NULL);
    SendMessageW(g->status, WM_SETFONT, (WPARAM)g->ui_font, TRUE);
    // Three panes: playback state | filename | technical (codec/container) info.
    // The right edges are recomputed for the window width in layout().
    int parts[] = { dpi(g, 90), dpi(g, 400), -1 };
    SendMessageW(g->status, SB_SETPARTS, MP_ARRAY_SIZE(parts), (LPARAM)parts);
    SendMessageW(g->status, SB_SETTEXTW, 0, (LPARAM)L"Stopped");

    create_vol_tooltip(g);
}

static bool is_dark_mode(void)
{
    DWORD val = 1, sz = sizeof(val);
    HKEY k;
    if (RegOpenKeyExW(HKEY_CURRENT_USER,
            L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
            0, KEY_READ, &k) == ERROR_SUCCESS) {
        RegQueryValueExW(k, L"AppsUseLightTheme", NULL, NULL, (BYTE *)&val, &sz);
        RegCloseKey(k);
    }
    return val == 0;
}

// Match the Windows light/dark setting: dark title bar (DWM), dark-aware
// control theming, and a dark frame background. Called at startup and whenever
// the system theme changes.
static void apply_theme(struct mp_w32_gui *g)
{
    g->dark = is_dark_mode();

    BOOL dark = g->dark;
    DwmSetWindowAttribute(g->frame, DWMWA_USE_IMMERSIVE_DARK_MODE,
                          &dark, sizeof(dark));

    const wchar_t *theme = g->dark ? L"DarkMode_Explorer" : NULL;
    HWND ws[] = { g->frame, g->seek, g->volume, g->status, g->timelbl,
                  g->btn_prev, g->btn_back, g->btn_play, g->btn_stop,
                  g->btn_fwd, g->btn_next };
    for (int i = 0; i < MP_ARRAY_SIZE(ws); i++)
        SetWindowTheme(ws[i], theme, NULL);

    if (g->dark && !g->bg_brush)
        g->bg_brush = CreateSolidBrush(DARK_BG);

    InvalidateRect(g->frame, NULL, TRUE);
}

struct mp_w32_gui *mp_w32_gui_create(struct mp_log *log,
                                     struct mpv_global *global,
                                     struct input_ctx *input_ctx)
{
    ensure_class();
    if (!g_frame_class)
        return NULL;

    struct mp_w32_gui *g = talloc_zero(NULL, struct mp_w32_gui);
    g->log = log;
    g->global = global;
    g->input_ctx = input_ctx;
    g->dpi_scale = 1.0;
    g->paused = true;

    RECT r = { 0, 0, 1280, 760 };
    AdjustWindowRectEx(&r, WS_OVERLAPPEDWINDOW, TRUE, 0);

    g->frame = CreateWindowExW(0, FRAME_CLASS, L"jmwg",
        WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN,
        CW_USEDEFAULT, CW_USEDEFAULT, r.right - r.left, r.bottom - r.top,
        NULL, NULL, HINST_THISCOMPONENT, g);
    if (!g->frame) {
        talloc_free(g);
        return NULL;
    }

    build_menu(g);
    load_recent(g);
    load_state(g);
    create_controls(g);
    apply_theme(g);
    layout(g);
    init_client(g);
    // Restore the volume/mute saved from the previous session (no-osd, else mpv
    // flashes a "Mute: no" / "Volume" OSD on every launch).
    if (g->init_volume_set) {
        char *c = talloc_asprintf(NULL, "no-osd set volume %.2f", g->init_volume);
        run_cmd(g, c);
        talloc_free(c);
        run_cmd(g, g->init_mute ? "no-osd set mute yes" : "no-osd set mute no");
    }
    return g;
}

HWND mp_w32_gui_frame(struct mp_w32_gui *g)
{
    return g ? g->frame : NULL;
}

void mp_w32_gui_set_video(struct mp_w32_gui *g, HWND video)
{
    if (!g)
        return;
    g->video = video;
    layout(g);
}

void mp_w32_gui_show(struct mp_w32_gui *g)
{
    if (!g)
        return;
    if (g->init_rect_set) {
        // Restore the previous session's position/size (and maximized state).
        WINDOWPLACEMENT wp = {
            .length = sizeof(wp),
            .showCmd = g->init_max ? SW_SHOWMAXIMIZED : SW_SHOWNORMAL,
            .rcNormalPosition = g->init_rect,
        };
        SetWindowPlacement(g->frame, &wp);
    } else {
        ShowWindow(g->frame, SW_SHOWNORMAL);
    }
    UpdateWindow(g->frame);
}

void mp_w32_gui_context_menu(struct mp_w32_gui *g, int x, int y)
{
    if (!g)
        return;

    HMENU m = CreatePopupMenu();
    AppendMenuW(m, MF_STRING, ID_FILE_OPEN, L"&Open File...");
    AppendMenuW(m, MF_SEPARATOR, 0, NULL);
    AppendMenuW(m, MF_STRING, ID_PLAY_PAUSE, g->paused ? L"&Play" : L"&Pause");
    AppendMenuW(m, MF_STRING, ID_PLAY_STOP, L"&Stop");
    AppendMenuW(m, MF_SEPARATOR, 0, NULL);
    AppendMenuW(m, MF_STRING, ID_PLAY_PREV, L"Pre&vious");
    AppendMenuW(m, MF_STRING, ID_PLAY_NEXT, L"&Next");
    AppendMenuW(m, MF_SEPARATOR, 0, NULL);
    AppendMenuW(m, MF_STRING | (g->fullscreen ? MF_CHECKED : 0),
                ID_VIEW_FULLSCREEN, L"&Fullscreen");
    AppendMenuW(m, MF_SEPARATOR, 0, NULL);
    AppendMenuW(m, MF_STRING, ID_FILE_EXIT, L"E&xit");

    // Required so the menu dismisses when the user clicks elsewhere.
    SetForegroundWindow(g->frame);
    TrackPopupMenu(m, TPM_LEFTALIGN | TPM_TOPALIGN | TPM_RIGHTBUTTON,
                   x, y, 0, g->frame, NULL);
    DestroyMenu(m);
}

bool mp_w32_gui_native_input(struct mp_w32_gui *g)
{
    // The user opted into mpv's own controls (its key bindings and/or OSC). When
    // set, the shell hands the right mouse button to mpv (so its OSC / native
    // context menu work) instead of popping the MPC-style context menu.
    return g && (g->mpv_osc || g->mpv_keys);
}

void mp_w32_gui_destroy(struct mp_w32_gui *g)
{
    if (!g)
        return;
    if (g->client) {
        mpv_set_wakeup_callback(g->client, NULL, NULL);
        mpv_destroy(g->client);
    }
    if (g->frame)
        DestroyWindow(g->frame);
    if (g->glyph_font)
        DeleteObject(g->glyph_font);
    if (g->ui_font)
        DeleteObject(g->ui_font);
    if (g->bg_brush)
        DeleteObject(g->bg_brush);
    talloc_free(g);
}
