diff --git a/commands.go b/commands.go index dd167d30e..268ae5e5a 100644 --- a/commands.go +++ b/commands.go @@ -17,8 +17,10 @@ import ( "github.com/bjarneo/cliamp/external/qobuz" "github.com/bjarneo/cliamp/external/spotify" "github.com/bjarneo/cliamp/external/tidal" + "github.com/bjarneo/cliamp/internal/clipboard" "github.com/bjarneo/cliamp/ipc" "github.com/bjarneo/cliamp/player" + "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/pluginmgr" "github.com/bjarneo/cliamp/theme" "github.com/bjarneo/cliamp/ui" @@ -83,6 +85,7 @@ func buildApp() *cli.Command { ipcSimpleCommand("prev", "previous track"), ipcSimpleCommand("stop", "stop playback"), statusCommand(), + shareCommand(), volumeCommand(), seekCommand(), loadCommand(), @@ -757,6 +760,37 @@ func statusCommand() *cli.Command { } } +func shareCommand() *cli.Command { + return &cli.Command{ + Name: "share", + Usage: "print a shareable link for the playing track", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "copy", Usage: "copy the link to the clipboard as well"}, + }, + Action: func(ctx context.Context, c *cli.Command) error { + snapshot, err := ipcState() + if err != nil { + return err + } + if snapshot.Track == nil || snapshot.Track.Path == "" { + return fmt.Errorf("nothing playing") + } + link, ok := playlist.ShareLink(snapshot.Track.Path) + if !ok { + return fmt.Errorf("no shareable link for the current track") + } + if c.Bool("copy") { + if err := clipboard.Copy(link); err != nil { + fmt.Println(link) + return fmt.Errorf("copy to clipboard: %w", err) + } + } + fmt.Println(link) + return nil + }, + } +} + func volumeCommand() *cli.Command { return &cli.Command{ Name: "volume", diff --git a/docs/cli.md b/docs/cli.md index 5be74fe60..54e9eda66 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -232,6 +232,8 @@ cliamp play / pause / toggle / stop # playback control cliamp next / prev # track navigation cliamp status # current state cliamp status --json # machine-readable state +cliamp share # print a shareable link for the playing track +cliamp share --copy # print it and copy it to the clipboard cliamp volume -5 # adjust volume (dB) cliamp seek 30 # seek relative to current position (seconds) cliamp load "Playlist Name" # load a playlist diff --git a/docs/keybindings.md b/docs/keybindings.md index 63dc04cf9..47c2e1971 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -105,6 +105,7 @@ and `Esc` clears it. | `i` | From the playlist, open full info for the highlighted item, including Path (`Up`/`Down` or `j`/`k` scroll; `i`/`Esc` closes) | | `Ctrl+I` | Toggle Metadata below Settings for the highlighted playlist item (remembered in `show_metadata`; requires a terminal that distinguishes Ctrl+I from Tab) | | `Ctrl+S` | Save track to `~/Music/cliamp` | +| `Ctrl+Y` | Copy a shareable link for the playing track (Spotify URIs become open.spotify.com pages, URLs pass through; local files report no link) | | `w` | Write the highlighted track to a local playlist | | `N` | Open the active provider browser. On a selected Mixcloud show, open that creator's Uploads/Favorites. In the radio pane, open the country browser. | | `L` | Browse local playlists (with cliamp radio) | diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 000000000..4677ee779 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,105 @@ +// Package clipboard copies text to the system clipboard using whatever +// backend the platform provides. It is deliberately dependency-free: +// backends are resolved with exec.LookPath at call time so headless and +// minimal installs simply report an error instead of failing to build. +package clipboard + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +// copyTimeout bounds a single backend invocation. Backends normally exit +// at once; the deadline only fires when one stalls (locked Wayland socket, +// wedged X server), which must not freeze the TUI update path. +const copyTimeout = 5 * time.Second + +// Copy writes text to the system clipboard. It returns an error when no +// supported backend is installed; callers should fall back to showing the +// text (footer message, stdout) in that case. +func Copy(text string) error { + candidates, err := backends() + if err != nil { + return fmt.Errorf("resolve clipboard backends: %w", err) + } + var lastErr error + for _, c := range candidates { + if _, err := exec.LookPath(c.name); err != nil { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), copyTimeout) + cmd := exec.CommandContext(ctx, c.name, c.args...) + cmd.Stdin = strings.NewReader(text) + // Clipboard backends daemonize (wl-copy and xclip fork to + // keep serving the selection). Capturing their output would + // hold the pipes open until the daemon exits, so Run with + // discarded output instead of CombinedOutput. + cmd.Stdout = nil + cmd.Stderr = nil + runErr := cmd.Run() + cancel() + if ctx.Err() == context.DeadlineExceeded { + lastErr = fmt.Errorf("%s: timed out after %s", c.name, copyTimeout) + continue + } + if runErr != nil { + lastErr = fmt.Errorf("%s: %w", c.name, runErr) + continue + } + return nil + } + if lastErr != nil { + return lastErr + } + return fmt.Errorf("no clipboard backend found (%s)", backendNames(candidates)) +} + +type backend struct { + name string + args []string +} + +func backends() ([]backend, error) { + switch runtime.GOOS { + case "darwin": + return []backend{{name: "pbcopy"}}, nil + case "windows": + return []backend{{name: "clip"}}, nil + default: + // Wayland first when a Wayland session is present, then X11. + // WSLg sets WAYLAND_DISPLAY; plain WSL falls through to the error, + // where the caller prints the text instead. + if isWayland() { + return []backend{ + {name: "wl-copy"}, + {name: "xclip", args: []string{"-selection", "clipboard"}}, + {name: "xsel", args: []string{"--clipboard", "--input"}}, + }, nil + } + return []backend{ + {name: "xclip", args: []string{"-selection", "clipboard"}}, + {name: "xsel", args: []string{"--clipboard", "--input"}}, + {name: "wl-copy"}, + }, nil + } +} + +func isWayland() bool { + if os.Getenv("WAYLAND_DISPLAY") != "" { + return true + } + return strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") +} + +func backendNames(candidates []backend) string { + names := make([]string, 0, len(candidates)) + for _, c := range candidates { + names = append(names, c.name) + } + return strings.Join(names, ", ") +} diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 000000000..ec3b8d78f --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -0,0 +1,15 @@ +package clipboard + +import ( + "os" + "testing" +) + +func TestCopyNoBackend(t *testing.T) { + oldPath := os.Getenv("PATH") + os.Setenv("PATH", "/nonexistent-dir-for-test") + defer os.Setenv("PATH", oldPath) + if err := Copy("x"); err == nil { + t.Fatal("expected error with no backends on PATH") + } +} diff --git a/playlist/sharelink.go b/playlist/sharelink.go new file mode 100644 index 000000000..b70d0e95a --- /dev/null +++ b/playlist/sharelink.go @@ -0,0 +1,42 @@ +package playlist + +import ( + "net/url" + "strings" +) + +// ShareLink derives a shareable https URL for a track path. +// +// - Spotify URIs (spotify::) map to the corresponding +// open.spotify.com page, e.g. spotify:track: becomes +// https://open.spotify.com/track/. +// - Plain http(s) URLs are returned unchanged, since they are already +// links (radio streams, direct files, video pages). +// +// Anything else (local files, yt-dlp search expressions and other +// pseudo-protocols, provider URIs without a public page) has no shareable +// form and reports false. +func ShareLink(path string) (string, bool) { + if path == "" { + return "", false + } + if rest, ok := strings.CutPrefix(path, "spotify:"); ok { + typ, id, ok := strings.Cut(rest, ":") + if !ok || typ == "" || id == "" || strings.ContainsAny(id, ":/?#") { + return "", false + } + switch typ { + case "track", "episode", "album", "playlist", "artist", "show": + return "https://open.spotify.com/" + typ + "/" + url.PathEscape(id), true + default: + return "", false + } + } + if IsYTSearch(path) { + return "", false + } + if IsURL(path) { + return path, true + } + return "", false +} diff --git a/playlist/sharelink_test.go b/playlist/sharelink_test.go new file mode 100644 index 000000000..cac0a387a --- /dev/null +++ b/playlist/sharelink_test.go @@ -0,0 +1,87 @@ +package playlist + +import "testing" + +func TestShareLink(t *testing.T) { + tests := []struct { + name string + path string + want string + share bool + }{ + { + name: "spotify track", + path: "spotify:track:5FFTCVlkmd78TIw9mTfDUP", + want: "https://open.spotify.com/track/5FFTCVlkmd78TIw9mTfDUP", + share: true, + }, + { + name: "spotify episode", + path: "spotify:episode:4rOoJ6Egrf8K2IrywzwOMk", + want: "https://open.spotify.com/episode/4rOoJ6Egrf8K2IrywzwOMk", + share: true, + }, + { + name: "spotify album", + path: "spotify:album:6akEvsycLGftJxsoqdWqaw", + want: "https://open.spotify.com/album/6akEvsycLGftJxsoqdWqaw", + share: true, + }, + { + name: "spotify truncated id", + path: "spotify:track:", + want: "", + share: false, + }, + { + name: "spotify unknown type", + path: "spotify:user:repparw", + want: "", + share: false, + }, + { + name: "spotify id with reserved chars rejected", + path: "spotify:track:abc/def?x#y", + want: "", + share: false, + }, + { + name: "https stream", + path: "http://radio.cliamp.stream/lofi/stream", + want: "http://radio.cliamp.stream/lofi/stream", + share: true, + }, + { + name: "youtube page", + path: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + want: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + share: true, + }, + { + name: "yt-dlp search is not a link", + path: "ytsearch:never gonna give you up", + want: "", + share: false, + }, + { + name: "local file", + path: "/home/me/Music/song.flac", + want: "", + share: false, + }, + { + name: "empty", + path: "", + want: "", + share: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ShareLink(tt.path) + if ok != tt.share || got != tt.want { + t.Fatalf("ShareLink(%q) = (%q, %v), want (%q, %v)", tt.path, got, ok, tt.want, tt.share) + } + }) + } +} diff --git a/ui/model/command_registry.go b/ui/model/command_registry.go index 6aaa44d1c..ea601ff42 100644 --- a/ui/model/command_registry.go +++ b/ui/model/command_registry.go @@ -147,6 +147,7 @@ var commandRegistry = []commandSpec{ {Mode: commandModeMain, Keys: []string{"i"}, KeyLabel: "i", Label: "Track info / metadata", Keymap: true, ContextHelp: true}, {Mode: commandModeMain | commandModeInfo, Keys: []string{"ctrl+i"}, KeyLabel: "Ctrl+I", Label: "Metadata", Keymap: true, ContextHelp: true}, {Mode: commandModeMain, Keys: []string{"ctrl+s"}, KeyLabel: "Ctrl+S", Label: "Save/download track to ~/Music/cliamp", Keymap: true}, + {Mode: commandModeMain, Keys: []string{"ctrl+y"}, KeyLabel: "Ctrl+Y", Label: "Copy share link for the playing track", Keymap: true}, {Mode: commandModeMain, Keys: []string{"ctrl+x"}, KeyLabel: "Ctrl+X", Label: "Expand/collapse view", Enabled: func(m Model) bool { return !m.simplified }, Keymap: true}, {Mode: commandModeMain, Keys: []string{"ctrl+x"}, KeyLabel: "Ctrl+X", Label: "Expand", Enabled: func(m Model) bool { return !m.simplified && !m.heightExpanded && m.layout.bodyRows > m.plVisible diff --git a/ui/model/commands.go b/ui/model/commands.go index ae80f6176..9c819dd1b 100644 --- a/ui/model/commands.go +++ b/ui/model/commands.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/bjarneo/cliamp/history" + "github.com/bjarneo/cliamp/internal/clipboard" "github.com/bjarneo/cliamp/internal/playback" "github.com/bjarneo/cliamp/lyrics" "github.com/bjarneo/cliamp/player" @@ -136,6 +137,20 @@ type ytdlSavedMsg struct { err error } +// shareCopiedMsg carries the result of an async clipboard copy back to the +// update loop, so a stalled backend never blocks input handling. The link +// rides along so a failed copy still shows the usable result. +type shareCopiedMsg struct { + link string + err error +} + +func shareCopyCmd(link string) tea.Cmd { + return func() tea.Msg { + return shareCopiedMsg{link: link, err: clipboard.Copy(link)} + } +} + // — Navidrome browser message types — // navArtistsLoadedMsg carries the full artist list from a provider browser. diff --git a/ui/model/keys.go b/ui/model/keys.go index 80f2934cc..712e88b47 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -858,6 +858,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "ctrl+s": return m.saveTrack() + case "ctrl+y": + return m.shareTrack() case "S": return m.switchToProvider("spotify") @@ -1123,6 +1125,25 @@ func (m *Model) saveTrack() tea.Cmd { return nil } +// shareTrack copies a shareable link for the playing track to the clipboard. +// Spotify URIs become open.spotify.com pages and plain URLs pass through; +// anything without a public link (local files, search expressions) reports +// why instead of copying. +func (m *Model) shareTrack() tea.Cmd { + track, idx := m.currentPlaybackTrack() + if idx < 0 { + m.status.Warning("Nothing to share", statusTTLShort) + return nil + } + link, ok := playlist.ShareLink(track.Path) + if !ok { + m.status.Warning("No shareable link for this track", statusTTLShort) + return nil + } + m.status.Clear() + return shareCopyCmd(link) +} + func (m *Model) resetJumpInput() { m.jumpInput = "" m.jumpErr = "" diff --git a/ui/model/share_test.go b/ui/model/share_test.go new file mode 100644 index 000000000..47557e09d --- /dev/null +++ b/ui/model/share_test.go @@ -0,0 +1,42 @@ +package model + +import ( + "errors" + "testing" +) + +func TestShareCopiedMsg(t *testing.T) { + link := "https://open.spotify.com/track/abc123" + tests := []struct { + name string + msg shareCopiedMsg + want string + }{ + { + name: "success", + msg: shareCopiedMsg{link: link}, + want: "Link copied: " + link, + }, + { + name: "failure keeps link", + msg: shareCopiedMsg{link: link, err: errors.New("no clipboard backend found")}, + want: "Copy failed, link: " + link + " (no clipboard backend found)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := Model{} + nextModel, cmd := m.Update(tt.msg) + if cmd != nil { + t.Fatalf("Update() cmd = %v, want nil", cmd) + } + next, ok := nextModel.(Model) + if !ok { + t.Fatalf("Update() model = %T, want ui.Model", nextModel) + } + if got := next.status.text; got != tt.want { + t.Fatalf("status.text = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/ui/model/update.go b/ui/model/update.go index b91ca24c5..f58e87f39 100644 --- a/ui/model/update.go +++ b/ui/model/update.go @@ -772,6 +772,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case shareCopiedMsg: + if msg.err != nil { + m.status.Errorf(statusTTLShort, "Copy failed, link: %s (%s)", msg.link, msg.err) + } else { + m.status.Successf(statusTTLShort, "Link copied: %s", msg.link) + } + return m, nil + case ytdlResolvedMsg: m.buffering = false if msg.err != nil {