Skip to content

Commit e14d84c

Browse files
committed
Add play/append/queue actions to Spotify and YouTube search results
Search results overlays now support Enter (play now), a (append), and q (queue next) on the selected track, instead of only routing to a Spotify cloud-playlist add. The cloud-playlist add moves to p. YouTube/SoundCloud net search gains a results picker (ytsearch10 / scsearch10) using the same keymap, replacing the previous one-shot ytsearch1 auto-queue. Adds shared playTrackImmediate / appendTrack / queueTrackNext helpers and closeNetSearch / closeSpotSearch helpers that drop cached result slices on close. playlist.IsYTSearch generalises the ytsearch:/ytsearchN:/scsearch:/ scsearchN: prefix check used by IsURL, IsYouTubeURL, and IsYTDL.
1 parent dd4e074 commit e14d84c

11 files changed

Lines changed: 293 additions & 51 deletions

File tree

docs/keybindings.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ Press `Ctrl+K` in the player to see all keybindings.
7171
| `r` | Cycle repeat (Off / All / One) |
7272
| `z` | Toggle shuffle |
7373

74+
## Search results overlays
75+
76+
When `Ctrl+F` opens the Spotify search or YouTube/SoundCloud net search and you're viewing the results list:
77+
78+
| Key | Action |
79+
|---|---|
80+
| `` `` / `j` `k` | Move cursor |
81+
| `Enter` | Play the selected track now |
82+
| `a` | Append the selected track to the playlist |
83+
| `q` | Queue the selected track to play next |
84+
| `p` | (Spotify only) Save the selected track to a Spotify playlist |
85+
| `Esc` `Backspace` | Back to the search input |
86+
7487
## General
7588

7689
| Key | Action |

playlist/playlist.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,30 @@ func (t Track) Meta(key string) string {
6262
// IsURL reports whether path is an HTTP or HTTPS URL, or a yt-dlp search protocol string.
6363
func IsURL(path string) bool {
6464
return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") ||
65-
strings.HasPrefix(path, "ytsearch:") || strings.HasPrefix(path, "ytsearch1:") ||
66-
strings.HasPrefix(path, "scsearch:") || strings.HasPrefix(path, "scsearch1:")
65+
IsYTSearch(path)
66+
}
67+
68+
// IsYTSearch reports whether path is a yt-dlp search expression
69+
// (ytsearch:, ytsearchN:, scsearch:, scsearchN:).
70+
func IsYTSearch(path string) bool {
71+
return matchSearchPrefix(path, "ytsearch") || matchSearchPrefix(path, "scsearch")
72+
}
73+
74+
func matchSearchPrefix(path, name string) bool {
75+
if !strings.HasPrefix(path, name) {
76+
return false
77+
}
78+
rest := path[len(name):]
79+
colon := strings.IndexByte(rest, ':')
80+
if colon < 0 {
81+
return false
82+
}
83+
for _, c := range rest[:colon] {
84+
if c < '0' || c > '9' {
85+
return false
86+
}
87+
}
88+
return true
6789
}
6890

6991
// IsM3U reports whether the path points to an M3U playlist file (URL or local).
@@ -109,7 +131,7 @@ func IsYouTubeURL(path string) bool {
109131
return false
110132
}
111133
// ytsearch: protocols are handled by yt-dlp, not the native YouTube client.
112-
if strings.HasPrefix(path, "ytsearch:") || strings.HasPrefix(path, "ytsearch1:") {
134+
if IsYTSearch(path) {
113135
return false
114136
}
115137
u, err := url.Parse(path)
@@ -152,8 +174,7 @@ func IsYTDL(path string) bool {
152174
if IsYouTubeURL(path) || IsYouTubeMusicURL(path) {
153175
return true
154176
}
155-
if strings.HasPrefix(path, "ytsearch:") || strings.HasPrefix(path, "ytsearch1:") ||
156-
strings.HasPrefix(path, "scsearch:") || strings.HasPrefix(path, "scsearch1:") {
177+
if IsYTSearch(path) {
157178
return true
158179
}
159180
u, err := url.Parse(path)

playlist/url_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ func TestIsURL(t *testing.T) {
1111
{"https://example.com/stream.mp3", true},
1212
{"ytsearch:lofi hip hop", true},
1313
{"ytsearch1:some song", true},
14+
{"ytsearch10:multi result query", true},
1415
{"scsearch:artist name", true},
1516
{"scsearch1:track name", true},
17+
{"scsearch10:multi result query", true},
18+
{"ytsearchabc:bad", false},
1619
{"/home/user/music/song.mp3", false},
1720
{"relative/path.flac", false},
1821
{"", false},
@@ -122,6 +125,7 @@ func TestIsYouTubeURL(t *testing.T) {
122125
// ytsearch protocols should NOT match
123126
{"ytsearch:lofi hip hop", false},
124127
{"ytsearch1:some song", false},
128+
{"ytsearch10:multi result query", false},
125129
// Non-YouTube
126130
{"https://soundcloud.com/artist/track", false},
127131
{"/local/file.mp3", false},
@@ -170,8 +174,10 @@ func TestIsYTDL(t *testing.T) {
170174
// Search protocols
171175
{"ytsearch:lofi hip hop", true},
172176
{"ytsearch1:some song", true},
177+
{"ytsearch10:multi result query", true},
173178
{"scsearch:artist name", true},
174179
{"scsearch1:track name", true},
180+
{"scsearch5:multi result query", true},
175181
// SoundCloud
176182
{"https://soundcloud.com/artist/track", true},
177183
// Bandcamp

site/index.html

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1478,7 +1478,7 @@ <h3>Run the setup wizard</h3>
14781478
<div class="source" style="--src-color:#ff0000">
14791479
<div class="source-badge">yt-dlp</div>
14801480
<div class="source-name">YouTube</div>
1481-
<div class="source-desc">Search and queue videos. Press <kbd>f</kbd> to search in-player.</div>
1481+
<div class="source-desc">Search videos with <kbd>Ctrl+F</kbd>, then play, append, or queue from a results list.</div>
14821482
</div>
14831483
<div class="source" style="--src-color:#ff0000">
14841484
<div class="source-badge">OAuth · cached</div>
@@ -1910,6 +1910,17 @@ <h3>Run the setup wizard</h3>
19101910
</div>
19111911
</div>
19121912

1913+
<div class="keys-group">
1914+
<div class="keys-group-title">Search Results</div>
1915+
<div class="keys-group-body">
1916+
<div class="key-row"><kbd>Enter</kbd><span>Play selected track now</span></div>
1917+
<div class="key-row"><kbd>a</kbd><span>Append selected to playlist</span></div>
1918+
<div class="key-row"><kbd>q</kbd><span>Queue selected to play next</span></div>
1919+
<div class="key-row"><kbd>p</kbd><span>Save to Spotify playlist (Spotify only)</span></div>
1920+
<div class="key-row"><kbd>Esc</kbd><span>Back to search input</span></div>
1921+
</div>
1922+
</div>
1923+
19131924
<div class="keys-group">
19141925
<div class="keys-group-title">Providers &amp; UI</div>
19151926
<div class="keys-group-body">

ui/model/commands.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,12 @@ type lyricsLoadedMsg struct {
6464
err error
6565
}
6666

67-
// netSearchLoadedMsg carries tracks dynamically searched from the internet.
68-
type netSearchLoadedMsg []playlist.Track
67+
// netSearchResultsMsg carries the result set of a yt-dlp/sc-dlp search query
68+
// so the UI can present a picker rather than auto-queuing.
69+
type netSearchResultsMsg struct {
70+
tracks []playlist.Track
71+
err error
72+
}
6973

7074
// streamPlayedMsg signals that async stream Play() completed.
7175
type streamPlayedMsg struct{ err error }
@@ -189,10 +193,7 @@ func fetchLyricsCmd(artist, title string) tea.Cmd {
189193
func fetchNetSearchCmd(query string) tea.Cmd {
190194
return func() tea.Msg {
191195
tracks, err := resolve.Remote([]string{query})
192-
if err != nil {
193-
return err
194-
}
195-
return netSearchLoadedMsg(tracks)
196+
return netSearchResultsMsg{tracks: tracks, err: err}
196197
}
197198
}
198199

ui/model/keys.go

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -652,9 +652,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
652652
screen: spotSearchInput,
653653
}
654654
} else {
655-
m.netSearch.active = true
656-
m.netSearch.query = ""
657-
m.netSearch.soundcloud = false
655+
m.netSearch = netSearchState{
656+
active: true,
657+
screen: netSearchInput,
658+
}
658659
m.prevFocus = m.focus
659660
m.focus = focusNetSearch
660661
}
@@ -1027,7 +1028,9 @@ func (m *Model) handlePaste(content string) tea.Cmd {
10271028
}
10281029

10291030
if m.netSearch.active {
1030-
m.netSearch.query += content
1031+
if m.netSearch.screen == netSearchInput {
1032+
m.netSearch.query += content
1033+
}
10311034
return nil
10321035
}
10331036

@@ -1118,32 +1121,37 @@ func (m *Model) handleSearchKey(msg tea.KeyPressMsg) tea.Cmd {
11181121
return nil
11191122
}
11201123

1121-
// handleNetSearchKey processes key presses while in net search mode.
1124+
// handleNetSearchKey dispatches key presses to the active net search screen.
11221125
func (m *Model) handleNetSearchKey(msg tea.KeyPressMsg) tea.Cmd {
1123-
switch msg.String() {
1124-
case "ctrl+k":
1126+
if msg.String() == "ctrl+k" {
11251127
m.openKeymap()
11261128
return nil
11271129
}
1130+
switch m.netSearch.screen {
1131+
case netSearchInput:
1132+
return m.handleNetSearchInputKey(msg)
1133+
case netSearchResults:
1134+
return m.handleNetSearchResultsKey(msg)
1135+
}
1136+
return nil
1137+
}
11281138

1139+
// handleNetSearchInputKey handles text entry on the net search overlay.
1140+
func (m *Model) handleNetSearchInputKey(msg tea.KeyPressMsg) tea.Cmd {
11291141
switch msg.Code {
11301142
case tea.KeyEscape:
1131-
m.netSearch.active = false
1132-
m.focus = m.prevFocus
1143+
m.closeNetSearch()
11331144

11341145
case tea.KeyEnter:
1135-
var cmd tea.Cmd
1136-
m.netSearch.active = false
1137-
m.focus = m.prevFocus
1138-
if strings.TrimSpace(m.netSearch.query) != "" {
1139-
prefix := "ytsearch1:"
1146+
if strings.TrimSpace(m.netSearch.query) != "" && !m.netSearch.loading {
1147+
prefix := "ytsearch10:"
11401148
if m.netSearch.soundcloud {
1141-
prefix = "scsearch1:"
1149+
prefix = "scsearch10:"
11421150
}
1143-
m.status.Show("Queuing search...", statusTTLShort)
1144-
cmd = fetchNetSearchCmd(prefix + strings.TrimSpace(m.netSearch.query))
1151+
m.netSearch.loading = true
1152+
m.netSearch.err = ""
1153+
return fetchNetSearchCmd(prefix + strings.TrimSpace(m.netSearch.query))
11451154
}
1146-
return cmd
11471155

11481156
case tea.KeyBackspace:
11491157
m.netSearch.query = removeLastRune(m.netSearch.query)
@@ -1156,7 +1164,50 @@ func (m *Model) handleNetSearchKey(msg tea.KeyPressMsg) tea.Cmd {
11561164
m.netSearch.query += msg.Text
11571165
}
11581166
}
1167+
return nil
1168+
}
1169+
1170+
// handleNetSearchResultsKey handles navigation through net search results.
1171+
func (m *Model) handleNetSearchResultsKey(msg tea.KeyPressMsg) tea.Cmd {
1172+
count := len(m.netSearch.results)
11591173

1174+
switch msg.String() {
1175+
case "up", "k":
1176+
if m.netSearch.cursor > 0 {
1177+
m.netSearch.cursor--
1178+
} else if count > 0 {
1179+
m.netSearch.cursor = count - 1
1180+
}
1181+
case "down", "j":
1182+
if m.netSearch.cursor < count-1 {
1183+
m.netSearch.cursor++
1184+
} else if count > 0 {
1185+
m.netSearch.cursor = 0
1186+
}
1187+
case "enter":
1188+
if count > 0 && !m.netSearch.loading {
1189+
track := m.netSearch.results[m.netSearch.cursor]
1190+
m.closeNetSearch()
1191+
return m.playTrackImmediate(track)
1192+
}
1193+
case "a":
1194+
if count > 0 && !m.netSearch.loading {
1195+
track := m.netSearch.results[m.netSearch.cursor]
1196+
m.closeNetSearch()
1197+
return m.appendTrack(track)
1198+
}
1199+
case "q":
1200+
if count > 0 && !m.netSearch.loading {
1201+
track := m.netSearch.results[m.netSearch.cursor]
1202+
m.closeNetSearch()
1203+
return m.queueTrackNext(track)
1204+
}
1205+
case "esc", "backspace":
1206+
m.netSearch.screen = netSearchInput
1207+
m.netSearch.results = nil
1208+
m.netSearch.cursor = 0
1209+
m.netSearch.err = ""
1210+
}
11601211
return nil
11611212
}
11621213

ui/model/keys_spotify_search.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
func (m *Model) handleSpotSearchKey(msg tea.KeyPressMsg) tea.Cmd {
1111
switch msg.String() {
1212
case "ctrl+c":
13-
m.spotSearch.visible = false
13+
m.closeSpotSearch()
1414
return m.quit()
1515
}
1616

@@ -31,7 +31,7 @@ func (m *Model) handleSpotSearchKey(msg tea.KeyPressMsg) tea.Cmd {
3131
func (m *Model) handleSpotSearchInputKey(msg tea.KeyPressMsg) tea.Cmd {
3232
switch msg.Code {
3333
case tea.KeyEscape:
34-
m.spotSearch.visible = false
34+
m.closeSpotSearch()
3535
case tea.KeyEnter:
3636
if m.spotSearch.query != "" && !m.spotSearch.loading {
3737
s, ok := m.spotSearch.prov.(provider.Searcher)
@@ -74,6 +74,24 @@ func (m *Model) handleSpotSearchResultsKey(msg tea.KeyPressMsg) tea.Cmd {
7474
m.spotSearch.cursor = 0
7575
}
7676
case "enter":
77+
if count > 0 && !m.spotSearch.loading {
78+
track := m.spotSearch.results[m.spotSearch.cursor]
79+
m.closeSpotSearch()
80+
return m.playTrackImmediate(track)
81+
}
82+
case "a":
83+
if count > 0 && !m.spotSearch.loading {
84+
track := m.spotSearch.results[m.spotSearch.cursor]
85+
m.closeSpotSearch()
86+
return m.appendTrack(track)
87+
}
88+
case "q":
89+
if count > 0 && !m.spotSearch.loading {
90+
track := m.spotSearch.results[m.spotSearch.cursor]
91+
m.closeSpotSearch()
92+
return m.queueTrackNext(track)
93+
}
94+
case "p":
7795
if count > 0 && !m.spotSearch.loading {
7896
m.spotSearch.selTrack = m.spotSearch.results[m.spotSearch.cursor]
7997
m.spotSearch.loading = true

ui/model/playback.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,66 @@ func (m *Model) playCurrentTrack() tea.Cmd {
7979
return m.playTrack(activation.Track)
8080
}
8181

82+
// playTrackImmediate appends a track to the playlist and starts playing it now,
83+
// stopping any current playback. Used by search-result "Play now" actions.
84+
func (m *Model) playTrackImmediate(track playlist.Track) tea.Cmd {
85+
m.player.Stop()
86+
m.player.ClearPreload()
87+
m.playlist.Add(track)
88+
idx := m.playlist.Len() - 1
89+
m.playlist.SetIndex(idx)
90+
m.plCursor = idx
91+
m.adjustScroll()
92+
m.status.Showf(statusTTLMedium, "Playing: %s", track.DisplayName())
93+
cmd := m.playCurrentTrack()
94+
m.notifyPlayback()
95+
return cmd
96+
}
97+
98+
// appendTrack appends a track to the playlist; auto-plays if nothing is playing.
99+
func (m *Model) appendTrack(track playlist.Track) tea.Cmd {
100+
wasEmpty := m.playlist.Len() == 0
101+
m.playlist.Add(track)
102+
idx := m.playlist.Len() - 1
103+
m.status.Showf(statusTTLMedium, "Added: %s", track.DisplayName())
104+
if wasEmpty || !m.player.IsPlaying() {
105+
m.playlist.SetIndex(idx)
106+
m.plCursor = idx
107+
m.adjustScroll()
108+
cmd := m.playCurrentTrack()
109+
m.notifyPlayback()
110+
return cmd
111+
}
112+
return nil
113+
}
114+
115+
// closeNetSearch fully resets the net search overlay and restores focus,
116+
// dropping any cached results so they don't linger between sessions.
117+
func (m *Model) closeNetSearch() {
118+
m.netSearch = netSearchState{}
119+
m.focus = m.prevFocus
120+
}
121+
122+
// closeSpotSearch fully resets the Spotify search overlay, dropping cached
123+
// results, playlists, and the selected track.
124+
func (m *Model) closeSpotSearch() {
125+
m.spotSearch = spotSearchState{}
126+
}
127+
128+
// queueTrackNext adds a track to the playlist and queues it to play next.
129+
func (m *Model) queueTrackNext(track playlist.Track) tea.Cmd {
130+
m.playlist.Add(track)
131+
idx := m.playlist.Len() - 1
132+
m.playlist.Queue(idx)
133+
m.status.Showf(statusTTLMedium, "Queued: %s", track.DisplayName())
134+
if !m.player.IsPlaying() {
135+
cmd := m.nextTrack()
136+
m.notifyPlayback()
137+
return cmd
138+
}
139+
return nil
140+
}
141+
82142
// playTrack plays a track, using async HTTP for streams and sync I/O for local files.
83143
// yt-dlp URLs are streamed via a piped yt-dlp | ffmpeg chain for instant playback.
84144
func (m *Model) playTrack(track playlist.Track) tea.Cmd {

0 commit comments

Comments
 (0)