Compare commits

...

4 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
11 changed files with 748 additions and 248 deletions

View File

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

View File

@ -1,4 +1,4 @@
FROM node:20-alpine as nodebuild
FROM node:21 as nodebuild
WORKDIR /ui

View File

@ -14,6 +14,7 @@ type InputState struct {
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"`
@ -32,10 +33,16 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
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,
@ -57,11 +64,16 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
if mixable {
mixer, _ = im.GetMixers()
}
var hasPlaylist bool
if p, withPlaylist := inp.(inputs.PlaylistInput); withPlaylist {
hasPlaylist = p.HasPlaylist()
}
c.JSON(http.StatusOK, &InputState{
Name: inp.GetName(),
Active: inp.IsActive(),
Controlable: controlable,
HasPlaylist: hasPlaylist,
Streams: inp.CurrentlyPlaying(),
Mixable: mixable,
Mixer: mixer,
@ -84,6 +96,7 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
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 {
@ -99,6 +112,8 @@ 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 {
@ -121,6 +136,62 @@ func declareInputsRoutes(cfg *config.Config, router *gin.RouterGroup) {
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) {

View File

@ -17,6 +17,7 @@ type SourceState struct {
Enabled bool `json:"enabled"`
Active *bool `json:"active,omitempty"`
Controlable bool `json:"controlable,omitempty"`
HasPlaylist bool `json:"hasplaylist,omitempty"`
CurrentTitle string `json:"currentTitle,omitempty"`
}
@ -28,6 +29,11 @@ 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()
@ -38,6 +44,7 @@ func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
Enabled: src.IsEnabled(),
Active: &active,
Controlable: controlable,
HasPlaylist: hasPlaylist,
CurrentTitle: title,
}
}
@ -54,6 +61,11 @@ 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()
@ -64,6 +76,7 @@ func declareSourcesRoutes(cfg *config.Config, router *gin.RouterGroup) {
Enabled: src.IsEnabled(),
Active: &active,
Controlable: controlable,
HasPlaylist: hasPlaylist,
CurrentTitle: title,
})
})
@ -123,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)
@ -139,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

@ -22,6 +22,13 @@ 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

@ -24,11 +24,11 @@ type MPVSource struct {
func init() {
sources.SoundSources["mpv-nig"] = &MPVSource{
Name: "Radio NIG",
File: "https://mediaserv38.live-streams.nl:18030/stream",
File: "http://stream.syntheticfm.com:8030/stream",
}
sources.SoundSources["mpv-synthfm"] = &MPVSource{
Name: "Radio Synthetic FM",
File: "https://mediaserv38.live-streams.nl:18040/live",
File: "http://stream.syntheticfm.com:8040/stream",
}
sources.SoundSources["mpv-nrw"] = &MPVSource{
Name: "NewRetroWave",
@ -228,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
}

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

@ -11,14 +11,33 @@
<span class="text-muted">{source.currentTitle}</span>
{/if}
</div>
{#if source.controlable}
<div>
<button
class="btn btn-sm btn-primary"
on:click={() => source.playpause()}
>
<i class="bi bi-pause"></i>
</button>
{#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>

View File

@ -6,10 +6,11 @@ export class Input {
}
}
update({ name, active, controlable, streams, mixable, mixer }) {
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;
@ -30,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,12 @@ export class Source {
}
}
update({ name, enabled, active, controlable, currentTitle }) {
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;
}
@ -37,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() {