Merge pull request #403 from dweymouth/cmdline-ipc
Add ability to control playback using command-line flags
This commit is contained in:
+74
-68
@@ -12,11 +12,11 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dweymouth/supersonic/backend/ipc"
|
||||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
"github.com/dweymouth/supersonic/backend/player"
|
"github.com/dweymouth/supersonic/backend/player"
|
||||||
"github.com/dweymouth/supersonic/backend/player/mpv"
|
"github.com/dweymouth/supersonic/backend/player/mpv"
|
||||||
"github.com/dweymouth/supersonic/backend/util"
|
"github.com/dweymouth/supersonic/backend/util"
|
||||||
"github.com/fsnotify/fsnotify"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/20after4/configdir"
|
"github.com/20after4/configdir"
|
||||||
@@ -24,13 +24,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
configFile = "config.toml"
|
configFile = "config.toml"
|
||||||
portableDir = "supersonic_portable"
|
portableDir = "supersonic_portable"
|
||||||
sessionDir = "session"
|
savedQueueFile = "saved_queue.json"
|
||||||
sessionLockFile = ".lock"
|
themesDir = "themes"
|
||||||
sessionActivateFile = ".activate"
|
|
||||||
savedQueueFile = "saved_queue.json"
|
|
||||||
themesDir = "themes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -46,6 +43,7 @@ type App struct {
|
|||||||
LocalPlayer *mpv.Player
|
LocalPlayer *mpv.Player
|
||||||
UpdateChecker UpdateChecker
|
UpdateChecker UpdateChecker
|
||||||
MPRISHandler *MPRISHandler
|
MPRISHandler *MPRISHandler
|
||||||
|
ipcServer ipc.IPCServer
|
||||||
|
|
||||||
// UI callbacks to be set in main
|
// UI callbacks to be set in main
|
||||||
OnReactivate func()
|
OnReactivate func()
|
||||||
@@ -83,26 +81,6 @@ func StartupApp(appName, displayAppName, appVersionTag, latestReleaseURL string)
|
|||||||
configdir.MakePath(confDir)
|
configdir.MakePath(confDir)
|
||||||
configdir.MakePath(cacheDir)
|
configdir.MakePath(cacheDir)
|
||||||
|
|
||||||
sessionPath := path.Join(confDir, sessionDir)
|
|
||||||
if _, err := os.Stat(path.Join(sessionPath, sessionLockFile)); err == nil {
|
|
||||||
log.Println("Another instance is running. Reactivating it...")
|
|
||||||
reactivateFile := path.Join(sessionPath, sessionActivateFile)
|
|
||||||
if f, err := os.Create(reactivateFile); err == nil {
|
|
||||||
f.Close()
|
|
||||||
}
|
|
||||||
time.Sleep(750 * time.Millisecond)
|
|
||||||
if _, err := os.Stat(reactivateFile); err == nil {
|
|
||||||
log.Println("No other instance responded. Starting as normal...")
|
|
||||||
os.RemoveAll(sessionPath)
|
|
||||||
} else {
|
|
||||||
return nil, ErrAnotherInstance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Starting %s...", appName)
|
|
||||||
log.Printf("Using config dir: %s", confDir)
|
|
||||||
log.Printf("Using cache dir: %s", cacheDir)
|
|
||||||
|
|
||||||
a := &App{
|
a := &App{
|
||||||
appName: appName,
|
appName: appName,
|
||||||
appVersionTag: appVersionTag,
|
appVersionTag: appVersionTag,
|
||||||
@@ -112,19 +90,24 @@ func StartupApp(appName, displayAppName, appVersionTag, latestReleaseURL string)
|
|||||||
}
|
}
|
||||||
a.bgrndCtx, a.cancel = context.WithCancel(context.Background())
|
a.bgrndCtx, a.cancel = context.WithCancel(context.Background())
|
||||||
a.readConfig()
|
a.readConfig()
|
||||||
a.startConfigWriter(a.bgrndCtx)
|
|
||||||
|
|
||||||
if !a.Config.Application.AllowMultiInstance {
|
cli, _ := ipc.Connect()
|
||||||
log.Println("Creating session lock file")
|
if HaveCommandLineOptions() {
|
||||||
os.MkdirAll(sessionPath, 0770)
|
if err := a.checkFlagsAndSendIPCMsg(cli); err != nil {
|
||||||
if f, err := os.Create(path.Join(sessionPath, sessionLockFile)); err == nil {
|
// we were supposed to control another instance and couldn't
|
||||||
f.Close()
|
log.Fatalf("error sending IPC message: %s", err.Error())
|
||||||
} else {
|
|
||||||
log.Printf("error creating session file: %s", err.Error())
|
|
||||||
}
|
}
|
||||||
a.startSessionWatcher(sessionPath)
|
return nil, ErrAnotherInstance
|
||||||
|
} else if cli != nil && !a.Config.Application.AllowMultiInstance {
|
||||||
|
log.Println("Another instance is running. Reactivating it...")
|
||||||
|
cli.Show()
|
||||||
|
return nil, ErrAnotherInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("Starting %s...", appName)
|
||||||
|
log.Printf("Using config dir: %s", confDir)
|
||||||
|
log.Printf("Using cache dir: %s", cacheDir)
|
||||||
|
|
||||||
a.UpdateChecker = NewUpdateChecker(appVersionTag, latestReleaseURL, &a.Config.Application.LastCheckedVersion)
|
a.UpdateChecker = NewUpdateChecker(appVersionTag, latestReleaseURL, &a.Config.Application.LastCheckedVersion)
|
||||||
a.UpdateChecker.Start(a.bgrndCtx, 24*time.Hour)
|
a.UpdateChecker.Start(a.bgrndCtx, 24*time.Hour)
|
||||||
|
|
||||||
@@ -144,6 +127,19 @@ func StartupApp(appName, displayAppName, appVersionTag, latestReleaseURL string)
|
|||||||
_, _ = a.ImageManager.GetCoverThumbnail(coverID)
|
_, _ = a.ImageManager.GetCoverThumbnail(coverID)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Start IPC server if another not already running in a different instance
|
||||||
|
if cli == nil {
|
||||||
|
ipc.DestroyConn() // cleanup socket possibly orphaned by crashed process
|
||||||
|
listener, err := ipc.Listen()
|
||||||
|
if err == nil {
|
||||||
|
a.ipcServer = ipc.NewServer(a.PlaybackManager, a.callOnReactivate,
|
||||||
|
func() { _ = a.callOnExit() })
|
||||||
|
go a.ipcServer.Serve(listener)
|
||||||
|
} else {
|
||||||
|
log.Printf("error starting IPC server: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OS media center integrations
|
// OS media center integrations
|
||||||
a.setupMPRIS(displayAppName)
|
a.setupMPRIS(displayAppName)
|
||||||
InitMPMediaHandler(a.PlaybackManager, func(id string) (string, error) {
|
InitMPMediaHandler(a.PlaybackManager, func(id string) (string, error) {
|
||||||
@@ -151,6 +147,8 @@ func StartupApp(appName, displayAppName, appVersionTag, latestReleaseURL string)
|
|||||||
return a.ImageManager.GetCoverArtUrl(id)
|
return a.ImageManager.GetCoverArtUrl(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
a.startConfigWriter(a.bgrndCtx)
|
||||||
|
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,26 +194,6 @@ func (a *App) readConfig() {
|
|||||||
a.Config = cfg
|
a.Config = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) startSessionWatcher(sessionPath string) {
|
|
||||||
if sessionWatch, err := fsnotify.NewWatcher(); err == nil {
|
|
||||||
sessionWatch.Add(sessionPath)
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-a.bgrndCtx.Done():
|
|
||||||
return
|
|
||||||
case <-sessionWatch.Events:
|
|
||||||
activatePath := path.Join(sessionPath, sessionActivateFile)
|
|
||||||
if _, err := os.Stat(activatePath); err == nil {
|
|
||||||
os.Remove(path.Join(sessionPath, sessionActivateFile))
|
|
||||||
a.callOnReactivate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// periodically save config file so abnormal exit won't lose settings
|
// periodically save config file so abnormal exit won't lose settings
|
||||||
func (a *App) startConfigWriter(ctx context.Context) {
|
func (a *App) startConfigWriter(ctx context.Context) {
|
||||||
tick := time.NewTicker(2 * time.Minute)
|
tick := time.NewTicker(2 * time.Minute)
|
||||||
@@ -239,6 +217,17 @@ func (a *App) callOnReactivate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) callOnExit() error {
|
||||||
|
if a.OnExit == nil {
|
||||||
|
return errors.New("no quit handler registered")
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
a.OnExit()
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) initMPV() error {
|
func (a *App) initMPV() error {
|
||||||
p := mpv.NewWithClientName(a.appName)
|
p := mpv.NewWithClientName(a.appName)
|
||||||
c := a.Config.LocalPlayback
|
c := a.Config.LocalPlayback
|
||||||
@@ -314,16 +303,7 @@ func (a *App) setupMPRIS(mprisAppName string) {
|
|||||||
return a.ImageManager.GetCoverArtUrl(id)
|
return a.ImageManager.GetCoverArtUrl(id)
|
||||||
}
|
}
|
||||||
a.MPRISHandler.OnRaise = func() error { a.callOnReactivate(); return nil }
|
a.MPRISHandler.OnRaise = func() error { a.callOnReactivate(); return nil }
|
||||||
a.MPRISHandler.OnQuit = func() error {
|
a.MPRISHandler.OnQuit = a.callOnExit
|
||||||
if a.OnExit == nil {
|
|
||||||
return errors.New("no quit handler registered")
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
a.OnExit()
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
a.MPRISHandler.Start()
|
a.MPRISHandler.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +326,9 @@ func (a *App) DeleteServerCacheDir(serverID uuid.UUID) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Shutdown() {
|
func (a *App) Shutdown() {
|
||||||
|
if a.ipcServer != nil {
|
||||||
|
a.ipcServer.Shutdown(a.bgrndCtx)
|
||||||
|
}
|
||||||
a.MPRISHandler.Shutdown()
|
a.MPRISHandler.Shutdown()
|
||||||
a.PlaybackManager.DisableCallbacks()
|
a.PlaybackManager.DisableCallbacks()
|
||||||
if a.Config.Application.SavePlayQueue {
|
if a.Config.Application.SavePlayQueue {
|
||||||
@@ -362,7 +345,6 @@ func (a *App) Shutdown() {
|
|||||||
a.cancel()
|
a.cancel()
|
||||||
a.LocalPlayer.Destroy()
|
a.LocalPlayer.Destroy()
|
||||||
a.Config.WriteConfigFile(a.configFilePath())
|
a.Config.WriteConfigFile(a.configFilePath())
|
||||||
os.RemoveAll(path.Join(a.configDir, sessionDir))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) LoadSavedPlayQueue() error {
|
func (a *App) LoadSavedPlayQueue() error {
|
||||||
@@ -397,6 +379,30 @@ func (a *App) SaveConfigFile() {
|
|||||||
a.lastWrittenCfg = *a.Config
|
a.lastWrittenCfg = *a.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) checkFlagsAndSendIPCMsg(cli *ipc.Client) error {
|
||||||
|
if cli == nil {
|
||||||
|
return errors.New("no IPC connection")
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case *FlagPlay:
|
||||||
|
return cli.Play()
|
||||||
|
case *FlagPause:
|
||||||
|
return cli.Pause()
|
||||||
|
case *FlagPlayPause:
|
||||||
|
return cli.PlayPause()
|
||||||
|
case *FlagPrevious:
|
||||||
|
return cli.SeekBackOrPrevious()
|
||||||
|
case *FlagNext:
|
||||||
|
return cli.SeekNext()
|
||||||
|
case VolumeCLIArg >= 0:
|
||||||
|
return cli.SetVolume(VolumeCLIArg)
|
||||||
|
case SeekToCLIArg >= 0:
|
||||||
|
return cli.SeekSeconds(SeekToCLIArg)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) configFilePath() string {
|
func (a *App) configFilePath() string {
|
||||||
return path.Join(a.configDir, configFile)
|
return path.Join(a.configDir, configFile)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
VolumeCLIArg int = -1
|
||||||
|
SeekToCLIArg float64 = -1
|
||||||
|
|
||||||
|
FlagPlay = flag.Bool("play", false, "unpause or begin playback")
|
||||||
|
FlagPause = flag.Bool("pause", false, "pause playback")
|
||||||
|
FlagPlayPause = flag.Bool("play-pause", false, "toggle play/pause state")
|
||||||
|
FlagPrevious = flag.Bool("previous", false, "seek to previous track or beginning of current")
|
||||||
|
FlagNext = flag.Bool("next", false, "seek to next track")
|
||||||
|
FlagVersion = flag.Bool("version", false, "print app version and exit")
|
||||||
|
FlagHelp = flag.Bool("help", false, "print command line options and exit")
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flag.Func("volume", "sets the playback volume (0-100)", func(s string) error {
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
VolumeCLIArg = v
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
flag.Func("seek-to", "seeks to the given position in seconds in the current file (0.0 - <trackDur>)", func(s string) error {
|
||||||
|
v, err := strconv.ParseFloat(s, 64)
|
||||||
|
SeekToCLIArg = v
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HaveCommandLineOptions() bool {
|
||||||
|
visitedAny := false
|
||||||
|
flag.Visit(func(*flag.Flag) {
|
||||||
|
visitedAny = true
|
||||||
|
})
|
||||||
|
return visitedAny
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package ipc
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
const (
|
||||||
|
PingPath = "/ping"
|
||||||
|
PlayPath = "/transport/play"
|
||||||
|
PlayPausePath = "/transport/playpause"
|
||||||
|
PausePath = "/transport/pause"
|
||||||
|
StopPath = "/transport/stop"
|
||||||
|
PreviousPath = "/transport/previous"
|
||||||
|
NextPath = "/transport/next"
|
||||||
|
TimePosPath = "/transport/timepos" // ?s=<seconds>
|
||||||
|
VolumePath = "/volume" // ?v=<vol>
|
||||||
|
ShowPath = "/window/show"
|
||||||
|
QuitPath = "/window/quit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetVolumePath(vol int) string {
|
||||||
|
return fmt.Sprintf("%s?v=%d", VolumePath, vol)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SeekToSecondsPath(secs float64) string {
|
||||||
|
return fmt.Sprintf("%s?s=%0.2f", TimePosPath, secs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package ipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrPingFail = errors.New("ping failed")
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
httpC http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect attempts to connect to the IPC socket as client.
|
||||||
|
func Connect() (*Client, error) {
|
||||||
|
client := &Client{httpC: http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return Dial()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
if err := client.Ping(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Ping() error {
|
||||||
|
if c.sendRequest(PingPath) != nil {
|
||||||
|
return ErrPingFail
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Play() error {
|
||||||
|
return c.sendRequest(PlayPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Pause() error {
|
||||||
|
return c.sendRequest(PausePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) PlayPause() error {
|
||||||
|
return c.sendRequest(PlayPausePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SeekNext() error {
|
||||||
|
return c.sendRequest(NextPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SeekBackOrPrevious() error {
|
||||||
|
return c.sendRequest(PreviousPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SeekSeconds(secs float64) error {
|
||||||
|
return c.sendRequest(SeekToSecondsPath(secs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SetVolume(vol int) error {
|
||||||
|
return c.sendRequest(SetVolumePath(vol))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Show() error {
|
||||||
|
return c.sendRequest(ShowPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Quit() error {
|
||||||
|
return c.sendRequest(QuitPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sendRequest(path string) error {
|
||||||
|
resp, err := c.httpC.Get("http://supersonic/" + path)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var r Response
|
||||||
|
json.NewDecoder(resp.Body).Decode(&r)
|
||||||
|
return errors.New(r.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package ipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
var socketPath = "/tmp/supersonic.sock"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
socketPath = path.Join(home, "Library", "Caches", "supersonic", "supersonic.sock")
|
||||||
|
} else if user, err := user.Current(); err == nil {
|
||||||
|
socketPath = fmt.Sprintf("/tmp/supersonic-%s.sock", user.Uid)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
|
||||||
|
socketPath = path.Join(runtime, "supersonic.sock")
|
||||||
|
} else if user, err := user.Current(); err == nil {
|
||||||
|
socketPath = fmt.Sprintf("/tmp/supersonic-%s.sock", user.Uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Dial() (net.Conn, error) {
|
||||||
|
// TODO - use XDG runtime dir, also handle portable mode
|
||||||
|
return net.Dial("unix", socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Listen() (net.Listener, error) {
|
||||||
|
return net.Listen("unix", socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DestroyConn() error {
|
||||||
|
return os.Remove(socketPath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package ipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os/user"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/Microsoft/go-winio"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pipeName = `\\.\pipe\supersonic`
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if user, err := user.Current(); err == nil {
|
||||||
|
pipeName += regexp.MustCompile(`[^a-zA-Z0-9]+`).ReplaceAllString(user.Name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Dial() (net.Conn, error) {
|
||||||
|
return winio.DialPipe(pipeName, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Listen() (net.Listener, error) {
|
||||||
|
return winio.ListenPipe(pipeName, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DestroyConn() error {
|
||||||
|
// Windows named pipes automatically clean up
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package ipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlaybackHandler interface {
|
||||||
|
PlayPause() error
|
||||||
|
Stop() error
|
||||||
|
Pause() error
|
||||||
|
Continue() error
|
||||||
|
SeekBackOrPrevious() error
|
||||||
|
SeekNext() error
|
||||||
|
SeekSeconds(float64) error
|
||||||
|
Volume() int
|
||||||
|
SetVolume(int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type IPCServer interface {
|
||||||
|
Serve(net.Listener) error
|
||||||
|
Shutdown(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type serverImpl struct {
|
||||||
|
server *http.Server
|
||||||
|
pbHandler PlaybackHandler
|
||||||
|
showFn func()
|
||||||
|
quitFn func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(pbHandler PlaybackHandler, showFn, quitFn func()) IPCServer {
|
||||||
|
s := &serverImpl{pbHandler: pbHandler, showFn: showFn, quitFn: quitFn}
|
||||||
|
s.server = &http.Server{
|
||||||
|
Handler: s.createHandler(),
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serverImpl) Serve(listener net.Listener) error {
|
||||||
|
return s.server.Serve(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serverImpl) Shutdown(ctx context.Context) error {
|
||||||
|
err := s.server.Shutdown(ctx)
|
||||||
|
DestroyConn()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serverImpl) createHandler() http.Handler {
|
||||||
|
m := http.NewServeMux()
|
||||||
|
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte("The given path is not valid"))
|
||||||
|
})
|
||||||
|
m.HandleFunc(PingPath, s.makeSimpleEndpointHandler(func() error { return nil }))
|
||||||
|
m.HandleFunc(ShowPath, s.makeSimpleEndpointHandler(func() error {
|
||||||
|
s.showFn()
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
m.HandleFunc(QuitPath, s.makeSimpleEndpointHandler(func() error {
|
||||||
|
s.quitFn()
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
m.HandleFunc(PlayPath, s.makeSimpleEndpointHandler(s.pbHandler.Continue))
|
||||||
|
m.HandleFunc(PausePath, s.makeSimpleEndpointHandler(s.pbHandler.Pause))
|
||||||
|
m.HandleFunc(PlayPausePath, s.makeSimpleEndpointHandler(s.pbHandler.PlayPause))
|
||||||
|
m.HandleFunc(StopPath, s.makeSimpleEndpointHandler(s.pbHandler.Stop))
|
||||||
|
m.HandleFunc(PreviousPath, s.makeSimpleEndpointHandler(s.pbHandler.SeekBackOrPrevious))
|
||||||
|
m.HandleFunc(NextPath, s.makeSimpleEndpointHandler(s.pbHandler.SeekNext))
|
||||||
|
m.HandleFunc(TimePosPath, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_s := r.URL.Query().Get("s")
|
||||||
|
if secs, err := strconv.ParseFloat(_s, 64); err == nil {
|
||||||
|
s.writeSimpleResponse(w, s.pbHandler.SeekSeconds(secs))
|
||||||
|
} else {
|
||||||
|
s.writeErr(w, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
m.HandleFunc(VolumePath, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v := r.URL.Query().Get("v")
|
||||||
|
if vol, err := strconv.Atoi(v); err == nil {
|
||||||
|
s.writeSimpleResponse(w, s.pbHandler.SetVolume(vol))
|
||||||
|
} else {
|
||||||
|
s.writeErr(w, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serverImpl) makeSimpleEndpointHandler(f func() error) func(http.ResponseWriter, *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.writeSimpleResponse(w, f())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serverImpl) writeSimpleResponse(w http.ResponseWriter, err error) {
|
||||||
|
if err == nil {
|
||||||
|
s.writeOK(w)
|
||||||
|
} else {
|
||||||
|
s.writeErr(w, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serverImpl) writeOK(w http.ResponseWriter) (int, error) {
|
||||||
|
var r Response
|
||||||
|
b, err := json.Marshal(&r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serverImpl) writeErr(w http.ResponseWriter, err error) (int, error) {
|
||||||
|
r := Response{Error: err.Error()}
|
||||||
|
b, err := json.Marshal(&r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return w.Write(b)
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ go 1.21
|
|||||||
require (
|
require (
|
||||||
fyne.io/fyne/v2 v2.5.0
|
fyne.io/fyne/v2 v2.5.0
|
||||||
github.com/20after4/configdir v0.1.1
|
github.com/20after4/configdir v0.1.1
|
||||||
|
github.com/Microsoft/go-winio v0.6.2
|
||||||
github.com/cenkalti/dominantcolor v1.0.2
|
github.com/cenkalti/dominantcolor v1.0.2
|
||||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
||||||
github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64
|
github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64
|
||||||
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645
|
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
|
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
|
||||||
github.com/dweymouth/go-subsonic v0.0.0-20240603150834-605046e7c78a
|
github.com/dweymouth/go-subsonic v0.0.0-20240603150834-605046e7c78a
|
||||||
github.com/fsnotify/fsnotify v1.7.0
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8
|
github.com/pelletier/go-toml/v2 v2.0.8
|
||||||
@@ -29,6 +29,7 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/fredbi/uri v1.1.0 // indirect
|
github.com/fredbi/uri v1.1.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect
|
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect
|
||||||
github.com/fyne-io/glfw-js v0.0.0-20240101223322-6e1efdc71b7a // indirect
|
github.com/fyne-io/glfw-js v0.0.0-20240101223322-6e1efdc71b7a // indirect
|
||||||
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect
|
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
|||||||
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
|
||||||
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
|
||||||
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
|
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
|
||||||
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -10,14 +12,28 @@ import (
|
|||||||
"github.com/dweymouth/supersonic/res"
|
"github.com/dweymouth/supersonic/res"
|
||||||
"github.com/dweymouth/supersonic/ui"
|
"github.com/dweymouth/supersonic/ui"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
|
||||||
"fyne.io/fyne/v2/app"
|
"fyne.io/fyne/v2/app"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// parse cmd line flags - see backend/cmdlineoptions.go
|
||||||
|
flag.Parse()
|
||||||
|
if *backend.FlagVersion {
|
||||||
|
fmt.Println(res.AppVersion)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *backend.FlagHelp {
|
||||||
|
flag.Usage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// rest of flag actions are handled in backend.StartupApp
|
||||||
|
|
||||||
myApp, err := backend.StartupApp(res.AppName, res.DisplayName, res.AppVersionTag, res.LatestReleaseURL)
|
myApp, err := backend.StartupApp(res.AppName, res.DisplayName, res.AppVersionTag, res.LatestReleaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("fatal startup error: %v", err.Error())
|
if err != backend.ErrAnotherInstance {
|
||||||
|
log.Fatalf("fatal startup error: %v", err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if myApp.Config.Application.UIScaleSize == "Smaller" {
|
if myApp.Config.Application.UIScaleSize == "Smaller" {
|
||||||
@@ -29,15 +45,7 @@ func main() {
|
|||||||
fyneApp := app.New()
|
fyneApp := app.New()
|
||||||
fyneApp.SetIcon(res.ResAppicon256Png)
|
fyneApp.SetIcon(res.ResAppicon256Png)
|
||||||
|
|
||||||
w := float32(myApp.Config.Application.WindowWidth)
|
mainWindow := ui.NewMainWindow(fyneApp, res.AppName, res.DisplayName, res.AppVersion, myApp)
|
||||||
if w <= 1 {
|
|
||||||
w = 1000
|
|
||||||
}
|
|
||||||
h := float32(myApp.Config.Application.WindowHeight)
|
|
||||||
if h <= 1 {
|
|
||||||
h = 800
|
|
||||||
}
|
|
||||||
mainWindow := ui.NewMainWindow(fyneApp, res.AppName, res.DisplayName, res.AppVersion, myApp, fyne.NewSize(w, h))
|
|
||||||
myApp.OnReactivate = mainWindow.Show
|
myApp.OnReactivate = mainWindow.Show
|
||||||
myApp.OnExit = mainWindow.Quit
|
myApp.OnExit = mainWindow.Quit
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -60,7 +60,7 @@ type MainWindow struct {
|
|||||||
radioBtn *widget.Button
|
radioBtn *widget.Button
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App, size fyne.Size) MainWindow {
|
func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App) MainWindow {
|
||||||
m := MainWindow{
|
m := MainWindow{
|
||||||
App: app,
|
App: app,
|
||||||
Window: fyneApp.NewWindow(displayAppName),
|
Window: fyneApp.NewWindow(displayAppName),
|
||||||
@@ -89,7 +89,16 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
|
|||||||
m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller)
|
m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller)
|
||||||
m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane)
|
m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane)
|
||||||
m.Window.SetContent(m.container)
|
m.Window.SetContent(m.container)
|
||||||
m.Window.Resize(size)
|
|
||||||
|
w := float32(app.Config.Application.WindowWidth)
|
||||||
|
if w <= 1 {
|
||||||
|
w = 1000
|
||||||
|
}
|
||||||
|
h := float32(app.Config.Application.WindowHeight)
|
||||||
|
if h <= 1 {
|
||||||
|
h = 800
|
||||||
|
}
|
||||||
|
m.Window.Resize(fyne.NewSize(w, h))
|
||||||
app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
|
app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
|
||||||
if item == nil {
|
if item == nil {
|
||||||
m.Window.SetTitle(displayAppName)
|
m.Window.SetTitle(displayAppName)
|
||||||
|
|||||||
Reference in New Issue
Block a user