diff --git a/extensions/kaset-control/CHANGELOG.md b/extensions/kaset-control/CHANGELOG.md index 11ddf3ceee4..52c40ca27d4 100644 --- a/extensions/kaset-control/CHANGELOG.md +++ b/extensions/kaset-control/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [Queue, YouTube Search and History] - 2026-10-31 + +### Added + +- Now Playing rebuilt around Kaset's queue: queue, recently played and up next in one list, with live progress and full playback controls +- Album names resolved by searching YouTube Music, since Kaset's player payload does not include them +- YouTube search in Play Song, merged with YouTube Music and filterable by source +- Queue actions: play now, remove from queue, play next, add to queue +- Recently played history recorded in the background without a menu bar item +- Track Info view (`⌘I`) with large artwork and a metadata column + +### Changed + +- Upgraded to `@raycast/api` 2.8.2 +- Menu bar player: artwork or progress-ring icon, marquee titles, hide when idle + ## [Update] - 2026-05-05 ### Added diff --git a/extensions/kaset-control/README.md b/extensions/kaset-control/README.md index 0bf2347054f..13cbff272c8 100644 --- a/extensions/kaset-control/README.md +++ b/extensions/kaset-control/README.md @@ -1,73 +1,51 @@ -# Kaset Raycast Extension +# Kaset Control -Control [Kaset](https://github.com/sozercan/kaset) - YouTube Music client for macOS - directly from Raycast. +Control [Kaset](https://github.com/sozercan/kaset) — the YouTube Music client for +macOS — from Raycast. -## Features - -- **Menu Bar Player** - Shows current track in macOS menu bar with full playback controls -- **Now Playing** - View current track with artwork, progress, and controls -- **Toggle Play/Pause** - Quick playback control -- **Next/Previous Track** - Skip tracks -- **Volume Control** - Set volume from 0-100% -- **Toggle Shuffle** - Enable/disable shuffle mode -- **Cycle Repeat** - Cycle through repeat modes (off → all → one) -- **Toggle Mute** - Mute/unmute audio -- **Like/Dislike** - Rate the current track +Kaset is a separate app: this extension drives it over AppleScript, so Kaset has +to be installed and running. ## Requirements -- [Kaset](https://github.com/sozercan/kaset) with AppleScript support -- macOS 14.0 or later +- **Kaset 0.14.2 or newer.** The queue commands and the merged YouTube Music and + YouTube search need 0.14.2 or later. +- macOS. ## Commands -| Command | Description | -|---------|-------------| -| Show Menu Bar | Show current track in menu bar with controls | -| Now Playing | View current track with artwork and controls | -| Toggle Play/Pause | Play or pause | -| Next Track | Skip to next | -| Previous Track | Go back | -| Set Volume | Choose volume level | -| Toggle Shuffle | Toggle shuffle mode | -| Cycle Repeat | Cycle repeat modes | -| Toggle Mute | Mute/unmute | -| Like Track | Like or unlike current track | -| Dislike Track | Dislike or remove dislike | - -## Menu Bar - -The menu bar player shows the current track and provides quick access to all controls: - -- **Show**: Run "Show Menu Bar" from Raycast -- **Hide**: Click "Hide Menu Bar" in the menu bar dropdown - -### Settings - -Configure the menu bar display style in Raycast extension preferences: - -- **Icon only** - Just the music icon -- **Short** - Track name truncated to 20 characters -- **Medium** - Track name truncated to 35 characters -- **Long** - Track name truncated to 50 characters - -## Development - -```bash -# Install dependencies -npm install - -# Run in development mode -npm run dev - -# Build for production -npm run build - -# Lint -npm run lint -npm run fix-lint -``` - -## License - -MIT +| Command | What it does | +| --- | --- | +| **Now Playing** | The current track with live progress, the queue, and recently played. Full playback controls, queue actions, and rating. | +| **Play Song** | Search YouTube Music and YouTube together and play, queue, or play next. | +| **Toggle Play/Pause**, **Next Track**, **Previous Track** | Playback control, no window. | +| **Set Volume** | Pick a volume level. | +| **Toggle Shuffle**, **Cycle Repeat**, **Toggle Mute** | Playback modes. | +| **Like Track**, **Dislike Track** | Rate the current track. | +| **Show Menu Bar** | Keep the current track and artwork in the menu bar with controls. | +| **Record Player State** | Records history in the background. See below. | + +## Preferences + +- **Play Song Source** — whether Play Song searches YouTube Music, YouTube, or + both (the default). You can also narrow this inside the search itself. +- **Menu Bar Style** / **Menu Bar Icon** / **Hide When Idle** — control the menu + bar item. + +## Recently Played + +History is recorded without the menu bar. Open **Record Player State** once, or +turn on its background refresh in Raycast's extension settings, so it can keep +recording while the Now Playing window is closed. + +A track is added to Recently Played only once it has stopped playing, so the +track you are listening to never appears in both Now Playing and Recently +Played. + +## Notes + +- **Seeking is display-only.** Kaset exposes no AppleScript command to seek, so + the progress ring tracks playback but cannot scrub. +- **Album** is resolved by searching YouTube Music, because Kaset's player + payload does not carry album names. This costs one search per track change. +- The menu bar item is optional and off unless you enable it. diff --git a/extensions/kaset-control/metadata/kaset-control-1.png b/extensions/kaset-control/metadata/kaset-control-1.png new file mode 100644 index 00000000000..90fa12dcc87 Binary files /dev/null and b/extensions/kaset-control/metadata/kaset-control-1.png differ diff --git a/extensions/kaset-control/metadata/kaset-control-2.png b/extensions/kaset-control/metadata/kaset-control-2.png new file mode 100644 index 00000000000..95acada83ed Binary files /dev/null and b/extensions/kaset-control/metadata/kaset-control-2.png differ diff --git a/extensions/kaset-control/metadata/screenshot-01.png b/extensions/kaset-control/metadata/screenshot-01.png deleted file mode 100644 index 60bef89c27a..00000000000 Binary files a/extensions/kaset-control/metadata/screenshot-01.png and /dev/null differ diff --git a/extensions/kaset-control/metadata/screenshot-02.png b/extensions/kaset-control/metadata/screenshot-02.png deleted file mode 100644 index 12439022ef2..00000000000 Binary files a/extensions/kaset-control/metadata/screenshot-02.png and /dev/null differ diff --git a/extensions/kaset-control/metadata/screenshot-03.png b/extensions/kaset-control/metadata/screenshot-03.png deleted file mode 100644 index 2457686f01c..00000000000 Binary files a/extensions/kaset-control/metadata/screenshot-03.png and /dev/null differ diff --git a/extensions/kaset-control/package-lock.json b/extensions/kaset-control/package-lock.json index 6e5f487686a..954057a9eb7 100644 --- a/extensions/kaset-control/package-lock.json +++ b/extensions/kaset-control/package-lock.json @@ -7,7 +7,7 @@ "name": "kaset-control", "license": "MIT", "dependencies": { - "@raycast/api": "^1.103.3", + "@raycast/api": "^2.8.2", "@raycast/utils": "^2.2.2", "run-applescript": "^7.0.0" }, @@ -21,9 +21,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -37,9 +37,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -53,9 +53,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -69,9 +69,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -85,9 +85,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -101,9 +101,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -117,9 +117,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -149,9 +149,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -165,9 +165,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -181,9 +181,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -197,9 +197,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -213,9 +213,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -229,9 +229,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -245,9 +245,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -261,9 +261,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -277,9 +277,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -293,9 +293,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -309,9 +309,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -325,9 +325,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -341,9 +341,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -357,9 +357,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -373,9 +373,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -389,9 +389,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -405,9 +405,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -421,9 +421,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -972,9 +972,9 @@ } }, "node_modules/@oclif/core": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.8.0.tgz", - "integrity": "sha512-jteNUQKgJHLHFbbz806aGZqf+RJJ7t4gwF4MYa8fCwCxQ8/klJNWc0MvaJiBebk7Mc+J39mdlsB4XraaCKznFw==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.14.0.tgz", + "integrity": "sha512-QCJIZoVJxV7jywgVAUlsQwl3dr3Sa21kfi5z9KrYolbexWJUtFSEtMoNiBWojD05mYycLnB50gp/VxlGdaOCRA==", "license": "MIT", "dependencies": { "ansi-escapes": "^4.3.2", @@ -982,28 +982,28 @@ "clean-stack": "^3.0.1", "cli-spinners": "^2.9.2", "debug": "^4.4.3", - "ejs": "^3.1.10", + "ejs": "^6", "get-package-type": "^0.1.0", "indent-string": "^4.0.0", - "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", - "minimatch": "^9.0.5", - "semver": "^7.7.3", + "minimatch": "^10.2.5", + "semver": "^7.8.1", "string-width": "^4.2.3", "supports-color": "^8", - "tinyglobby": "^0.2.14", + "tinyglobby": "^0.2.17", "widest-line": "^3.1.0", "wordwrap": "^1.0.0", - "wrap-ansi": "^7.0.0" + "wrap-ansi": "^7.0.0", + "wsl-utils": "^0.4.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@oclif/plugin-autocomplete": { - "version": "3.2.40", - "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-3.2.40.tgz", - "integrity": "sha512-HCfDuUV3l5F5Wz7SKkaoFb+OMQ5vKul8zvsPNgI0QbZcQuGHmn3svk+392wSfXboyA1gq8kzEmKPAoQK6r6UNw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-3.3.0.tgz", + "integrity": "sha512-5CRpSH9FNub2IwAej2PxR6GEbQNLCy0XTzp/KqpZh6U4qiCTLPz20IXcUx9luarJBKRe2qu1cy1acHggo2MMcQ==", "license": "MIT", "dependencies": { "@oclif/core": "^4", @@ -1012,13 +1012,28 @@ "ejs": "^3.1.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" + } + }, + "node_modules/@oclif/plugin-autocomplete/node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" } }, "node_modules/@oclif/plugin-help": { - "version": "6.2.37", - "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.2.37.tgz", - "integrity": "sha512-5N/X/FzlJaYfpaHwDC0YHzOzKDWa41s9t+4FpCDu4f9OMReds4JeNBaaWk9rlIzdKjh2M6AC5Q18ORfECRkHGA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.3.0.tgz", + "integrity": "sha512-cpSRv7tyHHFPv39HrV22eNmu8ylrwjmPvqdlNo3URp79+p5319p+a8PI/3ez6A6GZmjwBj1EWUdVz1aPUAsSmw==", "license": "MIT", "dependencies": { "@oclif/core": "^4" @@ -1028,13 +1043,13 @@ } }, "node_modules/@oclif/plugin-not-found": { - "version": "3.2.74", - "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-3.2.74.tgz", - "integrity": "sha512-6RD/EuIUGxAYR45nMQg+nw+PqwCXUxkR6Eyn+1fvbVjtb9d+60OPwB77LCRUI4zKNI+n0LOFaMniEdSpb+A7kQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-3.3.0.tgz", + "integrity": "sha512-GbdWJOmmBO3xmrVjDXeWG7tXl4vzeGZ+af9ztCfAmhOPEkd/+eeAAadzQPeZoygedqCvsdux3QLq9/MAxusPKg==", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.10.1", - "@oclif/core": "^4.8.0", + "@oclif/core": "^4.13.3", "ansis": "^3.17.0", "fast-levenshtein": "^3.0.0" }, @@ -1043,28 +1058,28 @@ } }, "node_modules/@raycast/api": { - "version": "1.104.2", - "resolved": "https://registry.npmjs.org/@raycast/api/-/api-1.104.2.tgz", - "integrity": "sha512-y2LNulxmKGUUoq/yC3LXYkgmGoUO1FPjg/tWZAqYKdj0h+/pLxplzascSIep22jgYZmpnSUVGBQMBAD7NlCFwQ==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@raycast/api/-/api-2.8.2.tgz", + "integrity": "sha512-lJ9586eWj0z+DRi9BiiR9d7FDuB1FhMwblfIl/yHl1hKwg8gZz+HtSPzw9XqnG7qytHULy2EqqweJRLvCQdzJA==", "license": "MIT", "dependencies": { - "@oclif/core": "^4.5.4", - "@oclif/plugin-autocomplete": "^3.2.35", - "@oclif/plugin-help": "^6.2.33", - "@oclif/plugin-not-found": "^3.2.68", - "@types/node": "22.13.10", + "@oclif/core": "^4.11.4", + "@oclif/plugin-autocomplete": "^3.2.50", + "@oclif/plugin-help": "^6.2.50", + "@oclif/plugin-not-found": "^3.2.87", + "@types/node": "22.19.17", "@types/react": "19.0.10", - "esbuild": "^0.25.10", + "esbuild": "^0.28.0", "react": "19.0.0" }, "bin": { "ray": "bin/run.js" }, "engines": { - "node": ">=22.14.0" + "node": ">=22.22.2" }, "peerDependencies": { - "@types/node": "22.13.10", + "@types/node": "22.19.17", "@types/react": "19.0.10", "react-devtools": "6.1.1" }, @@ -1081,12 +1096,12 @@ } }, "node_modules/@raycast/api/node_modules/@types/node": { - "version": "22.13.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", - "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@raycast/api/node_modules/@types/react": { @@ -1098,6 +1113,12 @@ "csstype": "^3.0.2" } }, + "node_modules/@raycast/api/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/@raycast/eslint-config": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@raycast/eslint-config/-/eslint-config-1.0.11.tgz", @@ -1721,9 +1742,9 @@ } }, "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "license": "MIT" }, "node_modules/clean-stack": { @@ -1868,18 +1889,15 @@ } }, "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz", + "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==", "license": "Apache-2.0", - "dependencies": { - "jake": "^10.8.5" - }, "bin": { "ejs": "bin/cli.js" }, "engines": { - "node": ">=0.10.0" + "node": ">=0.12.18" } }, "node_modules/emoji-regex": { @@ -1889,9 +1907,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -1901,32 +1919,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escape-string-regexp": { @@ -2259,9 +2277,9 @@ } }, "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" @@ -2473,9 +2491,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -2554,15 +2572,15 @@ "license": "ISC" }, "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "license": "MIT", "bin": { "is-docker": "cli.js" }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2600,6 +2618,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2621,15 +2657,18 @@ } }, "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "license": "MIT", "dependencies": { - "is-docker": "^2.0.0" + "is-inside-container": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/isexe": { @@ -2725,9 +2764,9 @@ } }, "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz", + "integrity": "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==", "license": "MIT", "engines": { "node": ">=14" @@ -2784,20 +2823,41 @@ } }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2959,6 +3019,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3106,9 +3178,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3224,13 +3296,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -3360,6 +3432,7 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "devOptional": true, "license": "MIT" }, "node_modules/uri-js": { @@ -3440,6 +3513,22 @@ "dev": true, "license": "ISC" }, + "node_modules/wsl-utils": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.4.0.tgz", + "integrity": "sha512-9YmF+2sFEd+T7TkwlmE337F0IVzfDvDknhtpBQxxXzEOfgPphGlFYpyx0cTuCIFj8/p+sqwBYAeGxOMNSzPPDA==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/extensions/kaset-control/package.json b/extensions/kaset-control/package.json index 9572f1a8665..cecbf282bd2 100644 --- a/extensions/kaset-control/package.json +++ b/extensions/kaset-control/package.json @@ -34,6 +34,59 @@ { "title": "Long (50 chars)", "value": "long" + }, + { + "title": "Marquee", + "value": "marquee" + } + ] + }, + { + "name": "menuBarIconStyle", + "type": "dropdown", + "title": "Menu Bar Icon", + "description": "Show album artwork or a progress ring", + "required": false, + "default": "artwork", + "data": [ + { + "title": "Album Artwork", + "value": "artwork" + }, + { + "title": "Progress Ring", + "value": "progress" + } + ] + }, + { + "name": "hideWhenIdle", + "type": "checkbox", + "title": "Hide When Idle", + "label": "Hide the menu bar icon when nothing is playing", + "description": "Hide the menu bar item when no track is playing", + "required": false, + "default": false + }, + { + "name": "searchSource", + "type": "dropdown", + "title": "Play Song Source", + "description": "Which source Play Song searches by default", + "required": false, + "default": "both", + "data": [ + { + "title": "YouTube Music + YouTube", + "value": "both" + }, + { + "title": "YouTube Music", + "value": "ytmusic" + }, + { + "title": "YouTube", + "value": "youtube" } ] } @@ -49,7 +102,7 @@ { "name": "now-playing", "title": "Now Playing", - "description": "View the currently playing track", + "description": "View the current track, queue, and recently played", "mode": "view" }, { @@ -111,10 +164,17 @@ "title": "Dislike Track", "description": "Dislike or undislike the current track", "mode": "no-view" + }, + { + "name": "record-player-state", + "title": "Record Player State", + "description": "Records the current track for the recently played list", + "mode": "no-view", + "interval": "1m" } ], "dependencies": { - "@raycast/api": "^1.103.3", + "@raycast/api": "^2.8.2", "@raycast/utils": "^2.2.2", "run-applescript": "^7.0.0" }, @@ -136,5 +196,8 @@ }, "platforms": [ "macOS" + ], + "contributors": [ + "danieltsam" ] } diff --git a/extensions/kaset-control/src/menu-bar.tsx b/extensions/kaset-control/src/menu-bar.tsx index 35f6d94ae72..6113c5c15ec 100644 --- a/extensions/kaset-control/src/menu-bar.tsx +++ b/extensions/kaset-control/src/menu-bar.tsx @@ -1,5 +1,7 @@ import { + Color, Icon, + Image, LocalStorage, MenuBarExtra, open, @@ -9,15 +11,26 @@ import { environment, showHUD, } from "@raycast/api"; -import { usePromise } from "@raycast/utils"; -import { useState } from "react"; -import { getKasetState, isKasetRunning, KasetState } from "./utils/kaset"; +import { getProgressIcon, usePromise } from "@raycast/utils"; +import { useEffect, useState } from "react"; +import { + getKasetState, + isKasetRunning, + recordFinishedTrack, + KasetState, +} from "./utils/kaset"; const MENU_BAR_ENABLED_KEY = "menuBarEnabled"; async function fetchState() { const running = await isKasetRunning(); - return running ? await getKasetState() : null; + if (!running) return null; + const state = await getKasetState(); + // Same rule as Now Playing: a track joins the history only once it has + // stopped. This used to log the playing song on every refresh, which put the + // same track in both Now Playing and Recently Played. + await recordFinishedTrack(state?.currentTrack ?? null); + return state; } export default function Command() { @@ -45,16 +58,27 @@ export default function Command() { return { enabled: true, state: await fetchState() }; }); + // Hooks must run before any early return. + const title = useMenuBarTitle(data?.state); + // If disabled (either by local state or persisted), hide the menu bar if (isHidden || data?.enabled === false) { return null; } const state = data?.state; - const title = getMenuBarTitle(state); + const prefs = getPreferenceValues(); + + if (prefs.hideWhenIdle && !state?.currentTrack) { + return null; + } return ( - + {state?.currentTrack ? ( <> @@ -214,32 +238,65 @@ export default function Command() { ); } -function getMenuBarTitle( +function getMenuBarIcon( state: KasetState | null | undefined, -): string | undefined { - const { menuBarStyle } = getPreferenceValues(); +): Image.ImageLike | string { + const { menuBarIconStyle } = getPreferenceValues(); - if (menuBarStyle === "icon") { - return undefined; + if (menuBarIconStyle === "progress" && state && state.duration > 0) { + return getProgressIcon(state.position / state.duration, Color.PrimaryText); } - if (!state?.currentTrack) { - return undefined; + if (state?.currentTrack?.artworkURL) { + return { + source: state.currentTrack.artworkURL, + mask: Image.Mask.RoundedRectangle, + }; } - const maxLengthMap = { + return Icon.Music; +} + +function useMenuBarTitle( + state: KasetState | null | undefined, +): string | undefined { + const { menuBarStyle } = getPreferenceValues(); + const [offset, setOffset] = useState(0); + + const maxLengthMap: Record = { short: 20, medium: 35, long: 50, + marquee: 40, }; - const maxLength = maxLengthMap[menuBarStyle] || 20; + const maxLength = maxLengthMap[menuBarStyle] ?? 20; - const { name, artist } = state.currentTrack; - const title = `${name} - ${artist}`; + const track = state?.currentTrack; + const full = track ? `${track.name} — ${track.artist}` : undefined; + const marquee = menuBarStyle === "marquee"; + const needsScroll = marquee && !!full && full.length > maxLength; + + useEffect(() => { + if (!needsScroll || !full) return; + const padded = `${full} `; + const id = setInterval( + () => setOffset((value) => (value + 1) % padded.length), + 300, + ); + return () => clearInterval(id); + }, [needsScroll, full]); + + if (menuBarStyle === "icon" || !full) { + return undefined; + } - if (title.length > maxLength) { - return title.substring(0, maxLength - 1) + "…"; + if (!needsScroll) { + return full.length > maxLength + ? `${full.substring(0, maxLength - 1)}…` + : full; } - return title; + const padded = `${full} `; + const doubled = padded + padded; + return doubled.substring(offset, offset + maxLength); } diff --git a/extensions/kaset-control/src/now-playing.tsx b/extensions/kaset-control/src/now-playing.tsx index f10a6a7ae5f..16e75bb8ddb 100644 --- a/extensions/kaset-control/src/now-playing.tsx +++ b/extensions/kaset-control/src/now-playing.tsx @@ -1,164 +1,698 @@ -import { Action, ActionPanel, Detail, Icon, Color } from "@raycast/api"; -import { usePromise } from "@raycast/utils"; import { + Action, + ActionPanel, + Color, + Detail, + Icon, + LaunchType, + List, + launchCommand, + showHUD, +} from "@raycast/api"; +import { getProgressIcon, usePromise } from "@raycast/utils"; +import { useEffect, useState } from "react"; +import { resolveAlbum } from "./utils/album"; +import { + cycleRepeat, + dislikeTrack, + formatTime, getKasetState, - playPause, + getPlayQueue, + getRecent, + isKasetRunning, + isPlaceholderTrack, + likeTrack, nextTrack, + playPause, + playTrackAtIndex, + playVideo, previousTrack, - toggleShuffle, - cycleRepeat, + recordFinishedTrack, + removeFromQueue, toggleMute, - formatTime, - isKasetRunning, + toggleShuffle, + visibleRecent, + type KasetState, + type PlayQueue, + type RecentTrack, + type Track, } from "./utils/kaset"; +import { readSnapshot, SNAPSHOT_KEYS, writeSnapshot } from "./utils/snapshot"; + +interface LibraryData { + running: boolean; + queue: PlayQueue | null; + recent: RecentTrack[]; +} + +const ARTWORK_SIZE = 200; +const NOW_PLAYING_ID = "kaset-now-playing-row"; export default function NowPlaying() { - const { data: state, isLoading, revalidate } = usePromise(getKasetState); - const { data: running } = usePromise(isKasetRunning); + // Read the previous snapshot once, synchronously, so the first render has + // content to show before any async work finishes. + const [cachedLibrary] = useState(() => + readSnapshot(SNAPSHOT_KEYS.library), + ); + const [cachedState] = useState(() => + readSnapshot(SNAPSHOT_KEYS.state), + ); + + // Library data refreshes on actions; player state polls so progress is live. + const { data: liveLibrary, revalidate: refreshLibrary } = usePromise( + async (): Promise => { + const [running, queue, recent] = await Promise.all([ + isKasetRunning(), + getPlayQueue(), + getRecent(), + ]); + return { running, queue, recent }; + }, + ); + + const { data: liveState, revalidate: refreshState } = + usePromise(getKasetState); + + // Only fall back while the live value is still undefined. A completed `null` + // means there genuinely is no player (Kaset closed, or not initialised yet), + // and a stale snapshot must not paper over that — nor turn it into a + // permanent loading state on a fresh install with no snapshot. + const library = liveLibrary === undefined ? cachedLibrary : liveLibrary; + const state = liveState === undefined ? cachedState : liveState; + + useEffect(() => { + const id = setInterval(() => { + void refreshState(); + }, 1000); + return () => clearInterval(id); + }, [refreshState]); - if (!running) { + // Persist only when something meaningful changes; writing on every poll tick + // would hit the disk once a second for no benefit. + useEffect(() => { + if (liveLibrary) writeSnapshot(SNAPSHOT_KEYS.library, liveLibrary); + }, [liveLibrary]); + + useEffect(() => { + if (liveState) writeSnapshot(SNAPSHOT_KEYS.state, liveState); + }, [liveState?.currentTrack?.id, liveState?.playerState]); + + const currentTrack = state?.currentTrack?.id ? state.currentTrack : null; + // Resolved once per track id, not per poll tick (see useResolvedAlbum). + const currentAlbum = useResolvedAlbum(currentTrack); + + // Pin the first selection to Now Playing so it is on screen at load even + // though Recently Played sits above it; released once the user moves. + const [pinSelection, setPinSelection] = useState(true); + + // Keyed on the metadata as well as the id: Kaset first reports an ID-only + // "Loading..." placeholder and fills in the title and artist afterwards under + // the same video id, so an id-only dependency would never fire again and the + // real track would never reach the history. + useEffect(() => { + void recordFinishedTrack(currentTrack).then(() => refreshLibrary()); + }, [currentTrack?.id, currentTrack?.name, currentTrack?.artist]); + + // Only the first load shows the loading shell; polling must not re-trigger it. + if (library === undefined || state === undefined) { + return ; + } + + if (!library.running) { return ( - - - - } - /> + + + + + } + /> + ); } - if (isLoading) { - return ; + if (!state) { + return ( + + + + + } + /> + + ); } - if (!state || !state.currentTrack) { + const tracks = library.queue?.tracks ?? []; + const currentIndex = library.queue?.currentIndex ?? 0; + const upNext = currentIndex > 0 ? tracks.slice(currentIndex) : tracks; + // History is stored newest-first, but the section sits above Now Playing, so + // show it oldest-first: the most recent track then lands directly above the + // current one and the list reads as a single timeline. + // History is stored newest-first; show it oldest-first so the most recent + // track sits directly above Now Playing, and never show the track that is + // playing right now. + const recent = visibleRecent(library.recent ?? [], currentTrack?.id); + + if (!currentTrack && upNext.length === 0) { return ( - - - - } - /> + + + + launchCommand({ + name: "play-song", + type: LaunchType.UserInitiated, + }) + } + /> + + + } + /> + ); } - const track = state.currentTrack; - const progress = - state.duration > 0 ? (state.position / state.duration) * 100 : 0; - const progressBar = generateProgressBar(progress); + return ( + setPinSelection(false)} + > + {recent.length > 0 ? ( + + {recent.map((track) => ( + } + actions={ + + playVideo(track.id)} + /> + + + } + /> + ))} + + ) : null} + + {currentTrack ? ( + + + + ) : null} + + {upNext.length > 0 ? ( + + {upNext.map((track, offset) => ( + + ))} + + ) : null} + + ); +} + +function PlayerQueueItem({ + track, + itemId, + isCurrent = false, + album, + state, + onRevalidate, +}: { + track: Track; + itemId?: string; + isCurrent?: boolean; + album?: string; + state?: KasetState; + onRevalidate: () => void; +}) { + return ( + + ) : ( + + ) + } + actions={ + isCurrent && state ? ( + + ) : ( + + ) + } + /> + ); +} + +// Resolve the album once when the track id changes, not on every progress poll. +// `resolveAlbum` caches by video id, so returning to a track is instant. +function useResolvedAlbum(track: Track | null): string | undefined { + const id = track?.id; + const name = track?.name; + const artist = track?.artist; + const hasAlbum = Boolean(track?.album); + // Kaset's ID-only placeholder carries no usable title, so looking it up would + // search for "Loading..." and cache the miss under the real video id. + const placeholder = track ? isPlaceholderTrack(track) : true; + const [resolved, setResolved] = useState(undefined); + + useEffect(() => { + setResolved(undefined); + if (!id || !name || hasAlbum || placeholder) return; + + let cancelled = false; + void resolveAlbum(id, name, artist ?? "").then((album) => { + if (!cancelled) setResolved(album); + }); + return () => { + cancelled = true; + }; + }, [id, name, artist, hasAlbum, placeholder]); + + return track ? track.album || resolved : undefined; +} - const artworkMarkdown = track.artworkURL - ? `![Artwork](${track.artworkURL})\n\n` +// `List.Item.Detail` renders `markdown` first and then `metadata` underneath +// it, so the markdown region is the flexible part. Title and artist go at the +// top where they stay visible; the artwork sits below and absorbs the leftover +// space. +// `List.Item.Detail` renders `markdown` first and then `metadata` underneath it. +// Only the artwork goes in the markdown: headline text there adds height to the +// pane, and the metadata block cannot be resized, so the title and artist live +// in metadata rows instead. +function TrackDetail({ + track, + state, + album, +}: { + track: Track; + state?: KasetState; + album?: string; +}) { + const markdown = track.artworkURL + ? `![Artwork](${artworkURL(track.artworkURL, ARTWORK_SIZE)})` : ""; - const markdown = ` -${artworkMarkdown} -# ${track.name} + const progress = + state && state.duration > 0 ? state.position / state.duration : 0; + const isPlaying = state?.playerState === "playing"; + // Prefer whatever Kaset gave us; fall back to the album resolved from search. + const albumText = track.album || album; + + return ( + + {/* Each row costs height the artwork cannot have, so the playing + state is carried in the label slot of the progress row instead of + getting a row of its own. Keep every label short: Raycast divides + the row between label and value, and a longer label shrinks the + value column and truncates it sooner. */} + + + {albumText ? ( + + ) : null} + {state ? ( + + ) : null} + {!state && track.duration ? ( + + ) : null} + + } + /> + ); +} -**${track.artist}**${track.album ? ` • ${track.album}` : ""} +function escapeMarkdown(text: string): string { + return text.replace(/([\\`*_[\]()#|])/g, "\\$1"); +} ---- +// Kaset hands back a 60px thumbnail; ask the CDN for a larger source so the +// artwork is crisp rather than an upscale, then let Raycast scale it down. +function artworkURL(url: string, size: number): string { + const source = url.replace(/=w\d+-h\d+/, `=w${size * 2}-h${size * 2}`); + return `${source}?raycast-width=${size}&raycast-height=${size}`; +} -${progressBar} +// Full-screen view pushed from the current track, mirroring the Spotify +// extension's layout: text and artwork on the left, metadata in a right-hand +// column. This is only reachable with the standalone `Detail` component — +// inside a List's detail pane Raycast renders metadata underneath the markdown +// and offers no right-hand column. The List itself is untouched underneath. +function TrackInfoView({ + track, + onRevalidate, +}: { + track: Track; + onRevalidate: () => void; +}) { + const { data: liveState, revalidate: refreshState } = + usePromise(getKasetState); -${formatTime(state.position)} / ${formatTime(state.duration)} + useEffect(() => { + const id = setInterval(() => { + void refreshState(); + }, 1000); + return () => clearInterval(id); + }, [refreshState]); ---- + // Follow the player if the track changes while this view is open. + const current = liveState?.currentTrack ?? track; + const resolvedAlbum = useResolvedAlbum(current); + const albumText = current.album || resolvedAlbum; + const size = 280; + const artwork = current.artworkURL + ? `![Artwork](${artworkURL(current.artworkURL, size)})` + : ""; + const markdown = `# ${escapeMarkdown(current.name)}\n\nby ${escapeMarkdown(current.artist)}\n\n${artwork}`; -${state.playerState === "playing" ? "▶️ Playing" : "⏸️ Paused"}${state.shuffling ? " • 🔀 Shuffle" : ""}${state.repeating !== "off" ? ` • 🔁 Repeat ${state.repeating === "all" ? "All" : "One"}` : ""}${state.muted ? " • 🔇 Muted" : ` • 🔊 ${state.volume}%`} -`; + const progress = + liveState && liveState.duration > 0 + ? liveState.position / liveState.duration + : 0; + const isPlaying = liveState?.playerState === "playing"; return ( + {albumText ? ( + + ) : null} + {liveState ? ( + <> + + + + + + + ) : null} + + } actions={ - - - { - await playPause(); - revalidate(); - }} - /> - { - await nextTrack(); - setTimeout(revalidate, 500); - }} - /> - { - await previousTrack(); - setTimeout(revalidate, 500); - }} - /> - - - { - await toggleShuffle(); - revalidate(); - }} - /> - { - await cycleRepeat(); - revalidate(); - }} - /> - { - await toggleMute(); - revalidate(); - }} - /> - - - - - + liveState ? ( + + ) : undefined } /> ); } -function generateProgressBar(percentage: number): string { - const filled = Math.round(percentage / 5); - const empty = 20 - filled; - return `\`[${"█".repeat(filled)}${"░".repeat(empty)}]\``; +function CurrentTrackActions({ + track, + state, + onRevalidate, +}: { + track: Track; + state: KasetState; + onRevalidate: () => void; +}) { + const isPlaying = state.playerState === "playing"; + const isLiked = state.likeStatus === "liked"; + + return ( + + + { + await playPause(); + onRevalidate(); + }} + /> + { + await nextTrack(); + onRevalidate(); + }} + /> + { + await previousTrack(); + onRevalidate(); + }} + /> + + + } + /> + + + { + await likeTrack(); + onRevalidate(); + }} + /> + { + await dislikeTrack(); + onRevalidate(); + }} + /> + + + { + await toggleShuffle(); + onRevalidate(); + }} + /> + { + await cycleRepeat(); + onRevalidate(); + }} + /> + { + await toggleMute(); + onRevalidate(); + }} + /> + + launchCommand({ name: "volume", type: LaunchType.UserInitiated }) + } + /> + + + + launchCommand({ name: "play-song", type: LaunchType.UserInitiated }) + } + /> + + launchCommand({ + name: "play-song", + type: LaunchType.UserInitiated, + context: { mode: "add" }, + }) + } + /> + + + + + + + + + ); +} + +function QueueRowActions({ + track, + onRevalidate, +}: { + track: Track & { audioVideoId?: string }; + onRevalidate: () => void; +}) { + return ( + + + { + // Re-resolve against the freshest queue so indices cannot drift. + const fresh = await getPlayQueue(); + if (!fresh) { + onRevalidate(); + return; + } + const resolved = + fresh.tracks.findIndex((entry) => entry.id === track.id) + 1; + if (resolved <= 0) { + // The row is gone. Falling back to its old position would start + // whatever occupies that slot now, which is a different song. + await showHUD("Track is no longer in the queue"); + onRevalidate(); + return; + } + await playTrackAtIndex(resolved); + onRevalidate(); + }} + /> + { + // Re-fetch first, then remove by the freshest id (spec fallback). + const fresh = await getPlayQueue(); + const entry = fresh?.tracks.find((row) => row.id === track.id); + await removeFromQueue( + entry?.audioVideoId ?? + entry?.id ?? + track.audioVideoId ?? + track.id, + ); + onRevalidate(); + }} + /> + + + + + + ); +} + +function repeatLabel(mode: KasetState["repeating"]): string { + return mode === "off" ? "Off" : mode === "all" ? "All" : "One"; } diff --git a/extensions/kaset-control/src/play-song.tsx b/extensions/kaset-control/src/play-song.tsx index 508586ae2d1..04be3525496 100644 --- a/extensions/kaset-control/src/play-song.tsx +++ b/extensions/kaset-control/src/play-song.tsx @@ -1,54 +1,132 @@ -import { Action, ActionPanel, Icon, List } from "@raycast/api"; +import { + Action, + ActionPanel, + Icon, + LaunchProps, + List, + Toast, + getPreferenceValues, + showToast, +} from "@raycast/api"; import { usePromise } from "@raycast/utils"; import { useMemo, useState } from "react"; -import { SearchSong, playVideo, searchSongs } from "./utils/kaset"; +import { + addToQueue, + mergeSearchResults, + playVideo, + playYouTubeVideo, + searchYouTube, + searchYouTubeMusic, + type SearchResult, + type SongResult, + type VideoResult, +} from "./utils/kaset"; -export default function PlaySong() { +type Source = "both" | "ytmusic" | "youtube"; + +interface PlaySongLaunchContext { + mode?: "play" | "add"; +} + +export default function PlaySong( + props: LaunchProps<{ launchContext: PlaySongLaunchContext }>, +) { + const addMode = props.launchContext?.mode === "add"; const [searchText, setSearchText] = useState(""); + const [source, setSource] = useState( + () => getPreferenceValues().searchSource ?? "both", + ); const query = searchText.trim(); const { data, isLoading, revalidate } = usePromise( - async (q: string) => { - if (!q) return { query: q, songs: [] as SearchSong[] }; - const songs = await searchSongs(q); - return { query: q, songs }; + async (q: string, src: Source) => { + if (!q) return { query: q, source: src, results: [] as SearchResult[] }; + + let results: SearchResult[]; + if (src === "youtube") { + results = await searchYouTube(q); + } else if (src === "ytmusic") { + results = await searchYouTubeMusic(q); + } else { + const [songs, videos] = await Promise.allSettled([ + searchYouTubeMusic(q), + searchYouTube(q), + ]); + if (songs.status === "rejected" && videos.status === "rejected") { + throw songs.reason; + } + results = mergeSearchResults( + songs.status === "fulfilled" ? songs.value : [], + videos.status === "fulfilled" ? videos.value : [], + ); + } + + return { query: q, source: src, results }; + }, + [query, source], + { + onError: async () => { + await showToast({ + style: Toast.Style.Failure, + title: "Search temporarily unavailable", + message: "The search request failed. Please try again.", + }); + }, }, - [query], ); - const hasCurrentResponse = data?.query === query; - const songs = useMemo( - () => (hasCurrentResponse ? (data?.songs ?? []) : []), - [data, hasCurrentResponse], + const settled = + data?.query === query && data?.source === source && !isLoading; + const results = useMemo( + () => (settled ? (data?.results ?? []) : []), + [data, settled], ); const hasQuery = query.length > 0; return ( setSource(value as Source)} + > + + + + + } throttle > {!hasQuery ? ( - ) : !hasCurrentResponse ? ( + ) : !settled ? ( - ) : isLoading && songs.length === 0 ? ( + ) : results.length === 0 ? ( - ) : songs.length === 0 && !isLoading ? ( - ) : ( - songs.map((song) => ) + results.map((result) => ( + + )) )} ); } -function SongItem({ song }: { song: SearchSong }) { - const subtitle = [song.artist, song.album].filter(Boolean).join(" • "); +function ResultItem({ + result, + addMode, +}: { + result: SearchResult; + addMode: boolean; +}) { + const isVideo = result.source === "youtube"; + const song = result as SongResult; + const video = result as VideoResult; + + const subtitle = isVideo + ? video.channel + : [song.artist, song.album].filter(Boolean).join(" • "); + + const play = ( + + isVideo ? playYouTubeVideo(result.id) : playVideo(result.id) + } + /> + ); + + const addToQueueAction = ( + addToQueue([result.id])} + /> + ); + + const playNextAction = ( + addToQueue([result.id], true)} + /> + ); + + const copyLinkAction = ( + + ); return ( - await playVideo(song.id)} - /> - + {addMode ? addToQueueAction : play} + {addMode ? play : addToQueueAction} + + {playNextAction} + + + {copyLinkAction} + } /> diff --git a/extensions/kaset-control/src/record-player-state.ts b/extensions/kaset-control/src/record-player-state.ts new file mode 100644 index 00000000000..14bf10daa04 --- /dev/null +++ b/extensions/kaset-control/src/record-player-state.ts @@ -0,0 +1,8 @@ +import { getKasetState, recordFinishedTrack } from "./utils/kaset"; + +// Headless background recorder (no menu-bar item). Raycast keeps background +// refresh off until this command is opened once, or its preference is enabled. +export default async function Command() { + const state = await getKasetState(); + await recordFinishedTrack(state?.currentTrack ?? null); +} diff --git a/extensions/kaset-control/src/utils/album.ts b/extensions/kaset-control/src/utils/album.ts new file mode 100644 index 00000000000..20ba8c7a45b --- /dev/null +++ b/extensions/kaset-control/src/utils/album.ts @@ -0,0 +1,40 @@ +// Kaset's now-playing payload never carries an album (its parser hardcodes +// `album: nil`), so resolve it here by searching YouTube Music for the current +// track and reading the album off the best match. The Now Playing command +// re-renders every second for progress; results are cached by video id so each +// track is searched at most once and repeats are served from cache. +import { Cache } from "@raycast/api"; +import { pickAlbum, searchYouTubeMusic } from "./search"; + +const cache = new Cache({ namespace: "kaset-control" }); +const ALBUM_KEY_PREFIX = "album."; + +export async function resolveAlbum( + id: string, + title: string, + artist: string, +): Promise { + if (!id || !title) return undefined; + + const cached = cache.get(ALBUM_KEY_PREFIX + id); + // An empty string is a cached miss: the search ran but found no album, so + // there is no point repeating it every poll tick. + if (cached !== undefined) return cached || undefined; + + let album: string | undefined; + try { + const results = await searchYouTubeMusic(`${title} ${artist}`.trim()); + album = pickAlbum(results, id, artist); + } catch { + // A transient failure must not be cached, so a later change can retry. + return undefined; + } + + try { + cache.set(ALBUM_KEY_PREFIX + id, album ?? ""); + } catch { + // Cache writes are an optimisation; never let one break playback control. + } + + return album; +} diff --git a/extensions/kaset-control/src/utils/history.ts b/extensions/kaset-control/src/utils/history.ts new file mode 100644 index 00000000000..61a8a2daa75 --- /dev/null +++ b/extensions/kaset-control/src/utils/history.ts @@ -0,0 +1,51 @@ +// Pure recent-played log. LocalStorage I/O lives in ./kaset.ts so this module +// stays free of Raycast imports and unit-testable. + +export interface TrackLike { + name: string; + artist: string; + album: string; + duration: number; + id: string; + artworkURL: string; +} + +export interface RecentTrack extends TrackLike { + playedAt: number; +} + +export const MAX_RECENT = 5; + +// History is stored newest-first, but the section sits above Now Playing, so it +// is displayed oldest-first. The track that is currently playing is excluded: +// replaying a song that is already in the history would otherwise list it in +// both Now Playing and Recently Played at the same time. +export function visibleRecent( + recent: RecentTrack[], + currentId?: string, +): RecentTrack[] { + return recent + .filter((entry) => entry.id !== currentId) + .slice() + .reverse(); +} + +// Kaset exposes an ID-only placeholder song titled "Loading..." while it fills +// in real metadata (`Song.placeholder` in the app's source, which its own views +// guard against by that same title). Recording one left a junk "Loading..." row +// in Recently Played with no artwork and the fallback icon, so placeholders are +// never logged and are filtered out of anything already stored. +export function isPlaceholderTrack(track: TrackLike): boolean { + if (!track.name || track.name === "Loading...") return true; + return !track.artist && !track.artworkURL && !track.duration; +} + +export function mergeRecent( + prev: RecentTrack[], + track: TrackLike, + now: number, +): RecentTrack[] { + const entry: RecentTrack = { ...track, playedAt: now }; + const without = prev.filter((existing) => existing.id !== track.id); + return [entry, ...without].slice(0, MAX_RECENT); +} diff --git a/extensions/kaset-control/src/utils/kaset.ts b/extensions/kaset-control/src/utils/kaset.ts index 468bd43db9d..7ed3aeb1086 100644 --- a/extensions/kaset-control/src/utils/kaset.ts +++ b/extensions/kaset-control/src/utils/kaset.ts @@ -1,5 +1,19 @@ import { runAppleScript } from "@raycast/utils"; -import { showHUD, showToast, Toast, open } from "@raycast/api"; +import { LocalStorage, showHUD, showToast, Toast, open } from "@raycast/api"; +import { mergeRecent, isPlaceholderTrack, type RecentTrack } from "./history"; +import { + buildAddToQueue, + buildPlayTrackAtIndex, + buildPlayVideos, + buildRemoveFromQueue, + isValidVideoId, + parsePlayQueue, + type PlayQueue, +} from "./queue"; + +// Re-export the pure queue helpers so callers can keep importing from ./kaset. +export * from "./queue"; +export * from "./history"; // Player state types export type PlayerState = "playing" | "paused" | "stopped"; @@ -28,20 +42,20 @@ export interface KasetState { likeStatus: LikeStatus; } -export interface SearchSong { - id: string; - title: string; - artist: string; - album?: string; - duration?: string; - artworkURL?: string; -} - -const YTM_BASE_URL = "https://music.youtube.com/youtubei/v1"; -// Public YouTube Music web client key (not user-secret). This may rotate. -const YTM_API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"; -// WEB_REMIX client version used by youtubei search requests. This may rotate. -const YTM_CLIENT_VERSION = "1.20231204.01.00"; +// Search lives in ./search; re-exported here so existing imports keep working +// until play-song.tsx switches over (Task 5). +export { + searchYouTubeMusic, + searchYouTubeMusic as searchSongs, + searchYouTube, + mergeSearchResults, +} from "./search"; +export type { + SongResult, + SongResult as SearchSong, + VideoResult, + SearchResult, +} from "./search"; // Check if Kaset is running export async function isKasetRunning(): Promise { @@ -124,14 +138,14 @@ async function getPlayerInfo(): Promise { export async function play(): Promise { if (!(await ensureKasetRunning())) return; await runAppleScript(`tell application "Kaset" to play`); - await showHUD("▶️ Playing"); + await showHUD("Playing"); } // Pause export async function pause(): Promise { if (!(await ensureKasetRunning())) return; await runAppleScript(`tell application "Kaset" to pause`); - await showHUD("⏸️ Paused"); + await showHUD("Paused"); } // Toggle play/pause @@ -139,7 +153,7 @@ export async function playPause(): Promise { if (!(await ensureKasetRunning())) return; const state = await getPlayerInfo(); await runAppleScript(`tell application "Kaset" to playpause`); - await showHUD(state?.playerState === "playing" ? "⏸️ Paused" : "▶️ Playing"); + await showHUD(state?.playerState === "playing" ? "Paused" : "Playing"); } // Play a specific YouTube video by video ID @@ -158,21 +172,40 @@ export async function playVideo(videoId: string): Promise { await runAppleScript( `tell application "Kaset" to play video "${normalizedId}"`, ); - await showHUD("▶️ Playing selected song"); + await showHUD("Playing selected song"); +} + +// Play a regular YouTube video in Kaset (docs route these via `open -a Kaset`) +export async function playYouTubeVideo(videoId: string): Promise { + const normalizedId = videoId.trim(); + if (!isValidVideoId(normalizedId)) { + await showToast({ + style: Toast.Style.Failure, + title: "Invalid video ID", + message: "Please provide a valid YouTube video ID", + }); + return; + } + + if (!(await ensureKasetRunning())) return; + await runAppleScript( + `do shell script "open -a Kaset " & quoted form of "https://youtu.be/${normalizedId}"`, + ); + await showHUD("Playing video"); } // Next track export async function nextTrack(): Promise { if (!(await ensureKasetRunning())) return; await runAppleScript(`tell application "Kaset" to next track`); - await showHUD("⏭️ Next Track"); + await showHUD("Next Track"); } // Previous track export async function previousTrack(): Promise { if (!(await ensureKasetRunning())) return; await runAppleScript(`tell application "Kaset" to previous track`); - await showHUD("⏮️ Previous Track"); + await showHUD("Previous Track"); } // Set volume (0-100) @@ -182,7 +215,7 @@ export async function setVolume(volume: number): Promise { await runAppleScript( `tell application "Kaset" to set volume ${clampedVolume}`, ); - await showHUD(`🔊 Volume: ${clampedVolume}%`); + await showHUD(`Volume: ${clampedVolume}%`); } // Get volume @@ -197,7 +230,7 @@ export async function toggleShuffle(): Promise { const stateBefore = await getPlayerInfo(); await runAppleScript(`tell application "Kaset" to toggle shuffle`); // State flips, so show opposite of what it was - await showHUD(stateBefore?.shuffling ? "➡️ Shuffle Off" : "🔀 Shuffle On"); + await showHUD(stateBefore?.shuffling ? "Shuffle Off" : "Shuffle On"); } // Cycle repeat mode @@ -212,7 +245,7 @@ export async function cycleRepeat(): Promise { : stateBefore?.repeating === "all" ? "One" : "Off"; - await showHUD(`🔁 Repeat: ${nextMode}`); + await showHUD(`Repeat: ${nextMode}`); } // Toggle mute @@ -220,7 +253,7 @@ export async function toggleMute(): Promise { if (!(await ensureKasetRunning())) return; const stateBefore = await getPlayerInfo(); await runAppleScript(`tell application "Kaset" to toggle mute`); - await showHUD(stateBefore?.muted ? "🔊 Unmuted" : "🔇 Muted"); + await showHUD(stateBefore?.muted ? "Unmuted" : "Muted"); } // Get player state @@ -281,9 +314,7 @@ export async function likeTrack(): Promise { if (!(await ensureKasetRunning())) return; const stateBefore = await getPlayerInfo(); await runAppleScript(`tell application "Kaset" to like track`); - await showHUD( - stateBefore?.likeStatus === "liked" ? "👍 Unliked" : "👍 Liked", - ); + await showHUD(stateBefore?.likeStatus === "liked" ? "Unliked" : "Liked"); } // Dislike track (toggle) @@ -292,239 +323,160 @@ export async function dislikeTrack(): Promise { const stateBefore = await getPlayerInfo(); await runAppleScript(`tell application "Kaset" to dislike track`); await showHUD( - stateBefore?.likeStatus === "disliked" ? "👎 Undisliked" : "👎 Disliked", + stateBefore?.likeStatus === "disliked" ? "Undisliked" : "Disliked", ); } -export async function searchSongs(query: string): Promise { - const trimmedQuery = query.trim(); - if (!trimmedQuery) return []; +// --- Queue commands (Kaset >= 0.14.2) --- + +// Get the current play queue +export async function getPlayQueue(): Promise { + if (!(await isKasetRunning())) return null; try { - const response = await fetch( - `${YTM_BASE_URL}/search?key=${YTM_API_KEY}&prettyPrint=false`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Origin: "https://music.youtube.com", - }, - body: JSON.stringify({ - context: { - client: { - clientName: "WEB_REMIX", - clientVersion: YTM_CLIENT_VERSION, - hl: "en", - gl: "US", - }, - }, - query: trimmedQuery, - params: "EgWKAQIIAWoMEA4QChADEAQQCRAF", - }), - }, + const result = await runAppleScript( + `tell application "Kaset" to get play queue`, ); + if (!result || result === "{}") return null; + return parsePlayQueue(result); + } catch (e) { + console.error("Failed to get play queue:", e); + return null; + } +} - if (!response.ok) { - throw new Error(`Search failed (${response.status})`); - } +// Play a queue item by its 1-based index +export async function playTrackAtIndex(index: number): Promise { + if (!(await ensureKasetRunning())) return; + await runAppleScript(buildPlayTrackAtIndex(index)); + await showHUD("Playing queue item"); +} - const json = (await response.json()) as Record; - return extractSongsFromSearchResponse(json); +// Remove a video's entries from the queue (Kaset keeps the playing copy) +export async function removeFromQueue(id: string): Promise { + if (!(await ensureKasetRunning())) return; + try { + await runAppleScript(buildRemoveFromQueue(id)); } catch (error) { - console.error("YouTube Music search failed:", error); + // Kaset raises when there is nothing to remove, or when the only match is + // the track that is playing. Surface its reason instead of a raw failure. await showToast({ style: Toast.Style.Failure, - title: "Search temporarily unavailable", - message: "YouTube Music search request failed. Please try again.", + title: "Could not remove from queue", + message: error instanceof Error ? error.message : String(error), }); - return []; + return; } + await showHUD("Removed from queue"); } -function extractSongsFromSearchResponse( - response: Record, -): SearchSong[] { - const renderers = collectRenderers( - response, - "musicResponsiveListItemRenderer", - ); - const songs = renderers - .map(parseSongRenderer) - .filter((song): song is SearchSong => song !== null); - - const uniqueById = new Map(); - for (const song of songs) { - if (!uniqueById.has(song.id)) { - uniqueById.set(song.id, song); - } - } - - return [...uniqueById.values()]; -} - -function parseSongRenderer( - renderer: Record, -): SearchSong | null { - const videoId = extractVideoIdFromRenderer(renderer); - if (!videoId) return null; - - const title = extractFlexText(renderer, 0) ?? "Unknown Title"; - const subtitleText = extractFlexText(renderer, 1) ?? ""; - const subtitleParts = subtitleText - .split(" • ") - .map((part) => part.trim()) - .filter(Boolean); - const artist = subtitleParts[0] ?? "Unknown Artist"; - const album = subtitleParts[1]; - const duration = extractFixedText(renderer, 0); - const artworkURL = extractThumbnailURL(renderer); - - return { - id: videoId, - title, - artist, - album, - duration, - artworkURL, - }; -} - -function collectRenderers( - node: unknown, - rendererKey: string, - collected: Record[] = [], -): Record[] { - if (!node || typeof node !== "object") return collected; - - if (Array.isArray(node)) { - for (const item of node) { - collectRenderers(item, rendererKey, collected); - } - return collected; - } - - const nodeRecord = node as Record; - const renderer = nodeRecord[rendererKey]; - if (renderer && typeof renderer === "object" && !Array.isArray(renderer)) { - collected.push(renderer as Record); - } +// Append video IDs to the queue, or insert them after the current track +export async function addToQueue(ids: string[], next = false): Promise { + if (!(await ensureKasetRunning())) return; - for (const value of Object.values(nodeRecord)) { - collectRenderers(value, rendererKey, collected); + const valid = ids.filter(isValidVideoId); + if (valid.length === 0) { + await showToast({ + style: Toast.Style.Failure, + title: "Invalid video ID", + }); + return; } - return collected; + await runAppleScript(buildAddToQueue(valid, next)); + await showHUD(next ? "Playing next" : "Added to queue"); } -function extractVideoIdFromRenderer( - renderer: Record, -): string | null { - const playlistItemData = asRecord(renderer["playlistItemData"]); - const fromPlaylistData = asString(playlistItemData?.["videoId"]); - if (fromPlaylistData) return fromPlaylistData; - - const directNavigation = asRecord(renderer["navigationEndpoint"]); - const fromDirectNavigation = extractVideoIdFromNavigation(directNavigation); - if (fromDirectNavigation) return fromDirectNavigation; +// Replace the queue with these video IDs and start playing +export async function playVideos( + ids: string[], + startingAt?: number, +): Promise { + if (!(await ensureKasetRunning())) return; - const flexColumns = asArray(renderer["flexColumns"]); - for (const column of flexColumns) { - const columnRenderer = asRecord(column); - const flexRenderer = asRecord( - columnRenderer?.["musicResponsiveListItemFlexColumnRenderer"], - ); - const text = asRecord(flexRenderer?.["text"]); - const runs = asArray(text?.["runs"]); - for (const run of runs) { - const runRecord = asRecord(run); - const navigationEndpoint = asRecord(runRecord?.["navigationEndpoint"]); - const videoId = extractVideoIdFromNavigation(navigationEndpoint); - if (videoId) return videoId; - } + const valid = ids.filter(isValidVideoId); + if (valid.length === 0) { + await showToast({ + style: Toast.Style.Failure, + title: "Invalid video ID", + }); + return; } - return null; + await runAppleScript(buildPlayVideos(valid, startingAt)); + await showHUD("Playing"); } -function extractVideoIdFromNavigation( - navigationEndpoint: Record | undefined, -): string | null { - if (!navigationEndpoint) return null; - const watchEndpoint = asRecord(navigationEndpoint["watchEndpoint"]); - const videoId = asString(watchEndpoint?.["videoId"]); - return videoId ?? null; +// Format seconds to MM:SS +export function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; } -function extractFlexText( - renderer: Record, - index: number, -): string | null { - const flexColumns = asArray(renderer["flexColumns"]); - const column = asRecord(flexColumns[index]); - const flexRenderer = asRecord( - column?.["musicResponsiveListItemFlexColumnRenderer"], +// --- Recent-played log (forward-only, stored in LocalStorage) --- + +const RECENT_KEY = "kaset-recently-played"; + +export async function recordPlayed(track: Track): Promise { + const raw = await LocalStorage.getItem(RECENT_KEY); + let prev: RecentTrack[] = []; + if (raw) { + try { + prev = JSON.parse(raw) as RecentTrack[]; + } catch { + prev = []; + } + } + if (prev[0]?.id === track.id) return; + await LocalStorage.setItem( + RECENT_KEY, + JSON.stringify(mergeRecent(prev, track, Date.now())), ); - return extractTextRuns(asRecord(flexRenderer?.["text"])); } -function extractFixedText( - renderer: Record, - index: number, -): string | undefined { - const fixedColumns = asArray(renderer["fixedColumns"]); - const column = asRecord(fixedColumns[index]); - const fixedRenderer = asRecord( - column?.["musicResponsiveListItemFixedColumnRenderer"], - ); - return extractTextRuns(asRecord(fixedRenderer?.["text"])) ?? undefined; -} - -function extractTextRuns( - text: Record | undefined, -): string | null { - if (!text) return null; - const runs = asArray(text["runs"]); - if (runs.length > 0) { - const joined = runs - .map((run) => asString(asRecord(run)?.["text"])) - .filter((run): run is string => Boolean(run)) - .join(""); - return joined || null; +export async function getRecent(): Promise { + const raw = await LocalStorage.getItem(RECENT_KEY); + if (!raw) return []; + try { + // Filter on read as well, so placeholders logged by earlier versions vanish + // instead of lingering until they age out. + return (JSON.parse(raw) as RecentTrack[]).filter( + (entry) => !isPlaceholderTrack(entry), + ); + } catch { + return []; } - - return asString(text["simpleText"]) ?? null; } -function extractThumbnailURL( - renderer: Record, -): string | undefined { - const thumbnail = asRecord(renderer["thumbnail"]); - const musicThumbnailRenderer = asRecord( - thumbnail?.["musicThumbnailRenderer"], - ); - const thumbnailData = asRecord(musicThumbnailRenderer?.["thumbnail"]); - const thumbnails = asArray(thumbnailData?.["thumbnails"]); - const lastThumbnail = asRecord(thumbnails[thumbnails.length - 1]); - return asString(lastThumbnail?.["url"]) ?? undefined; -} +// Record a search result when the user plays it, so history does not depend on +// the background recorder or the menu bar. +const NOW_PLAYING_KEY = "kaset-now-playing"; -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) - return undefined; - return value as Record; -} +// A song enters the history only once it has stopped playing: remember the +// current track and record the previous one when the track changes. +export async function recordFinishedTrack( + current: Track | null, +): Promise { + // Kaset briefly reports an ID-only placeholder titled "Loading..." before the + // real metadata lands; logging it would add a junk row to the history. + if (current && isPlaceholderTrack(current)) return; -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} + const raw = await LocalStorage.getItem(NOW_PLAYING_KEY); + let previous: Track | null = null; + if (raw) { + try { + previous = JSON.parse(raw) as Track; + } catch { + previous = null; + } + } -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} + if (current) { + await LocalStorage.setItem(NOW_PLAYING_KEY, JSON.stringify(current)); + } -// Format seconds to MM:SS -export function formatTime(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, "0")}`; + if (previous && previous.id !== current?.id) { + await recordPlayed(previous); + } } diff --git a/extensions/kaset-control/src/utils/queue.ts b/extensions/kaset-control/src/utils/queue.ts new file mode 100644 index 00000000000..24a46015771 --- /dev/null +++ b/extensions/kaset-control/src/utils/queue.ts @@ -0,0 +1,72 @@ +// Pure queue helpers — no Raycast imports, so they are unit-testable. +// The AppleScript wrappers that use these live in ./kaset.ts. + +export interface QueueTrack { + name: string; + artist: string; + album: string; + duration: number; + id: string; + audioVideoId?: string; + artworkURL: string; +} + +export interface PlayQueue { + currentIndex: number; + tracks: QueueTrack[]; +} + +export function isValidVideoId(id: string): boolean { + return /^[A-Za-z0-9_-]{11}$/.test(id); +} + +export function parsePlayQueue(raw: string): PlayQueue { + const parsed = JSON.parse(raw) as { + currentIndex?: unknown; + tracks?: unknown; + }; + const rawTracks = Array.isArray(parsed.tracks) ? parsed.tracks : []; + + return { + currentIndex: + typeof parsed.currentIndex === "number" ? parsed.currentIndex : 0, + tracks: rawTracks.map((track) => { + const t = (track ?? {}) as Record; + return { + name: asString(t.name) ?? "Unknown", + artist: asString(t.artist) ?? "Unknown Artist", + album: asString(t.album) ?? "", + duration: typeof t.duration === "number" ? t.duration : 0, + id: asString(t.videoId) ?? "", + audioVideoId: asString(t.audioVideoId), + artworkURL: asString(t.artworkURL) ?? "", + }; + }), + }; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function appleScriptList(ids: string[]): string { + return `{${ids.map((id) => `"${id}"`).join(", ")}}`; +} + +export function buildPlayTrackAtIndex(index: number): string { + return `tell application "Kaset" to play track at index ${index}`; +} + +export function buildRemoveFromQueue(id: string): string { + return `tell application "Kaset" to remove from queue "${id}"`; +} + +export function buildAddToQueue(ids: string[], next = false): string { + const suffix = next ? " with next" : ""; + return `tell application "Kaset" to add to queue ${appleScriptList(ids)}${suffix}`; +} + +export function buildPlayVideos(ids: string[], startingAt?: number): string { + const suffix = startingAt === undefined ? "" : ` starting at ${startingAt}`; + return `tell application "Kaset" to play videos ${appleScriptList(ids)}${suffix}`; +} diff --git a/extensions/kaset-control/src/utils/search.ts b/extensions/kaset-control/src/utils/search.ts new file mode 100644 index 00000000000..be140b4ee6a --- /dev/null +++ b/extensions/kaset-control/src/utils/search.ts @@ -0,0 +1,410 @@ +// Search across YouTube Music (WEB_REMIX) and regular YouTube (WEB). +// Pure response parsers are exported for testing; the fetch wrappers sit below. + +export interface SongResult { + source: "ytmusic"; + id: string; + title: string; + artist: string; + album?: string; + duration?: string; + artworkURL?: string; +} + +export interface VideoResult { + source: "youtube"; + id: string; + title: string; + channel: string; + duration?: string; + artworkURL?: string; +} + +export type SearchResult = SongResult | VideoResult; + +const YTM_BASE_URL = "https://music.youtube.com/youtubei/v1"; +// Public YouTube Music web client key (not user-secret). This may rotate. +const YTM_API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30"; +// WEB_REMIX client version used by youtubei search requests. This may rotate. +const YTM_CLIENT_VERSION = "1.20231204.01.00"; + +const YOUTUBE_BASE_URL = "https://www.youtube.com/youtubei/v1"; +// Public YouTube web client key (not user-secret). This may rotate. +const YOUTUBE_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"; +// WEB client version used by YouTube search requests. This may rotate. +const YOUTUBE_CLIENT_VERSION = "2.20240101.00.00"; + +export function parseYouTubeMusicResults( + response: Record, +): SongResult[] { + const renderers = collectRenderers( + response, + "musicResponsiveListItemRenderer", + ); + const songs = renderers + .map(parseSongRenderer) + .filter((song): song is SongResult => song !== null); + + const uniqueById = new Map(); + for (const song of songs) { + if (!uniqueById.has(song.id)) { + uniqueById.set(song.id, song); + } + } + + return [...uniqueById.values()]; +} + +export function parseYouTubeResults( + response: Record, +): VideoResult[] { + const renderers = collectRenderers(response, "videoRenderer"); + const videos = renderers + .map(parseVideoRenderer) + .filter((video): video is VideoResult => video !== null); + + const uniqueById = new Map(); + for (const video of videos) { + if (!uniqueById.has(video.id)) { + uniqueById.set(video.id, video); + } + } + + return [...uniqueById.values()]; +} + +function parseVideoRenderer( + renderer: Record, +): VideoResult | null { + const id = asString(renderer["videoId"]); + if (!id) return null; + + return { + source: "youtube", + id, + title: extractTextRuns(asRecord(renderer["title"])) ?? "Unknown Title", + channel: + extractTextRuns(asRecord(renderer["ownerText"])) ?? "Unknown Channel", + duration: asString(asRecord(renderer["lengthText"])?.["simpleText"]), + artworkURL: extractDirectThumbnailURL(renderer), + }; +} + +export function mergeSearchResults( + songs: SongResult[], + videos: VideoResult[], +): SearchResult[] { + const merged: SearchResult[] = []; + const max = Math.max(songs.length, videos.length); + for (let index = 0; index < max; index += 1) { + const song = songs[index]; + const video = videos[index]; + if (song) merged.push(song); + if (video) merged.push(video); + } + return merged; +} + +export async function searchYouTubeMusic(query: string): Promise { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return []; + + const response = await fetch( + `${YTM_BASE_URL}/search?key=${YTM_API_KEY}&prettyPrint=false`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://music.youtube.com", + }, + body: JSON.stringify({ + context: { + client: { + clientName: "WEB_REMIX", + clientVersion: YTM_CLIENT_VERSION, + hl: "en", + gl: "US", + }, + }, + query: trimmedQuery, + params: "EgWKAQIIAWoMEA4QChADEAQQCRAF", + }), + }, + ); + + if (!response.ok) { + throw new Error(`Search failed (${response.status})`); + } + + const json = (await response.json()) as Record; + return parseYouTubeMusicResults(json); +} + +export async function searchYouTube(query: string): Promise { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return []; + + const response = await fetch( + `${YOUTUBE_BASE_URL}/search?key=${YOUTUBE_API_KEY}&prettyPrint=false`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "https://www.youtube.com", + }, + body: JSON.stringify({ + context: { + client: { + clientName: "WEB", + clientVersion: YOUTUBE_CLIENT_VERSION, + hl: "en", + gl: "US", + }, + }, + query: trimmedQuery, + }), + }, + ); + + if (!response.ok) { + throw new Error(`Search failed (${response.status})`); + } + + const json = (await response.json()) as Record; + return parseYouTubeResults(json); +} + +function parseSongRenderer( + renderer: Record, +): SongResult | null { + const videoId = extractVideoIdFromRenderer(renderer); + if (!videoId) return null; + + const title = extractFlexText(renderer, 0) ?? "Unknown Title"; + const subtitleText = extractFlexText(renderer, 1) ?? ""; + const subtitleParts = subtitleText + .split(" • ") + .map((part) => part.trim()) + .filter(Boolean); + + // Current YouTube Music responses carry the duration as the last subtitle + // run and omit fixedColumns; older responses use fixedColumns. + let duration = extractFixedText(renderer, 0); + const lastPart = subtitleParts[subtitleParts.length - 1]; + if (lastPart && /^\d+:\d+(?::\d+)?$/.test(lastPart)) { + duration = duration ?? lastPart; + subtitleParts.pop(); + } + + return { + source: "ytmusic", + id: videoId, + title, + artist: subtitleParts[0] ?? "Unknown Artist", + album: extractAlbumFromRenderer(renderer) ?? subtitleParts[1], + duration, + artworkURL: extractThumbnailURL(renderer), + }; +} + +// A search result's album link is a subtitle run whose browse endpoint points at +// an album page. Album browse ids are prefixed MPRE (music albums) and OLAK +// (audiobooks/albums); the run carrying one is authoritative, unlike the +// positional subtitle split, which assumes a fixed "artist • album • duration" +// order. +const ALBUM_BROWSE_ID_PREFIXES = ["MPRE", "OLAK"]; + +function extractAlbumFromRenderer( + renderer: Record, +): string | undefined { + const flexColumns = asArray(renderer["flexColumns"]); + for (const column of flexColumns) { + const columnRenderer = asRecord(column); + const flexRenderer = asRecord( + columnRenderer?.["musicResponsiveListItemFlexColumnRenderer"], + ); + const text = asRecord(flexRenderer?.["text"]); + const runs = asArray(text?.["runs"]); + for (const run of runs) { + const runRecord = asRecord(run); + const navigationEndpoint = asRecord(runRecord?.["navigationEndpoint"]); + const browseEndpoint = asRecord(navigationEndpoint?.["browseEndpoint"]); + const browseId = asString(browseEndpoint?.["browseId"]); + if ( + browseId && + ALBUM_BROWSE_ID_PREFIXES.some((prefix) => browseId.startsWith(prefix)) + ) { + const album = asString(runRecord?.["text"]); + if (album) return album; + } + } + } + + return undefined; +} + +// Pick the album for a track from a set of search results: an exact video-id +// match wins, then a result by the same artist, then the first result that +// carries an album. Returns undefined when none of them has one. +export function pickAlbum( + results: SongResult[], + id: string, + artist: string, +): string | undefined { + const withAlbum = results.filter((result) => result.album); + const byId = withAlbum.find((result) => result.id === id); + if (byId?.album) return byId.album; + + const wanted = artist.trim().toLowerCase(); + if (wanted) { + const byArtist = withAlbum.find((result) => { + const got = result.artist.trim().toLowerCase(); + return got === wanted || got.includes(wanted) || wanted.includes(got); + }); + if (byArtist?.album) return byArtist.album; + } + + return withAlbum[0]?.album; +} + +function collectRenderers( + node: unknown, + rendererKey: string, + collected: Record[] = [], +): Record[] { + if (!node || typeof node !== "object") return collected; + + if (Array.isArray(node)) { + for (const item of node) { + collectRenderers(item, rendererKey, collected); + } + return collected; + } + + const nodeRecord = node as Record; + const renderer = nodeRecord[rendererKey]; + if (renderer && typeof renderer === "object" && !Array.isArray(renderer)) { + collected.push(renderer as Record); + } + + for (const value of Object.values(nodeRecord)) { + collectRenderers(value, rendererKey, collected); + } + + return collected; +} + +function extractVideoIdFromRenderer( + renderer: Record, +): string | null { + const playlistItemData = asRecord(renderer["playlistItemData"]); + const fromPlaylistData = asString(playlistItemData?.["videoId"]); + if (fromPlaylistData) return fromPlaylistData; + + const directNavigation = asRecord(renderer["navigationEndpoint"]); + const fromDirectNavigation = extractVideoIdFromNavigation(directNavigation); + if (fromDirectNavigation) return fromDirectNavigation; + + const flexColumns = asArray(renderer["flexColumns"]); + for (const column of flexColumns) { + const columnRenderer = asRecord(column); + const flexRenderer = asRecord( + columnRenderer?.["musicResponsiveListItemFlexColumnRenderer"], + ); + const text = asRecord(flexRenderer?.["text"]); + const runs = asArray(text?.["runs"]); + for (const run of runs) { + const runRecord = asRecord(run); + const navigationEndpoint = asRecord(runRecord?.["navigationEndpoint"]); + const videoId = extractVideoIdFromNavigation(navigationEndpoint); + if (videoId) return videoId; + } + } + + return null; +} + +function extractVideoIdFromNavigation( + navigationEndpoint: Record | undefined, +): string | null { + if (!navigationEndpoint) return null; + const watchEndpoint = asRecord(navigationEndpoint["watchEndpoint"]); + const videoId = asString(watchEndpoint?.["videoId"]); + return videoId ?? null; +} + +function extractFlexText( + renderer: Record, + index: number, +): string | null { + const flexColumns = asArray(renderer["flexColumns"]); + const column = asRecord(flexColumns[index]); + const flexRenderer = asRecord( + column?.["musicResponsiveListItemFlexColumnRenderer"], + ); + return extractTextRuns(asRecord(flexRenderer?.["text"])); +} + +function extractFixedText( + renderer: Record, + index: number, +): string | undefined { + const fixedColumns = asArray(renderer["fixedColumns"]); + const column = asRecord(fixedColumns[index]); + const fixedRenderer = asRecord( + column?.["musicResponsiveListItemFixedColumnRenderer"], + ); + return extractTextRuns(asRecord(fixedRenderer?.["text"])) ?? undefined; +} + +function extractTextRuns( + text: Record | undefined, +): string | null { + if (!text) return null; + const runs = asArray(text["runs"]); + if (runs.length > 0) { + const joined = runs + .map((run) => asString(asRecord(run)?.["text"])) + .filter((run): run is string => Boolean(run)) + .join(""); + return joined || null; + } + + return asString(text["simpleText"]) ?? null; +} + +function extractThumbnailURL( + renderer: Record, +): string | undefined { + const thumbnail = asRecord(renderer["thumbnail"]); + const musicThumbnailRenderer = asRecord( + thumbnail?.["musicThumbnailRenderer"], + ); + const thumbnailData = asRecord(musicThumbnailRenderer?.["thumbnail"]); + const thumbnails = asArray(thumbnailData?.["thumbnails"]); + const lastThumbnail = asRecord(thumbnails[thumbnails.length - 1]); + return asString(lastThumbnail?.["url"]) ?? undefined; +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) + return undefined; + return value as Record; +} + +function extractDirectThumbnailURL( + renderer: Record, +): string | undefined { + const thumbnail = asRecord(renderer["thumbnail"]); + const thumbnails = asArray(thumbnail?.["thumbnails"]); + const lastThumbnail = asRecord(thumbnails[thumbnails.length - 1]); + return asString(lastThumbnail?.["url"]) ?? undefined; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} diff --git a/extensions/kaset-control/src/utils/snapshot.ts b/extensions/kaset-control/src/utils/snapshot.ts new file mode 100644 index 00000000000..6565474f10b --- /dev/null +++ b/extensions/kaset-control/src/utils/snapshot.ts @@ -0,0 +1,31 @@ +import { Cache } from "@raycast/api"; + +// Raycast's own start-up dominates the ~1s "Loading player..." wait: the +// AppleScript calls behind it measure ~0.2s each. `Cache` is synchronous +// (unlike `LocalStorage`), so the last known snapshot can be read during the +// very first render and painted immediately, then replaced by live data. +const cache = new Cache({ namespace: "kaset-control" }); + +export const SNAPSHOT_KEYS = { + library: "snapshot.library", + state: "snapshot.state", +} as const; + +export function readSnapshot(key: string): T | undefined { + const raw = cache.get(key); + if (!raw) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + // A corrupt entry is not worth failing the command over. + return undefined; + } +} + +export function writeSnapshot(key: string, value: T): void { + try { + cache.set(key, JSON.stringify(value)); + } catch { + // Cache writes are an optimisation; never let one break playback control. + } +}