Able to control stream volume
This commit is contained in:
parent
5d32f30a54
commit
d2090bee67
@ -14,6 +14,9 @@ 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()
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
@ -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,20 +21,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
{#each $inputsList as input}
|
{#each $inputsList as input, iid}
|
||||||
{#if showInactives || input.active}
|
{#if showInactives || input.active}
|
||||||
|
{#each Object.keys(input.streams) as idstream}
|
||||||
|
{@const title = input.streams[idstream]}
|
||||||
<li class="list-group-item py-3 d-flex flex-column">
|
<li class="list-group-item py-3 d-flex flex-column">
|
||||||
<strong>{input.name}</strong>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
{#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>
|
<div>
|
||||||
<span class="text-muted">{title}</span>
|
<label for={'input' + iid + 'stream' + idstream} class="form-label d-inline">{title}</label>
|
||||||
|
<span class="text-muted">({input.name})</span>
|
||||||
</div>
|
</div>
|
||||||
{#if input.controlable}
|
{#if input.controlable}
|
||||||
<div>
|
<div>
|
||||||
@ -36,11 +40,30 @@
|
|||||||
<i class="bi bi-pause"></i>
|
<i class="bi bi-pause"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{#if input.mixable && input.mixer[idstream]}
|
||||||
{/await}
|
<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>
|
</li>
|
||||||
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -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();
|
||||||
|
Loading…
Reference in New Issue
Block a user