Able to control stream volume

This commit is contained in:
nemunaire 2023-11-15 11:31:48 +01:00
parent 5d32f30a54
commit d2090bee67
5 changed files with 192 additions and 44 deletions

View File

@ -11,9 +11,12 @@ import (
) )
type InputState struct { type InputState struct {
Name string `json:"name"` Name string `json:"name"`
Active bool `json:"active"` Active bool `json:"active"`
Controlable bool `json:"controlable"` Controlable bool `json:"controlable"`
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) { func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
@ -21,12 +24,21 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
ret := map[string]*InputState{} ret := map[string]*InputState{}
for k, inp := range inputs.SoundInputs { for k, inp := range inputs.SoundInputs {
var mixer map[string]*inputs.InputMixer
_, controlable := inp.(inputs.ControlableInput) _, controlable := inp.(inputs.ControlableInput)
im, mixable := inp.(inputs.MixableInput)
if mixable {
mixer, _ = im.GetMixers()
}
ret[k] = &InputState{ ret[k] = &InputState{
Name: inp.GetName(), Name: inp.GetName(),
Active: inp.IsActive(), Active: inp.IsActive(),
Controlable: controlable, Controlable: controlable,
Streams: inp.CurrentlyPlaying(),
Mixable: mixable,
Mixer: mixer,
} }
} }
@ -37,25 +49,36 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
inputsRoutes.Use(InputHandler) inputsRoutes.Use(InputHandler)
inputsRoutes.GET("", func(c *gin.Context) { 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()
}
c.JSON(http.StatusOK, &InputState{ c.JSON(http.StatusOK, &InputState{
Name: src.GetName(), Name: inp.GetName(),
Active: src.IsActive(), Active: inp.IsActive(),
Controlable: controlable,
Streams: inp.CurrentlyPlaying(),
Mixable: mixable,
Mixer: mixer,
}) })
}) })
inputsRoutes.GET("/settings", func(c *gin.Context) { inputsRoutes.GET("/settings", func(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("input")) c.JSON(http.StatusOK, c.MustGet("input"))
}) })
inputsRoutes.GET("/streams", func(c *gin.Context) { 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"}) c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{"errmsg": "Input not active"})
return return
} }
c.JSON(http.StatusOK, src.CurrentlyPlaying()) c.JSON(http.StatusOK, inp.CurrentlyPlaying())
}) })
streamRoutes := inputsRoutes.Group("/streams/:stream") streamRoutes := inputsRoutes.Group("/streams/:stream")
@ -74,18 +97,40 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
return return
} }
c.JSON(http.StatusOK, true)
})
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) c.JSON(http.StatusOK, true)
}) })
} }
func InputHandler(c *gin.Context) { func InputHandler(c *gin.Context) {
src, ok := inputs.SoundInputs[c.Param("input")] inp, ok := inputs.SoundInputs[c.Param("input")]
if !ok { if !ok {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Input not found: %s", c.Param("input"))}) c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Input not found: %s", c.Param("input"))})
return return
} }
c.Set("input", src) c.Set("input", inp)
c.Next() c.Next()
} }

View File

@ -4,6 +4,14 @@ import ()
var SoundInputs = map[string]SoundInput{} 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 { type SoundInput interface {
GetName() string GetName() string
IsActive() bool IsActive() bool
@ -13,3 +21,8 @@ type SoundInput interface {
type ControlableInput interface { type ControlableInput interface {
TogglePause(string) error TogglePause(string) 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 return false
} }
func (s *PulseaudioInput) CurrentlyPlaying() map[string]string { func (s *PulseaudioInput) getPASinkInputs() ([]PASinkInput, error) {
cmd := exec.Command("pactl", "-f", "json", "list", "sink-inputs") cmd := exec.Command("pactl", "-f", "json", "list", "sink-inputs")
stdoutStderr, err := cmd.CombinedOutput() stdoutStderr, err := cmd.CombinedOutput()
if err != nil { if err != nil {
log.Println("Unable to list sink-inputs:", err.Error()) return nil, fmt.Errorf("unable to list sink-inputs: %w", err)
return nil
} }
var sinkinputs []PASinkInput var sinkinputs []PASinkInput
err = json.Unmarshal(stdoutStderr, &sinkinputs) 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 { if err != nil {
log.Println("Unable to list sink-inputs:", err.Error()) log.Println("Unable to list sink-inputs:", err.Error())
return nil return nil
@ -110,3 +118,59 @@ func (s *PulseaudioInput) CurrentlyPlaying() map[string]string {
return ret 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 {
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)
}
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)
}
}
return nil
}
}
return fmt.Errorf("unable to find stream %q", stream)
}

View File

@ -1,7 +1,16 @@
<script> <script>
import { activeInputs, inputsList } from '$lib/stores/inputs'; import { activeInputs, inputs, inputsList } from '$lib/stores/inputs';
export let showInactives = false; 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);
}
</script> </script>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
@ -12,35 +21,49 @@
</span> </span>
</li> </li>
{/if} {/if}
{#each $inputsList as input} {#each $inputsList as input, iid}
{#if showInactives || input.active} {#if showInactives || input.active}
<li class="list-group-item py-3 d-flex flex-column"> {#each Object.keys(input.streams) as idstream}
<strong>{input.name}</strong> {@const title = input.streams[idstream]}
{#await input.streams()} <li class="list-group-item py-3 d-flex flex-column">
<div class="spinner-border spinner-border-sm" role="status"> <div class="d-flex justify-content-between align-items-center">
<span class="visually-hidden">Loading...</span> <div>
</div> <label for={'input' + iid + 'stream' + idstream} class="form-label d-inline">{title}</label>
{:then streams} <span class="text-muted">({input.name})</span>
{#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>
{#if input.controlable}
<div>
<button
class="btn btn-sm btn-primary"
on:click={() => input.playpause(idstream)}
>
<i class="bi bi-pause"></i>
</button>
</div>
{/if}
</div> </div>
{/each} {#if input.controlable}
{/await} <div>
</li> <button
class="btn btn-sm btn-primary"
on:click={() => input.playpause(idstream)}
>
<i class="bi bi-pause"></i>
</button>
</div>
{:else if input.mixable && input.mixer[idstream]}
<div
class="badge bg-primary"
title={input.mixer[idstream].volume_percent}
>
{input.mixer[idstream].volume_db}
</div>
{/if}
</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} {/if}
{/each} {/each}
</ul> </ul>

View File

@ -6,13 +6,16 @@ export class Input {
} }
} }
update({ name, active, controlable }) { update({ name, active, controlable, streams, mixable, mixer }) {
this.name = name; this.name = name;
this.active = active; this.active = active;
this.controlable = controlable; this.controlable = controlable;
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'}}); const data = await fetch(`api/inputs/${this.id}/streams`, {headers: {'Accept': 'application/json'}});
if (data.status == 200) { if (data.status == 200) {
return await data.json(); return await data.json();