From 0688ab228092d96bc141e35a9e6ee0de4f60a906 Mon Sep 17 00:00:00 2001 From: LargeModGames <84450916+LargeModGames@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:26:51 +0200 Subject: [PATCH] Expand the Lua plugin API to v5 Plugins can now read app data, drive most of the app, and build their own screens: - Async data reads with a (data, err) callback convention, backed by per-domain generation counters bumped at network write sites: get_playlists, get_queue, get_search_results, get_saved_tracks, get_saved_albums, get_saved_shows, get_recently_played, get_devices, get_lyrics (15s timeout). Cached sync reads: playlists(), queue(), search_results(). New GetDevicesSilent/GetRecentlyPlayedSilent IoEvents keep plugin reads from navigating the UI. - Actions routed through whitelisted IoEvents: set_repeat/cycle_repeat, transfer_playback, play_uri, play_context, add_to_queue, create_playlist, playlist_add_track, playlist_remove_track, follow/unfollow_playlist, toggle_save_track, save/unsave_album, save/unsave_show, follow/unfollow_artist. - Timers: set_timeout/set_interval/cancel_timer, fired from the tick loop with intake queues, skip-missed-period rescheduling and one-strike removal of erroring intervals. - Plugin-scoped persistent storage (storage_get/set/remove/keys), one flat JSON file per plugin under plugin-data/, throttled flush plus forced flush on quit via temp-file + rename. - New events: shuffle_change, repeat_change, device_change, search_results, route_change ({name} arg); navigation via navigate(target)/back()/current_route() with a whitelist that mirrors the matching keybindings. - Retained-mode custom screens: register_screen with on_key/on_open/ on_close, set_screen with paragraph/list/gauge widgets, show_screen/close_screen (ownership-checked), keys forwarded as config.yml-style strings, RouteId::PluginScreen routing plus a dedicated renderer and handler. - config() read exposing theme colors and safe behavior scalars (secrets excluded). - docs/scripting.md v5 sweep, queue-browser.lua example, changelog. API_VERSION 4 -> 5 (additive; all v4 snapshot fields preserved). --- CHANGELOG.md | 1 + docs/scripting.md | 228 +++- examples/plugins/README.md | 1 + examples/plugins/queue-browser.lua | 115 ++ src/core/app.rs | 71 ++ src/core/plugin_api.rs | 357 +++++- src/infra/network/library.rs | 19 +- src/infra/network/mod.rs | 16 +- src/infra/network/playback.rs | 11 + src/infra/network/search.rs | 3 + src/infra/network/user.rs | 26 +- src/infra/network/utils.rs | 12 + src/infra/scripting/api.rs | 754 +++++++++++- src/infra/scripting/effects.rs | 120 ++ src/infra/scripting/engine.rs | 605 +++++++++- src/infra/scripting/events.rs | 69 +- src/infra/scripting/shared.rs | 102 +- src/infra/scripting/tests.rs | 1705 +++++++++++++++++++++++++--- src/tui/handlers/mod.rs | 7 +- src/tui/handlers/plugin_screen.rs | 116 ++ src/tui/runner.rs | 7 + src/tui/ui/mod.rs | 5 +- src/tui/ui/plugin_screen.rs | 136 +++ 23 files changed, 4306 insertions(+), 180 deletions(-) create mode 100644 examples/plugins/queue-browser.lua create mode 100644 src/tui/handlers/plugin_screen.rs create mode 100644 src/tui/ui/plugin_screen.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5018669e..9b862158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Added +- **Lua scripting API v5**: A major expansion of the plugin API. Plugins can now read library data asynchronously (`get_playlists`, `get_queue`, `get_search_results`, `get_saved_tracks`/`albums`/`shows`, `get_recently_played`, `get_devices`, `get_lyrics`, all with the `(data, err)` callback convention), drive far more of the app (`set_repeat`/`cycle_repeat`, `transfer_playback`, `play_uri`/`play_context`, `add_to_queue`, playlist create/edit, save/follow actions), schedule work with `set_timeout`/`set_interval`/`cancel_timer`, persist state in a per-plugin JSON store (`storage_get`/`set`/`remove`/`keys`), navigate (`navigate`/`back`/`current_route`), read the user config (`config()`), react to new events (`shuffle_change`, `repeat_change`, `device_change`, `search_results`, `route_change`), and register full custom screens with paragraph/list/gauge widgets and per-key callbacks (`register_screen`, `set_screen`, `show_screen`, `close_screen`). See `docs/scripting.md` and the new `examples/plugins/queue-browser.lua`. - **Playlist folders in the add-to-playlist picker**: The `w` "Add Track To Playlist" dialog now shows your Spotify playlist folders and navigates them like the sidebar: `Enter` on a 📁 row opens the folder, the `←` row goes back, and `Enter` on a playlist adds the track. Only editable (owned or collaborative) playlists are offered, and the "Playlist Folders First" setting orders the dialog too. If folder data is unavailable (librespot rootlist fetch fails, or a build without native streaming), the dialog falls back to the previous flat list. - **Global customization**: Nearly every part of the UI is now configurable via `config.yml` and the Settings screen. Pick the screen that opens at launch (`startup_route`: home, recently played, podcasts, discover, artists, or saved albums — fetched on startup so it arrives populated) and a default sort order per screen (`default_sort_playlist_tracks` / `_saved_albums` / `_saved_artists` / `_recently_played`, as `field` or `field:desc`). Rearrange the layout with `sidebar_position: left|right|hidden` (hidden auto-reveals while the sidebar has focus) and `playbar_position: bottom|top`, and tune the responsive breakpoints (`small_terminal_width`/`_height`). New icon fields (progress-gauge fill, sort arrows, episode-played marker, active-source dot, list cursor) join the existing ones in a new **Icons** Settings tab, with one-column width validation where table alignment depends on it. The playbar buttons can be relabeled (`behavior.playbar_control_labels`, hitboxes resize automatically), the playbar status line and window title are format templates (`format:` section with `{state}`, `{device}`, `{volume}`, … placeholders), and every table's columns can be reordered, removed, renamed, and resized (`tables:` section). Timing constants are exposed too: status-message duration (`status_message_ttl_percent`), playback poll interval, table scroll padding, and like-animation length. Invalid values never brick the app — structural config errors log a warning and fall back to the built-in default. Documented in `docs/configuration.md` with a full commented example at `examples/config.example.yml`. - **Native cross-source play queue**: Press `z` on any track to add it to a native queue that plays across sources (Spotify, Local Files, Subsonic, and YouTube in one FIFO; internet radio is excluded since a live stream is not a finite track). Queued tracks play before the underlying playlist/album context, then that context resumes where it left off. Open the queue with `Shift+Q`: remove the selected item with `x` (rebindable via `remove_from_queue`, default `x`), reorder it with `J`/`K`, or press `Enter` to skip ahead and play it now. The Queue screen also previews what resumes from the underlying context once the queue drains. The queue survives restarts (persisted in `last_session.yml`). Spotify tracks queue through native (librespot) streaming; when you are controlling an external Spotify Connect device instead, `z` keeps the previous Web-API "add to Spotify's queue" behavior ([#206](https://github.com/LargeModGames/spotatui/issues/206)). diff --git a/docs/scripting.md b/docs/scripting.md index 4a93488e..ba9f76bf 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -97,7 +97,7 @@ A global table named `spotatui` is available in every plugin. ### Constants -- `spotatui.api_version` - integer API version (currently `4`). +- `spotatui.api_version` - integer API version (currently `5`). ### Declaring API compatibility @@ -106,7 +106,7 @@ particular version, declare it on the first line so users on an older spotatui g message instead of a cryptic `attempt to call nil` error: ```lua -spotatui.require_api(4) +spotatui.require_api(5) ``` `spotatui.require_api(n)` raises a load error (`requires spotatui scripting API v{n} ...`) when @@ -127,6 +127,11 @@ error. Valid events: | `seek` | playback table or nil | Same track, same play state, and progress jumps backward by more than 1.5s or forward by more than 6.5s. Forward jumps inside that window are treated as normal Connect polling, not seeks. | | `volume_change` | playback table or nil | The device volume percentage changes. | | `queue_change` | none | The queue contents change. | +| `shuffle_change` | playback table or nil | Shuffle flips while playback exists on both sides of the tick. | +| `repeat_change` | playback table or nil | The repeat mode changes while playback exists on both sides of the tick. | +| `device_change` | none | The device list changes (by id, name, or which device is active). | +| `search_results` | none | New search results arrive (from the Search screen or `spotatui.search`). | +| `route_change` | `{ name = "..." }` | The visible screen changes. Names are listed under Navigation below; plugin screens are `"plugin:"`. | You can register multiple callbacks for the same event. @@ -137,6 +142,11 @@ These return a snapshot of the cached state. Snapshots are refreshed before call - `spotatui.playback()` - playback table, or `nil` when there is no playback. - `spotatui.current_track()` - track table, or `nil`. - `spotatui.devices()` - array of device tables. +- `spotatui.playlists()` - array of playlist tables (cached; empty until the app has fetched playlists). +- `spotatui.queue()` - `{ currently_playing = item or nil, items = { ... } }` (cached). +- `spotatui.search_results()` - `{ tracks, albums, artists, playlists, shows }` (cached). +- `spotatui.current_route()` - the current screen name as a string. +- `spotatui.config()` - theme + behavior settings (see "Reading configuration" below). The playback table has these fields: @@ -152,6 +162,46 @@ The playback table has these fields: } ``` +`repeat` is a Lua reserved word, so index it with `pb["repeat"]`, not `pb.repeat`. (The matching +action is named `set_repeat` for the same reason.) + +The cached reads for playlists, queue and search results refresh when the underlying data +changes; they are cheap to call but can be empty until the app has actually fetched that data. +Use the async `get_*` reads below when you want to trigger a fetch and be told when it lands. + +### Async data reads + +These request fresh data from Spotify and deliver it to a callback, following the same +`callback(data, err)` convention as HTTP. The call returns immediately; the callback runs on a +later UI tick once the data has arrived. If nothing arrives within 15 seconds the callback +receives `(nil, "request timed out")`. Callbacks are one-shot. + +- `spotatui.get_playlists(cb)` - `cb(array of playlist tables, err)`. +- `spotatui.get_queue(cb)` - `cb({ currently_playing, items }, err)`. Each queue item is + `{ kind = "track" | "episode", track = {...} or nil, episode = {...} or nil }`. An + unavailable queue (no active device) delivers an empty snapshot. +- `spotatui.get_search_results(query, cb)` - runs a search (without leaving the current + screen) and delivers `{ tracks, albums, artists, playlists, shows }`. +- `spotatui.get_saved_tracks(cb)` - liked songs fetched so far. +- `spotatui.get_saved_albums(cb)` - `{ album = {...}, added_at }` entries. +- `spotatui.get_saved_shows(cb)` - saved podcasts. +- `spotatui.get_recently_played(cb)` - recently played tracks. +- `spotatui.get_devices(cb)` - refreshes the device list without opening the device picker. +- `spotatui.get_lyrics(cb)` - `cb({ status, lines }, err)` for the current track, where + `status` is `"not_started" | "loading" | "found" | "not_found"` and each line is + `{ time_ms, text }`. Delivered immediately when lyrics are already resolved; otherwise waits + for the in-flight fetch. + +```lua +spotatui.get_playlists(function(playlists, err) + if err then + spotatui.notify("playlists: " .. err, 4) + return + end + spotatui.notify("you have " .. #playlists .. " playlists", 4) +end) +``` + ### Actions Actions are queued and applied by the app on the next opportunity; they do not return a @@ -168,6 +218,104 @@ native streaming fast paths (librespot) when the native player is active. - `spotatui.shuffle(on)` - set shuffle to the desired state. No-op if already in that state. - `spotatui.search(query)` - run a search and open the Search screen. - `spotatui.notify(msg, ttl_secs?)` - show a status message (default ttl 4 seconds). +- `spotatui.set_repeat(mode)` - set repeat to `"off"`, `"track"` or `"context"` (named + `set_repeat` because `repeat` is a Lua reserved word). +- `spotatui.cycle_repeat()` - cycle repeat exactly like the repeat keybinding (keeps the + native-streaming fast path). +- `spotatui.transfer_playback(device_id)` - transfer playback to a device (does not overwrite + your saved device preference). +- `spotatui.play_uri(uri)` - play a `spotify:track:`/`spotify:episode:` uri directly, or start + a `spotify:album:`/`playlist:`/`artist:`/`show:` uri as a context. Anything else raises. +- `spotatui.play_context(uri, offset?)` - play a container uri, optionally from a 0-based + track offset. +- `spotatui.add_to_queue(uri)` - add a track/episode to the queue. +- `spotatui.create_playlist(name, uris?)` - create a playlist, optionally seeded with track uris. +- `spotatui.playlist_add_track(playlist, track)` - add a track to a playlist (ids or uris). +- `spotatui.playlist_remove_track(playlist, track, position)` - remove the track occurrence at + the given 0-based position (required, like the Web API). +- `spotatui.follow_playlist(playlist)` / `spotatui.unfollow_playlist(playlist)`. +- `spotatui.toggle_save_track(uri)` - like/unlike a track. +- `spotatui.save_album(id)` / `spotatui.unsave_album(id)`. +- `spotatui.save_show(id)` / `spotatui.unsave_show(id)`. +- `spotatui.follow_artist(id)` / `spotatui.unfollow_artist(id)`. + +Arguments are lightly validated (non-empty, well-formed uri kinds, in-range numbers); invalid +arguments raise a Lua error at call time. The network call itself still happens later, so a +valid-looking id that Spotify rejects surfaces as a normal API error message, not a Lua error. + +### Timers + +- `spotatui.set_timeout(ms, fn) -> handle` - run `fn` once after roughly `ms` milliseconds. +- `spotatui.set_interval(ms, fn) -> handle` - run `fn` repeatedly every roughly `ms` + milliseconds (`ms` must be at least 1). +- `spotatui.cancel_timer(handle)` - cancel a pending timeout or interval. Unknown or expired + handles are a no-op. + +Timers fire from the app's tick loop, so their real resolution is the UI tick rate +(`behavior.tick_rate_milliseconds`, at most 999ms) -- a 10ms timeout still waits for the next +tick. If the app stalls past several interval periods, the interval fires once and reschedules +(no catch-up burst). An interval whose callback errors is removed after the first failure. + +### Navigation + +- `spotatui.navigate(target)` - open a screen, doing exactly what the matching keybinding does + (including any data fetch it performs). Valid targets: `home`, `queue`, `settings`, + `devices`, `help`, `lyrics`, `recently_played`, `party`, `analysis`, `miniplayer`. Unknown + targets raise. +- `spotatui.back()` - pop the navigation stack, like the back key. +- `spotatui.current_route()` - the current screen name. Screen names also show up in the + `route_change` event: `home`, `search`, `track_table`, `album_tracks`, `album_list`, + `artist`, `artists`, `recently_played`, `devices`, `queue`, `settings`, `help`, `lyrics`, + `cover_art`, `miniplayer`, `analysis`, `discover`, `podcasts`, `podcast_episodes`, + `recommendations`, `party`, `friends`, `local_browser`, `create_playlist`, `dialog`, + `announcement`, `exit_prompt`, `error`, and `plugin:` for plugin screens. + +### Persistent storage + +Each plugin gets a private key-value store persisted as plain JSON at +`~/.config/spotatui/plugin-data/.json`. Values must be JSON-serializable (tables, +strings, numbers, booleans); functions and userdata raise. + +- `spotatui.storage_get(key)` - the stored value, or `nil`. +- `spotatui.storage_set(key, value)` - store a value. `nil` removes the key. +- `spotatui.storage_remove(key)` - remove a key. +- `spotatui.storage_keys()` - array of stored key names. + +Writes are flushed to disk in the background (roughly every 3 seconds) and always on quit. +The files are plain JSON on disk -- other programs (and other plugins, via `io`) can read +them, so do not store secrets. If two spotatui instances run at once, the last writer wins. +A missing or corrupt file starts the plugin with an empty store (corruption is logged). + +```lua +local plays = (spotatui.storage_get("play_count") or 0) + 1 +spotatui.storage_set("play_count", plays) +``` + +### Reading configuration + +`spotatui.config()` returns the live user configuration: + +``` +{ + theme = { active = "0, 180, 180", playbar_text = "Reset", ... }, + behavior = { + seek_milliseconds, volume_increment, + tick_rate_milliseconds, animation_tick_rate_milliseconds, + liked_icon, shuffle_icon, repeat_track_icon, repeat_context_icon, + playing_icon, paused_icon, + enable_text_emphasis, show_loading_indicator, enforce_wide_search_bar, + set_window_title, shuffle_enabled, stop_after_current_track, + sidebar_width_percent, playbar_height_rows, library_height_percent, + active_source, + }, +} +``` + +Theme values use the same string forms as `config.yml` (named color or `"r, g, b"`), so they +can round-trip through `spotatui.set_theme`. Secrets and service credentials (sync token, +relay URL, Subsonic credentials, Discord client id) are never exposed. The snapshot is +populated once the app is running; reading it at load time (before the `start` event) returns +empty defaults. ### Logging @@ -360,6 +508,82 @@ spotatui.set_theme({ }) ``` +### Custom screens + +Plugins can register full-screen views. Screens are retained-mode: you publish content with +`set_screen`, the app renders it from its own state, and you update it again whenever you want +the view to change (typically from `on_key`, a timer, or a data callback). There is no +per-frame draw callback. + +- `spotatui.register_screen(name, spec)` - register a screen. `name` must be non-empty with no + whitespace and unique across all plugins. `spec` is a table: + - `title` (optional string) - the window title (defaults to the screen name). + - `on_key(key)` (required) - called for every key pressed while the screen is focused. `key` + is a `config.yml`-style string (`"a"`, `"ctrl-x"`, `"enter"`, `"up"`, `"space"`, ...). + - `on_open()` / `on_close()` (optional) - called when the screen gains/loses the route. +- `spotatui.set_screen(name, widgets)` - publish the screen's content (see widgets below). +- `spotatui.show_screen(name)` - navigate to the screen. +- `spotatui.close_screen(name)` - leave the screen if it is currently shown. + +`set_screen`/`show_screen`/`close_screen` verify the screen is registered and owned by the +calling plugin. + +`widgets` is an array; each entry is a table with a `type`: + +- `{ type = "paragraph", lines = , height? }` - styled text. `lines` uses the same + format as `spotatui.popup`. +- `{ type = "list", items = , title?, selected?, height? }` - a bordered list. + `selected` is 1-based (like Lua arrays) and highlights that item. +- `{ type = "gauge", ratio, label? }` - a progress bar; `ratio` is clamped to 0..1. + +Widgets with a `height` take exactly that many rows; the rest split the remaining space +evenly. + +Keys reach `on_key` only after the global keybindings have run, so keys like the back key or +volume keys keep their normal meaning. `Esc` (and the back key) leave the screen, and +`PageUp`/`PageDown` scroll paragraph text; everything else is forwarded. An `on_key` that +errors is disabled after the first failure (one strike). + +A minimal interactive screen: + +```lua +spotatui.require_api(5) + +local selected = 1 +local names = {} + +local function render() + spotatui.set_screen("my_playlists", { + { type = "paragraph", lines = { { text = "j/k to move, Esc to leave", italic = true } }, height = 2 }, + { type = "list", title = "Playlists", items = names, selected = selected }, + }) +end + +spotatui.register_screen("my_playlists", { + title = "My Playlists", + on_key = function(key) + if key == "j" and selected < #names then selected = selected + 1 end + if key == "k" and selected > 1 then selected = selected - 1 end + render() + end, + on_open = function() + spotatui.get_playlists(function(playlists, err) + names = {} + for _, p in ipairs(playlists or {}) do + names[#names + 1] = p.name + end + render() + end) + end, +}) + +spotatui.register_command("my_playlists", function() + spotatui.show_screen("my_playlists") +end) +``` + +Bind `my_playlists` under `plugin_commands` in `config.yml` to open it with a key. + ## Error behavior Plugin code can never crash the app. If a callback raises an error or panics, the error is diff --git a/examples/plugins/README.md b/examples/plugins/README.md index 9e755965..3765c34d 100644 --- a/examples/plugins/README.md +++ b/examples/plugins/README.md @@ -12,6 +12,7 @@ privileges, so read anything you install from elsewhere (see | [`track-info-popup.lua`](track-info-popup.lua) | `register_command`, reads, `popup` | | [`accent-cycler.lua`](accent-cycler.lua) | `register_command`, `set_theme` | | [`now-playing-webhook.lua`](now-playing-webhook.lua) | `http_post`, `json_encode` | +| [`queue-browser.lua`](queue-browser.lua) | Custom screens, async data reads, timers, storage | | [`session-stats/`](session-stats) | A directory plugin with a `require`-d helper module | ## Installing diff --git a/examples/plugins/queue-browser.lua b/examples/plugins/queue-browser.lua new file mode 100644 index 00000000..63b09611 --- /dev/null +++ b/examples/plugins/queue-browser.lua @@ -0,0 +1,115 @@ +-- queue-browser: a custom plugin screen that lists the play queue and lets you +-- queue more music from your library, showing off the v5 API (custom screens, +-- async data reads, timers, storage, navigation). +-- +-- Install (single file): +-- cp queue-browser.lua ~/.config/spotatui/plugins/ +-- +-- Suggested binding, in ~/.config/spotatui/config.yml: +-- plugin_commands: +-- queue_browser: "ctrl-b" +-- +-- Keys inside the screen: j/k move, enter queues the selected liked song, +-- r refreshes, Esc leaves. + +spotatui.require_api(5) + +local SCREEN = "queue_browser" + +local queue_lines = {} +local liked = {} -- { { name = ..., uri = ... }, ... } +local selected = 1 +local status = "loading..." + +local function render() + local items = {} + for _, entry in ipairs(liked) do + items[#items + 1] = entry.name + end + if #items == 0 then + items = { { text = "(no liked songs loaded)", italic = true } } + end + + spotatui.set_screen(SCREEN, { + { + type = "paragraph", + height = 2, + lines = { + { text = "j/k move - enter queues the selected liked song - r refreshes - Esc leaves", italic = true }, + { text = status, fg = "Yellow" }, + }, + }, + { type = "paragraph", lines = queue_lines, height = #queue_lines + 1 }, + { type = "list", title = "Liked songs", items = items, selected = math.min(selected, math.max(#items, 1)) }, + }) +end + +local function refresh() + status = "refreshing..." + render() + + spotatui.get_queue(function(queue, err) + queue_lines = {} + if err then + queue_lines = { { text = "queue: " .. err, fg = "Red" } } + else + local now = queue.currently_playing + local now_name = now and (now.track and now.track.name or (now.episode and now.episode.name)) or "nothing" + queue_lines[1] = { text = "Now playing: " .. now_name, bold = true, fg = "Green" } + for i, item in ipairs(queue.items) do + if i > 5 then + queue_lines[#queue_lines + 1] = { text = (" ... and %d more"):format(#queue.items - 5), italic = true } + break + end + local name = item.track and item.track.name or (item.episode and item.episode.name) or "?" + queue_lines[#queue_lines + 1] = " " .. i .. ". " .. name + end + end + status = "" + render() + end) + + spotatui.get_saved_tracks(function(tracks, err) + if err then + status = "liked songs: " .. err + render() + return + end + liked = {} + for _, t in ipairs(tracks) do + if t.uri then + liked[#liked + 1] = { name = t.name .. " - " .. table.concat(t.artists, ", "), uri = t.uri } + end + end + status = #liked .. " liked songs (queued opens count: " .. (spotatui.storage_get("opens") or 0) .. ")" + render() + end) +end + +spotatui.register_screen(SCREEN, { + title = "Queue Browser", + on_key = function(key) + if key == "j" and selected < #liked then + selected = selected + 1 + render() + elseif key == "k" and selected > 1 then + selected = selected - 1 + render() + elseif key == "enter" and liked[selected] then + spotatui.add_to_queue(liked[selected].uri) + spotatui.notify("Queued: " .. liked[selected].name, 3) + -- The queue changes server-side; re-read it shortly after. + spotatui.set_timeout(1000, refresh) + elseif key == "r" then + refresh() + end + end, + on_open = function() + spotatui.storage_set("opens", (spotatui.storage_get("opens") or 0) + 1) + refresh() + end, +}) + +spotatui.register_command("queue_browser", function() + spotatui.show_screen(SCREEN) +end) diff --git a/src/core/app.rs b/src/core/app.rs index 7cbd21bf..4e203a3a 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -299,6 +299,11 @@ pub enum ActiveBlock { Friends, LocalBrowser, Stats, + /// A plugin-registered custom screen (the screen name lives in + /// [`RouteId::PluginScreen`]; `ActiveBlock` is `Copy` and can't carry it). + /// Only script effects construct it. + #[cfg_attr(not(feature = "scripting"), allow(dead_code))] + PluginScreen, } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] @@ -341,6 +346,10 @@ pub enum RouteId { Friends, LocalBrowser, Stats, + /// A plugin-registered custom screen, keyed by its registered name. + /// Only script effects construct it. + #[cfg_attr(not(feature = "scripting"), allow(dead_code))] + PluginScreen(String), } impl RouteId { @@ -669,6 +678,51 @@ pub enum LyricsStatus { NotFound, } +/// Data domains plugins can request through the scripting API. Each domain has +/// a generation counter in [`PluginDataGenerations`] that the network layer +/// bumps whenever it writes that domain to `App`, so the script engine can tell +/// "the data a plugin asked for has arrived" without a per-request completion +/// signal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginDataKind { + Playlists, + Queue, + Search, + SavedTracks, + SavedAlbums, + SavedShows, + RecentlyPlayed, + Devices, + Lyrics, +} + +impl PluginDataKind { + pub const COUNT: usize = 9; + + pub fn index(self) -> usize { + self as usize + } +} + +/// Per-domain write counters for plugin data requests. See [`PluginDataKind`]. +#[derive(Debug, Default)] +pub struct PluginDataGenerations { + counters: [u64; PluginDataKind::COUNT], +} + +impl PluginDataGenerations { + pub fn bump(&mut self, kind: PluginDataKind) { + let slot = &mut self.counters[kind.index()]; + *slot = slot.wrapping_add(1); + } + + // Only the scripting engine reads generations; the network layer just bumps. + #[cfg_attr(not(feature = "scripting"), allow(dead_code))] + pub fn get(&self, kind: PluginDataKind) -> u64 { + self.counters[kind.index()] + } +} + /// Status of the currently-playing track's cover art, mirroring [`LyricsStatus`]. /// Drives the placeholder message shown when art can't be displayed, so a /// missing image reads as an explicit state rather than silently showing @@ -1295,6 +1349,19 @@ pub struct App { pub create_playlist_focus: CreatePlaylistFocus, /// Commands queued by keybindings for the scripting engine to run. pub pending_plugin_commands: Vec, + /// Per-domain write counters driving async plugin data reads (see + /// [`PluginDataKind`]). Ungated: the network layer bumps them in every build; + /// only the scripting engine reads them. + pub plugin_data_generations: PluginDataGenerations, + /// Retained content of plugin-registered custom screens, keyed by screen + /// name. Written by script effects; read by the draw loop. + pub plugin_screens: + std::collections::BTreeMap, + /// Keys pressed while a plugin screen was focused: `(screen_name, key_string)`, + /// drained by the script engine after each key event. + pub pending_plugin_screen_keys: Vec<(String, String)>, + /// Vertical scroll for the focused plugin screen. + pub plugin_screen_scroll: u16, /// Per-plugin playbar segments, keyed by plugin name (BTreeMap for deterministic order). pub plugin_playbar_segments: std::collections::BTreeMap, /// Currently displayed plugin popup, if any. @@ -1542,6 +1609,10 @@ impl Default for App { create_playlist_selected_result: 0, create_playlist_focus: CreatePlaylistFocus::SearchInput, pending_plugin_commands: Vec::new(), + plugin_data_generations: PluginDataGenerations::default(), + plugin_screens: std::collections::BTreeMap::new(), + pending_plugin_screen_keys: Vec::new(), + plugin_screen_scroll: 0, plugin_playbar_segments: std::collections::BTreeMap::new(), plugin_popup: None, plugin_popup_scroll: 0, diff --git a/src/core/plugin_api.rs b/src/core/plugin_api.rs index 9e2beb46..6ce28c33 100644 --- a/src/core/plugin_api.rs +++ b/src/core/plugin_api.rs @@ -13,7 +13,7 @@ use crate::infra::media_metadata::current_playback_snapshot; use rspotify::model::RepeatState; use serde::{Deserialize, Serialize}; -pub const API_VERSION: u32 = 4; +pub const API_VERSION: u32 = 5; /// A popup dialog produced by a plugin. #[derive(Debug, Clone, PartialEq)] @@ -241,6 +241,186 @@ pub struct SearchResults { pub shows: Vec, } +/// Retained content of a plugin custom screen. Screens are retained-mode by +/// design: draw runs with `&App` only (the engine is unreachable there), so +/// plugins publish content via effects and the renderer just reads it. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct PluginScreenContent { + pub title: String, + pub widgets: Vec, +} + +/// A widget in a [`PluginScreenContent`]. Fixed-height widgets take their +/// requested rows; the remaining vertical space is split evenly between the +/// rest. +#[derive(Debug, Clone, PartialEq)] +pub enum PluginWidget { + Paragraph { + lines: Vec, + height: Option, + }, + List { + title: Option, + items: Vec, + /// 0-based index of the highlighted item. + selected: Option, + height: Option, + }, + Gauge { + /// 0.0..=1.0 (clamped at the API layer). + ratio: f64, + label: Option, + }, +} + +/// A queue item as exposed to plugins. [`PlayableInfo`]'s externally-tagged +/// serde shape (`{ Track = {...} }`) is hostile to Lua, so queue items are +/// flattened into an explicit `kind` plus at most one populated payload field. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct QueueItemSnapshot { + /// `"track"` or `"episode"`. + pub kind: String, + #[serde(default)] + pub track: Option, + #[serde(default)] + pub episode: Option, +} + +impl QueueItemSnapshot { + pub fn from_playable(item: &PlayableInfo) -> Self { + match item { + PlayableInfo::Track(t) => QueueItemSnapshot { + kind: "track".to_string(), + track: Some(t.clone()), + episode: None, + }, + PlayableInfo::Episode(e) => QueueItemSnapshot { + kind: "episode".to_string(), + track: None, + episode: Some(e.clone()), + }, + } + } +} + +/// The playback queue as exposed to plugins. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct QueueSnapshot { + #[serde(default)] + pub currently_playing: Option, + #[serde(default)] + pub items: Vec, +} + +/// A single timestamped lyrics line. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LyricsLineSnapshot { + pub time_ms: u64, + pub text: String, +} + +/// Lyrics for the current track as exposed to plugins. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct LyricsSnapshot { + /// One of `"not_started"`, `"loading"`, `"found"`, `"not_found"`. + pub status: String, + #[serde(default)] + pub lines: Vec, +} + +/// Safe, plugin-visible behavior settings. Secrets and service credentials +/// (sync_token, relay_server_url, subsonic credentials, discord client id, +/// announcement feeds) are deliberately excluded. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct BehaviorSnapshot { + pub seek_milliseconds: u32, + pub volume_increment: u8, + pub tick_rate_milliseconds: u64, + pub animation_tick_rate_milliseconds: u64, + pub enable_text_emphasis: bool, + pub show_loading_indicator: bool, + pub enforce_wide_search_bar: bool, + pub liked_icon: String, + pub shuffle_icon: String, + pub repeat_track_icon: String, + pub repeat_context_icon: String, + pub playing_icon: String, + pub paused_icon: String, + pub set_window_title: bool, + pub shuffle_enabled: bool, + pub stop_after_current_track: bool, + pub sidebar_width_percent: u8, + pub playbar_height_rows: u16, + pub library_height_percent: u8, + /// Lowercased active source name, e.g. `"spotify"`, `"local"`. + pub active_source: String, +} + +/// User configuration as exposed to plugins: theme colors as config-file +/// strings plus safe behavior scalars. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct ConfigSnapshot { + pub theme: std::collections::BTreeMap, + pub behavior: BehaviorSnapshot, +} + +/// Build a [`ConfigSnapshot`] from the live user config. Theme colors use the +/// same string forms `parse_theme_item` accepts (named or `"r, g, b"`). +pub fn config_snapshot(config: &crate::core::user_config::UserConfig) -> ConfigSnapshot { + use crate::core::user_config::color_to_string; + let t = &config.theme; + let theme: std::collections::BTreeMap = [ + ("active", t.active), + ("analysis_bar", t.analysis_bar), + ("analysis_bar_text", t.analysis_bar_text), + ("banner", t.banner), + ("background", t.background), + ("error_border", t.error_border), + ("error_text", t.error_text), + ("header", t.header), + ("highlighted_lyrics", t.highlighted_lyrics), + ("hint", t.hint), + ("hovered", t.hovered), + ("inactive", t.inactive), + ("playbar_background", t.playbar_background), + ("playbar_progress", t.playbar_progress), + ("playbar_progress_text", t.playbar_progress_text), + ("playbar_text", t.playbar_text), + ("selected", t.selected), + ("text", t.text), + ] + .into_iter() + .map(|(name, color)| (name.to_string(), color_to_string(color))) + .collect(); + + let b = &config.behavior; + ConfigSnapshot { + theme, + behavior: BehaviorSnapshot { + seek_milliseconds: b.seek_milliseconds, + volume_increment: b.volume_increment, + tick_rate_milliseconds: b.tick_rate_milliseconds, + animation_tick_rate_milliseconds: b.animation_tick_rate_milliseconds, + enable_text_emphasis: b.enable_text_emphasis, + show_loading_indicator: b.show_loading_indicator, + enforce_wide_search_bar: b.enforce_wide_search_bar, + liked_icon: b.liked_icon.clone(), + shuffle_icon: b.shuffle_icon.clone(), + repeat_track_icon: b.repeat_track_icon.clone(), + repeat_context_icon: b.repeat_context_icon.clone(), + playing_icon: b.playing_icon.clone(), + paused_icon: b.paused_icon.clone(), + set_window_title: b.set_window_title, + shuffle_enabled: b.shuffle_enabled, + stop_after_current_track: b.stop_after_current_track, + sidebar_width_percent: b.sidebar_width_percent, + playbar_height_rows: b.playbar_height_rows, + library_height_percent: b.library_height_percent, + active_source: format!("{:?}", b.active_source).to_lowercase(), + }, + } +} + /// Default for serde `is_playable` fields: a track/episode is assumed playable /// unless the API explicitly says otherwise. fn default_true() -> bool { @@ -371,6 +551,181 @@ pub fn device_list(app: &App) -> Vec { .unwrap_or_default() } +/// Stable plugin-facing name of a route. Exhaustive on purpose (no `_` arm): +/// adding a `RouteId` without naming it here is a compile error, keeping the +/// scripting contract in sync. +pub fn route_name(route: &crate::core::app::Route) -> String { + use crate::core::app::RouteId; + match &route.id { + RouteId::Analysis => "analysis", + RouteId::AlbumTracks => "album_tracks", + RouteId::AlbumList => "album_list", + RouteId::Artist => "artist", + RouteId::LyricsView => "lyrics", + RouteId::CoverArtView => "cover_art", + RouteId::MiniPlayer => "miniplayer", + RouteId::Error => "error", + RouteId::Home => "home", + RouteId::RecentlyPlayed => "recently_played", + RouteId::Search => "search", + RouteId::SelectedDevice => "devices", + RouteId::TrackTable => "track_table", + RouteId::Discover => "discover", + RouteId::Artists => "artists", + RouteId::Podcasts => "podcasts", + RouteId::PodcastEpisodes => "podcast_episodes", + RouteId::Recommendations => "recommendations", + RouteId::Dialog => "dialog", + RouteId::AnnouncementPrompt => "announcement", + RouteId::RecapPrompt => "recap_prompt", + RouteId::ExitPrompt => "exit_prompt", + RouteId::Settings => "settings", + RouteId::HelpMenu => "help", + RouteId::Queue => "queue", + RouteId::Party => "party", + RouteId::CreatePlaylist => "create_playlist", + RouteId::Friends => "friends", + RouteId::LocalBrowser => "local_browser", + RouteId::Stats => "stats", + RouteId::PluginScreen(name) => return format!("plugin:{name}"), + } + .to_string() +} + +// --------------------------------------------------------------------------- +// Snapshot serializers for plugin data reads +// --------------------------------------------------------------------------- + +/// The user's playlists (full list, folder structure flattened away). +pub fn playlists_snapshot(app: &App) -> Vec { + app.all_playlists.clone() +} + +/// Saved ("liked") tracks fetched so far, in library order. +pub fn saved_tracks_snapshot(app: &App) -> Vec { + app + .library + .saved_tracks + .pages + .iter() + .flat_map(|page| page.items.iter().cloned()) + .collect() +} + +/// Saved albums fetched so far, in library order. +pub fn saved_albums_snapshot(app: &App) -> Vec { + app + .library + .saved_albums + .pages + .iter() + .flat_map(|page| page.items.iter().cloned()) + .collect() +} + +/// Saved shows fetched so far, in library order. +pub fn saved_shows_snapshot(app: &App) -> Vec { + app + .library + .saved_shows + .pages + .iter() + .flat_map(|page| page.items.iter().cloned()) + .collect() +} + +/// Recently played tracks (most recent first, as returned by the API). +pub fn recently_played_snapshot(app: &App) -> Vec { + app + .recently_played + .result + .as_ref() + .map(|page| page.items.clone()) + .unwrap_or_default() +} + +/// The Spotify playback queue. An unavailable queue (no active device) maps to +/// an empty snapshot rather than an error. +pub fn queue_snapshot(app: &App) -> QueueSnapshot { + let Some(queue) = app.queue.as_ref() else { + return QueueSnapshot::default(); + }; + QueueSnapshot { + currently_playing: queue + .currently_playing + .as_ref() + .map(QueueItemSnapshot::from_playable), + items: queue + .queue + .iter() + .map(QueueItemSnapshot::from_playable) + .collect(), + } +} + +/// The current search results (first page of each category). +pub fn search_results_snapshot(app: &App) -> SearchResults { + SearchResults { + tracks: app + .search_results + .tracks + .as_ref() + .map(|p| p.items.clone()) + .unwrap_or_default(), + albums: app + .search_results + .albums + .as_ref() + .map(|p| p.items.clone()) + .unwrap_or_default(), + artists: app + .search_results + .artists + .as_ref() + .map(|p| p.items.clone()) + .unwrap_or_default(), + playlists: app + .search_results + .playlists + .as_ref() + .map(|p| p.items.clone()) + .unwrap_or_default(), + shows: app + .search_results + .shows + .as_ref() + .map(|p| p.items.clone()) + .unwrap_or_default(), + } +} + +/// Lyrics for the current track, with the fetch status spelled out. +pub fn lyrics_snapshot(app: &App) -> LyricsSnapshot { + use crate::core::app::LyricsStatus; + let status = match app.lyrics_status { + LyricsStatus::NotStarted => "not_started", + LyricsStatus::Loading => "loading", + LyricsStatus::Found => "found", + LyricsStatus::NotFound => "not_found", + }; + LyricsSnapshot { + status: status.to_string(), + lines: app + .lyrics + .as_ref() + .map(|lines| { + lines + .iter() + .map(|(time_ms, text)| LyricsLineSnapshot { + time_ms: *time_ms as u64, + text: text.clone(), + }) + .collect() + }) + .unwrap_or_default(), + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/src/infra/network/library.rs b/src/infra/network/library.rs index 42b51f8c..725a7f8f 100644 --- a/src/infra/network/library.rs +++ b/src/infra/network/library.rs @@ -462,6 +462,9 @@ impl LibraryNetwork for Network { let mut app = self.app.lock().await; app.playlists = first_page; app.all_playlists = all_playlists; + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Playlists); app._playlist_folder_nodes = folder_nodes; app.playlist_folder_items = folder_items; @@ -623,6 +626,9 @@ impl LibraryNetwork for Network { crate::infra::network::mapping::map_page(&saved_tracks, |st| TrackInfo::from(&st.track)); let saved_tracks_index = app.library.saved_tracks.upsert_page_by_offset(domain_page); app.set_saved_tracks_to_table_continuous(); + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::SavedTracks); let next_offset = app.next_missing_saved_tracks_offset(saved_tracks_index); let generation = app.saved_tracks_prefetch_generation; @@ -659,14 +665,19 @@ impl LibraryNetwork for Network { .await { Ok(saved_albums) => { + let mut app = self.app.lock().await; if !saved_albums.items.is_empty() { let domain_page = crate::infra::network::mapping::map_page( &saved_albums, crate::infra::network::mapping::saved_album_info, ); - let mut app = self.app.lock().await; app.library.saved_albums.add_pages(domain_page); } + // Bump even on an empty page: completion is the signal plugin data + // requests wait on, and an empty library never writes a page. + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::SavedAlbums); } Err(e) => { self.handle_error(anyhow!(e)).await; @@ -786,12 +797,16 @@ impl LibraryNetwork for Network { .await { Ok(saved_shows) => { + let mut app = self.app.lock().await; if !saved_shows.items.is_empty() { let domain_page = crate::infra::network::mapping::map_page(&saved_shows, |s| ShowInfo::from(&s.show)); - let mut app = self.app.lock().await; app.library.saved_shows.add_pages(domain_page); } + // Bump even on an empty page (see saved-albums note above). + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::SavedShows); } Err(e) => { self.handle_error(anyhow!(e)).await; diff --git a/src/infra/network/mod.rs b/src/infra/network/mod.rs index 26e83574..fc09e6b5 100644 --- a/src/infra/network/mod.rs +++ b/src/infra/network/mod.rs @@ -50,6 +50,9 @@ pub enum IoEvent { RefreshAuthentication, GetPlaylists, GetDevices, + /// Refresh the device list without opening the device picker (plugin data reads). + #[cfg_attr(not(feature = "scripting"), allow(dead_code))] + GetDevicesSilent, GetSearchResults(String, Option), /// Playlist id/URI, page offset. GetPlaylistItems(String, u32), @@ -97,6 +100,9 @@ pub enum IoEvent { /// Track id/URI, market. GetRecommendationsForTrackId(String, Option), GetRecentlyPlayed, + /// Refresh recently-played data without opening the screen (plugin data reads). + #[cfg_attr(not(feature = "scripting"), allow(dead_code))] + GetRecentlyPlayedSilent, /// Pagination cursor: artist id/URI to fetch after. GetFollowedArtists(Option), UserArtistFollowCheck(Vec), @@ -424,7 +430,10 @@ impl Network { self.get_user().await; } IoEvent::GetDevices => { - self.get_devices().await; + self.get_devices(true).await; + } + IoEvent::GetDevicesSilent => { + self.get_devices(false).await; } IoEvent::GetCurrentPlayback => { self.get_current_playback().await; @@ -554,7 +563,10 @@ impl Network { } } IoEvent::GetRecentlyPlayed => { - self.get_recently_played().await; + self.get_recently_played(true).await; + } + IoEvent::GetRecentlyPlayedSilent => { + self.get_recently_played(false).await; } IoEvent::GetFollowedArtists(after) => { self diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index acffa817..0c16a94c 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -1803,6 +1803,9 @@ impl PlaybackNetwork for Network { if native_confirmed || name_seen { let mut app = self.app.lock().await; app.devices = Some(payload); + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Devices); } if native_confirmed { @@ -1926,10 +1929,18 @@ impl PlaybackNetwork for Network { }; let mut app = self.app.lock().await; app.queue = Some(domain_queue); + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Queue); } Err(e) => { let mut app = self.app.lock().await; app.queue = None; + // Bump on failure too: completion (not success) is what plugin data + // requests wait on. + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Queue); app.status_message = Some("Could not load queue (no active device?)".to_string()); app.status_message_expires_at = Some(Instant::now() + Duration::from_secs(3)); log::warn!("get_queue failed: {}", e); diff --git a/src/infra/network/search.rs b/src/infra/network/search.rs index 35590fa3..00e8b083 100644 --- a/src/infra/network/search.rs +++ b/src/infra/network/search.rs @@ -189,6 +189,9 @@ impl SearchNetwork for Network { playlists_len, ); clamp_selected_index(&mut app.search_results.selected_shows_index, shows_len); + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Search); } async fn search_tracks_for_playlist(&mut self, search_term: String) { diff --git a/src/infra/network/user.rs b/src/infra/network/user.rs index 641e3e4c..ac874852 100644 --- a/src/infra/network/user.rs +++ b/src/infra/network/user.rs @@ -65,11 +65,15 @@ fn include_native_streaming_device(app: &crate::core::app::App, payload: &mut De pub trait UserNetwork { async fn get_user(&mut self); - async fn get_devices(&mut self); + /// `navigate: false` refreshes the device list without opening the device + /// picker (used by plugin data reads). + async fn get_devices(&mut self, navigate: bool); async fn get_user_top_tracks(&mut self, time_range: DiscoverTimeRange); async fn get_top_artists_mix(&mut self); + /// `navigate: false` refreshes the data without opening the screen (used by + /// plugin data reads). #[allow(dead_code)] - async fn get_recently_played(&mut self); + async fn get_recently_played(&mut self, navigate: bool); } impl UserNetwork for Network { @@ -104,14 +108,16 @@ impl UserNetwork for Network { } } - async fn get_devices(&mut self) { + async fn get_devices(&mut self, navigate: bool) { match self .spotify_get_typed::("me/player/devices", &[]) .await { Ok(result) => { let mut app = self.app.lock().await; - app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); + if navigate { + app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); + } #[cfg(feature = "streaming")] let mut result = result; @@ -132,6 +138,9 @@ impl UserNetwork for Network { .or(Some(0)) }; app.devices = Some(result); + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Devices); } Err(e) => { self.handle_error(anyhow!(e)).await; @@ -235,7 +244,7 @@ impl UserNetwork for Network { app.discover_loading = false; } - async fn get_recently_played(&mut self) { + async fn get_recently_played(&mut self, navigate: bool) { let limit = self.large_search_limit; match self .spotify_get_typed::>( @@ -255,7 +264,12 @@ impl UserNetwork for Network { } app.recently_played.result = Some(domain_page); app.sort_recently_played_items(); - app.push_navigation_stack(RouteId::RecentlyPlayed, ActiveBlock::RecentlyPlayed); + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::RecentlyPlayed); + if navigate { + app.push_navigation_stack(RouteId::RecentlyPlayed, ActiveBlock::RecentlyPlayed); + } } Err(e) => { self.handle_error(anyhow!(e)).await; diff --git a/src/infra/network/utils.rs b/src/infra/network/utils.rs index 147376b0..4b405027 100644 --- a/src/infra/network/utils.rs +++ b/src/infra/network/utils.rs @@ -118,18 +118,30 @@ impl UtilsNetwork for Network { } else { app.lyrics_status = LyricsStatus::NotFound; } + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Lyrics); } else { let mut app = self.app.lock().await; app.lyrics_status = LyricsStatus::NotFound; + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Lyrics); } } else { let mut app = self.app.lock().await; app.lyrics_status = LyricsStatus::NotFound; + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Lyrics); } } Err(_) => { let mut app = self.app.lock().await; app.lyrics_status = LyricsStatus::NotFound; + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Lyrics); } } } diff --git a/src/infra/scripting/api.rs b/src/infra/scripting/api.rs index e909ce36..44d85a20 100644 --- a/src/infra/scripting/api.rs +++ b/src/infra/scripting/api.rs @@ -3,13 +3,16 @@ use std::rc::Rc; use mlua::{Lua, LuaSerdeExt, Value}; use tokio::sync::mpsc::UnboundedSender; +use crate::core::app::PluginDataKind; use crate::core::plugin_api::{self, PluginPopup, PopupLine}; use crate::core::user_config::parse_theme_item; +use crate::infra::network::IoEvent; use super::effects::ScriptEffect; use super::events::VALID_EVENT_NAMES; use super::shared::{ - HttpResponseData, HttpResult, ScriptShared, COMMANDS_KEY, HANDLERS_KEY, HTTP_CALLBACKS_KEY, + DataRequest, HttpResponseData, HttpResult, NewTimer, ScriptShared, COMMANDS_KEY, + DATA_CALLBACKS_KEY, HANDLERS_KEY, HTTP_CALLBACKS_KEY, SCREENS_KEY, TIMER_CALLBACKS_KEY, }; /// Build the `spotatui` global table and its functions. @@ -349,10 +352,631 @@ pub(super) fn install_api( tbl.set("set_theme", set_theme)?; } + // Async data reads: spotatui.get_X(cb) with cb(data, err), like http. + install_data_read( + lua, + &tbl, + shared, + "get_playlists", + PluginDataKind::Playlists, + )?; + install_data_read(lua, &tbl, shared, "get_queue", PluginDataKind::Queue)?; + install_data_read( + lua, + &tbl, + shared, + "get_saved_tracks", + PluginDataKind::SavedTracks, + )?; + install_data_read( + lua, + &tbl, + shared, + "get_saved_albums", + PluginDataKind::SavedAlbums, + )?; + install_data_read( + lua, + &tbl, + shared, + "get_saved_shows", + PluginDataKind::SavedShows, + )?; + install_data_read( + lua, + &tbl, + shared, + "get_recently_played", + PluginDataKind::RecentlyPlayed, + )?; + install_data_read(lua, &tbl, shared, "get_devices", PluginDataKind::Devices)?; + install_data_read(lua, &tbl, shared, "get_lyrics", PluginDataKind::Lyrics)?; + + // spotatui.get_search_results(query, cb): the only data read with an argument. + { + let lua_inner = lua.clone(); + let shared = shared.clone(); + let get_search_results = + lua.create_function(move |_, (query, callback): (String, mlua::Function)| { + if query.is_empty() { + return Err(mlua::Error::RuntimeError( + "spotatui.get_search_results: query must be a non-empty string".to_string(), + )); + } + let token = register_callback(&lua_inner, &shared, DATA_CALLBACKS_KEY, callback)?; + shared.data_requests.borrow_mut().push(DataRequest { + token, + kind: PluginDataKind::Search, + arg: Some(query), + }); + Ok(()) + })?; + tbl.set("get_search_results", get_search_results)?; + } + + // Cached synchronous reads, refreshed by the engine when the matching data + // generation advances. + { + let shared_pl = shared.clone(); + let playlists = + lua.create_function(move |lua, ()| lua.to_value(&*shared_pl.playlists_cache.borrow()))?; + tbl.set("playlists", playlists)?; + + let shared_q = shared.clone(); + let queue = + lua.create_function(move |lua, ()| lua.to_value(&*shared_q.queue_cache.borrow()))?; + tbl.set("queue", queue)?; + + let shared_sr = shared.clone(); + let search_results = lua + .create_function(move |lua, ()| lua.to_value(&*shared_sr.search_results_cache.borrow()))?; + tbl.set("search_results", search_results)?; + } + + // Transport / library actions routed through whitelisted IoEvents. + { + let shared = shared.clone(); + let set_repeat = lua.create_function(move |_, mode: String| { + let state = match mode.as_str() { + "off" => rspotify::model::RepeatState::Off, + "track" => rspotify::model::RepeatState::Track, + "context" => rspotify::model::RepeatState::Context, + other => { + return Err(mlua::Error::RuntimeError(format!( + "spotatui.set_repeat: expected \"off\", \"track\" or \"context\", got '{other}'" + ))); + } + }; + shared + .effects + .borrow_mut() + .push(ScriptEffect::Dispatch(IoEvent::Repeat(state))); + Ok(()) + })?; + tbl.set("set_repeat", set_repeat)?; + } + + install_action(lua, &tbl, shared, "cycle_repeat", || { + ScriptEffect::CycleRepeat + })?; + + install_string_action(lua, &tbl, shared, "transfer_playback", |device_id| { + // `false`: a plugin-initiated transfer must not overwrite the user's saved + // device preference (only the interactive device picker persists). + ScriptEffect::Dispatch(IoEvent::TransferPlaybackToDevice(device_id, false)) + })?; + + // spotatui.play_uri(uri): tracks/episodes play as a uri list, containers as context. + { + let shared = shared.clone(); + let play_uri = lua.create_function(move |_, uri: String| { + let effect = if uri.starts_with("spotify:track:") || uri.starts_with("spotify:episode:") { + ScriptEffect::Dispatch(IoEvent::StartPlayback(None, Some(vec![uri]), None)) + } else if is_context_uri(&uri) { + ScriptEffect::Dispatch(IoEvent::StartPlayback(Some(uri), None, None)) + } else { + return Err(mlua::Error::RuntimeError(format!( + "spotatui.play_uri: expected a spotify:track/episode/album/playlist/artist/show uri, got '{uri}'" + ))); + }; + shared.effects.borrow_mut().push(effect); + Ok(()) + })?; + tbl.set("play_uri", play_uri)?; + } + + // spotatui.play_context(uri, offset?): play a container, optionally from an + // 0-based track offset. + { + let shared = shared.clone(); + let play_context = lua.create_function(move |_, (uri, offset): (String, Option)| { + if !is_context_uri(&uri) { + return Err(mlua::Error::RuntimeError(format!( + "spotatui.play_context: expected a spotify:album/playlist/artist/show uri, got '{uri}'" + ))); + } + shared + .effects + .borrow_mut() + .push(ScriptEffect::Dispatch(IoEvent::StartPlayback( + Some(uri), + None, + offset, + ))); + Ok(()) + })?; + tbl.set("play_context", play_context)?; + } + + install_string_action(lua, &tbl, shared, "add_to_queue", |uri| { + ScriptEffect::Dispatch(IoEvent::AddItemToQueue(uri)) + })?; + + // spotatui.create_playlist(name, uris?) + { + let shared = shared.clone(); + let create_playlist = + lua.create_function(move |_, (name, uris): (String, Option)| { + if name.trim().is_empty() { + return Err(mlua::Error::RuntimeError( + "spotatui.create_playlist: name must be a non-empty string".to_string(), + )); + } + let mut track_ids = Vec::new(); + if let Some(uris) = uris { + for uri in uris.sequence_values::() { + track_ids.push(uri?); + } + } + shared + .effects + .borrow_mut() + .push(ScriptEffect::Dispatch(IoEvent::CreateNewPlaylist( + name, track_ids, + ))); + Ok(()) + })?; + tbl.set("create_playlist", create_playlist)?; + } + + // spotatui.playlist_add_track(playlist, track) + { + let shared = shared.clone(); + let playlist_add_track = + lua.create_function(move |_, (playlist, track): (String, String)| { + require_nonempty("spotatui.playlist_add_track", "playlist", &playlist)?; + require_nonempty("spotatui.playlist_add_track", "track", &track)?; + shared + .effects + .borrow_mut() + .push(ScriptEffect::Dispatch(IoEvent::AddTrackToPlaylist( + playlist, track, + ))); + Ok(()) + })?; + tbl.set("playlist_add_track", playlist_add_track)?; + } + + // spotatui.playlist_remove_track(playlist, track, position) -- 0-based, required. + { + let shared = shared.clone(); + let playlist_remove_track = + lua.create_function(move |_, (playlist, track, position): (String, String, i64)| { + require_nonempty("spotatui.playlist_remove_track", "playlist", &playlist)?; + require_nonempty("spotatui.playlist_remove_track", "track", &track)?; + let position = usize::try_from(position).map_err(|_| { + mlua::Error::RuntimeError(format!( + "spotatui.playlist_remove_track: position must be a non-negative (0-based) integer, got {position}" + )) + })?; + shared.effects.borrow_mut().push(ScriptEffect::Dispatch( + IoEvent::RemoveTrackFromPlaylistAtPosition(playlist, track, position), + )); + Ok(()) + })?; + tbl.set("playlist_remove_track", playlist_remove_track)?; + } + + install_string_action(lua, &tbl, shared, "follow_playlist", |playlist| { + // The network handler ignores the owner-id parameter; "unknown" mirrors the + // fallback the built-in follow flow uses. + ScriptEffect::Dispatch(IoEvent::UserFollowPlaylist( + "unknown".to_string(), + playlist, + None, + )) + })?; + install_string_action( + lua, + &tbl, + shared, + "unfollow_playlist", + ScriptEffect::UnfollowPlaylist, + )?; + install_string_action(lua, &tbl, shared, "toggle_save_track", |uri| { + ScriptEffect::Dispatch(IoEvent::ToggleSaveTrack(uri)) + })?; + install_string_action(lua, &tbl, shared, "save_album", |id| { + ScriptEffect::Dispatch(IoEvent::CurrentUserSavedAlbumAdd(id)) + })?; + install_string_action(lua, &tbl, shared, "unsave_album", |id| { + ScriptEffect::Dispatch(IoEvent::CurrentUserSavedAlbumDelete(id)) + })?; + install_string_action(lua, &tbl, shared, "save_show", |id| { + ScriptEffect::Dispatch(IoEvent::CurrentUserSavedShowAdd(id)) + })?; + install_string_action(lua, &tbl, shared, "unsave_show", |id| { + ScriptEffect::Dispatch(IoEvent::CurrentUserSavedShowDelete(id)) + })?; + install_string_action(lua, &tbl, shared, "follow_artist", |id| { + ScriptEffect::Dispatch(IoEvent::UserFollowArtists(vec![id])) + })?; + install_string_action(lua, &tbl, shared, "unfollow_artist", |id| { + ScriptEffect::Dispatch(IoEvent::UserUnfollowArtists(vec![id])) + })?; + + // Timers. Fired from the engine's tick pass, so the effective resolution is + // the UI tick rate (behavior.tick_rate_milliseconds). + install_timer(lua, &tbl, shared, "set_timeout", false)?; + install_timer(lua, &tbl, shared, "set_interval", true)?; + + { + let shared = shared.clone(); + let cancel_timer = lua.create_function(move |_, handle: i64| { + if let Ok(token) = u64::try_from(handle) { + shared.cancelled_timers.borrow_mut().push(token); + } + Ok(()) + })?; + tbl.set("cancel_timer", cancel_timer)?; + } + + // spotatui.config(): theme colors + safe behavior scalars (cached sync read). + { + let shared = shared.clone(); + let config = + lua.create_function(move |lua, ()| lua.to_value(&*shared.config_cache.borrow()))?; + tbl.set("config", config)?; + } + + // Navigation. + { + let shared = shared.clone(); + let navigate = lua.create_function(move |_, target: String| { + if !super::effects::NAV_TARGETS.contains(&target.as_str()) { + return Err(mlua::Error::RuntimeError(format!( + "spotatui.navigate: unknown target '{target}'; valid targets: {}", + super::effects::NAV_TARGETS.join(", ") + ))); + } + shared + .effects + .borrow_mut() + .push(ScriptEffect::Navigate(target)); + Ok(()) + })?; + tbl.set("navigate", navigate)?; + } + + install_action(lua, &tbl, shared, "back", || ScriptEffect::Back)?; + + { + let shared = shared.clone(); + let current_route = + lua.create_function(move |_, ()| Ok(shared.current_route.borrow().clone()))?; + tbl.set("current_route", current_route)?; + } + + // Custom plugin screens (retained-mode). + { + let lua_inner = lua.clone(); + let shared = shared.clone(); + let register_screen = lua.create_function(move |_, (name, spec): (String, mlua::Table)| { + if name.is_empty() || name.contains(char::is_whitespace) { + return Err(mlua::Error::RuntimeError( + "spotatui.register_screen: name must be a non-empty string without whitespace" + .to_string(), + )); + } + let screens: mlua::Table = lua_inner.named_registry_value(SCREENS_KEY)?; + if screens.get::>(name.clone())?.is_some() { + return Err(mlua::Error::RuntimeError(format!( + "spotatui.register_screen: screen '{name}' is already registered" + ))); + } + let on_key: mlua::Function = + spec + .get::>("on_key")? + .ok_or_else(|| { + mlua::Error::RuntimeError( + "spotatui.register_screen: spec must have an 'on_key' function".to_string(), + ) + })?; + let entry = lua_inner.create_table()?; + entry.set("plugin", shared.current_plugin.borrow().clone())?; + entry.set( + "title", + spec.get::>("title")?.unwrap_or_default(), + )?; + entry.set("on_key", on_key)?; + if let Some(on_open) = spec.get::>("on_open")? { + entry.set("on_open", on_open)?; + } + if let Some(on_close) = spec.get::>("on_close")? { + entry.set("on_close", on_close)?; + } + screens.set(name, entry)?; + Ok(()) + })?; + tbl.set("register_screen", register_screen)?; + } + + { + let lua_inner = lua.clone(); + let shared = shared.clone(); + let set_screen = lua.create_function(move |_, (name, widgets): (String, mlua::Table)| { + let title = verify_screen_owner(&lua_inner, &shared, "spotatui.set_screen", &name)?; + let widgets = parse_screen_widgets(widgets)?; + shared + .effects + .borrow_mut() + .push(ScriptEffect::SetScreenContent { + name, + content: plugin_api::PluginScreenContent { title, widgets }, + }); + Ok(()) + })?; + tbl.set("set_screen", set_screen)?; + } + + { + let lua_inner = lua.clone(); + let shared = shared.clone(); + let show_screen = lua.create_function(move |_, name: String| { + verify_screen_owner(&lua_inner, &shared, "spotatui.show_screen", &name)?; + shared + .effects + .borrow_mut() + .push(ScriptEffect::ShowScreen(name)); + Ok(()) + })?; + tbl.set("show_screen", show_screen)?; + } + + { + let lua_inner = lua.clone(); + let shared = shared.clone(); + let close_screen = lua.create_function(move |_, name: String| { + verify_screen_owner(&lua_inner, &shared, "spotatui.close_screen", &name)?; + shared + .effects + .borrow_mut() + .push(ScriptEffect::CloseScreen(name)); + Ok(()) + })?; + tbl.set("close_screen", close_screen)?; + } + + // Plugin-scoped persistent storage: a flat JSON object per plugin under + // /plugin-data/.json. Lazily loaded on first touch, + // flushed by the engine (throttled on tick, forced on quit). + { + let shared_get = shared.clone(); + let storage_get = lua.create_function(move |lua, key: String| { + let namespace = storage_namespace(&shared_get)?; + ensure_storage_loaded(&shared_get, &namespace); + let storage = shared_get.storage.borrow(); + match storage.get(&namespace).and_then(|ns| ns.get(&key)) { + Some(value) => lua.to_value(value), + None => Ok(Value::Nil), + } + })?; + tbl.set("storage_get", storage_get)?; + + let shared_set = shared.clone(); + let storage_set = lua.create_function(move |lua, (key, value): (String, Value)| { + let namespace = storage_namespace(&shared_set)?; + ensure_storage_loaded(&shared_set, &namespace); + if value.is_nil() { + let mut storage = shared_set.storage.borrow_mut(); + if let Some(ns) = storage.get_mut(&namespace) { + ns.remove(&key); + } + } else { + // serde round-trip rejects functions/userdata with a clear error. + let json: serde_json::Value = lua.from_value(value).map_err(|e| { + mlua::Error::RuntimeError(format!( + "spotatui.storage_set: value must be JSON-serializable: {e}" + )) + })?; + let mut storage = shared_set.storage.borrow_mut(); + storage + .entry(namespace.clone()) + .or_default() + .insert(key, json); + } + shared_set.storage_dirty.borrow_mut().insert(namespace); + Ok(()) + })?; + tbl.set("storage_set", storage_set)?; + + let shared_remove = shared.clone(); + let storage_remove = lua.create_function(move |_, key: String| { + let namespace = storage_namespace(&shared_remove)?; + ensure_storage_loaded(&shared_remove, &namespace); + let removed = shared_remove + .storage + .borrow_mut() + .get_mut(&namespace) + .and_then(|ns| ns.remove(&key)) + .is_some(); + if removed { + shared_remove.storage_dirty.borrow_mut().insert(namespace); + } + Ok(()) + })?; + tbl.set("storage_remove", storage_remove)?; + + let shared_keys = shared.clone(); + let storage_keys = lua.create_function(move |_, ()| { + let namespace = storage_namespace(&shared_keys)?; + ensure_storage_loaded(&shared_keys, &namespace); + let storage = shared_keys.storage.borrow(); + let keys: Vec = storage + .get(&namespace) + .map(|ns| ns.keys().cloned().collect()) + .unwrap_or_default(); + Ok(keys) + })?; + tbl.set("storage_keys", storage_keys)?; + } + lua.globals().set("spotatui", tbl)?; Ok(()) } +/// The storage namespace for the plugin currently on the call stack. +fn storage_namespace(shared: &Rc) -> mlua::Result { + let namespace = super::shared::plugin_id(&shared.current_plugin.borrow()); + if namespace.is_empty() { + return Err(mlua::Error::RuntimeError( + "spotatui.storage_*: no plugin context (storage is only available from plugin code)" + .to_string(), + )); + } + Ok(namespace) +} + +/// Load a namespace from disk on first touch. Missing or corrupt files map to +/// an empty namespace (corruption is logged, never fatal). +fn ensure_storage_loaded(shared: &Rc, namespace: &str) { + if shared.storage.borrow().contains_key(namespace) { + return; + } + let loaded = shared + .storage_path(namespace) + .and_then(|path| match std::fs::read_to_string(&path) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(serde_json::Value::Object(map)) => Some(map), + Ok(_) => { + log::warn!( + "[lua] plugin storage {} is not a JSON object; starting empty", + path.display() + ); + None + } + Err(e) => { + log::warn!( + "[lua] plugin storage {} is corrupt ({e}); starting empty", + path.display() + ); + None + } + }, + Err(_) => None, // missing file: first run + }) + .unwrap_or_default(); + shared + .storage + .borrow_mut() + .insert(namespace.to_string(), loaded); +} + +/// Install `spotatui.set_timeout(ms, fn)` / `set_interval(ms, fn)`, returning +/// an opaque handle for `cancel_timer`. +fn install_timer( + lua: &Lua, + tbl: &mlua::Table, + shared: &Rc, + name: &'static str, + repeating: bool, +) -> mlua::Result<()> { + let lua_inner = lua.clone(); + let shared = shared.clone(); + let f = lua.create_function(move |_, (ms, callback): (i64, mlua::Function)| { + if ms < 0 { + return Err(mlua::Error::RuntimeError(format!( + "spotatui.{name}: milliseconds must be non-negative, got {ms}" + ))); + } + if repeating && ms == 0 { + return Err(mlua::Error::RuntimeError(format!( + "spotatui.{name}: interval must be at least 1 ms" + ))); + } + let token = register_callback(&lua_inner, &shared, TIMER_CALLBACKS_KEY, callback)?; + let duration = std::time::Duration::from_millis(ms as u64); + shared.new_timers.borrow_mut().push(NewTimer { + token, + delay: duration, + interval: repeating.then_some(duration), + }); + // register_callback guarantees the token fits in i64. + Ok(token as i64) + })?; + tbl.set(name, f)?; + Ok(()) +} + +/// True for Spotify container URIs playable as a context. +fn is_context_uri(uri: &str) -> bool { + uri.starts_with("spotify:album:") + || uri.starts_with("spotify:playlist:") + || uri.starts_with("spotify:artist:") + || uri.starts_with("spotify:show:") +} + +fn require_nonempty(function_name: &str, arg_name: &str, value: &str) -> mlua::Result<()> { + if value.trim().is_empty() { + return Err(mlua::Error::RuntimeError(format!( + "{function_name}: {arg_name} must be a non-empty string" + ))); + } + Ok(()) +} + +/// Install an action taking a single non-empty string argument. +fn install_string_action( + lua: &Lua, + tbl: &mlua::Table, + shared: &Rc, + name: &'static str, + make: fn(String) -> ScriptEffect, +) -> mlua::Result<()> { + let shared = shared.clone(); + let f = lua.create_function(move |_, value: String| { + require_nonempty(&format!("spotatui.{name}"), "argument", &value)?; + shared.effects.borrow_mut().push(make(value)); + Ok(()) + })?; + tbl.set(name, f)?; + Ok(()) +} + +/// Install an argument-less async data read: `spotatui.(cb)` registers the +/// callback and queues a [`DataRequest`] for the engine's next intake pass. +fn install_data_read( + lua: &Lua, + tbl: &mlua::Table, + shared: &Rc, + name: &'static str, + kind: PluginDataKind, +) -> mlua::Result<()> { + let lua_inner = lua.clone(); + let shared = shared.clone(); + let f = lua.create_function(move |_, callback: mlua::Function| { + let token = register_callback(&lua_inner, &shared, DATA_CALLBACKS_KEY, callback)?; + shared.data_requests.borrow_mut().push(DataRequest { + token, + kind, + arg: None, + }); + Ok(()) + })?; + tbl.set(name, f)?; + Ok(()) +} + fn validate_http_url(function_name: &str, url: &str) -> mlua::Result<()> { let parsed = reqwest::Url::parse(url) .map_err(|e| mlua::Error::RuntimeError(format!("{function_name}: invalid URL '{url}': {e}")))?; @@ -364,21 +988,24 @@ fn validate_http_url(function_name: &str, url: &str) -> mlua::Result<()> { } } -fn register_http_callback( +/// Store a `{ plugin, callback }` entry under a fresh token in the given +/// registry table. Shared by HTTP requests, data requests and timers. +pub(super) fn register_callback( lua: &Lua, shared: &Rc, + registry_key: &str, callback: mlua::Function, ) -> mlua::Result { let token = shared - .next_http_token + .next_token .get() .checked_add(1) - .ok_or_else(|| mlua::Error::RuntimeError("spotatui.http: token overflow".to_string()))?; + .ok_or_else(|| mlua::Error::RuntimeError("spotatui: token overflow".to_string()))?; let key = i64::try_from(token) - .map_err(|_| mlua::Error::RuntimeError("spotatui.http: token overflow".to_string()))?; - shared.next_http_token.set(token); + .map_err(|_| mlua::Error::RuntimeError("spotatui: token overflow".to_string()))?; + shared.next_token.set(token); - let callbacks: mlua::Table = lua.named_registry_value(HTTP_CALLBACKS_KEY)?; + let callbacks: mlua::Table = lua.named_registry_value(registry_key)?; let entry = lua.create_table()?; entry.set("plugin", shared.current_plugin.borrow().clone())?; entry.set("callback", callback)?; @@ -386,6 +1013,14 @@ fn register_http_callback( Ok(token) } +fn register_http_callback( + lua: &Lua, + shared: &Rc, + callback: mlua::Function, +) -> mlua::Result { + register_callback(lua, shared, HTTP_CALLBACKS_KEY, callback) +} + fn collect_headers(headers: Option) -> mlua::Result> { let Some(headers) = headers else { return Ok(Vec::new()); @@ -423,11 +1058,102 @@ async fn response_data(response: reqwest::Response) -> Result, + fn_name: &str, + name: &str, +) -> mlua::Result { + let screens: mlua::Table = lua.named_registry_value(SCREENS_KEY)?; + let entry: mlua::Table = screens + .get::>(name.to_string())? + .ok_or_else(|| { + mlua::Error::RuntimeError(format!( + "{fn_name}: no screen named '{name}' is registered (call register_screen first)" + )) + })?; + let owner: String = entry.get("plugin").unwrap_or_default(); + let caller = shared.current_plugin.borrow().clone(); + if owner != caller { + return Err(mlua::Error::RuntimeError(format!( + "{fn_name}: screen '{name}' belongs to plugin '{owner}'" + ))); + } + entry + .get::>("title") + .map(Option::unwrap_or_default) +} + +/// Parse the widget array for `spotatui.set_screen`. Each item is a table with +/// a `type` of "paragraph", "list" or "gauge". +fn parse_screen_widgets(widgets: mlua::Table) -> mlua::Result> { + const FN: &str = "spotatui.set_screen"; + let mut out = Vec::new(); + for item in widgets.sequence_values::() { + let item = item?; + let kind: String = item.get::>("type")?.ok_or_else(|| { + mlua::Error::RuntimeError(format!("{FN}: each widget must have a 'type' field")) + })?; + match kind.as_str() { + "paragraph" => { + let lines: mlua::Value = item.get("lines")?; + let lines = parse_styled_lines(FN, lines)?; + let height: Option = item.get("height")?; + out.push(plugin_api::PluginWidget::Paragraph { lines, height }); + } + "list" => { + let items_val: mlua::Value = item.get("items")?; + let items = parse_styled_lines(FN, items_val)?; + let title: Option = item.get("title")?; + let height: Option = item.get("height")?; + // Lua-side `selected` is 1-based (like Lua arrays); stored 0-based. + let selected: Option = item.get("selected")?; + let selected = match selected { + Some(s) if s >= 1 => Some((s - 1) as usize), + Some(s) => { + return Err(mlua::Error::RuntimeError(format!( + "{FN}: list 'selected' is 1-based and must be >= 1, got {s}" + ))); + } + None => None, + }; + out.push(plugin_api::PluginWidget::List { + title, + items, + selected, + height, + }); + } + "gauge" => { + let ratio: f64 = item.get::>("ratio")?.unwrap_or(0.0); + let label: Option = item.get("label")?; + out.push(plugin_api::PluginWidget::Gauge { + ratio: ratio.clamp(0.0, 1.0), + label, + }); + } + other => { + return Err(mlua::Error::RuntimeError(format!( + "{FN}: unknown widget type '{other}' (expected paragraph, list or gauge)" + ))); + } + } + } + Ok(out) +} + /// Parse the `lines` argument for `spotatui.popup`. +fn parse_popup_lines(val: mlua::Value) -> mlua::Result> { + parse_styled_lines("spotatui.popup", val) +} + +/// Parse styled lines shared by popups and screen widgets. /// /// Accepts: a single string, or an array whose items are each a string or a table /// `{ text, fg?, bold?, italic? }`. -fn parse_popup_lines(val: mlua::Value) -> mlua::Result> { +fn parse_styled_lines(fn_name: &str, val: mlua::Value) -> mlua::Result> { match val { mlua::Value::String(s) => Ok(vec![PopupLine { text: s.to_str()?.to_string(), @@ -449,15 +1175,15 @@ fn parse_popup_lines(val: mlua::Value) -> mlua::Result> { mlua::Value::Table(t) => { let text: Option = t.get("text")?; let text = text.ok_or_else(|| { - mlua::Error::RuntimeError( - "spotatui.popup: each line table must have a 'text' field".to_string(), - ) + mlua::Error::RuntimeError(format!( + "{fn_name}: each line table must have a 'text' field" + )) })?; let fg_str: Option = t.get("fg")?; let fg = fg_str .map(|s| { parse_theme_item(&s).map_err(|e| { - mlua::Error::RuntimeError(format!("spotatui.popup: invalid color '{}': {}", s, e)) + mlua::Error::RuntimeError(format!("{fn_name}: invalid color '{}': {}", s, e)) }) }) .transpose()?; @@ -472,7 +1198,7 @@ fn parse_popup_lines(val: mlua::Value) -> mlua::Result> { } other => { return Err(mlua::Error::RuntimeError(format!( - "spotatui.popup: each line must be a string or table, got {}", + "{fn_name}: each line must be a string or table, got {}", other.type_name() ))); } @@ -481,7 +1207,7 @@ fn parse_popup_lines(val: mlua::Value) -> mlua::Result> { Ok(lines) } other => Err(mlua::Error::RuntimeError(format!( - "spotatui.popup: lines must be a string or array, got {}", + "{fn_name}: lines must be a string or array, got {}", other.type_name() ))), } diff --git a/src/infra/scripting/effects.rs b/src/infra/scripting/effects.rs index f5dc9bb1..db2763fc 100644 --- a/src/infra/scripting/effects.rs +++ b/src/infra/scripting/effects.rs @@ -30,6 +30,93 @@ pub(crate) enum ScriptEffect { ShowPopup(PluginPopup), /// Apply theme color overrides at runtime (field name -> color). SetTheme(Vec<(String, ratatui::style::Color)>), + /// A whitelisted IoEvent built by the API layer. Lua never constructs raw + /// IoEvents; only the documented `spotatui.*` actions can produce these. + Dispatch(IoEvent), + /// Cycle repeat off -> context -> track via `App::repeat()`, which keeps the + /// native-streaming fast path. + CycleRepeat, + /// Unfollow a playlist; the current user id is resolved at drain time. + UnfollowPlaylist(String), + /// Navigate to a whitelisted target (validated at the API layer against + /// [`NAV_TARGETS`]); apply mirrors the matching keybinding exactly. + Navigate(String), + /// Pop the navigation stack (same as the back key). + Back, + /// Publish (retained) content for a registered plugin screen. + SetScreenContent { + name: String, + content: crate::core::plugin_api::PluginScreenContent, + }, + /// Navigate to a registered plugin screen. + ShowScreen(String), + /// Pop the named plugin screen if it is the current route. + CloseScreen(String), +} + +/// Screens reachable via `spotatui.navigate(name)`. +pub(super) const NAV_TARGETS: &[&str] = &[ + "home", + "queue", + "settings", + "devices", + "help", + "lyrics", + "recently_played", + "party", + "analysis", + "miniplayer", +]; + +/// Replicate the matching keybinding for each nav target. Unknown targets are +/// rejected at the API layer, so the fallback arm is unreachable in practice. +fn apply_navigate(app: &mut App, target: &str) { + use crate::core::app::{ActiveBlock, RouteId, SourceFocus}; + use crate::core::source::Source; + + match target { + "home" => app.push_navigation_stack(RouteId::Home, ActiveBlock::Empty), + "queue" => { + app.dispatch(IoEvent::GetQueue); + app.push_navigation_stack(RouteId::Queue, ActiveBlock::Queue); + } + "settings" => { + app.load_settings_for_category(); + app.push_navigation_stack(RouteId::Settings, ActiveBlock::Settings); + } + "devices" => { + // Mirrors the manage_devices keybinding: open the Source & Device picker, + // focus per active source, and only fetch devices under Spotify. + app.source_list_index = Source::ALL + .iter() + .position(|s| *s == app.active_source) + .unwrap_or(0); + app.source_device_focus = if app.active_source == Source::Spotify { + SourceFocus::Devices + } else { + SourceFocus::Source + }; + app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); + if app.active_source == Source::Spotify { + app.dispatch(IoEvent::GetDevices); + } + } + "help" => app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::HelpMenu), + "lyrics" => app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView), + // The network handler pushes the route once the data arrives, exactly like + // the keybinding. + "recently_played" => app.dispatch(IoEvent::GetRecentlyPlayed), + "party" => app.push_navigation_stack(RouteId::Party, ActiveBlock::Party), + "analysis" => app.get_audio_analysis(), + "miniplayer" => { + if app.get_current_route().id == RouteId::MiniPlayer { + app.pop_navigation_stack(); + } else { + app.push_navigation_stack(RouteId::MiniPlayer, ActiveBlock::MiniPlayer); + } + } + _ => {} + } } /// Returns `true` when the current playback state indicates active playback. @@ -83,6 +170,39 @@ pub(super) fn apply_effects(effects: Vec, app: &mut App) { app.plugin_popup = Some(popup); app.plugin_popup_scroll = 0; } + ScriptEffect::Dispatch(event) => app.dispatch(event), + ScriptEffect::CycleRepeat => app.repeat(), + ScriptEffect::Navigate(target) => apply_navigate(app, &target), + ScriptEffect::Back => { + app.pop_navigation_stack(); + } + ScriptEffect::SetScreenContent { name, content } => { + app.plugin_screens.insert(name, content); + } + ScriptEffect::ShowScreen(name) => { + use crate::core::app::{ActiveBlock, RouteId}; + if app.get_current_route().id != RouteId::PluginScreen(name.clone()) { + app.push_navigation_stack(RouteId::PluginScreen(name), ActiveBlock::PluginScreen); + } + app.plugin_screen_scroll = 0; + } + ScriptEffect::CloseScreen(name) => { + use crate::core::app::RouteId; + if app.get_current_route().id == RouteId::PluginScreen(name) { + app.pop_navigation_stack(); + } + } + ScriptEffect::UnfollowPlaylist(playlist_id) => { + let user_id = app.user.as_ref().map(|u| u.id.clone()); + if let Some(user_id) = user_id { + app.dispatch(IoEvent::UserUnfollowPlaylist(user_id, playlist_id)); + } else { + app.set_error_status_message( + "plugin unfollow_playlist: user profile not loaded yet".to_string(), + 4, + ); + } + } ScriptEffect::SetTheme(pairs) => { for (field, color) in pairs { match field.as_str() { diff --git a/src/infra/scripting/engine.rs b/src/infra/scripting/engine.rs index 7d914917..b91c7f05 100644 --- a/src/infra/scripting/engine.rs +++ b/src/infra/scripting/engine.rs @@ -7,16 +7,42 @@ use mlua::{Lua, LuaSerdeExt, Value}; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; -use crate::core::app::App; +use crate::core::app::{App, LyricsStatus, PluginDataKind}; use crate::core::plugin_api; +use crate::infra::network::IoEvent; use super::api::install_api; use super::effects::{apply_effects, ScriptEffect}; -use super::events::{diff_events, queue_uris, ScriptEvent}; +use super::events::{diff_events, diff_state_events, queue_uris, ScriptEvent}; use super::shared::{ - HttpResponseData, HttpResult, ScriptShared, COMMANDS_KEY, HANDLERS_KEY, HTTP_CALLBACKS_KEY, + DataRequest, HttpResponseData, HttpResult, ScriptShared, COMMANDS_KEY, DATA_CALLBACKS_KEY, + HANDLERS_KEY, HTTP_CALLBACKS_KEY, SCREENS_KEY, TIMER_CALLBACKS_KEY, }; +/// How long a plugin data request may wait for its generation to advance +/// before its callback is failed with a timeout error. +const DATA_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +/// Minimum interval between throttled plugin-storage flushes (mirrors the +/// runner's session-save cadence). +const STORAGE_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3); + +/// An in-flight plugin data request: dispatched, waiting for the domain's +/// generation counter to move past the value captured at intake. +struct PendingDataRequest { + token: u64, + kind: PluginDataKind, + requested_gen: u64, + deadline: std::time::Instant, +} + +/// An armed plugin timer. `interval` is `Some` for `set_interval`. +struct ActiveTimer { + token: u64, + due: std::time::Instant, + interval: Option, +} + pub struct ScriptEngine { pub(super) lua: Lua, pub(crate) shared: Rc, @@ -24,6 +50,20 @@ pub struct ScriptEngine { last_playback: Option, /// Previous queue item uris, for diffing on tick. last_queue: Option>, + /// Previous route name, for `route_change` diffing. + last_route: String, + /// Set when the Search generation advanced since the last tick diff + /// (consumed by the `search_results` event). + search_gen_advanced: bool, + /// Data requests dispatched but not yet resolved. + pending_data: Vec, + /// Armed plugin timers, fired from the tick pass. + timers: Vec, + /// Generations backing the cached sync reads at their last refresh. + /// `u64::MAX` sentinels force the first refresh. + last_cache_gens: [u64; PluginDataKind::COUNT], + /// Last throttled storage flush. + last_storage_flush: Option, http_rx: UnboundedReceiver, #[cfg(test)] http_tx: UnboundedSender, @@ -54,6 +94,18 @@ impl ScriptEngine { let http_callbacks = lua.create_table()?; lua.set_named_registry_value(HTTP_CALLBACKS_KEY, http_callbacks)?; + // Registry data callback table: { token = { plugin=, callback= } }. + let data_callbacks = lua.create_table()?; + lua.set_named_registry_value(DATA_CALLBACKS_KEY, data_callbacks)?; + + // Registry timer callback table: { token = { plugin=, callback= } }. + let timer_callbacks = lua.create_table()?; + lua.set_named_registry_value(TIMER_CALLBACKS_KEY, timer_callbacks)?; + + // Registry screens table: { name = { plugin=, title=, on_key=, on_open?, on_close? } }. + let screens = lua.create_table()?; + lua.set_named_registry_value(SCREENS_KEY, screens)?; + install_api(&lua, &shared, http_tx.clone(), http_client, rt_handle)?; Ok(ScriptEngine { @@ -61,6 +113,12 @@ impl ScriptEngine { shared, last_playback: None, last_queue: None, + last_route: String::new(), + search_gen_advanced: false, + pending_data: Vec::new(), + timers: Vec::new(), + last_cache_gens: [u64::MAX; PluginDataKind::COUNT], + last_storage_flush: None, http_rx, #[cfg(test)] http_tx, @@ -72,6 +130,7 @@ impl ScriptEngine { /// Missing files/dir are fine. A failing file logs an error and queues a Notify effect but /// never aborts the others. Returns the number of plugins loaded successfully. pub fn load_user_scripts(&mut self, config_dir: &Path) -> usize { + *self.shared.config_dir.borrow_mut() = Some(config_dir.to_path_buf()); let mut loaded = 0; let init_path = config_dir.join("init.lua"); @@ -221,8 +280,10 @@ impl ScriptEngine { /// Refresh caches, emit Start, drain effects. pub fn on_start(&mut self, app: &mut App) { self.refresh_caches(app); + self.refresh_data_caches(app); self.emit(ScriptEvent::Start); self.drain_http_callbacks(); + self.process_data_requests(app); self.drain_effects(app); } @@ -230,23 +291,52 @@ impl ScriptEngine { /// diff against the previous snapshot, emit each derived event, then drain. pub fn on_tick(&mut self, app: &mut App) { self.drain_http_callbacks(); + self.process_data_requests(app); + self.process_timers(); + self.flush_storage(false); + self.refresh_data_caches(app); if !self.has_any_handlers() { + // Still drain effects and track route transitions: screens and timers + // work without any event handlers registered. self.drain_effects(app); + self.sync_route_and_emit_change(app); return; } let new_playback = plugin_api::playback_state(app); let new_queue = Some(queue_uris(app)); - let events = diff_events( + let mut events = diff_events( &self.last_playback, &self.last_queue, &new_playback, &new_queue, ); + // Non-playback state diffs: route, devices, search generation. The device + // diff must read the old cache before it is overwritten below. + let new_route = plugin_api::route_name(app.get_current_route()); + let new_devices = plugin_api::device_list(app); + let search_gen_advanced = self.search_gen_advanced; + self.search_gen_advanced = false; + { + let old_devices = self.shared.devices.borrow(); + events.extend(diff_state_events( + &self.last_route, + &new_route, + &old_devices, + &new_devices, + search_gen_advanced, + )); + } + *self.shared.playback.borrow_mut() = new_playback.clone(); - *self.shared.devices.borrow_mut() = plugin_api::device_list(app); + *self.shared.devices.borrow_mut() = new_devices; + if self.last_route != new_route { + let old_route = std::mem::replace(&mut self.last_route, new_route.clone()); + self.handle_screen_transition(&old_route, &new_route); + } + *self.shared.current_route.borrow_mut() = new_route; self.last_playback = new_playback; self.last_queue = new_queue; @@ -255,14 +345,19 @@ impl ScriptEngine { self.emit(ev); } self.drain_http_callbacks(); + self.process_data_requests(app); self.drain_effects(app); + // Catch route changes caused by effects drained just above (e.g. a timer + // callback calling show_screen). + self.sync_route_and_emit_change(app); } - /// Emit Quit, drain effects. + /// Emit Quit, drain effects, force-flush plugin storage. pub fn on_quit(&mut self, app: &mut App) { self.refresh_caches(app); self.emit(ScriptEvent::Quit); self.drain_effects(app); + self.flush_storage(true); } fn refresh_caches(&mut self, app: &App) { @@ -271,6 +366,88 @@ impl ScriptEngine { *self.shared.devices.borrow_mut() = plugin_api::device_list(app); self.last_playback = pb; self.last_queue = Some(queue_uris(app)); + let route = plugin_api::route_name(app.get_current_route()); + self.last_route.clone_from(&route); + *self.shared.current_route.borrow_mut() = route; + } + + /// Detect a route change outside the tick diff (plugin commands and the key + /// handlers that ran just before them can navigate). Fires screen + /// on_close/on_open, emits `route_change`, and drains any queued effects. + fn sync_route_and_emit_change(&mut self, app: &mut App) { + let new_route = plugin_api::route_name(app.get_current_route()); + if new_route != self.last_route { + let old_route = std::mem::replace(&mut self.last_route, new_route.clone()); + *self.shared.current_route.borrow_mut() = new_route.clone(); + self.handle_screen_transition(&old_route, &new_route); + self.emit(ScriptEvent::RouteChange(new_route)); + self.drain_effects(app); + } + } + + /// Fire a plugin screen's on_close/on_open across a route transition. + fn handle_screen_transition(&mut self, old_route: &str, new_route: &str) { + if let Some(name) = old_route.strip_prefix("plugin:") { + self.call_screen_callback(&name.to_string(), "on_close", None); + } + if let Some(name) = new_route.strip_prefix("plugin:") { + self.call_screen_callback(&name.to_string(), "on_open", None); + } + } + + /// Forward keys queued while a plugin screen was focused to its `on_key`. + fn dispatch_screen_keys(&mut self, app: &mut App) { + if app.pending_plugin_screen_keys.is_empty() { + return; + } + let keys: Vec<(String, String)> = app.pending_plugin_screen_keys.drain(..).collect(); + for (screen, key) in keys { + self.call_screen_callback(&screen, "on_key", Some(key)); + } + } + + /// Invoke one of a screen's registered callbacks (`on_key` gets the key + /// string as its argument). Errors/panics queue a NotifyError and remove the + /// offending callback from the screen entry (one strike). + fn call_screen_callback(&mut self, screen: &String, field: &'static str, key: Option) { + let screens: mlua::Table = match self.lua.named_registry_value(SCREENS_KEY) { + Ok(t) => t, + Err(_) => return, + }; + let entry: mlua::Table = match screens.get::>(screen.clone()) { + Ok(Some(t)) => t, + _ => return, + }; + let plugin: String = entry.get("plugin").unwrap_or_default(); + let callback: mlua::Function = match entry.get::>(field) { + Ok(Some(f)) => f, + _ => return, // on_open/on_close are optional + }; + drop(screens); + + *self.shared.current_plugin.borrow_mut() = plugin.clone(); + let call_result = catch_unwind(AssertUnwindSafe(|| match key { + Some(key) => callback.call::<()>(key), + None => callback.call::<()>(()), + })); + self.shared.current_plugin.borrow_mut().clear(); + + let err_msg = match call_result { + Ok(Ok(())) => return, + Ok(Err(e)) => first_line(&e.to_string()), + Err(_) => "panic".to_string(), + }; + log::error!("[lua] plugin '{plugin}': error in screen '{screen}' {field}: {err_msg}"); + self + .shared + .effects + .borrow_mut() + .push(ScriptEffect::NotifyError( + format!("plugin '{plugin}': error in screen '{screen}' {field}: {err_msg}"), + 6, + )); + // One strike: remove the erroring callback so it can't fire again. + let _ = entry.set(field, Value::Nil); } fn has_any_handlers(&self) -> bool { @@ -300,10 +477,16 @@ impl ScriptEngine { return; } - let arg = if event.passes_playback_arg() { - self.playback_value() - } else { - Value::Nil + let arg = match &event { + ScriptEvent::RouteChange(name) => match self.lua.create_table() { + Ok(t) => { + let _ = t.set("name", name.clone()); + Value::Table(t) + } + Err(_) => Value::Nil, + }, + _ if event.passes_playback_arg() => self.playback_value(), + _ => Value::Nil, }; // Indices to remove after the pass (descending so removal stays valid). @@ -363,9 +546,14 @@ impl ScriptEngine { } /// Run any commands queued in `app.pending_plugin_commands`, then drain effects. + /// Also the route-change fast path: the key handler that ran just before may + /// have navigated. pub fn run_pending_commands(&mut self, app: &mut App) { + self.sync_route_and_emit_change(app); + self.dispatch_screen_keys(app); if app.pending_plugin_commands.is_empty() { self.drain_http_callbacks(); + self.process_data_requests(app); self.drain_effects(app); return; } @@ -375,6 +563,7 @@ impl ScriptEngine { Ok(t) => t, Err(_) => { self.drain_http_callbacks(); + self.process_data_requests(app); self.drain_effects(app); return; } @@ -430,7 +619,374 @@ impl ScriptEngine { } } self.drain_http_callbacks(); + self.process_data_requests(app); self.drain_effects(app); + self.sync_route_and_emit_change(app); + } + + /// Intake queued `spotatui.get_*` requests (capture generation + dispatch the + /// matching IoEvent) and resolve pending ones whose generation advanced or + /// whose deadline passed. Intake is atomic on the UI thread while holding + /// `&mut App`, so no update can be lost between capture and dispatch. + fn process_data_requests(&mut self, app: &mut App) { + self.process_data_requests_at(app, std::time::Instant::now()); + } + + fn process_data_requests_at(&mut self, app: &mut App, now: std::time::Instant) { + // Intake. Drain first so the RefCell borrow is dropped before any Lua or + // App call, and so requests queued by callbacks below land in the next pass. + let requests: Vec = self.shared.data_requests.borrow_mut().drain(..).collect(); + for req in requests { + // Lyrics fetches are driven by the runner's track-change detector, not by + // dispatch: deliver immediately when the current status is terminal, + // otherwise wait for the in-flight fetch to bump the generation. + if req.kind == PluginDataKind::Lyrics + && matches!( + app.lyrics_status, + LyricsStatus::Found | LyricsStatus::NotFound + ) + { + let value = self.data_value(req.kind, app); + self.deliver_data_result(req.token, value.map_err(|e| e.to_string())); + continue; + } + + let requested_gen = app.plugin_data_generations.get(req.kind); + let event = match req.kind { + PluginDataKind::Playlists => Some(IoEvent::GetPlaylists), + PluginDataKind::Queue => Some(IoEvent::GetQueue), + PluginDataKind::Search => Some(IoEvent::GetSearchResults( + req.arg.clone().unwrap_or_default(), + app.get_user_country(), + )), + PluginDataKind::SavedTracks => Some(IoEvent::GetCurrentSavedTracks(None)), + PluginDataKind::SavedAlbums => Some(IoEvent::GetCurrentUserSavedAlbums(None)), + PluginDataKind::SavedShows => Some(IoEvent::GetCurrentUserSavedShows(None)), + PluginDataKind::RecentlyPlayed => Some(IoEvent::GetRecentlyPlayedSilent), + PluginDataKind::Devices => Some(IoEvent::GetDevicesSilent), + PluginDataKind::Lyrics => None, + }; + if let Some(event) = event { + app.dispatch(event); + } + self.pending_data.push(PendingDataRequest { + token: req.token, + kind: req.kind, + requested_gen, + deadline: now + DATA_REQUEST_TIMEOUT, + }); + } + + // Resolution. Serialize snapshots from `&App` first, collect the outcomes, + // then call into Lua with no engine-side borrows held. + if self.pending_data.is_empty() { + return; + } + let mut resolved: Vec<(u64, Result)> = Vec::new(); + let lua = self.lua.clone(); + self.pending_data.retain(|p| { + if app.plugin_data_generations.get(p.kind) != p.requested_gen { + let value = data_value_for(&lua, p.kind, app).map_err(|e| e.to_string()); + resolved.push((p.token, value)); + false + } else if now >= p.deadline { + resolved.push((p.token, Err("request timed out".to_string()))); + false + } else { + true + } + }); + for (token, result) in resolved { + self.deliver_data_result(token, result); + } + } + + /// Serialize the snapshot for a data kind from current `App` state. + fn data_value(&self, kind: PluginDataKind, app: &App) -> mlua::Result { + data_value_for(&self.lua, kind, app) + } + + /// Arm newly-set timers, apply cancellations, then fire everything due. + /// Callbacks run with catch_unwind + one strike (an erroring interval is + /// removed). Intervals reschedule to `now + interval`, skipping missed + /// periods rather than firing catch-up bursts. + fn process_timers(&mut self) { + self.process_timers_at(std::time::Instant::now()); + } + + fn process_timers_at(&mut self, now: std::time::Instant) { + // Arm first, then cancel: a timer set and cancelled in the same pass stays + // cancelled. + let new_timers: Vec<_> = self.shared.new_timers.borrow_mut().drain(..).collect(); + for t in new_timers { + self.timers.push(ActiveTimer { + token: t.token, + due: now + t.delay, + interval: t.interval, + }); + } + let cancelled: Vec = self + .shared + .cancelled_timers + .borrow_mut() + .drain(..) + .collect(); + if !cancelled.is_empty() { + self.timers.retain(|t| !cancelled.contains(&t.token)); + if let Ok(callbacks) = self + .lua + .named_registry_value::(TIMER_CALLBACKS_KEY) + { + for token in cancelled { + if let Ok(key) = i64::try_from(token) { + let _ = callbacks.raw_set(key, Value::Nil); + } + } + } + } + + // Collect due tokens with no borrows held, rescheduling/removing as we go. + let mut due: Vec<(u64, bool)> = Vec::new(); // (token, repeating) + self.timers.retain_mut(|t| { + if now < t.due { + return true; + } + match t.interval { + Some(interval) => { + due.push((t.token, true)); + t.due = now + interval; + true + } + None => { + due.push((t.token, false)); + false + } + } + }); + + for (token, repeating) in due { + if !self.fire_timer(token, repeating) && repeating { + // One strike: remove the erroring interval. + self.timers.retain(|t| t.token != token); + } + } + } + + /// Invoke a timer callback. Returns false when the callback errored or + /// panicked (the registry entry is removed in that case, and always for + /// one-shot timeouts). + fn fire_timer(&mut self, token: u64, repeating: bool) -> bool { + let callbacks: mlua::Table = match self.lua.named_registry_value(TIMER_CALLBACKS_KEY) { + Ok(t) => t, + Err(_) => return false, + }; + let key = match i64::try_from(token) { + Ok(key) => key, + Err(_) => return false, + }; + let entry: mlua::Table = match callbacks.raw_get::>(key) { + Ok(Some(t)) => t, + _ => return false, + }; + let plugin: String = entry.get("plugin").unwrap_or_default(); + let callback: mlua::Function = match entry.get("callback") { + Ok(f) => f, + Err(_) => { + let _ = callbacks.raw_set(key, Value::Nil); + return false; + } + }; + if !repeating { + let _ = callbacks.raw_set(key, Value::Nil); + } + drop(entry); + drop(callbacks); + + *self.shared.current_plugin.borrow_mut() = plugin.clone(); + let call_result = catch_unwind(AssertUnwindSafe(|| callback.call::<()>(()))); + self.shared.current_plugin.borrow_mut().clear(); + + let err_msg = match call_result { + Ok(Ok(())) => return true, + Ok(Err(e)) => first_line(&e.to_string()), + Err(_) => "panic".to_string(), + }; + log::error!("[lua] plugin '{plugin}': error in timer callback: {err_msg}"); + self + .shared + .effects + .borrow_mut() + .push(ScriptEffect::NotifyError( + format!("plugin '{plugin}': error in timer callback: {err_msg}"), + 6, + )); + if repeating { + // Remove the interval's registry entry too (one strike). + if let Ok(callbacks) = self + .lua + .named_registry_value::(TIMER_CALLBACKS_KEY) + { + if let Ok(key) = i64::try_from(token) { + let _ = callbacks.raw_set(key, Value::Nil); + } + } + } + false + } + + #[cfg(test)] + pub(super) fn process_timers_for_test(&mut self, now: std::time::Instant) { + self.process_timers_at(now); + } + + /// Write dirty storage namespaces to disk. Throttled unless `force` (quit). + /// Last writer wins across concurrent spotatui instances -- documented. + pub(super) fn flush_storage(&mut self, force: bool) { + if self.shared.storage_dirty.borrow().is_empty() { + return; + } + let now = std::time::Instant::now(); + if !force { + if let Some(last) = self.last_storage_flush { + if now.duration_since(last) < STORAGE_FLUSH_INTERVAL { + return; + } + } + } + self.last_storage_flush = Some(now); + + let dirty: Vec = std::mem::take(&mut *self.shared.storage_dirty.borrow_mut()) + .into_iter() + .collect(); + let storage = self.shared.storage.borrow(); + for namespace in dirty { + let Some(path) = self.shared.storage_path(&namespace) else { + log::warn!("[lua] plugin storage for '{namespace}' has no config dir; not persisted"); + continue; + }; + let Some(map) = storage.get(&namespace) else { + continue; + }; + if let Err(e) = write_storage_file(&path, map) { + log::error!( + "[lua] failed to write plugin storage {}: {e}", + path.display() + ); + } + } + } + + /// Call a data callback with `(data, nil)` or `(nil, err)`, one-shot. Errors + /// and panics queue a NotifyError effect; the entry is always removed. + fn deliver_data_result(&mut self, token: u64, result: Result) { + let callbacks: mlua::Table = match self.lua.named_registry_value(DATA_CALLBACKS_KEY) { + Ok(t) => t, + Err(_) => return, + }; + let key = match i64::try_from(token) { + Ok(key) => key, + Err(_) => return, + }; + let entry: mlua::Table = match callbacks.raw_get::>(key) { + Ok(Some(t)) => t, + _ => return, + }; + let plugin: String = entry.get("plugin").unwrap_or_default(); + let callback: mlua::Function = match entry.get("callback") { + Ok(f) => f, + Err(_) => { + let _ = callbacks.raw_set(key, Value::Nil); + return; + } + }; + let _ = callbacks.raw_set(key, Value::Nil); + drop(entry); + drop(callbacks); + + let args = match result { + Ok(value) => (value, Value::Nil), + Err(err) => match self.lua.create_string(&err) { + Ok(s) => (Value::Nil, Value::String(s)), + Err(_) => (Value::Nil, Value::Nil), + }, + }; + + *self.shared.current_plugin.borrow_mut() = plugin.clone(); + let call_result = catch_unwind(AssertUnwindSafe(|| callback.call::<()>(args))); + self.shared.current_plugin.borrow_mut().clear(); + + match call_result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + let msg = first_line(&e.to_string()); + log::error!("[lua] plugin '{plugin}': error in data callback: {msg}"); + self + .shared + .effects + .borrow_mut() + .push(ScriptEffect::NotifyError( + format!("plugin '{plugin}': error in data callback: {msg}"), + 6, + )); + } + Err(_) => { + log::error!("[lua] plugin '{plugin}': panic in data callback"); + self + .shared + .effects + .borrow_mut() + .push(ScriptEffect::NotifyError( + format!("plugin '{plugin}': panic in data callback"), + 6, + )); + } + } + } + + /// Refresh the caches backing the synchronous reads (`spotatui.playlists()` + /// etc.) when their data generation advanced since the last refresh. + fn refresh_data_caches(&mut self, app: &App) { + let refresh = |slot: &mut u64, kind: PluginDataKind| -> bool { + let current = app.plugin_data_generations.get(kind); + if *slot == current { + return false; + } + *slot = current; + true + }; + + if refresh( + &mut self.last_cache_gens[PluginDataKind::Playlists.index()], + PluginDataKind::Playlists, + ) { + *self.shared.playlists_cache.borrow_mut() = plugin_api::playlists_snapshot(app); + } + if refresh( + &mut self.last_cache_gens[PluginDataKind::Queue.index()], + PluginDataKind::Queue, + ) { + *self.shared.queue_cache.borrow_mut() = plugin_api::queue_snapshot(app); + } + // Config has no generation counter (theme/settings can change from many + // places); rebuilding the small snapshot each pass is cheap. + *self.shared.config_cache.borrow_mut() = plugin_api::config_snapshot(&app.user_config); + + let search_was = self.last_cache_gens[PluginDataKind::Search.index()]; + if refresh( + &mut self.last_cache_gens[PluginDataKind::Search.index()], + PluginDataKind::Search, + ) { + *self.shared.search_results_cache.borrow_mut() = plugin_api::search_results_snapshot(app); + // Feed the `search_results` event, but not off the startup sentinel. + if search_was != u64::MAX { + self.search_gen_advanced = true; + } + } + } + + #[cfg(test)] + pub(super) fn process_data_requests_for_test(&mut self, app: &mut App, now: std::time::Instant) { + self.process_data_requests_at(app, now); } fn drain_http_callbacks(&mut self) { @@ -549,6 +1105,35 @@ impl ScriptEngine { } } +/// Serialize the plugin-facing snapshot for a data kind from `App` state. +fn data_value_for(lua: &Lua, kind: PluginDataKind, app: &App) -> mlua::Result { + match kind { + PluginDataKind::Playlists => lua.to_value(&plugin_api::playlists_snapshot(app)), + PluginDataKind::Queue => lua.to_value(&plugin_api::queue_snapshot(app)), + PluginDataKind::Search => lua.to_value(&plugin_api::search_results_snapshot(app)), + PluginDataKind::SavedTracks => lua.to_value(&plugin_api::saved_tracks_snapshot(app)), + PluginDataKind::SavedAlbums => lua.to_value(&plugin_api::saved_albums_snapshot(app)), + PluginDataKind::SavedShows => lua.to_value(&plugin_api::saved_shows_snapshot(app)), + PluginDataKind::RecentlyPlayed => lua.to_value(&plugin_api::recently_played_snapshot(app)), + PluginDataKind::Devices => lua.to_value(&plugin_api::device_list(app)), + PluginDataKind::Lyrics => lua.to_value(&plugin_api::lyrics_snapshot(app)), + } +} + +/// Temp-file + rename write of one storage namespace. +fn write_storage_file( + path: &Path, + map: &serde_json::Map, +) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(map).map_err(std::io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, path) +} + /// First line of an error string (Lua tracebacks are multi-line). fn first_line(s: &str) -> String { s.lines().next().unwrap_or(s).trim().to_string() diff --git a/src/infra/scripting/events.rs b/src/infra/scripting/events.rs index 720c2a47..3ee14969 100644 --- a/src/infra/scripting/events.rs +++ b/src/infra/scripting/events.rs @@ -1,8 +1,8 @@ use crate::core::app::App; -use crate::core::plugin_api::PlaybackState; +use crate::core::plugin_api::{DeviceInfo, PlaybackState}; /// Discrete events delivered to plugins (mpv model: never per-tick polling). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ScriptEvent { Start, Quit, @@ -11,11 +11,17 @@ pub enum ScriptEvent { Seek, VolumeChange, QueueChange, + ShuffleChange, + RepeatChange, + DeviceChange, + SearchResults, + /// Carries the new route name (see `plugin_api::route_name`). + RouteChange(String), } impl ScriptEvent { /// Lua-facing event name accepted by `spotatui.on`. - pub(super) fn lua_name(self) -> &'static str { + pub(super) fn lua_name(&self) -> &'static str { match self { ScriptEvent::Start => "start", ScriptEvent::Quit => "quit", @@ -24,17 +30,24 @@ impl ScriptEvent { ScriptEvent::Seek => "seek", ScriptEvent::VolumeChange => "volume_change", ScriptEvent::QueueChange => "queue_change", + ScriptEvent::ShuffleChange => "shuffle_change", + ScriptEvent::RepeatChange => "repeat_change", + ScriptEvent::DeviceChange => "device_change", + ScriptEvent::SearchResults => "search_results", + ScriptEvent::RouteChange(_) => "route_change", } } /// Events that receive the current playback table (or nil) as their single argument. - pub(super) fn passes_playback_arg(self) -> bool { + pub(super) fn passes_playback_arg(&self) -> bool { matches!( self, ScriptEvent::TrackChange | ScriptEvent::PlaybackStateChange | ScriptEvent::Seek | ScriptEvent::VolumeChange + | ScriptEvent::ShuffleChange + | ScriptEvent::RepeatChange ) } } @@ -47,6 +60,11 @@ pub(super) const VALID_EVENT_NAMES: &[&str] = &[ "seek", "volume_change", "queue_change", + "shuffle_change", + "repeat_change", + "device_change", + "search_results", + "route_change", ]; /// Seek heuristic thresholds (Connect polling can legitimately jump a few seconds forward). @@ -107,6 +125,17 @@ pub(crate) fn diff_events( events.push(ScriptEvent::PlaybackStateChange); } + // Shuffle / repeat: differ while both snapshots exist (a None -> Some + // transition is a startup refresh, not a user toggle). + if let (Some(o), Some(n)) = (old, new) { + if o.shuffle != n.shuffle { + events.push(ScriptEvent::ShuffleChange); + } + if o.repeat != n.repeat { + events.push(ScriptEvent::RepeatChange); + } + } + // Seek: same track, is_playing unchanged, progress jumped beyond tolerance. if let (Some(o), Some(n)) = (old, new) { let same_track = old_identity.is_some() && old_identity == new_identity; @@ -138,3 +167,35 @@ pub(super) fn track_identity(state: &PlaybackState) -> Option { let track = state.track.as_ref()?; track.uri.clone().or_else(|| Some(track.name.clone())) } + +/// Pure diff of non-playback engine state into events. Order is fixed and +/// testable: route_change, device_change, search_results. +pub(crate) fn diff_state_events( + old_route: &str, + new_route: &str, + old_devices: &[DeviceInfo], + new_devices: &[DeviceInfo], + search_gen_advanced: bool, +) -> Vec { + let mut events = Vec::new(); + + if old_route != new_route { + events.push(ScriptEvent::RouteChange(new_route.to_string())); + } + + let device_key = |d: &DeviceInfo| (d.id.clone(), d.name.clone(), d.is_active); + if old_devices.len() != new_devices.len() + || old_devices + .iter() + .zip(new_devices) + .any(|(a, b)| device_key(a) != device_key(b)) + { + events.push(ScriptEvent::DeviceChange); + } + + if search_gen_advanced { + events.push(ScriptEvent::SearchResults); + } + + events +} diff --git a/src/infra/scripting/shared.rs b/src/infra/scripting/shared.rs index 3748b9e0..ec0dec99 100644 --- a/src/infra/scripting/shared.rs +++ b/src/infra/scripting/shared.rs @@ -1,6 +1,11 @@ use std::cell::{Cell, RefCell}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; -use crate::core::plugin_api::{DeviceInfo, PlaybackState}; +use crate::core::app::PluginDataKind; +use crate::core::plugin_api::{ + ConfigSnapshot, DeviceInfo, PlaybackState, PlaylistInfo, QueueSnapshot, SearchResults, +}; use super::effects::ScriptEffect; @@ -13,6 +18,16 @@ pub(super) const COMMANDS_KEY: &str = "spotatui.commands"; /// Registry key for the table mapping HTTP token -> `{ plugin, callback }`. pub(super) const HTTP_CALLBACKS_KEY: &str = "spotatui.http_callbacks"; +/// Registry key for the table mapping data-request token -> `{ plugin, callback }`. +pub(super) const DATA_CALLBACKS_KEY: &str = "spotatui.data_callbacks"; + +/// Registry key for the table mapping timer token -> `{ plugin, callback }`. +pub(super) const TIMER_CALLBACKS_KEY: &str = "spotatui.timer_callbacks"; + +/// Registry key for the table mapping screen name -> +/// `{ plugin, title, on_key, on_open?, on_close? }`. +pub(super) const SCREENS_KEY: &str = "spotatui.screens"; + pub(super) type HttpResult = (u64, Result); pub(super) struct HttpResponseData { @@ -20,6 +35,43 @@ pub(super) struct HttpResponseData { pub(super) body: String, } +/// A timer armed by `spotatui.set_timeout` / `set_interval`, waiting for the +/// engine's next timer pass. Queued (rather than inserted directly into the +/// engine's active list) so arming a timer from inside a firing callback can't +/// invalidate the iteration. +pub(super) struct NewTimer { + pub(super) token: u64, + pub(super) delay: std::time::Duration, + /// `Some` for `set_interval`: the reschedule period. + pub(super) interval: Option, +} + +/// A queued `spotatui.get_*` call, drained by the engine's intake pass while +/// it holds `&mut App` (so the generation capture + dispatch is atomic). +pub(super) struct DataRequest { + pub(super) token: u64, + pub(super) kind: PluginDataKind, + /// Search query for `PluginDataKind::Search` requests. + pub(super) arg: Option, +} + +/// Stable identifier for a plugin, derived from its load name: strip a +/// trailing `.lua`, then map anything outside `[A-Za-z0-9_-]` to `_`. +/// Used for storage namespaces and anywhere a filename-safe id is needed. +pub(crate) fn plugin_id(name: &str) -> String { + let base = name.strip_suffix(".lua").unwrap_or(name); + base + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + /// State shared between the engine and the Lua closures via `Rc`. /// /// `mlua` is built without the `send` feature, so `Rc`/`RefCell` are fine here: everything @@ -31,7 +83,31 @@ pub(crate) struct ScriptShared { pub(crate) effects: RefCell>, /// Plugin name currently being loaded, so `spotatui.on` can tag its callbacks. pub(super) current_plugin: RefCell, - pub(super) next_http_token: Cell, + /// Single token counter shared by HTTP requests, data requests and timers. + pub(super) next_token: Cell, + /// Intake queue for `spotatui.get_*` data requests. + pub(super) data_requests: RefCell>, + /// Intake queue for newly-armed timers. + pub(super) new_timers: RefCell>, + /// Intake queue for `spotatui.cancel_timer` tokens. + pub(super) cancelled_timers: RefCell>, + // Caches backing the synchronous reads (`spotatui.playlists()` etc.). + // Refreshed by the engine only when the matching generation advanced. + pub(super) playlists_cache: RefCell>, + pub(super) queue_cache: RefCell, + pub(super) search_results_cache: RefCell, + /// Backs `spotatui.config()`; refreshed by the engine each tick. + pub(super) config_cache: RefCell, + /// Current route name, refreshed alongside the engine's route diffing; + /// backs the synchronous `spotatui.current_route()` read. + pub(super) current_route: RefCell, + /// The app config directory, set by `load_user_scripts`. Plugin storage + /// lives under `/plugin-data/.json`. + pub(super) config_dir: RefCell>, + /// Lazily-loaded storage namespaces: plugin_id -> flat JSON object. + pub(super) storage: RefCell>>, + /// Namespaces with unflushed writes. + pub(super) storage_dirty: RefCell>, } impl ScriptShared { @@ -41,7 +117,27 @@ impl ScriptShared { devices: RefCell::new(Vec::new()), effects: RefCell::new(Vec::new()), current_plugin: RefCell::new(String::new()), - next_http_token: Cell::new(0), + next_token: Cell::new(0), + data_requests: RefCell::new(Vec::new()), + new_timers: RefCell::new(Vec::new()), + cancelled_timers: RefCell::new(Vec::new()), + playlists_cache: RefCell::new(Vec::new()), + queue_cache: RefCell::new(QueueSnapshot::default()), + search_results_cache: RefCell::new(SearchResults::default()), + config_cache: RefCell::new(ConfigSnapshot::default()), + current_route: RefCell::new(String::new()), + config_dir: RefCell::new(None), + storage: RefCell::new(BTreeMap::new()), + storage_dirty: RefCell::new(BTreeSet::new()), } } + + /// Path of a plugin's storage file, when a config dir is known. + pub(super) fn storage_path(&self, namespace: &str) -> Option { + self + .config_dir + .borrow() + .as_ref() + .map(|dir| dir.join("plugin-data").join(format!("{namespace}.json"))) + } } diff --git a/src/infra/scripting/tests.rs b/src/infra/scripting/tests.rs index b13bf503..1dce2e5d 100644 --- a/src/infra/scripting/tests.rs +++ b/src/infra/scripting/tests.rs @@ -1374,166 +1374,1603 @@ fn diff_queue_change() { assert_eq!(events, vec![ScriptEvent::QueueChange]); } +// --- dispatch-backed actions --- + +#[cfg(test)] +mod action_tests { + use super::*; + use crate::core::app::{App, UserInfo}; + use crate::core::user_config::UserConfig; + use crate::infra::network::IoEvent; + use rspotify::model::RepeatState; + use std::sync::mpsc::channel; + use std::time::SystemTime; + + fn make_app() -> (App, std::sync::mpsc::Receiver) { + let (tx, rx) = channel(); + let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); + (app, rx) + } + + #[test] + fn set_repeat_maps_all_modes() { + for (mode, expected) in [ + ("off", RepeatState::Off), + ("track", RepeatState::Track), + ("context", RepeatState::Context), + ] { + match run_action(&format!(r#"spotatui.set_repeat("{mode}")"#)) { + ScriptEffect::Dispatch(IoEvent::Repeat(state)) => assert_eq!(state, expected), + _ => panic!("expected Dispatch(Repeat) for mode '{mode}'"), + } + } + } + + #[test] + fn set_repeat_invalid_mode_raises() { + let mut engine = ScriptEngine::new().unwrap(); + assert!(engine + .load_source("test", r#"spotatui.set_repeat("all")"#) + .is_err()); + } + + #[test] + fn cycle_repeat_queues_cycle_effect() { + assert!(matches!( + run_action("spotatui.cycle_repeat()"), + ScriptEffect::CycleRepeat + )); + } + + #[test] + fn play_uri_track_uses_uri_list() { + match run_action(r#"spotatui.play_uri("spotify:track:abc123")"#) { + ScriptEffect::Dispatch(IoEvent::StartPlayback(None, Some(uris), None)) => { + assert_eq!(uris, vec!["spotify:track:abc123".to_string()]); + } + _ => panic!("expected StartPlayback with uri list"), + } + } + + #[test] + fn play_uri_album_uses_context() { + match run_action(r#"spotatui.play_uri("spotify:album:abc123")"#) { + ScriptEffect::Dispatch(IoEvent::StartPlayback(Some(ctx), None, None)) => { + assert_eq!(ctx, "spotify:album:abc123"); + } + _ => panic!("expected StartPlayback with context"), + } + } + + #[test] + fn play_uri_garbage_raises() { + let mut engine = ScriptEngine::new().unwrap(); + assert!(engine + .load_source("test", r#"spotatui.play_uri("not-a-uri")"#) + .is_err()); + } + + #[test] + fn play_context_carries_offset() { + match run_action(r#"spotatui.play_context("spotify:playlist:p1", 5)"#) { + ScriptEffect::Dispatch(IoEvent::StartPlayback(Some(ctx), None, Some(offset))) => { + assert_eq!(ctx, "spotify:playlist:p1"); + assert_eq!(offset, 5); + } + _ => panic!("expected StartPlayback with context + offset"), + } + } + + #[test] + fn play_context_rejects_track_uri() { + let mut engine = ScriptEngine::new().unwrap(); + assert!(engine + .load_source("test", r#"spotatui.play_context("spotify:track:t1")"#) + .is_err()); + } + + #[test] + fn add_to_queue_queues_dispatch() { + match run_action(r#"spotatui.add_to_queue("spotify:track:t1")"#) { + ScriptEffect::Dispatch(IoEvent::AddItemToQueue(uri)) => { + assert_eq!(uri, "spotify:track:t1"); + } + _ => panic!("expected AddItemToQueue dispatch"), + } + } + + #[test] + fn create_playlist_with_uris() { + match run_action(r#"spotatui.create_playlist("Mix", {"spotify:track:a", "spotify:track:b"})"#) { + ScriptEffect::Dispatch(IoEvent::CreateNewPlaylist(name, uris)) => { + assert_eq!(name, "Mix"); + assert_eq!(uris.len(), 2); + } + _ => panic!("expected CreateNewPlaylist dispatch"), + } + } + + #[test] + fn create_playlist_empty_name_raises() { + let mut engine = ScriptEngine::new().unwrap(); + assert!(engine + .load_source("test", r#"spotatui.create_playlist(" ")"#) + .is_err()); + } + + #[test] + fn playlist_remove_track_requires_position() { + match run_action(r#"spotatui.playlist_remove_track("p1", "t1", 0)"#) { + ScriptEffect::Dispatch(IoEvent::RemoveTrackFromPlaylistAtPosition(p, t, pos)) => { + assert_eq!(p, "p1"); + assert_eq!(t, "t1"); + assert_eq!(pos, 0); + } + _ => panic!("expected RemoveTrackFromPlaylistAtPosition dispatch"), + } + + let mut engine = ScriptEngine::new().unwrap(); + assert!(engine + .load_source("test", r#"spotatui.playlist_remove_track("p1", "t1")"#) + .is_err()); + assert!(engine + .load_source("test", r#"spotatui.playlist_remove_track("p1", "t1", -1)"#) + .is_err()); + } + + #[test] + fn transfer_playback_does_not_persist_device() { + match run_action(r#"spotatui.transfer_playback("dev-1")"#) { + ScriptEffect::Dispatch(IoEvent::TransferPlaybackToDevice(id, persist)) => { + assert_eq!(id, "dev-1"); + assert!(!persist); + } + _ => panic!("expected TransferPlaybackToDevice dispatch"), + } + } + + #[test] + fn follow_and_save_actions_map_to_events() { + assert!(matches!( + run_action(r#"spotatui.toggle_save_track("spotify:track:t1")"#), + ScriptEffect::Dispatch(IoEvent::ToggleSaveTrack(_)) + )); + assert!(matches!( + run_action(r#"spotatui.save_album("a1")"#), + ScriptEffect::Dispatch(IoEvent::CurrentUserSavedAlbumAdd(_)) + )); + assert!(matches!( + run_action(r#"spotatui.unsave_album("a1")"#), + ScriptEffect::Dispatch(IoEvent::CurrentUserSavedAlbumDelete(_)) + )); + assert!(matches!( + run_action(r#"spotatui.save_show("s1")"#), + ScriptEffect::Dispatch(IoEvent::CurrentUserSavedShowAdd(_)) + )); + assert!(matches!( + run_action(r#"spotatui.unsave_show("s1")"#), + ScriptEffect::Dispatch(IoEvent::CurrentUserSavedShowDelete(_)) + )); + match run_action(r#"spotatui.follow_artist("ar1")"#) { + ScriptEffect::Dispatch(IoEvent::UserFollowArtists(ids)) => { + assert_eq!(ids, vec!["ar1".to_string()]); + } + _ => panic!("expected UserFollowArtists dispatch"), + } + assert!(matches!( + run_action(r#"spotatui.unfollow_artist("ar1")"#), + ScriptEffect::Dispatch(IoEvent::UserUnfollowArtists(_)) + )); + assert!(matches!( + run_action(r#"spotatui.follow_playlist("p1")"#), + ScriptEffect::Dispatch(IoEvent::UserFollowPlaylist(_, _, None)) + )); + assert!(matches!( + run_action(r#"spotatui.unfollow_playlist("p1")"#), + ScriptEffect::UnfollowPlaylist(_) + )); + } + + #[test] + fn empty_string_argument_raises() { + let mut engine = ScriptEngine::new().unwrap(); + assert!(engine + .load_source("test", r#"spotatui.add_to_queue("")"#) + .is_err()); + } + + #[test] + fn drain_dispatch_sends_io_event() { + let (mut app, rx) = make_app(); + let engine = ScriptEngine::new().unwrap(); + engine + .shared + .effects + .borrow_mut() + .push(ScriptEffect::Dispatch(IoEvent::AddItemToQueue( + "spotify:track:t1".to_string(), + ))); + engine.drain_effects(&mut app); + + match rx.try_recv() { + Ok(IoEvent::AddItemToQueue(uri)) => assert_eq!(uri, "spotify:track:t1"), + _ => panic!("expected AddItemToQueue on channel (IoEvent is not Debug)"), + } + } + + #[test] + fn drain_unfollow_playlist_resolves_current_user() { + let (mut app, rx) = make_app(); + app.user = Some(UserInfo { + id: "me-123".to_string(), + display_name: None, + country: None, + }); + let engine = ScriptEngine::new().unwrap(); + engine + .shared + .effects + .borrow_mut() + .push(ScriptEffect::UnfollowPlaylist("p1".to_string())); + engine.drain_effects(&mut app); + + match rx.try_recv() { + Ok(IoEvent::UserUnfollowPlaylist(user_id, playlist_id)) => { + assert_eq!(user_id, "me-123"); + assert_eq!(playlist_id, "p1"); + } + _ => panic!("expected UserUnfollowPlaylist on channel (IoEvent is not Debug)"), + } + } + + #[test] + fn drain_unfollow_playlist_without_user_sets_error() { + let (mut app, rx) = make_app(); + let engine = ScriptEngine::new().unwrap(); + engine + .shared + .effects + .borrow_mut() + .push(ScriptEffect::UnfollowPlaylist("p1".to_string())); + engine.drain_effects(&mut app); + + assert!(rx.try_recv().is_err(), "no IoEvent expected"); + assert!(app.status_message_is_error); + } +} + +// --- async data reads (spotatui.get_*) --- + +#[cfg(test)] +mod data_read_tests { + use super::*; + use crate::core::app::{App, LyricsStatus, PluginDataKind}; + use crate::core::plugin_api::PlaylistInfo; + use crate::core::user_config::UserConfig; + use crate::infra::network::IoEvent; + use std::sync::mpsc::channel; + use std::time::{Duration, Instant, SystemTime}; + + fn make_app() -> (App, std::sync::mpsc::Receiver) { + let (tx, rx) = channel(); + let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); + (app, rx) + } + + fn playlist(name: &str) -> PlaylistInfo { + PlaylistInfo { + uri: format!("spotify:playlist:{name}"), + name: name.to_string(), + owner: "owner".to_string(), + track_count: 3, + id: Some(name.to_string()), + owner_id: Some("owner".to_string()), + collaborative: false, + public: Some(true), + image_url: None, + } + } + + const GET_PLAYLISTS_NOTIFY: &str = r#" + spotatui.get_playlists(function(data, err) + if err then + spotatui.notify("err: " .. err, 1) + else + spotatui.notify("ok: " .. #data .. ":" .. (data[1] and data[1].name or "-"), 1) + end + end) + "#; + + #[test] + fn get_playlists_dispatches_io_event_and_resolves_on_bump() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, rx) = make_app(); + engine.load_source("reader", GET_PLAYLISTS_NOTIFY).unwrap(); + + let now = Instant::now(); + engine.process_data_requests_for_test(&mut app, now); + + match rx.try_recv() { + Ok(IoEvent::GetPlaylists) => {} + _ => panic!("expected GetPlaylists dispatch (IoEvent is not Debug)"), + } + // Not resolved yet: generation unchanged. + assert!(drain(&engine).is_empty()); + + // Simulate the network write + bump, then the next engine pass resolves. + app.all_playlists = vec![playlist("Jams")]; + app.plugin_data_generations.bump(PluginDataKind::Playlists); + engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(500)); + + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "ok: 1:Jams"), + _ => panic!("expected data callback notify"), + } + } + + #[test] + fn data_request_times_out_with_distinct_error() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine.load_source("reader", GET_PLAYLISTS_NOTIFY).unwrap(); + + let now = Instant::now(); + engine.process_data_requests_for_test(&mut app, now); + assert!(drain(&engine).is_empty()); + + // Never bump; jump past the deadline. + engine.process_data_requests_for_test(&mut app, now + Duration::from_secs(16)); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "err: request timed out"), + _ => panic!("expected timeout notify"), + } + } + + #[test] + fn erroring_data_callback_queues_notify_error_and_is_one_shot() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine + .load_source( + "bad_reader", + r#"spotatui.get_playlists(function(data, err) error("data boom") end)"#, + ) + .unwrap(); + + let now = Instant::now(); + engine.process_data_requests_for_test(&mut app, now); + app.plugin_data_generations.bump(PluginDataKind::Playlists); + engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(1)); + + match one(&engine) { + ScriptEffect::NotifyError(msg, 6) => { + assert!(msg.contains("bad_reader")); + assert!(msg.contains("data boom")); + } + _ => panic!("expected data callback error notify"), + } + assert!(engine.shared.current_plugin.borrow().is_empty()); + + // The slot is cleared: another bump must not re-fire the callback. + app.plugin_data_generations.bump(PluginDataKind::Playlists); + engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(2)); + assert!(drain(&engine).is_empty()); + } + + #[test] + fn two_same_kind_requests_both_resolve_on_one_bump() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine + .load_source( + "reader", + r#" + spotatui.get_playlists(function(data, err) spotatui.notify("first", 1) end) + spotatui.get_playlists(function(data, err) spotatui.notify("second", 1) end) + "#, + ) + .unwrap(); + + let now = Instant::now(); + engine.process_data_requests_for_test(&mut app, now); + app.plugin_data_generations.bump(PluginDataKind::Playlists); + engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(1)); + + let effects = drain(&engine); + let messages: Vec = effects + .into_iter() + .filter_map(|e| match e { + ScriptEffect::Notify(m, _) => Some(m), + _ => None, + }) + .collect(); + assert_eq!(messages, vec!["first".to_string(), "second".to_string()]); + } + + #[test] + fn get_search_results_dispatches_query() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, rx) = make_app(); + engine + .load_source( + "searcher", + r#"spotatui.get_search_results("daft punk", function(data, err) end)"#, + ) + .unwrap(); + + engine.process_data_requests_for_test(&mut app, Instant::now()); + match rx.try_recv() { + Ok(IoEvent::GetSearchResults(q, _)) => assert_eq!(q, "daft punk"), + _ => panic!("expected GetSearchResults dispatch (IoEvent is not Debug)"), + } + } + + #[test] + fn get_search_results_empty_query_raises() { + let mut engine = ScriptEngine::new().unwrap(); + let result = engine.load_source( + "searcher", + r#"spotatui.get_search_results("", function() end)"#, + ); + assert!(result.is_err()); + } + + #[test] + fn get_lyrics_terminal_status_delivers_immediately_without_dispatch() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, rx) = make_app(); + app.lyrics_status = LyricsStatus::Found; + app.lyrics = Some(vec![(1500, "hello lyrics".to_string())]); + + engine + .load_source( + "lyricist", + r#" + spotatui.get_lyrics(function(data, err) + spotatui.notify(data.status .. ":" .. data.lines[1].text .. ":" .. data.lines[1].time_ms, 1) + end) + "#, + ) + .unwrap(); + + engine.process_data_requests_for_test(&mut app, Instant::now()); + assert!( + rx.try_recv().is_err(), + "lyrics must not dispatch an IoEvent" + ); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "found:hello lyrics:1500"), + _ => panic!("expected immediate lyrics notify"), + } + } + + #[test] + fn get_lyrics_pending_resolves_when_fetch_completes() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, rx) = make_app(); + app.lyrics_status = LyricsStatus::Loading; + + engine + .load_source( + "lyricist", + r#"spotatui.get_lyrics(function(data, err) spotatui.notify(data.status, 1) end)"#, + ) + .unwrap(); + + let now = Instant::now(); + engine.process_data_requests_for_test(&mut app, now); + assert!( + rx.try_recv().is_err(), + "lyrics must not dispatch an IoEvent" + ); + assert!(drain(&engine).is_empty()); + + app.lyrics_status = LyricsStatus::NotFound; + app.plugin_data_generations.bump(PluginDataKind::Lyrics); + engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(1)); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "not_found"), + _ => panic!("expected pending lyrics notify"), + } + } + + #[test] + fn get_queue_resolves_with_flattened_items() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine + .load_source( + "queuer", + r#" + spotatui.get_queue(function(data, err) + local current = data.currently_playing and data.currently_playing.track.name or "-" + spotatui.notify(current .. ":" .. #data.items .. ":" .. data.items[1].kind, 1) + end) + "#, + ) + .unwrap(); + + let now = Instant::now(); + engine.process_data_requests_for_test(&mut app, now); + + app.queue = Some(crate::core::app::QueueState { + currently_playing: Some(crate::core::plugin_api::PlayableInfo::Track(track( + "uri:now", + "Now Playing", + ))), + queue: vec![crate::core::plugin_api::PlayableInfo::Track(track( + "uri:next", "Next Up", + ))], + }); + app.plugin_data_generations.bump(PluginDataKind::Queue); + engine.process_data_requests_for_test(&mut app, now + Duration::from_millis(1)); + + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "Now Playing:1:track"), + _ => panic!("expected queue notify"), + } + } + + #[test] + fn cached_playlists_read_refreshes_on_generation_change() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + + // First tick: sentinel forces a refresh of the (empty) snapshot. + engine.on_tick(&mut app); + engine + .load_source( + "sync", + r#"spotatui.notify("n=" .. #spotatui.playlists(), 1)"#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "n=0"), + _ => panic!("expected empty cached read"), + } + + // Data lands without a bump: the cache must NOT refresh. + app.all_playlists = vec![playlist("A")]; + engine.on_tick(&mut app); + engine + .load_source( + "sync2", + r#"spotatui.notify("n=" .. #spotatui.playlists(), 1)"#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "n=0"), + _ => panic!("expected stale cached read before bump"), + } + + // After the bump the next tick refreshes. + app.plugin_data_generations.bump(PluginDataKind::Playlists); + engine.on_tick(&mut app); + engine + .load_source( + "sync3", + r#"spotatui.notify("n=" .. #spotatui.playlists(), 1)"#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "n=1"), + _ => panic!("expected refreshed cached read"), + } + } +} + +// --- timers --- + +#[cfg(test)] +mod timer_tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + fn set_timeout_fires_once() { + let mut engine = ScriptEngine::new().unwrap(); + engine + .load_source( + "timer", + r#"spotatui.set_timeout(100, function() spotatui.notify("fired", 1) end)"#, + ) + .unwrap(); + + let now = Instant::now(); + engine.process_timers_for_test(now); // arms; not due yet + assert!(drain(&engine).is_empty()); + + engine.process_timers_for_test(now + Duration::from_millis(150)); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "fired"), + _ => panic!("expected timeout notify"), + } + + // One-shot: never fires again. + engine.process_timers_for_test(now + Duration::from_millis(500)); + assert!(drain(&engine).is_empty()); + } + + #[test] + fn set_interval_repeats_and_skips_missed_periods() { + let mut engine = ScriptEngine::new().unwrap(); + engine + .load_source( + "timer", + r#"spotatui.set_interval(100, function() spotatui.notify("tick", 1) end)"#, + ) + .unwrap(); + + let now = Instant::now(); + engine.process_timers_for_test(now); // arm + engine.process_timers_for_test(now + Duration::from_millis(150)); + assert_eq!(drain(&engine).len(), 1); + + engine.process_timers_for_test(now + Duration::from_millis(260)); + assert_eq!(drain(&engine).len(), 1); + + // A long stall fires ONCE (no catch-up burst), rescheduled from `now`. + engine.process_timers_for_test(now + Duration::from_millis(2000)); + assert_eq!(drain(&engine).len(), 1); + } + + #[test] + fn cancel_timer_before_due_prevents_firing() { + let mut engine = ScriptEngine::new().unwrap(); + engine + .load_source( + "timer", + r#" + local h = spotatui.set_timeout(100, function() spotatui.notify("nope", 1) end) + spotatui.cancel_timer(h) + "#, + ) + .unwrap(); + + let now = Instant::now(); + engine.process_timers_for_test(now); + engine.process_timers_for_test(now + Duration::from_millis(500)); + assert!(drain(&engine).is_empty()); + } + + #[test] + fn erroring_interval_is_removed_with_notify_error() { + let mut engine = ScriptEngine::new().unwrap(); + engine + .load_source( + "bad_timer", + r#"spotatui.set_interval(100, function() error("interval boom") end)"#, + ) + .unwrap(); + + let now = Instant::now(); + engine.process_timers_for_test(now); + engine.process_timers_for_test(now + Duration::from_millis(150)); + match one(&engine) { + ScriptEffect::NotifyError(msg, 6) => { + assert!(msg.contains("bad_timer")); + assert!(msg.contains("interval boom")); + } + _ => panic!("expected timer error notify"), + } + + // One strike: the interval is gone. + engine.process_timers_for_test(now + Duration::from_millis(1000)); + assert!(drain(&engine).is_empty()); + } + + #[test] + fn timer_set_inside_event_handler_fires() { + let mut engine = ScriptEngine::new().unwrap(); + engine + .load_source( + "timer", + r#" + spotatui.on("start", function() + spotatui.set_timeout(50, function() spotatui.notify("deferred", 1) end) + end) + "#, + ) + .unwrap(); + + engine.emit(ScriptEvent::Start); + assert!(drain(&engine).is_empty()); + + let now = Instant::now(); + engine.process_timers_for_test(now); // arms next pass + engine.process_timers_for_test(now + Duration::from_millis(100)); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "deferred"), + _ => panic!("expected deferred timer notify"), + } + } + + #[test] + fn timer_set_inside_timer_callback_fires_next_pass() { + let mut engine = ScriptEngine::new().unwrap(); + engine + .load_source( + "timer", + r#" + spotatui.set_timeout(10, function() + spotatui.set_timeout(10, function() spotatui.notify("nested", 1) end) + end) + "#, + ) + .unwrap(); + + let now = Instant::now(); + engine.process_timers_for_test(now); + engine.process_timers_for_test(now + Duration::from_millis(20)); + assert!(drain(&engine).is_empty(), "outer fired, nested only queued"); + // The nested timer arms on the NEXT pass; its delay counts from there. + engine.process_timers_for_test(now + Duration::from_millis(50)); + assert!(drain(&engine).is_empty(), "nested armed, not yet due"); + engine.process_timers_for_test(now + Duration::from_millis(100)); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "nested"), + _ => panic!("expected nested timer notify"), + } + } + + #[test] + fn negative_ms_raises() { + let mut engine = ScriptEngine::new().unwrap(); + assert!(engine + .load_source("timer", r#"spotatui.set_timeout(-5, function() end)"#) + .is_err()); + assert!(engine + .load_source("timer", r#"spotatui.set_interval(0, function() end)"#) + .is_err()); + } +} + +// --- plugin_id sanitizer --- + +#[test] +fn plugin_id_strips_lua_suffix_and_sanitizes() { + use super::shared::plugin_id; + assert_eq!(plugin_id("stats.lua"), "stats"); + assert_eq!(plugin_id("my-plugin"), "my-plugin"); + assert_eq!(plugin_id("weird name!.lua"), "weird_name_"); + assert_eq!(plugin_id("dir.plugin"), "dir_plugin"); + assert_eq!(plugin_id("под.lua"), "___"); +} + // --- directory plugin loading (spotatui plugin add) --- -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; + +static TMP_COUNTER: AtomicU32 = AtomicU32::new(0); + +/// Fresh, unique temp directory to act as a config dir. +fn temp_config_dir() -> PathBuf { + let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("spotatui_lua_load_{}_{}", std::process::id(), n)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn write_file(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); +} + +/// True if any queued effect is a successful Notify carrying `needle`. +fn has_notify(engine: &ScriptEngine, needle: &str) -> bool { + drain(engine).into_iter().any(|e| match e { + ScriptEffect::Notify(msg, _) => msg.contains(needle), + _ => false, + }) +} + +#[test] +fn dir_plugin_main_lua_is_loaded() { + let cfg = temp_config_dir(); + write_file( + &cfg.join("plugins").join("foo").join("main.lua"), + r#"spotatui.notify("loaded foo", 1)"#, + ); + + let mut engine = ScriptEngine::new().unwrap(); + let loaded = engine.load_user_scripts(&cfg); + + assert_eq!(loaded, 1); + assert!(has_notify(&engine, "loaded foo")); + std::fs::remove_dir_all(&cfg).unwrap(); +} + +#[test] +fn dir_plugin_init_lua_is_used_as_fallback() { + let cfg = temp_config_dir(); + write_file( + &cfg.join("plugins").join("bar").join("init.lua"), + r#"spotatui.notify("loaded bar", 1)"#, + ); + + let mut engine = ScriptEngine::new().unwrap(); + let loaded = engine.load_user_scripts(&cfg); + + assert_eq!(loaded, 1); + assert!(has_notify(&engine, "loaded bar")); + std::fs::remove_dir_all(&cfg).unwrap(); +} + +#[test] +fn dir_plugin_without_entry_point_is_skipped() { + let cfg = temp_config_dir(); + // Directory exists but has no main.lua/init.lua, plus a hidden dir that must be ignored. + std::fs::create_dir_all(cfg.join("plugins").join("empty")).unwrap(); + write_file( + &cfg.join("plugins").join(".hidden").join("main.lua"), + r#"spotatui.notify("should not load", 1)"#, + ); + + let mut engine = ScriptEngine::new().unwrap(); + let loaded = engine.load_user_scripts(&cfg); + + assert_eq!(loaded, 0); + assert!(drain(&engine).is_empty()); + std::fs::remove_dir_all(&cfg).unwrap(); +} + +#[test] +fn dir_plugin_can_require_sibling_module() { + let cfg = temp_config_dir(); + let plugin = cfg.join("plugins").join("qux"); + write_file( + &plugin.join("helper.lua"), + r#"return { msg = "from helper" }"#, + ); + write_file( + &plugin.join("main.lua"), + r#" + local helper = require("helper") + spotatui.notify(helper.msg, 1) + "#, + ); + + let mut engine = ScriptEngine::new().unwrap(); + let loaded = engine.load_user_scripts(&cfg); + + // A successful load proves `require` resolved the sibling module via package.path. + assert_eq!(loaded, 1); + assert!(has_notify(&engine, "from helper")); + std::fs::remove_dir_all(&cfg).unwrap(); +} + +#[test] +fn single_file_and_directory_plugins_both_load() { + let cfg = temp_config_dir(); + write_file( + &cfg.join("plugins").join("flat.lua"), + r#"spotatui.notify("flat", 1)"#, + ); + write_file( + &cfg.join("plugins").join("nested").join("main.lua"), + r#"spotatui.notify("nested", 1)"#, + ); + + let mut engine = ScriptEngine::new().unwrap(); + let loaded = engine.load_user_scripts(&cfg); + + assert_eq!(loaded, 2); + std::fs::remove_dir_all(&cfg).unwrap(); +} + +#[test] +fn directory_named_with_lua_extension_loads_once_without_error() { + // A directory literally named `weird.lua` must be treated only as a directory plugin, + // not also fed to the single-file path (which would raise a spurious load error). + let cfg = temp_config_dir(); + write_file( + &cfg.join("plugins").join("weird.lua").join("main.lua"), + r#"spotatui.notify("weird ok", 1)"#, + ); + + let mut engine = ScriptEngine::new().unwrap(); + let loaded = engine.load_user_scripts(&cfg); + + assert_eq!(loaded, 1); + let effects = drain(&engine); + assert!( + !effects + .iter() + .any(|e| matches!(e, ScriptEffect::NotifyError(_, _))), + "a .lua-named directory must not produce a load error" + ); + std::fs::remove_dir_all(&cfg).unwrap(); +} + +#[test] +fn hidden_single_file_plugin_is_skipped() { + // Hidden files (e.g. macOS `._foo.lua` cruft) must be ignored, matching the directory branch. + let cfg = temp_config_dir(); + write_file( + &cfg.join("plugins").join(".secret.lua"), + r#"spotatui.notify("should not load", 1)"#, + ); + + let mut engine = ScriptEngine::new().unwrap(); + let loaded = engine.load_user_scripts(&cfg); + + assert_eq!(loaded, 0); + assert!(drain(&engine).is_empty()); + std::fs::remove_dir_all(&cfg).unwrap(); +} + +// --- plugin storage --- + +#[cfg(test)] +mod storage_tests { + use super::*; + + /// Engine with a temp config dir registered (empty plugins dir is fine). + fn engine_with_dir(cfg: &Path) -> ScriptEngine { + let mut engine = ScriptEngine::new().unwrap(); + engine.load_user_scripts(cfg); + engine + } + + #[test] + fn storage_round_trip_within_one_engine() { + let cfg = temp_config_dir(); + let mut engine = engine_with_dir(&cfg); + engine + .load_source( + "a.lua", + r#" + spotatui.storage_set("count", 42) + spotatui.storage_set("nested", { x = 1, tags = {"a", "b"} }) + spotatui.notify(spotatui.storage_get("count") .. ":" .. spotatui.storage_get("nested").tags[2], 1) + "#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "42:b"), + _ => panic!("expected storage round-trip notify"), + } + std::fs::remove_dir_all(&cfg).unwrap(); + } + + #[test] + fn storage_flushes_and_reloads_in_new_engine() { + let cfg = temp_config_dir(); + { + let mut engine = engine_with_dir(&cfg); + engine + .load_source("a.lua", r#"spotatui.storage_set("song", "Nightcall")"#) + .unwrap(); + engine.flush_storage(true); + } + + // The file exists under the sanitized plugin id. + let file = cfg.join("plugin-data").join("a.json"); + assert!(file.is_file(), "expected {}", file.display()); + + let mut engine = engine_with_dir(&cfg); + engine + .load_source( + "a.lua", + r#"spotatui.notify(spotatui.storage_get("song") or "missing", 1)"#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "Nightcall"), + _ => panic!("expected persisted value notify"), + } + std::fs::remove_dir_all(&cfg).unwrap(); + } + + #[test] + fn storage_set_nil_deletes_key() { + let cfg = temp_config_dir(); + let mut engine = engine_with_dir(&cfg); + engine + .load_source( + "a.lua", + r#" + spotatui.storage_set("gone", "soon") + spotatui.storage_set("gone", nil) + spotatui.notify(tostring(spotatui.storage_get("gone")) .. ":" .. #spotatui.storage_keys(), 1) + "#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "nil:0"), + _ => panic!("expected nil-delete notify"), + } + std::fs::remove_dir_all(&cfg).unwrap(); + } + + #[test] + fn storage_remove_and_keys() { + let cfg = temp_config_dir(); + let mut engine = engine_with_dir(&cfg); + engine + .load_source( + "a.lua", + r#" + spotatui.storage_set("one", 1) + spotatui.storage_set("two", 2) + spotatui.storage_remove("one") + local keys = spotatui.storage_keys() + spotatui.notify(#keys .. ":" .. keys[1], 1) + "#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "1:two"), + _ => panic!("expected remove/keys notify"), + } + std::fs::remove_dir_all(&cfg).unwrap(); + } + + #[test] + fn storage_set_function_value_raises() { + let cfg = temp_config_dir(); + let mut engine = engine_with_dir(&cfg); + let result = engine.load_source("a.lua", r#"spotatui.storage_set("f", function() end)"#); + assert!(result.is_err(), "functions must not be storable"); + std::fs::remove_dir_all(&cfg).unwrap(); + } + + #[test] + fn storage_isolated_per_plugin() { + let cfg = temp_config_dir(); + { + let mut engine = engine_with_dir(&cfg); + engine + .load_source("a.lua", r#"spotatui.storage_set("who", "plugin a")"#) + .unwrap(); + engine + .load_source("b", r#"spotatui.storage_set("who", "plugin b")"#) + .unwrap(); + engine.flush_storage(true); + } + + assert!(cfg.join("plugin-data").join("a.json").is_file()); + assert!(cfg.join("plugin-data").join("b.json").is_file()); + + let mut engine = engine_with_dir(&cfg); + engine + .load_source("b", r#"spotatui.notify(spotatui.storage_get("who"), 1)"#) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "plugin b"), + _ => panic!("expected isolated namespace notify"), + } + std::fs::remove_dir_all(&cfg).unwrap(); + } + + #[test] + fn corrupt_storage_file_starts_empty() { + let cfg = temp_config_dir(); + write_file(&cfg.join("plugin-data").join("a.json"), "{not json"); + + let mut engine = engine_with_dir(&cfg); + engine + .load_source( + "a.lua", + r#"spotatui.notify(tostring(spotatui.storage_get("anything")), 1)"#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "nil"), + _ => panic!("expected empty-after-corruption notify"), + } + std::fs::remove_dir_all(&cfg).unwrap(); + } + + #[test] + fn storage_outside_plugin_context_raises() { + // current_plugin is empty outside load/callback paths; simulate by loading + // a chunk with an empty plugin name. + let cfg = temp_config_dir(); + let mut engine = engine_with_dir(&cfg); + let result = engine.load_source("", r#"spotatui.storage_get("x")"#); + assert!(result.is_err()); + std::fs::remove_dir_all(&cfg).unwrap(); + } +} + +// --- state events (route/device/search) + shuffle/repeat diffs --- + +#[cfg(test)] +mod state_event_tests { + use super::*; + use crate::core::app::{ActiveBlock, App, RouteId}; + use crate::core::plugin_api::DeviceInfo; + use crate::core::user_config::UserConfig; + use crate::infra::network::IoEvent; + use crate::infra::scripting::events::diff_state_events; + use std::sync::mpsc::channel; + use std::time::SystemTime; + + fn make_app() -> (App, std::sync::mpsc::Receiver) { + let (tx, rx) = channel(); + let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); + (app, rx) + } + + fn device(id: &str, active: bool) -> DeviceInfo { + DeviceInfo { + id: Some(id.to_string()), + name: id.to_string(), + kind: "computer".to_string(), + is_active: active, + volume_percent: Some(50), + } + } + + #[test] + fn diff_shuffle_change() { + let old = Some(playback(Some(track("uri:1", "A")), true, 0)); + let mut new_pb = playback(Some(track("uri:1", "A")), true, 0); + new_pb.shuffle = true; + let q = Some(vec![]); + let events = diff_events(&old, &q, &Some(new_pb), &q); + assert_eq!(events, vec![ScriptEvent::ShuffleChange]); + } + + #[test] + fn diff_repeat_change() { + let old = Some(playback(Some(track("uri:1", "A")), true, 0)); + let mut new_pb = playback(Some(track("uri:1", "A")), true, 0); + new_pb.repeat = "track".to_string(); + let q = Some(vec![]); + let events = diff_events(&old, &q, &Some(new_pb), &q); + assert_eq!(events, vec![ScriptEvent::RepeatChange]); + } + + #[test] + fn diff_none_to_some_is_not_shuffle_or_repeat_change() { + let mut new_pb = playback(Some(track("uri:1", "A")), false, 0); + new_pb.shuffle = true; + new_pb.repeat = "context".to_string(); + let q = Some(vec![]); + let events = diff_events(&None, &q, &Some(new_pb), &q); + assert!(!events.contains(&ScriptEvent::ShuffleChange)); + assert!(!events.contains(&ScriptEvent::RepeatChange)); + } + + #[test] + fn diff_state_route_change_carries_new_name() { + let events = diff_state_events("home", "queue", &[], &[], false); + assert_eq!(events, vec![ScriptEvent::RouteChange("queue".to_string())]); + } + + #[test] + fn diff_state_device_change_on_active_flip() { + let old = [device("a", true), device("b", false)]; + let new = [device("a", false), device("b", true)]; + let events = diff_state_events("home", "home", &old, &new, false); + assert_eq!(events, vec![ScriptEvent::DeviceChange]); + } + + #[test] + fn diff_state_no_events_when_identical() { + let devs = [device("a", true)]; + assert!(diff_state_events("home", "home", &devs, &devs, false).is_empty()); + } + + #[test] + fn diff_state_search_advance() { + let events = diff_state_events("home", "home", &[], &[], true); + assert_eq!(events, vec![ScriptEvent::SearchResults]); + } + + #[test] + fn route_change_event_fires_after_key_navigation() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine + .load_source( + "router", + r#"spotatui.on("route_change", function(ev) spotatui.notify("now at " .. ev.name, 1) end)"#, + ) + .unwrap(); + + // Baseline the route (as on_start would), then navigate like a key handler. + engine.run_pending_commands(&mut app); + let _ = drain(&engine); + app.push_navigation_stack(RouteId::Queue, ActiveBlock::Queue); + engine.run_pending_commands(&mut app); + + assert_eq!(app.status_message.as_deref(), Some("now at queue")); + } + + #[test] + fn current_route_sync_read_tracks_navigation() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + app.push_navigation_stack(RouteId::Settings, ActiveBlock::Settings); + engine.on_tick(&mut app); + engine + .load_source("reader", r#"spotatui.notify(spotatui.current_route(), 1)"#) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "settings"), + _ => panic!("expected current_route notify"), + } + } + + #[test] + fn navigate_unknown_target_raises() { + let mut engine = ScriptEngine::new().unwrap(); + assert!(engine + .load_source("nav", r#"spotatui.navigate("narnia")"#) + .is_err()); + } + + #[test] + fn navigate_queue_pushes_route_and_fetches() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, rx) = make_app(); + engine + .load_source("nav", r#"spotatui.navigate("queue")"#) + .unwrap(); + engine.drain_effects(&mut app); + + assert_eq!(app.get_current_route().id, RouteId::Queue); + match rx.try_recv() { + Ok(IoEvent::GetQueue) => {} + _ => panic!("expected GetQueue dispatch (IoEvent is not Debug)"), + } + } + + #[test] + fn back_pops_navigation_stack() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + app.push_navigation_stack(RouteId::Queue, ActiveBlock::Queue); + engine.load_source("nav", r#"spotatui.back()"#).unwrap(); + engine.drain_effects(&mut app); + assert_eq!(app.get_current_route().id, RouteId::Home); + } + + #[test] + fn device_change_event_fires_on_active_device_swap() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine + .load_source( + "dev", + r#"spotatui.on("device_change", function() spotatui.notify("devices moved", 1) end)"#, + ) + .unwrap(); + + engine.on_tick(&mut app); // baseline: no devices + let _ = drain(&engine); + + #[allow(deprecated)] + let payload = rspotify::model::device::DevicePayload { + devices: vec![rspotify::model::Device { + id: Some("dev-1".to_string()), + is_active: true, + is_private_session: false, + is_restricted: false, + name: "Desk".to_string(), + _type: rspotify::model::DeviceType::Computer, + volume_percent: Some(30), + }], + }; + app.devices = Some(payload); + engine.on_tick(&mut app); + assert_eq!(app.status_message.as_deref(), Some("devices moved")); + } +} + +// --- custom plugin screens --- + +#[cfg(test)] +mod screen_tests { + use super::*; + use crate::core::app::{ActiveBlock, App, RouteId}; + use crate::core::plugin_api::PluginWidget; + use crate::core::user_config::UserConfig; + use crate::infra::network::IoEvent; + use std::sync::mpsc::channel; + use std::time::SystemTime; + + fn make_app() -> (App, std::sync::mpsc::Receiver) { + let (tx, rx) = channel(); + let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); + (app, rx) + } + + const STATS_SCREEN: &str = r#" + spotatui.register_screen("stats", { + title = "Stats", + on_key = function(key) + spotatui.notify("key: " .. key, 1) + end, + on_open = function() spotatui.notify("opened", 1) end, + on_close = function() spotatui.notify("closed", 1) end, + }) + "#; + + #[test] + fn show_screen_pushes_route_and_close_pops() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine.load_source("stats.lua", STATS_SCREEN).unwrap(); + engine + .load_source("stats.lua", r#"spotatui.show_screen("stats")"#) + .unwrap(); + engine.drain_effects(&mut app); + + assert_eq!( + app.get_current_route().id, + RouteId::PluginScreen("stats".to_string()) + ); + assert_eq!( + app.get_current_route().active_block, + ActiveBlock::PluginScreen + ); -static TMP_COUNTER: AtomicU32 = AtomicU32::new(0); + engine + .load_source("stats.lua", r#"spotatui.close_screen("stats")"#) + .unwrap(); + engine.drain_effects(&mut app); + assert!(!matches!( + app.get_current_route().id, + RouteId::PluginScreen(_) + )); + } -/// Fresh, unique temp directory to act as a config dir. -fn temp_config_dir() -> PathBuf { - let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("spotatui_lua_load_{}_{}", std::process::id(), n)); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir -} + #[test] + fn set_screen_publishes_widgets() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine.load_source("stats.lua", STATS_SCREEN).unwrap(); + engine + .load_source( + "stats.lua", + r#" + spotatui.set_screen("stats", { + { type = "paragraph", lines = {"hello", { text = "styled", bold = true }}, height = 4 }, + { type = "list", title = "Songs", items = {"one", "two"}, selected = 2 }, + { type = "gauge", ratio = 1.7, label = "70%" }, + }) + "#, + ) + .unwrap(); + engine.drain_effects(&mut app); -fn write_file(path: &Path, contents: &str) { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, contents).unwrap(); -} + let content = app.plugin_screens.get("stats").expect("content published"); + assert_eq!(content.title, "Stats"); + assert_eq!(content.widgets.len(), 3); + match &content.widgets[0] { + PluginWidget::Paragraph { lines, height } => { + assert_eq!(lines.len(), 2); + assert!(lines[1].bold); + assert_eq!(*height, Some(4)); + } + _ => panic!("expected paragraph widget"), + } + match &content.widgets[1] { + PluginWidget::List { + title, + items, + selected, + .. + } => { + assert_eq!(title.as_deref(), Some("Songs")); + assert_eq!(items.len(), 2); + // Lua-side selected is 1-based; stored 0-based. + assert_eq!(*selected, Some(1)); + } + _ => panic!("expected list widget"), + } + match &content.widgets[2] { + PluginWidget::Gauge { ratio, label } => { + assert_eq!(*ratio, 1.0, "ratio must be clamped"); + assert_eq!(label.as_deref(), Some("70%")); + } + _ => panic!("expected gauge widget"), + } + } -/// True if any queued effect is a successful Notify carrying `needle`. -fn has_notify(engine: &ScriptEngine, needle: &str) -> bool { - drain(engine).into_iter().any(|e| match e { - ScriptEffect::Notify(msg, _) => msg.contains(needle), - _ => false, - }) -} + #[test] + fn set_screen_rejects_unknown_widget_type_and_bad_selected() { + let mut engine = ScriptEngine::new().unwrap(); + engine.load_source("stats.lua", STATS_SCREEN).unwrap(); + assert!(engine + .load_source( + "stats.lua", + r#"spotatui.set_screen("stats", {{ type = "table" }})"# + ) + .is_err()); + assert!(engine + .load_source( + "stats.lua", + r#"spotatui.set_screen("stats", {{ type = "list", items = {"x"}, selected = 0 }})"# + ) + .is_err()); + } -#[test] -fn dir_plugin_main_lua_is_loaded() { - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join("foo").join("main.lua"), - r#"spotatui.notify("loaded foo", 1)"#, - ); + #[test] + fn set_screen_unregistered_or_foreign_raises() { + let mut engine = ScriptEngine::new().unwrap(); + engine.load_source("stats.lua", STATS_SCREEN).unwrap(); - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); + // Not registered at all. + assert!(engine + .load_source("stats.lua", r#"spotatui.set_screen("nope", {})"#) + .is_err()); - assert_eq!(loaded, 1); - assert!(has_notify(&engine, "loaded foo")); - std::fs::remove_dir_all(&cfg).unwrap(); -} + // Registered, but owned by another plugin. + let err = engine + .load_source("intruder.lua", r#"spotatui.show_screen("stats")"#) + .unwrap_err() + .to_string(); + assert!(err.contains("belongs to plugin"), "got: {err}"); + } -#[test] -fn dir_plugin_init_lua_is_used_as_fallback() { - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join("bar").join("init.lua"), - r#"spotatui.notify("loaded bar", 1)"#, - ); + #[test] + fn duplicate_screen_name_raises() { + let mut engine = ScriptEngine::new().unwrap(); + engine.load_source("stats.lua", STATS_SCREEN).unwrap(); + assert!(engine + .load_source( + "other.lua", + r#"spotatui.register_screen("stats", { on_key = function() end })"# + ) + .is_err()); + } - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); + #[test] + fn register_screen_requires_on_key() { + let mut engine = ScriptEngine::new().unwrap(); + assert!(engine + .load_source( + "stats.lua", + r#"spotatui.register_screen("stats", { title = "T" })"# + ) + .is_err()); + } - assert_eq!(loaded, 1); - assert!(has_notify(&engine, "loaded bar")); - std::fs::remove_dir_all(&cfg).unwrap(); -} + #[test] + fn pending_screen_key_reaches_on_key() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine.load_source("stats.lua", STATS_SCREEN).unwrap(); -#[test] -fn dir_plugin_without_entry_point_is_skipped() { - let cfg = temp_config_dir(); - // Directory exists but has no main.lua/init.lua, plus a hidden dir that must be ignored. - std::fs::create_dir_all(cfg.join("plugins").join("empty")).unwrap(); - write_file( - &cfg.join("plugins").join(".hidden").join("main.lua"), - r#"spotatui.notify("should not load", 1)"#, - ); + app + .pending_plugin_screen_keys + .push(("stats".to_string(), "ctrl-x".to_string())); + engine.run_pending_commands(&mut app); - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); + assert_eq!(app.status_message.as_deref(), Some("key: ctrl-x")); + } - assert_eq!(loaded, 0); - assert!(drain(&engine).is_empty()); - std::fs::remove_dir_all(&cfg).unwrap(); -} + #[test] + fn erroring_on_key_is_one_strike() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine + .load_source( + "bad.lua", + r#"spotatui.register_screen("boom", { on_key = function() error("kaput") end })"#, + ) + .unwrap(); -#[test] -fn dir_plugin_can_require_sibling_module() { - let cfg = temp_config_dir(); - let plugin = cfg.join("plugins").join("qux"); - write_file( - &plugin.join("helper.lua"), - r#"return { msg = "from helper" }"#, - ); - write_file( - &plugin.join("main.lua"), - r#" - local helper = require("helper") - spotatui.notify(helper.msg, 1) - "#, - ); + app + .pending_plugin_screen_keys + .push(("boom".to_string(), "a".to_string())); + engine.run_pending_commands(&mut app); + assert!(app.status_message_is_error); + assert!(app + .status_message + .as_deref() + .unwrap_or("") + .contains("kaput")); - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); + // Second key: the erroring on_key was removed, nothing fires. + app.status_message = None; + app.status_message_is_error = false; + app + .pending_plugin_screen_keys + .push(("boom".to_string(), "a".to_string())); + engine.run_pending_commands(&mut app); + assert!(app.status_message.is_none()); + } - // A successful load proves `require` resolved the sibling module via package.path. - assert_eq!(loaded, 1); - assert!(has_notify(&engine, "from helper")); - std::fs::remove_dir_all(&cfg).unwrap(); -} + #[test] + fn on_open_and_on_close_fire_on_route_transitions() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + engine.load_source("stats.lua", STATS_SCREEN).unwrap(); -#[test] -fn single_file_and_directory_plugins_both_load() { - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join("flat.lua"), - r#"spotatui.notify("flat", 1)"#, - ); - write_file( - &cfg.join("plugins").join("nested").join("main.lua"), - r#"spotatui.notify("nested", 1)"#, - ); + // Baseline route tracking. + engine.run_pending_commands(&mut app); - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); + engine + .load_source("stats.lua", r#"spotatui.show_screen("stats")"#) + .unwrap(); + engine.drain_effects(&mut app); + engine.run_pending_commands(&mut app); + assert_eq!(app.status_message.as_deref(), Some("opened")); - assert_eq!(loaded, 2); - std::fs::remove_dir_all(&cfg).unwrap(); + app.pop_navigation_stack(); + engine.run_pending_commands(&mut app); + assert_eq!(app.status_message.as_deref(), Some("closed")); + } + + #[test] + fn route_name_for_plugin_screen_is_prefixed() { + use crate::core::app::Route; + let route = Route { + id: RouteId::PluginScreen("stats".to_string()), + active_block: ActiveBlock::PluginScreen, + hovered_block: ActiveBlock::PluginScreen, + }; + assert_eq!(crate::core::plugin_api::route_name(&route), "plugin:stats"); + } } -#[test] -fn directory_named_with_lua_extension_loads_once_without_error() { - // A directory literally named `weird.lua` must be treated only as a directory plugin, - // not also fed to the single-file path (which would raise a spurious load error). - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join("weird.lua").join("main.lua"), - r#"spotatui.notify("weird ok", 1)"#, - ); +// --- config() read --- - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); +#[cfg(test)] +mod config_tests { + use super::*; + use crate::core::app::App; + use crate::core::user_config::UserConfig; + use crate::infra::network::IoEvent; + use std::sync::mpsc::channel; + use std::time::SystemTime; - assert_eq!(loaded, 1); - let effects = drain(&engine); - assert!( - !effects - .iter() - .any(|e| matches!(e, ScriptEffect::NotifyError(_, _))), - "a .lua-named directory must not produce a load error" - ); - std::fs::remove_dir_all(&cfg).unwrap(); -} + fn make_app() -> (App, std::sync::mpsc::Receiver) { + let (tx, rx) = channel(); + let app = App::new(tx, UserConfig::new(), Some(SystemTime::now())); + (app, rx) + } -#[test] -fn hidden_single_file_plugin_is_skipped() { - // Hidden files (e.g. macOS `._foo.lua` cruft) must be ignored, matching the directory branch. - let cfg = temp_config_dir(); - write_file( - &cfg.join("plugins").join(".secret.lua"), - r#"spotatui.notify("should not load", 1)"#, - ); + #[test] + fn config_exposes_theme_and_behavior_scalars() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + app.user_config.theme.playbar_text = ratatui::style::Color::Magenta; + app.user_config.behavior.seek_milliseconds = 12345; + engine.on_tick(&mut app); - let mut engine = ScriptEngine::new().unwrap(); - let loaded = engine.load_user_scripts(&cfg); + engine + .load_source( + "cfg", + r#" + local c = spotatui.config() + spotatui.notify(c.theme.playbar_text .. ":" .. c.behavior.seek_milliseconds, 1) + "#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "Magenta:12345"), + _ => panic!("expected config notify"), + } + } - assert_eq!(loaded, 0); - assert!(drain(&engine).is_empty()); - std::fs::remove_dir_all(&cfg).unwrap(); + #[test] + fn config_excludes_secrets() { + let mut engine = ScriptEngine::new().unwrap(); + let (mut app, _rx) = make_app(); + app.user_config.behavior.sync_token = Some("SECRET".to_string()); + engine.on_tick(&mut app); + + engine + .load_source( + "cfg", + r#" + local c = spotatui.config() + spotatui.notify(tostring(c.behavior.sync_token) .. ":" .. tostring(c.behavior.relay_server_url), 1) + "#, + ) + .unwrap(); + match one(&engine) { + ScriptEffect::Notify(msg, 1) => assert_eq!(msg, "nil:nil"), + _ => panic!("expected secrets-excluded notify"), + } + } } diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index 84a2a12b..aaa224a6 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -25,6 +25,7 @@ mod mouse; mod party; mod playbar; mod playlist; +mod plugin_screen; mod podcasts; mod queue_menu; mod recap_prompt; @@ -512,6 +513,9 @@ fn handle_block_events(key: Key, app: &mut App) { ActiveBlock::RecapPrompt => { recap_prompt::handler(key, app); } + ActiveBlock::PluginScreen => { + plugin_screen::handler(key, app); + } } } @@ -547,7 +551,8 @@ fn handle_escape(app: &mut App) { ActiveBlock::SelectDevice | ActiveBlock::LyricsView | ActiveBlock::CoverArtView - | ActiveBlock::MiniPlayer => { + | ActiveBlock::MiniPlayer + | ActiveBlock::PluginScreen => { app.pop_navigation_stack(); } // This is a global view that has no active/inactive distinction so do nothing diff --git a/src/tui/handlers/plugin_screen.rs b/src/tui/handlers/plugin_screen.rs new file mode 100644 index 00000000..630e2eab --- /dev/null +++ b/src/tui/handlers/plugin_screen.rs @@ -0,0 +1,116 @@ +use crate::core::app::{App, RouteId}; +use crate::tui::event::Key; + +/// Handler for plugin custom screens. Global keybindings have already run; +/// everything that reaches here is forwarded to the owning plugin's `on_key` +/// callback as a config.yml-style key string (drained by the script engine +/// right after the key event). +pub fn handler(key: Key, app: &mut App) { + let screen = match &app.get_current_route().id { + RouteId::PluginScreen(name) => name.clone(), + _ => return, + }; + + match key { + Key::Esc => { + app.pop_navigation_stack(); + } + // Scroll affordance for paragraph-heavy screens; not forwarded. + Key::PageUp => { + app.plugin_screen_scroll = app.plugin_screen_scroll.saturating_sub(5); + } + Key::PageDown => { + app.plugin_screen_scroll = app.plugin_screen_scroll.saturating_add(5); + } + _ => { + let key_string = plugin_key_string(key); + if !key_string.is_empty() { + app.pending_plugin_screen_keys.push((screen, key_string)); + } + } + } +} + +/// Serialize a key with the same vocabulary config.yml keybindings use +/// (mirrors `key_to_config_string` in user_config). +fn plugin_key_string(key: Key) -> String { + match key { + Key::Char(' ') => "space".to_string(), + Key::Char(c) => c.to_string(), + Key::Ctrl(c) => format!("ctrl-{}", c), + Key::Alt(c) => format!("alt-{}", c), + Key::Enter => "enter".to_string(), + Key::Tab => "tab".to_string(), + Key::Esc => "esc".to_string(), + Key::Backspace => "backspace".to_string(), + Key::Delete => "del".to_string(), + Key::Left => "left".to_string(), + Key::Right => "right".to_string(), + Key::Up => "up".to_string(), + Key::Down => "down".to_string(), + Key::Home => "home".to_string(), + Key::End => "end".to_string(), + Key::Ins => "ins".to_string(), + Key::PageUp => "pageup".to_string(), + Key::PageDown => "pagedown".to_string(), + Key::F0 => "f0".to_string(), + Key::F1 => "f1".to_string(), + Key::F2 => "f2".to_string(), + Key::F3 => "f3".to_string(), + Key::F4 => "f4".to_string(), + Key::F5 => "f5".to_string(), + Key::F6 => "f6".to_string(), + Key::F7 => "f7".to_string(), + Key::F8 => "f8".to_string(), + Key::F9 => "f9".to_string(), + Key::F10 => "f10".to_string(), + Key::F11 => "f11".to_string(), + Key::F12 => "f12".to_string(), + Key::Unknown => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::app::ActiveBlock; + + #[test] + fn key_on_plugin_screen_lands_in_pending_queue() { + let mut app = App::default(); + app.push_navigation_stack( + RouteId::PluginScreen("stats".to_string()), + ActiveBlock::PluginScreen, + ); + + handler(Key::Char('a'), &mut app); + handler(Key::Ctrl('x'), &mut app); + handler(Key::Enter, &mut app); + + assert_eq!( + app.pending_plugin_screen_keys, + vec![ + ("stats".to_string(), "a".to_string()), + ("stats".to_string(), "ctrl-x".to_string()), + ("stats".to_string(), "enter".to_string()), + ] + ); + } + + #[test] + fn esc_pops_back_out_of_plugin_screen() { + let mut app = App::default(); + app.push_navigation_stack( + RouteId::PluginScreen("stats".to_string()), + ActiveBlock::PluginScreen, + ); + + handler(Key::Esc, &mut app); + + assert!(app.pending_plugin_screen_keys.is_empty()); + assert!(!matches!( + app.get_current_route().id, + RouteId::PluginScreen(_) + )); + } +} diff --git a/src/tui/runner.rs b/src/tui/runner.rs index ed2d1e18..ebf90c04 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -677,6 +677,7 @@ pub async fn start_ui( } ActiveBlock::ExitPrompt => ui::draw_exit_prompt(f, &app), ActiveBlock::Settings => ui::settings::draw_settings(f, &app), + ActiveBlock::PluginScreen => ui::draw_plugin_screen(f, &app), ActiveBlock::CreatePlaylistForm => { ui::draw_main_layout(f, &app); ui::draw_create_playlist_form(f, &app); @@ -851,12 +852,18 @@ pub async fn start_ui( } else { app.lyrics = None; app.lyrics_status = crate::core::app::LyricsStatus::NotFound; + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Lyrics); } } None => { // Nothing is playing: reset so no stale lyrics linger. app.lyrics = None; app.lyrics_status = crate::core::app::LyricsStatus::NotStarted; + app + .plugin_data_generations + .bump(crate::core::app::PluginDataKind::Lyrics); } } } diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index b6705b02..a3677bd2 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -8,6 +8,7 @@ pub mod help; pub mod home; pub mod library; pub mod player; +pub mod plugin_screen; pub mod popups; pub mod search; pub mod settings; @@ -29,6 +30,7 @@ pub use self::library::draw_user_block; pub use self::player::draw_cover_art_view; pub use self::player::draw_miniplayer; pub use self::player::{draw_device_list, draw_lyrics_view, draw_playbar}; +pub use self::plugin_screen::draw_plugin_screen; pub use self::popups::{ draw_announcement_prompt, draw_dialog, draw_error_screen, draw_exit_prompt, draw_help_menu, draw_party, draw_plugin_popup, draw_queue, draw_recap_prompt, draw_sort_menu, @@ -123,7 +125,8 @@ fn draw_route_content(f: &mut Frame<'_>, app: &App, content_area: Rect) { | RouteId::Settings | RouteId::HelpMenu | RouteId::Queue - | RouteId::Party => {} // These are drawn outside the main routed content area. + | RouteId::Party + | RouteId::PluginScreen(_) => {} // These are drawn outside the main routed content area. RouteId::Dialog => {} // This is handled in draw_dialog. RouteId::CreatePlaylist => {} // This is drawn as an overlay via draw_create_playlist_form. }; diff --git a/src/tui/ui/plugin_screen.rs b/src/tui/ui/plugin_screen.rs new file mode 100644 index 00000000..b0265a5f --- /dev/null +++ b/src/tui/ui/plugin_screen.rs @@ -0,0 +1,136 @@ +use crate::core::app::{App, RouteId}; +use crate::core::plugin_api::{PluginScreenContent, PluginWidget, PopupLine}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph}, + Frame, +}; + +/// Draw the plugin custom screen named by the current route. Retained-mode: +/// this only reads `app.plugin_screens`; plugins update content via effects. +pub fn draw_plugin_screen(f: &mut Frame<'_>, app: &App) { + let name = match &app.get_current_route().id { + RouteId::PluginScreen(name) => name.clone(), + _ => return, + }; + + let area = f.area(); + let content = app.plugin_screens.get(&name); + let title = content + .map(|c| c.title.clone()) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| name.clone()); + + let outer = Block::default() + .borders(Borders::ALL) + .style(app.user_config.theme.base_style()) + .border_style(Style::default().fg(app.user_config.theme.active)) + .title(Span::styled( + title, + Style::default() + .fg(app.user_config.theme.header) + .add_modifier(Modifier::BOLD), + )); + let inner = outer.inner(area); + f.render_widget(outer, area); + + let Some(content) = content else { + // Registered (or mistyped) screen with no content published yet. + let placeholder = Paragraph::new(Line::from(Span::styled( + format!("plugin screen '{name}' has no content yet"), + Style::default().fg(app.user_config.theme.hint), + ))); + f.render_widget(placeholder, inner); + return; + }; + + draw_widgets(f, app, content, inner); +} + +fn draw_widgets(f: &mut Frame<'_>, app: &App, content: &PluginScreenContent, area: Rect) { + if content.widgets.is_empty() { + return; + } + + // Fixed-height widgets take their rows; the rest split the remainder evenly. + let constraints: Vec = content + .widgets + .iter() + .map(|w| match w { + PluginWidget::Paragraph { + height: Some(h), .. + } => Constraint::Length(*h), + PluginWidget::List { + height: Some(h), .. + } => Constraint::Length(*h), + PluginWidget::Gauge { .. } => Constraint::Length(3), + _ => Constraint::Fill(1), + }) + .collect(); + let chunks = Layout::vertical(constraints).split(area); + + for (widget, chunk) in content.widgets.iter().zip(chunks.iter()) { + match widget { + PluginWidget::Paragraph { lines, .. } => { + let text: Vec = lines.iter().map(styled_line).collect(); + let paragraph = Paragraph::new(text).scroll((app.plugin_screen_scroll, 0)); + f.render_widget(paragraph, *chunk); + } + PluginWidget::List { + title, + items, + selected, + .. + } => { + let list_items: Vec = items + .iter() + .map(|pl| ListItem::new(styled_line(pl))) + .collect(); + let mut block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.user_config.theme.inactive)); + if let Some(title) = title { + block = block.title(Span::styled( + title.clone(), + Style::default().fg(app.user_config.theme.header), + )); + } + let list = List::new(list_items).block(block).highlight_style( + Style::default() + .fg(app.user_config.theme.selected) + .add_modifier(Modifier::BOLD), + ); + let mut state = ListState::default(); + state.select(selected.filter(|s| *s < items.len())); + f.render_stateful_widget(list, *chunk, &mut state); + } + PluginWidget::Gauge { ratio, label } => { + let gauge = Gauge::default() + .block(Block::default().borders(Borders::ALL)) + .gauge_style(Style::default().fg(app.user_config.theme.playbar_progress)) + .ratio(ratio.clamp(0.0, 1.0)) + .label(Span::styled( + label.clone().unwrap_or_default(), + Style::default().fg(app.user_config.theme.playbar_progress_text), + )); + f.render_widget(gauge, *chunk); + } + } + } +} + +fn styled_line(pl: &PopupLine) -> Line<'static> { + let mut style = Style::default(); + if let Some(fg) = pl.fg { + style = style.fg(fg); + } + if pl.bold { + style = style.add_modifier(Modifier::BOLD); + } + if pl.italic { + style = style.add_modifier(Modifier::ITALIC); + } + Line::from(Span::styled(pl.text.clone(), style)) +}