Compare commits

...

15 Commits

Author SHA1 Message Date
nemunaire 85ecd89104 Update synthetic-fm url
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2024-03-02 12:42:16 +01:00
nemunaire 19fa419d89 Can skip to a random track on double click
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2024-01-14 10:22:13 +01:00
nemunaire 677f93723b migration to SvelteKit 2
continuous-integration/drone/push Build is passing Details
2024-01-09 09:59:06 +01:00
nemunaire f7760416b9 Can control playlist
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2024-01-07 15:09:05 +01:00
nemunaire 1d091be264 Add 2 sources from youtube
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2024-01-07 11:53:55 +01:00
nemunaire a5e3452342 mpv: Refactor the use of ipc socket
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2023-11-27 13:19:50 +01:00
nemunaire e224b6a986 spdif: Consider spdif enabled if aplay is still running 2023-11-27 13:01:48 +01:00
nemunaire 4891d5f7b7 Fix segv
continuous-integration/drone/push Build is passing Details
2023-11-16 00:17:29 +01:00
nemunaire 9926248109 Handle muting streams
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2023-11-15 13:51:56 +01:00
nemunaire bfd0bb866a Quit by disabling all sources 2023-11-15 13:51:56 +01:00
nemunaire d2090bee67 Able to control stream volume 2023-11-15 11:31:48 +01:00
nemunaire 5d32f30a54 Add a button to show inactive inputs + refined 2023-11-15 11:31:25 +01:00
nemunaire b8bff830ca Communicate current title if the player is compatible 2023-11-15 11:30:38 +01:00
nemunaire a591ed17a6 Split inputs in inputs and applications 2023-11-15 10:10:24 +01:00
nemunaire e89099a18c Add Dockerfile
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2023-11-14 11:29:48 +01:00
18 changed files with 1142 additions and 375 deletions

View File

@ -13,7 +13,7 @@ workspace:
steps:
- name: build front
image: node:20-alpine
image: node:21
commands:
- mkdir deploy
- cd ui

26
Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM node:21 as nodebuild
WORKDIR /ui
COPY ui/ .
RUN npm install --network-timeout=100000 && \
npm run build
FROM golang:1-alpine AS build
RUN apk --no-cache add git go-bindata
COPY . /go/src/git.nemunai.re/nemunaire/hathoris
COPY --from=nodebuild /ui/build /go/src/git.nemunai.re/nemunaire/hathoris/ui/build
WORKDIR /go/src/git.nemunai.re/nemunaire/hathoris
RUN go get && go generate && go build -ldflags="-s -w"
FROM alpine:3.18
EXPOSE 8080
CMD ["/srv/hathoris"]
COPY --from=build /go/src/git.nemunai.re/nemunaire/hathoris/hathoris /srv/hathoris

6
Dockerfile-norebuild Normal file
View File

@ -0,0 +1,6 @@
FROM alpine:3.18
EXPOSE 8080
CMD ["/srv/hathoris"]
COPY hathoris /srv/hathoris

View File

@ -11,9 +11,13 @@ import (
)
type InputState struct {
Name string `json:"name"`
Active bool `json:"active"`
Controlable bool `json:"controlable"`
Name string `json:"name"`
Active bool `json:"active"`
Controlable bool `json:"controlable"`
HasPlaylist bool `json:"hasplaylist"`
Streams map[string]string `json:"streams,omitempty"`
Mixable bool `json:"mixable"`
Mixer map[string]*inputs.InputMixer `json:"mixer,omitempty"`
}
func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
@ -21,12 +25,27 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
ret := map[string]*InputState{}
for k, inp := range inputs.SoundInputs {
var mixer map[string]*inputs.InputMixer
_, controlable := inp.(inputs.ControlableInput)
im, mixable := inp.(inputs.MixableInput)
if mixable {
mixer, _ = im.GetMixers()
}
var hasPlaylist bool
if p, withPlaylist := inp.(inputs.PlaylistInput); withPlaylist {
hasPlaylist = p.HasPlaylist()
}
ret[k] = &InputState{
Name: inp.GetName(),
Active: inp.IsActive(),
Controlable: controlable,
HasPlaylist: hasPlaylist,
Streams: inp.CurrentlyPlaying(),
Mixable: mixable,
Mixer: mixer,
}
}
@ -37,30 +56,47 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
inputsRoutes.Use(InputHandler)
inputsRoutes.GET("", func(c *gin.Context) {
src := c.MustGet("input").(inputs.SoundInput)
inp := c.MustGet("input").(inputs.SoundInput)
var mixer map[string]*inputs.InputMixer
_, controlable := inp.(inputs.ControlableInput)
im, mixable := inp.(inputs.MixableInput)
if mixable {
mixer, _ = im.GetMixers()
}
var hasPlaylist bool
if p, withPlaylist := inp.(inputs.PlaylistInput); withPlaylist {
hasPlaylist = p.HasPlaylist()
}
c.JSON(http.StatusOK, &InputState{
Name: src.GetName(),
Active: src.IsActive(),
Name: inp.GetName(),
Active: inp.IsActive(),
Controlable: controlable,
HasPlaylist: hasPlaylist,
Streams: inp.CurrentlyPlaying(),
Mixable: mixable,
Mixer: mixer,
})
})
inputsRoutes.GET("/settings", func(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("input"))
})
inputsRoutes.GET("/streams", func(c *gin.Context) {
src := c.MustGet("input").(inputs.SoundInput)
inp := c.MustGet("input").(inputs.SoundInput)
if !src.IsActive() {
if !inp.IsActive() {
c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{"errmsg": "Input not active"})
return
}
c.JSON(http.StatusOK, src.CurrentlyPlaying())
c.JSON(http.StatusOK, inp.CurrentlyPlaying())
})
streamRoutes := inputsRoutes.Group("/streams/:stream")
streamRoutes.Use(StreamHandler)
// ControlableInput
streamRoutes.POST("/pause", func(c *gin.Context) {
input, ok := c.MustGet("input").(inputs.ControlableInput)
if !ok {
@ -76,16 +112,96 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
c.JSON(http.StatusOK, true)
})
// MixableInput
streamRoutes.POST("/volume", func(c *gin.Context) {
input, ok := c.MustGet("input").(inputs.MixableInput)
if !ok {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support that"})
return
}
var mixer inputs.InputMixer
err := c.BindJSON(&mixer)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()})
return
}
err = input.SetMixer(c.MustGet("streamid").(string), &mixer)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to pause the input: %s", err.Error())})
return
}
c.JSON(http.StatusOK, true)
})
// PlaylistInput
streamRoutes.POST("/has_playlist", func(c *gin.Context) {
input, ok := c.MustGet("input").(inputs.PlaylistInput)
if !ok {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support that"})
return
}
c.JSON(http.StatusOK, input.HasPlaylist())
})
streamRoutes.POST("/next_track", func(c *gin.Context) {
input, ok := c.MustGet("input").(inputs.PlaylistInput)
if !ok {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support that"})
return
}
err := input.NextTrack()
if err != nil {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, true)
})
streamRoutes.POST("/next_random_track", func(c *gin.Context) {
input, ok := c.MustGet("input").(inputs.PlaylistInput)
if !ok {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support that"})
return
}
err := input.NextRandomTrack()
if err != nil {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, true)
})
streamRoutes.POST("/prev_track", func(c *gin.Context) {
input, ok := c.MustGet("input").(inputs.PlaylistInput)
if !ok {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support that"})
return
}
err := input.PreviousTrack()
if err != nil {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, true)
})
}
func InputHandler(c *gin.Context) {
src, ok := inputs.SoundInputs[c.Param("input")]
inp, ok := inputs.SoundInputs[c.Param("input")]
if !ok {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Input not found: %s", c.Param("input"))})
return
}
c.Set("input", src)
c.Set("input", inp)
c.Next()
}

View File

@ -13,10 +13,12 @@ import (
)
type SourceState struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Active *bool `json:"active,omitempty"`
Controlable bool `json:"controlable,omitempty"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Active *bool `json:"active,omitempty"`
Controlable bool `json:"controlable,omitempty"`
HasPlaylist bool `json:"hasplaylist,omitempty"`
CurrentTitle string `json:"currentTitle,omitempty"`
}
func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
@ -27,11 +29,23 @@ func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
active := src.IsActive()
_, controlable := src.(inputs.ControlableInput)
var hasPlaylist bool
if p, withPlaylist := src.(inputs.PlaylistInput); withPlaylist {
hasPlaylist = p.HasPlaylist()
}
var title string
if s, ok := src.(sources.PlayingSource); ok && active {
title = s.CurrentlyPlaying()
}
ret[k] = &SourceState{
Name: src.GetName(),
Enabled: src.IsEnabled(),
Active: &active,
Controlable: controlable,
Name: src.GetName(),
Enabled: src.IsEnabled(),
Active: &active,
Controlable: controlable,
HasPlaylist: hasPlaylist,
CurrentTitle: title,
}
}
@ -45,11 +59,25 @@ func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
src := c.MustGet("source").(sources.SoundSource)
active := src.IsActive()
_, controlable := src.(inputs.ControlableInput)
var hasPlaylist bool
if p, withPlaylist := src.(inputs.PlaylistInput); withPlaylist {
hasPlaylist = p.HasPlaylist()
}
var title string
if s, ok := src.(sources.PlayingSource); ok && active {
title = s.CurrentlyPlaying()
}
c.JSON(http.StatusOK, &SourceState{
Name: src.GetName(),
Enabled: src.IsEnabled(),
Active: &active,
Name: src.GetName(),
Enabled: src.IsEnabled(),
Active: &active,
Controlable: controlable,
HasPlaylist: hasPlaylist,
CurrentTitle: title,
})
})
sourcesRoutes.GET("/settings", func(c *gin.Context) {
@ -108,6 +136,8 @@ func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
c.JSON(http.StatusOK, true)
})
// ControlableInput
sourcesRoutes.POST("/pause", func(c *gin.Context) {
src := c.MustGet("source").(sources.SoundSource)
@ -124,6 +154,90 @@ func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
c.JSON(http.StatusOK, s.TogglePause("default"))
})
// PlaylistInput
sourcesRoutes.POST("/has_playlist", func(c *gin.Context) {
src := c.MustGet("source").(sources.SoundSource)
if !src.IsActive() {
c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{"errmsg": "Source not active"})
return
}
s, ok := src.(inputs.PlaylistInput)
if !ok {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support"})
return
}
c.JSON(http.StatusOK, s.HasPlaylist())
})
sourcesRoutes.POST("/next_track", func(c *gin.Context) {
src := c.MustGet("source").(sources.SoundSource)
if !src.IsActive() {
c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{"errmsg": "Source not active"})
return
}
s, ok := src.(inputs.PlaylistInput)
if !ok || !s.HasPlaylist() {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support"})
return
}
err := s.NextTrack()
if err != nil {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, true)
})
sourcesRoutes.POST("/next_random_track", func(c *gin.Context) {
src := c.MustGet("source").(sources.SoundSource)
if !src.IsActive() {
c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{"errmsg": "Source not active"})
return
}
s, ok := src.(inputs.PlaylistInput)
if !ok || !s.HasPlaylist() {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support"})
return
}
err := s.NextRandomTrack()
if err != nil {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, true)
})
sourcesRoutes.POST("/prev_track", func(c *gin.Context) {
src := c.MustGet("source").(sources.SoundSource)
if !src.IsActive() {
c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{"errmsg": "Source not active"})
return
}
s, ok := src.(inputs.PlaylistInput)
if !ok || !s.HasPlaylist() {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": "The source doesn't support"})
return
}
err := s.PreviousTrack()
if err != nil {
c.AbortWithStatusJSON(http.StatusMethodNotAllowed, gin.H{"errmsg": err.Error()})
return
}
c.JSON(http.StatusOK, true)
})
}
func SourceHandler(c *gin.Context) {

View File

@ -4,7 +4,6 @@ import (
"bufio"
"flag"
"fmt"
"log"
"net/http"
"os/exec"
"strconv"
@ -285,7 +284,6 @@ func (cc *CardControl) CsetAmixer(values ...string) error {
fmt.Sprintf("numid=%d", cc.NumID),
}
opts = append(opts, strings.Join(values, ","))
log.Println(opts)
cmd := exec.Command("amixer", opts...)
if err := cmd.Start(); err != nil {

22
app.go
View File

@ -10,6 +10,7 @@ import (
"git.nemunai.re/nemunaire/hathoris/api"
"git.nemunai.re/nemunaire/hathoris/config"
"git.nemunai.re/nemunaire/hathoris/sources"
"git.nemunai.re/nemunaire/hathoris/ui"
)
@ -60,6 +61,27 @@ func (app *App) Start() {
}
func (app *App) Stop() {
// Disable all sources
someEnabled := false
for k, src := range sources.SoundSources {
if src.IsEnabled() {
someEnabled = true
go func(k string, src sources.SoundSource) {
log.Printf("Stopping %s...", k)
err := src.Disable()
if err != nil {
log.Printf("Unable to disable %s source", k)
}
log.Printf("%s stopped", k)
}(k, src)
}
}
// Wait for fadeout
if someEnabled {
time.Sleep(2000 * time.Millisecond)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.srv.Shutdown(ctx); err != nil {

View File

@ -4,6 +4,14 @@ import ()
var SoundInputs = map[string]SoundInput{}
type InputMixer struct {
Volume uint `json:"volume"`
VolumePercent string `json:"volume_percent"`
VolumeDB string `json:"volume_db"`
Mute bool `json:"mute"`
Balance float64 `json:"balance"`
}
type SoundInput interface {
GetName() string
IsActive() bool
@ -13,3 +21,15 @@ type SoundInput interface {
type ControlableInput interface {
TogglePause(string) error
}
type PlaylistInput interface {
HasPlaylist() bool
NextTrack() error
NextRandomTrack() error
PreviousTrack() error
}
type MixableInput interface {
GetMixers() (map[string]*InputMixer, error)
SetMixer(string, *InputMixer) error
}

View File

@ -82,16 +82,24 @@ func (s *PulseaudioInput) IsActive() bool {
return false
}
func (s *PulseaudioInput) CurrentlyPlaying() map[string]string {
func (s *PulseaudioInput) getPASinkInputs() ([]PASinkInput, error) {
cmd := exec.Command("pactl", "-f", "json", "list", "sink-inputs")
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
log.Println("Unable to list sink-inputs:", err.Error())
return nil
return nil, fmt.Errorf("unable to list sink-inputs: %w", err)
}
var sinkinputs []PASinkInput
err = json.Unmarshal(stdoutStderr, &sinkinputs)
if err != nil {
return nil, fmt.Errorf("unable to parse sink-inputs list: %w", err)
}
return sinkinputs, nil
}
func (s *PulseaudioInput) CurrentlyPlaying() map[string]string {
sinkinputs, err := s.getPASinkInputs()
if err != nil {
log.Println("Unable to list sink-inputs:", err.Error())
return nil
@ -110,3 +118,59 @@ func (s *PulseaudioInput) CurrentlyPlaying() map[string]string {
return ret
}
func (s *PulseaudioInput) GetMixers() (map[string]*inputs.InputMixer, error) {
sinkinputs, err := s.getPASinkInputs()
if err != nil {
return nil, err
}
ret := map[string]*inputs.InputMixer{}
for _, input := range sinkinputs {
var maxvolume string
for k, vol := range input.Volume {
if maxvolume == "" || vol.Value > input.Volume[maxvolume].Value {
maxvolume = k
}
}
ret[strconv.FormatInt(input.Index, 10)] = &inputs.InputMixer{
Volume: input.Volume[maxvolume].Value,
VolumePercent: input.Volume[maxvolume].ValuePercent,
VolumeDB: input.Volume[maxvolume].DB,
Balance: input.Balance,
Mute: input.Mute,
}
}
return ret, nil
}
func (s *PulseaudioInput) SetMixer(stream string, volume *inputs.InputMixer) error {
sinkinputs, err := s.getPASinkInputs()
if err != nil {
return err
}
for _, input := range sinkinputs {
if strconv.FormatInt(input.Index, 10) == stream {
if input.Mute != volume.Mute {
cmd := exec.Command("pactl", "set-sink-input-mute", stream, "toggle")
err := cmd.Run()
if err != nil {
return fmt.Errorf("unable to change mute state: %w", err)
}
} else {
cmd := exec.Command("pactl", "set-sink-input-volume", stream, strconv.FormatUint(uint64(volume.Volume), 10))
err := cmd.Run()
if err != nil {
return fmt.Errorf("unable to set volume: %w", err)
}
}
return nil
}
}
return fmt.Errorf("unable to find stream %q", stream)
}

View File

@ -5,6 +5,7 @@ import (
"log"
"os"
"os/exec"
"path"
"time"
"github.com/DexterLB/mpvipc"
@ -13,25 +14,29 @@ import (
)
type MPVSource struct {
process *exec.Cmd
ipcSocket string
Name string
Options []string
File string
process *exec.Cmd
ipcSocketDir string
Name string
Options []string
File string
}
func init() {
sources.SoundSources["mpv-1"] = &MPVSource{
Name: "Radio 1",
ipcSocket: "/tmp/tmpmpv.radio-1",
Options: []string{"--no-video", "--no-terminal"},
File: "https://mediaserv38.live-streams.nl:18030/stream",
sources.SoundSources["mpv-nig"] = &MPVSource{
Name: "Radio NIG",
File: "http://stream.syntheticfm.com:8030/stream",
}
sources.SoundSources["mpv-2"] = &MPVSource{
Name: "Radio 2",
ipcSocket: "/tmp/tmpmpv.radio-2",
Options: []string{"--no-video", "--no-terminal"},
File: "https://mediaserv38.live-streams.nl:18040/live",
sources.SoundSources["mpv-synthfm"] = &MPVSource{
Name: "Radio Synthetic FM",
File: "http://stream.syntheticfm.com:8040/stream",
}
sources.SoundSources["mpv-nrw"] = &MPVSource{
Name: "NewRetroWave",
File: "https://youtube.com/channel/UCD-4g5w1h8xQpLaNS_ghU4g/videos",
}
sources.SoundSources["mpv-abgt"] = &MPVSource{
Name: "ABGT",
File: "https://youtube.com/playlist?list=PL6RLee9oArCArCAjnOtZ17dlVZQxaHG8G",
}
}
@ -47,15 +52,20 @@ func (s *MPVSource) IsEnabled() bool {
return s.process != nil
}
func (s *MPVSource) ipcSocket() string {
return path.Join(s.ipcSocketDir, "mpv.socket")
}
func (s *MPVSource) Enable() (err error) {
if s.process != nil {
return fmt.Errorf("Already running")
}
var opts []string
opts = append(opts, s.Options...)
if s.ipcSocket != "" {
opts = append(opts, "--input-ipc-server="+s.ipcSocket, "--pause")
s.ipcSocketDir, err = os.MkdirTemp("", "hathoris")
opts := append([]string{"--no-video", "--no-terminal"}, s.Options...)
if s.ipcSocketDir != "" {
opts = append(opts, "--input-ipc-server="+s.ipcSocket(), "--pause")
}
opts = append(opts, s.File)
@ -70,18 +80,22 @@ func (s *MPVSource) Enable() (err error) {
s.process.Process.Kill()
}
if s.ipcSocketDir != "" {
os.RemoveAll(s.ipcSocketDir)
}
s.process = nil
}()
if s.ipcSocket != "" {
_, err = os.Stat(s.ipcSocket)
if s.ipcSocketDir != "" {
_, err = os.Stat(s.ipcSocket())
for i := 20; i >= 0 && err != nil; i-- {
time.Sleep(100 * time.Millisecond)
_, err = os.Stat(s.ipcSocket)
_, err = os.Stat(s.ipcSocket())
}
time.Sleep(200 * time.Millisecond)
conn := mpvipc.NewConnection(s.ipcSocket)
conn := mpvipc.NewConnection(s.ipcSocket())
err = conn.Open()
for i := 20; i >= 0 && err != nil; i-- {
time.Sleep(100 * time.Millisecond)
@ -145,11 +159,13 @@ func (s *MPVSource) FadeOut(conn *mpvipc.Connection, speed int) {
func (s *MPVSource) Disable() error {
if s.process != nil {
if s.process.Process != nil {
conn := mpvipc.NewConnection(s.ipcSocket)
err := conn.Open()
if err == nil {
s.FadeOut(conn, 3)
conn.Close()
if s.ipcSocketDir != "" {
conn := mpvipc.NewConnection(s.ipcSocket())
err := conn.Open()
if err == nil {
s.FadeOut(conn, 3)
conn.Close()
}
}
s.process.Process.Kill()
@ -160,8 +176,8 @@ func (s *MPVSource) Disable() error {
}
func (s *MPVSource) CurrentlyPlaying() string {
if s.ipcSocket != "" {
conn := mpvipc.NewConnection(s.ipcSocket)
if s.ipcSocketDir != "" {
conn := mpvipc.NewConnection(s.ipcSocket())
err := conn.Open()
if err != nil {
log.Println("Unable to open mpv socket:", err.Error())
@ -181,11 +197,11 @@ func (s *MPVSource) CurrentlyPlaying() string {
}
func (s *MPVSource) TogglePause(id string) error {
if s.ipcSocket == "" {
if s.ipcSocketDir == "" {
return fmt.Errorf("Not supported")
}
conn := mpvipc.NewConnection(s.ipcSocket)
conn := mpvipc.NewConnection(s.ipcSocket())
err := conn.Open()
if err != nil {
return err
@ -212,3 +228,93 @@ func (s *MPVSource) TogglePause(id string) error {
return nil
}
func (s *MPVSource) HasPlaylist() bool {
if s.ipcSocketDir == "" {
return false
}
conn := mpvipc.NewConnection(s.ipcSocket())
err := conn.Open()
if err != nil {
return false
}
defer conn.Close()
plistCount, err := conn.Get("playlist-count")
if err != nil {
return false
}
return plistCount.(float64) > 1
}
func (s *MPVSource) NextTrack() error {
if s.ipcSocketDir == "" {
return fmt.Errorf("Not supported")
}
conn := mpvipc.NewConnection(s.ipcSocket())
err := conn.Open()
if err != nil {
return err
}
defer conn.Close()
_, err = conn.Call("playlist-next", "weak")
if err != nil {
return err
}
return nil
}
func (s *MPVSource) NextRandomTrack() error {
if s.ipcSocketDir == "" {
return fmt.Errorf("Not supported")
}
conn := mpvipc.NewConnection(s.ipcSocket())
err := conn.Open()
if err != nil {
return err
}
defer conn.Close()
_, err = conn.Call("playlist-shuffle")
if err != nil {
return err
}
_, err = conn.Call("playlist-next", "weak")
if err != nil {
return err
}
_, err = conn.Call("playlist-unshuffle")
if err != nil {
return err
}
return nil
}
func (s *MPVSource) PreviousTrack() error {
if s.ipcSocketDir == "" {
return fmt.Errorf("Not supported")
}
conn := mpvipc.NewConnection(s.ipcSocket())
err := conn.Open()
if err != nil {
return err
}
defer conn.Close()
_, err = conn.Call("playlist-prev", "weak")
if err != nil {
return err
}
return nil
}

View File

@ -52,7 +52,7 @@ func (s *SPDIFSource) IsActive() bool {
}
func (s *SPDIFSource) IsEnabled() bool {
return s.processRec != nil
return s.processRec != nil || s.processPlay != nil
}
func (s *SPDIFSource) Enable() error {
@ -77,10 +77,11 @@ func (s *SPDIFSource) Enable() error {
go func() {
err := s.processPlay.Wait()
if err != nil {
s.processPlay.Process.Kill()
pipeR.Close()
pipeW.Close()
if s.processPlay != nil && s.processPlay.Process != nil {
s.processPlay.Process.Kill()
}
}
pipeR.Close()
s.processPlay = nil
}()
@ -97,6 +98,7 @@ func (s *SPDIFSource) Enable() error {
if err != nil {
s.processRec.Process.Kill()
}
pipeW.Close()
s.processRec = nil
}()

627
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,21 +10,22 @@
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.20.4",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.30.0",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.5",
"vite": "^4.4.2"
"vite": "^5.0.0"
},
"type": "module",
"dependencies": {
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/adapter-static": "^3.0.0",
"bootstrap": "^5.3.2",
"bootstrap-icons": "^1.11.1",
"sass": "^1.69.5"
}
}
}

View File

@ -0,0 +1,45 @@
<script>
import { activeSources } from '$lib/stores/sources';
</script>
<ul class="list-group list-group-flush">
{#each $activeSources as source}
<li class="list-group-item py-3 d-flex justify-content-between align-items-center">
<div>
<strong>{source.name}</strong>
{#if source.currentTitle}
<span class="text-muted">{source.currentTitle}</span>
{/if}
</div>
{#if source.controlable || source.hasplaylist}
<div class="d-flex gap-1">
{#if source.hasplaylist}
<div class="btn-group" role="group">
<button
class="btn btn-sm btn-primary"
on:click={() => source.prevtrack()}
>
<i class="bi bi-skip-backward-fill"></i>
</button>
<button
class="btn btn-sm btn-primary"
on:click={() => source.nexttrack()}
on:dblclick={() => source.nextrandomtrack()}
>
<i class="bi bi-skip-forward-fill"></i>
</button>
</div>
{/if}
{#if source.controlable}
<button
class="btn btn-sm btn-primary"
on:click={() => source.playpause()}
>
<i class="bi bi-pause"></i>
</button>
{/if}
</div>
{/if}
</li>
{/each}
</ul>

View File

@ -1,71 +1,82 @@
<script>
import { activeInputs, inputsList } from '$lib/stores/inputs';
import { activeSources } from '$lib/stores/sources';
import { activeInputs, inputs, inputsList } from '$lib/stores/inputs';
export let showInactives = false;
let altering_mixer = null;
async function alterMixer(input, streamid, volume) {
if (altering_mixer) altering_mixer.abort();
altering_mixer = setTimeout(() => {
fetch(`api/inputs/${input.name}/streams/${streamid}/volume`, {headers: {'Accept': 'application/json'}, method: 'POST', body: JSON.stringify({'volume': volume ? volume : input.mixer[streamid].volume})}).then(() => inputs.refresh());
altering_mixer = null;
}, 450);
}
async function muteMixer(input, streamid, mute) {
fetch(`api/inputs/${input.name}/streams/${streamid}/volume`, {headers: {'Accept': 'application/json'}, method: 'POST', body: JSON.stringify({'mute': mute !== undefined ? mute : input.mixer[streamid].mute})}).then(() => inputs.refresh());
}
</script>
<ul class="list-group list-group-flush">
{#if $activeSources.length === 0 && ((showInactives && $inputsList.length === 0) || (!showInactives && $activeInputs.length === 0))}
{#if (showInactives && $inputsList.length === 0) || (!showInactives && $activeInputs.length === 0)}
<li class="list-group-item py-3">
<span class="text-muted">
Aucune source active.
</span>
</li>
{/if}
{#each $activeSources as source}
<li class="list-group-item py-3 d-flex justify-content-between">
<div>
<strong>{source.name}</strong>
{#await source.currently()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{:then title}
<span class="text-muted">{title}</span>
{/await}
</div>
{#if source.controlable}
<div>
<button
class="btn btn-sm btn-primary"
on:click={() => source.playpause()}
>
<i class="bi bi-pause"></i>
</button>
</div>
{/if}
</li>
{/each}
{#each $inputsList as input}
{#each $inputsList as input, iid}
{#if showInactives || input.active}
<li class="list-group-item py-3 d-flex flex-column">
<strong>{input.name}</strong>
{#await input.streams()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{:then streams}
{#each Object.keys(streams) as idstream}
{@const title = streams[idstream]}
<div class="d-flex justify-content-between">
<div>
<span class="text-muted">{title}</span>
</div>
{#each Object.keys(input.streams) as idstream}
{@const title = input.streams[idstream]}
<li class="list-group-item py-3 d-flex flex-column">
<div class="d-flex justify-content-between align-items-center">
<div>
<label for={'input' + iid + 'stream' + idstream} class="form-label d-inline">{title}</label>
<span class="text-muted">({input.name})</span>
</div>
<div class="d-flex align-items-center">
{#if input.mixable && input.mixer[idstream]}
<button
class="btn btn-sm ms-1"
class:btn-primary={input.mixer[idstream].mute}
class:btn-secondary={!input.mixer[idstream].mute}
on:click={() => {muteMixer(input, idstream, !input.mixer[idstream].mute);}}
>
<i class="bi bi-volume-mute-fill"></i>
</button>
{/if}
{#if input.controlable}
<div>
<button
class="btn btn-sm btn-primary"
on:click={() => input.playpause(idstream)}
>
<i class="bi bi-pause"></i>
</button>
<button
class="btn btn-sm btn-primary ms-1"
on:click={() => input.playpause(idstream)}
>
<i class="bi bi-pause"></i>
</button>
{:else if input.mixable && input.mixer[idstream]}
<div
class="badge bg-primary ms-1"
title={input.mixer[idstream].volume_percent}
>
{input.mixer[idstream].volume_db}
</div>
{/if}
</div>
{/each}
{/await}
</li>
</div>
{#if input.mixable && input.mixer[idstream]}
<div>
<input
type="range"
class="form-range"
id={'input' + iid + 'stream' + idstream}
min={0}
max={65536}
bind:value={input.mixer[idstream].volume}
on:change={() => alterMixer(input, idstream)}
>
</div>
{/if}
</li>
{/each}
{/if}
{/each}
</ul>

View File

@ -6,13 +6,17 @@ export class Input {
}
}
update({ name, active, controlable }) {
update({ name, active, controlable, hasplaylist, streams, mixable, mixer }) {
this.name = name;
this.active = active;
this.controlable = controlable;
this.hasplaylist = hasplaylist;
this.streams = streams;
this.mixable = mixable;
this.mixer = mixer;
}
async streams() {
async getStreams() {
const data = await fetch(`api/inputs/${this.id}/streams`, {headers: {'Accept': 'application/json'}});
if (data.status == 200) {
return await data.json();
@ -27,6 +31,27 @@ export class Input {
throw new Error((await res.json()).errmsg);
}
}
async nexttrack(idstream) {
const data = await fetch(`api/inputs/${this.id}/streams/${idstream}/next_track`, {headers: {'Accept': 'application/json'}, method: 'POST'});
if (data.status != 200) {
throw new Error((await res.json()).errmsg);
}
}
async nextrandomtrack(idstream) {
const data = await fetch(`api/inputs/${this.id}/streams/${idstream}/next_random_track`, {headers: {'Accept': 'application/json'}, method: 'POST'});
if (data.status != 200) {
throw new Error((await res.json()).errmsg);
}
}
async prevtrack(idstream) {
const data = await fetch(`api/inputs/${this.id}/streams/${idstream}/prev_track`, {headers: {'Accept': 'application/json'}, method: 'POST'});
if (data.status != 200) {
throw new Error((await res.json()).errmsg);
}
}
}
export async function getInputs() {

View File

@ -6,11 +6,13 @@ export class Source {
}
}
update({ name, enabled, active, controlable }) {
update({ name, enabled, active, controlable, hasplaylist, currentTitle }) {
this.name = name;
this.enabled = enabled;
this.active = active;
this.controlable = controlable;
this.hasplaylist = hasplaylist;
this.currentTitle = currentTitle;
}
async activate() {
@ -36,6 +38,27 @@ export class Source {
throw new Error((await res.json()).errmsg);
}
}
async nexttrack() {
const data = await fetch(`api/sources/${this.id}/next_track`, {headers: {'Accept': 'application/json'}, method: 'POST'});
if (data.status != 200) {
throw new Error((await res.json()).errmsg);
}
}
async nextrandomtrack() {
const data = await fetch(`api/sources/${this.id}/next_random_track`, {headers: {'Accept': 'application/json'}, method: 'POST'});
if (data.status != 200) {
throw new Error((await res.json()).errmsg);
}
}
async prevtrack() {
const data = await fetch(`api/sources/${this.id}/prev_track`, {headers: {'Accept': 'application/json'}, method: 'POST'});
if (data.status != 200) {
throw new Error((await res.json()).errmsg);
}
}
}
export async function getSources() {

View File

@ -1,4 +1,5 @@
<script>
import Applications from '$lib/components/Applications.svelte';
import Inputs from '$lib/components/Inputs.svelte';
import Mixer from '$lib/components/Mixer.svelte';
import SourceSelection from '$lib/components/SourceSelection.svelte';
@ -6,9 +7,10 @@
import { activeInputs } from '$lib/stores/inputs';
let mixerAdvanced = false;
let showInactiveInputs = false;
</script>
<div class="my-2">
<div class="my-3">
<SourceSelection />
</div>
@ -23,15 +25,11 @@
<div class="d-inline-block me-3">
<div class="d-flex justify-content-between align-items-center">
<div>
{#await source.currently()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div> <span class="text-muted">@ {source.name}</span>
{:then title}
<strong>{title}</strong> <span class="text-muted">@ {source.name}</span>
{:catch error}
{#if source.currentTitle}
<strong>{source.currentTitle}</strong> <span class="text-muted">@ {source.name}</span>
{:else}
{source.name} activée
{/await}
{/if}
</div>
</div>
</div>
@ -40,19 +38,15 @@
<div class="d-inline-block me-3">
<div class="d-flex justify-content-between align-items-center">
<div>
{#await input.streams()}
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div> <span class="text-muted">@ {input.name}</span>
{:then streams}
{#each Object.keys(streams) as idstream}
{@const title = streams[idstream]}
{#if input.streams.length}
{#each Object.keys(input.streams) as idstream}
{@const title = input.streams[idstream]}
<strong>{title}</strong>
{/each}
<span class="text-muted">@ {input.name}</span>
{:catch error}
{:else}
{input.name} activée
{/await}
{/if}
</div>
</div>
</div>
@ -62,7 +56,7 @@
<div class="row">
<div class="col-md">
<div class="card my-2">
<div class="card my-3">
<h4 class="card-header">
<div class="d-flex justify-content-between">
<div>
@ -84,12 +78,37 @@
</div>
<div class="col-md">
<div class="card my-2">
{#if $activeSources.length > 0}
<div class="card my-3">
<h4 class="card-header">
<div class="d-flex justify-content-between">
<div>
<i class="bi bi-window-stack"></i>
Applications
</div>
</div>
</h4>
<Applications />
</div>
{/if}
<div class="card my-3">
<h4 class="card-header">
<i class="bi bi-speaker"></i>
Sources
<div class="d-flex justify-content-between">
<div>
<i class="bi bi-speaker"></i>
Sources
</div>
<button
class="btn btn-sm"
class:btn-info={showInactiveInputs}
class:btn-secondary={!showInactiveInputs}
on:click={() => { showInactiveInputs = !showInactiveInputs; }}
>
<i class="bi bi-eye-fill"></i>
</button>
</div>
</h4>
<Inputs />
<Inputs showInactive={showInactiveInputs} />
</div>
</div>
</div>