Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -83,6 +85,7 @@ func buildApp() *cli.Command {
ipcSimpleCommand("prev", "previous track"),
ipcSimpleCommand("stop", "stop playback"),
statusCommand(),
shareCommand(),
volumeCommand(),
seekCommand(),
loadCommand(),
Expand Down Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
fmt.Println(link)
return nil
},
}
}

func volumeCommand() *cli.Command {
return &cli.Command{
Name: "volume",
Expand Down
2 changes: 2 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
105 changes: 105 additions & 0 deletions internal/clipboard/clipboard.go
Original file line number Diff line number Diff line change
@@ -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, ", ")
}
15 changes: 15 additions & 0 deletions internal/clipboard/clipboard_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
42 changes: 42 additions & 0 deletions playlist/sharelink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package playlist

import (
"net/url"
"strings"
)

// ShareLink derives a shareable https URL for a track path.
//
// - Spotify URIs (spotify:<type>:<id>) map to the corresponding
// open.spotify.com page, e.g. spotify:track:<id> becomes
// https://open.spotify.com/track/<id>.
// - 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
}
87 changes: 87 additions & 0 deletions playlist/sharelink_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
1 change: 1 addition & 0 deletions ui/model/command_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions ui/model/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions ui/model/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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 = ""
Expand Down
Loading