Initial logic with Playlist and Volume Controls
This commit is contained in:
parent
5fde7e15cb
commit
7795ce0a73
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
media/
|
39
Makefile
Normal file
39
Makefile
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
VERSION=$(shell grep -e 'Version' sonar.go | head -n 1 | cut -d '"' -f 2)
|
||||||
|
BUILD=$(shell git describe --always)
|
||||||
|
CURDIR=$(shell curdir)
|
||||||
|
|
||||||
|
# Inject the build version (commit hash) into the executable.
|
||||||
|
LDFLAGS := -ldflags "-X main.Build=$(BUILD)"
|
||||||
|
|
||||||
|
# `make setup` to set up a new environment, pull dependencies, etc.
|
||||||
|
.PHONY: setup
|
||||||
|
setup: clean
|
||||||
|
go get -u ./...
|
||||||
|
|
||||||
|
# `make build` to build the binary.
|
||||||
|
.PHONY: build
|
||||||
|
build:
|
||||||
|
gofmt -w .
|
||||||
|
go build $(LDFLAGS) -i -o bin/sonar cmd/sonar/main.go
|
||||||
|
|
||||||
|
# `make run` to run it in debug mode.
|
||||||
|
.PHONY: run
|
||||||
|
run:
|
||||||
|
go run cmd/sonar/main.go -debug
|
||||||
|
|
||||||
|
# `make watch` to run it in debug mode.
|
||||||
|
.PHONY: watch
|
||||||
|
watch:
|
||||||
|
./go-reload cmd/sonar/main.go -debug
|
||||||
|
|
||||||
|
# `make test` to run unit tests.
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# `make clean` cleans everything up.
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
rm -rf bin dist
|
33
cmd/sonar/main.go
Normal file
33
cmd/sonar/main.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/sonar"
|
||||||
|
"github.com/kirsle/golog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var debug bool
|
||||||
|
var listen string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
|
||||||
|
flag.StringVar(&listen, "listen", "127.0.0.1:8000", "Interface to listen on, default localhost only")
|
||||||
|
flag.BoolVar(&debug, "debug", false, "Debug level logging")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
if debug {
|
||||||
|
log := golog.GetLogger("sonar")
|
||||||
|
log.Config.Level = golog.DebugLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
app := sonar.New()
|
||||||
|
app.ListenAndServe(listen)
|
||||||
|
|
||||||
|
_ = app
|
||||||
|
}
|
84
config.go
Normal file
84
config.go
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
package sonar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kirsle/configdir"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config file at ~/.config/sonar.json
|
||||||
|
var configFile = configdir.LocalConfig("sonar.json")
|
||||||
|
|
||||||
|
// Config for the Sonar app.
|
||||||
|
type Config struct {
|
||||||
|
CookieName string `json:"cookieName"`
|
||||||
|
MediaPath string `json:"mediaPath"` // where the playlist media is stored
|
||||||
|
MediaCommand []string `json:"mediaCommand"` // how to play the media
|
||||||
|
|
||||||
|
// Volume commands.
|
||||||
|
VolumeUpCommand []string `json:"volUpCommand"`
|
||||||
|
VolumeDownCommand []string `json:"volDnCommand"`
|
||||||
|
VolumeMuteCommand []string `json:"volMuteCommand"`
|
||||||
|
VolumeStatusCommand []string `json:"volStatusCommand"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns the default config.
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
CookieName: "session",
|
||||||
|
MediaPath: "./media",
|
||||||
|
MediaCommand: []string{"mplayer", "%s"},
|
||||||
|
|
||||||
|
// PulseAudio pactl commands.
|
||||||
|
// pactl set-sink-volume <SINK> -<INT>%
|
||||||
|
// Find the sink number from: pactl list
|
||||||
|
VolumeUpCommand: strings.Fields("pactl set-sink-volume 0 +5%"),
|
||||||
|
VolumeDownCommand: strings.Fields("pactl set-sink-volume 0 -5%"),
|
||||||
|
VolumeMuteCommand: strings.Fields("pactl set-sink-mute 0 toggle"),
|
||||||
|
|
||||||
|
// Get the current volume. TODO: PulseAudio specific. The command
|
||||||
|
// output is run thru a regexp for `(\d+)%`
|
||||||
|
VolumeStatusCommand: []string{
|
||||||
|
"bash", "-c",
|
||||||
|
`pacmd dump-volumes | grep "Sink 0" | egrep -o '([0-9]+)%' | head -1`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads the config or uses the default.
|
||||||
|
func LoadConfig() *Config {
|
||||||
|
filepath := configFile
|
||||||
|
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||||
|
log.Warn("No stored config file found (%s); loading default settings", filepath)
|
||||||
|
return DefaultConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
fh, err := os.Open(filepath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error reading config from %s: %s", filepath, err)
|
||||||
|
return DefaultConfig()
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
var c *Config
|
||||||
|
decoder := json.NewDecoder(fh)
|
||||||
|
decoder.Decode(&c)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfig writes the config file to disk.
|
||||||
|
func SaveConfig(c *Config) error {
|
||||||
|
filepath := configFile
|
||||||
|
fh, err := os.Create(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("SaveConfig: %s", err)
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(fh)
|
||||||
|
encoder.SetIndent("", "\t")
|
||||||
|
return encoder.Encode(c)
|
||||||
|
}
|
95
go-reload
Executable file
95
go-reload
Executable file
|
@ -0,0 +1,95 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Credit from: https://github.com/alexedwards/go-reload/tree/aabe19d0a9935d1238763a4a35e71787854cd5f5
|
||||||
|
|
||||||
|
####################################################
|
||||||
|
# @kirsle's custom changes from upstream are below #
|
||||||
|
####################################################
|
||||||
|
|
||||||
|
function die() {
|
||||||
|
echo >&2 $1
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Before we crash and burn, make sure necessary programs are installed!
|
||||||
|
command -v inotifywait || die "I need the inotifywait command (apt install inotify-tools)"
|
||||||
|
|
||||||
|
################################
|
||||||
|
# end @kirsle's custom changes #
|
||||||
|
################################
|
||||||
|
|
||||||
|
function monitor() {
|
||||||
|
if [ "$2" = "true" ]; then
|
||||||
|
# Watch all files in the specified directory
|
||||||
|
# Call the restart function when they are saved
|
||||||
|
inotifywait -q -m -r -e close_write -e moved_to $1 |
|
||||||
|
while read line; do
|
||||||
|
restart
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# Watch all *.go files in the specified directory
|
||||||
|
# Call the restart function when they are saved
|
||||||
|
inotifywait -q -m -r -e close_write -e moved_to --exclude '[^g][^o]$' $1 |
|
||||||
|
while read line; do
|
||||||
|
restart
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Terminate and rerun the main Go program
|
||||||
|
function restart {
|
||||||
|
if [ "$(pidof $PROCESS_NAME)" ]; then
|
||||||
|
killall -q -w -9 $PROCESS_NAME
|
||||||
|
fi
|
||||||
|
echo ">> Reloading..."
|
||||||
|
eval "go run $ARGS &"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make sure all background processes get terminated
|
||||||
|
function close {
|
||||||
|
killall -q -w -9 inotifywait
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trap close INT
|
||||||
|
echo "== Go-reload"
|
||||||
|
|
||||||
|
WATCH_ALL=false
|
||||||
|
while getopts ":a" opt; do
|
||||||
|
case $opt in
|
||||||
|
a)
|
||||||
|
WATCH_ALL=true
|
||||||
|
;;
|
||||||
|
\?)
|
||||||
|
echo "Invalid option: -$OPTARG" >&2
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
shift "$((OPTIND - 1))"
|
||||||
|
|
||||||
|
FILE_NAME=$(basename $1)
|
||||||
|
PROCESS_NAME=${FILE_NAME%%.*}
|
||||||
|
|
||||||
|
ARGS=$@
|
||||||
|
|
||||||
|
# Start the main Go program
|
||||||
|
echo ">> Watching directories, CTRL+C to stop"
|
||||||
|
eval "go run $ARGS &"
|
||||||
|
|
||||||
|
# Monitor all /src directories on the GOPATH
|
||||||
|
OIFS="$IFS"
|
||||||
|
IFS=':'
|
||||||
|
for path in $GOPATH
|
||||||
|
do
|
||||||
|
monitor $path/src $WATCH_ALL &
|
||||||
|
done
|
||||||
|
IFS="$OIFS"
|
||||||
|
|
||||||
|
# If the current working directory isn't on the GOPATH, monitor it too
|
||||||
|
if [[ $PWD != "$GOPATH/"* ]]
|
||||||
|
then
|
||||||
|
monitor $PWD $WATCH_ALL
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait
|
14
log.go
Normal file
14
log.go
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
package sonar
|
||||||
|
|
||||||
|
import "github.com/kirsle/golog"
|
||||||
|
|
||||||
|
var log *golog.Logger
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log = golog.GetLogger("sonar")
|
||||||
|
log.Configure(&golog.Config{
|
||||||
|
Colors: golog.ExtendedColor,
|
||||||
|
Theme: golog.DarkTheme,
|
||||||
|
Level: golog.InfoLevel,
|
||||||
|
})
|
||||||
|
}
|
181
music.go
Normal file
181
music.go
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
package sonar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Player manages playing music one track at a time.
|
||||||
|
type Player struct {
|
||||||
|
index int
|
||||||
|
playlist []string // shuffled file array
|
||||||
|
cmd *exec.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the playlist.
|
||||||
|
func (p *Player) Run(playlist []string) {
|
||||||
|
p.playlist = playlist
|
||||||
|
p.index = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the playlist.
|
||||||
|
func (p *Player) Stop() error {
|
||||||
|
if p.cmd == nil {
|
||||||
|
return errors.New("no active player command")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.cmd.Process.Kill()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("StopPlaylist: kill: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.cmd = nil
|
||||||
|
p.index = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the next song.
|
||||||
|
func (p *Player) Next() (string, bool) {
|
||||||
|
p.index++
|
||||||
|
if p.index < len(p.playlist) {
|
||||||
|
return p.playlist[p.index], true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status prints the playlist status.
|
||||||
|
func (p *Player) Status() string {
|
||||||
|
var parts []string
|
||||||
|
if p.Running() {
|
||||||
|
parts = append(parts, "Running")
|
||||||
|
|
||||||
|
var (
|
||||||
|
index = p.index
|
||||||
|
length = len(p.playlist)
|
||||||
|
pct float64
|
||||||
|
)
|
||||||
|
if length > 0 {
|
||||||
|
pct = float64(index / length)
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf(
|
||||||
|
"Track %d of %d (%.2f%%)",
|
||||||
|
index,
|
||||||
|
length,
|
||||||
|
pct,
|
||||||
|
))
|
||||||
|
|
||||||
|
// Append current track.
|
||||||
|
if index < len(p.playlist) {
|
||||||
|
parts = append(parts, fmt.Sprintf(
|
||||||
|
"Now playing: %s",
|
||||||
|
p.playlist[index],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts = append(parts, "Not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running returns whether the playlist is actively running.
|
||||||
|
func (p *Player) Running() bool {
|
||||||
|
return p.cmd != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume returns the current volume as an int between 0 and 100.
|
||||||
|
func (s *Sonar) Volume() int {
|
||||||
|
args := s.Config.VolumeStatusCommand
|
||||||
|
out, err := exec.Command(args[0], args[1:]...).Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
volume := strings.Trim(string(out), "%\n\t ")
|
||||||
|
a, err := strconv.Atoi(volume)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Volume: %s", err.Error())
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeUp turns up the volume.
|
||||||
|
func (s *Sonar) VolumeUp() error {
|
||||||
|
args := s.Config.VolumeUpCommand
|
||||||
|
return exec.Command(args[0], args[1:]...).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeDown turns down the volume.
|
||||||
|
func (s *Sonar) VolumeDown() error {
|
||||||
|
args := s.Config.VolumeDownCommand
|
||||||
|
return exec.Command(args[0], args[1:]...).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeMute toggles muted volume.
|
||||||
|
func (s *Sonar) VolumeMute() error {
|
||||||
|
args := s.Config.VolumeMuteCommand
|
||||||
|
return exec.Command(args[0], args[1:]...).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPlaylist starts the playlist.
|
||||||
|
func (s *Sonar) StartPlaylist() error {
|
||||||
|
if s.Player.Running() {
|
||||||
|
log.Error("StartPlaylist: already running")
|
||||||
|
return errors.New("playlist is already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the media files.
|
||||||
|
files, err := filepath.Glob(filepath.Join(s.Config.MediaPath, "*.*"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Can't glob %s/*.*: %s", s.Config.MediaPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle the files.
|
||||||
|
log.Debug("StartPlaylist: shuffled %d media files", len(files))
|
||||||
|
rand.Shuffle(len(files), func(i, j int) {
|
||||||
|
files[i], files[j] = files[j], files[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Install the playlist and go.
|
||||||
|
s.Player.Run(files)
|
||||||
|
s.PlayNext()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPlaylist kills the playlist.
|
||||||
|
func (s *Sonar) StopPlaylist() error {
|
||||||
|
return s.Player.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play a track.
|
||||||
|
func (s *Sonar) Play(filename string) error {
|
||||||
|
log.Info("Play: %s", filename)
|
||||||
|
args := make([]string, len(s.Config.MediaCommand))
|
||||||
|
for i, part := range s.Config.MediaCommand {
|
||||||
|
args[i] = strings.Replace(part, "%s", filename, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Exec: %v", args)
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Player.cmd = cmd
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayNext track.
|
||||||
|
func (s *Sonar) PlayNext() error {
|
||||||
|
filename, ok := s.Player.Next()
|
||||||
|
if !ok {
|
||||||
|
return errors.New("end of playlist")
|
||||||
|
}
|
||||||
|
return s.Play(filename)
|
||||||
|
}
|
75
routes.go
Normal file
75
routes.go
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
package sonar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register the routes.
|
||||||
|
func (s *Sonar) Register() *mux.Router {
|
||||||
|
r := mux.NewRouter()
|
||||||
|
|
||||||
|
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
templ, err := template.ParseFiles("./www/index.gohtml")
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
flashes := s.GetFlashes(r, w)
|
||||||
|
|
||||||
|
v := map[string]interface{}{
|
||||||
|
"Flashes": flashes,
|
||||||
|
"Volume": s.Volume(),
|
||||||
|
"PlaylistStatus": s.Player.Status(),
|
||||||
|
}
|
||||||
|
templ.ExecuteTemplate(w, "index.gohtml", v)
|
||||||
|
})
|
||||||
|
|
||||||
|
// API functions
|
||||||
|
POST := r.Methods("GET", "POST").Subrouter()
|
||||||
|
POST.HandleFunc("/playlist/start", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := s.StartPlaylist()
|
||||||
|
if err != nil {
|
||||||
|
log.Info("Start: %s", err.Error())
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Error: "+err.Error())
|
||||||
|
} else {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Playlist started!")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
POST.HandleFunc("/playlist/stop", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := s.StopPlaylist()
|
||||||
|
if err != nil {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Error: "+err.Error())
|
||||||
|
} else {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Playlist stopped!")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
POST.HandleFunc("/volume/higher", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := s.VolumeUp()
|
||||||
|
if err != nil {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Error:"+err.Error())
|
||||||
|
} else {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Volume increased!")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
POST.HandleFunc("/volume/lower", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := s.VolumeDown()
|
||||||
|
if err != nil {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Error:"+err.Error())
|
||||||
|
} else {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Volume lowered!")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
POST.HandleFunc("/volume/mute", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := s.VolumeMute()
|
||||||
|
if err != nil {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Error:"+err.Error())
|
||||||
|
} else {
|
||||||
|
s.FlashAndRedirect(r, w, "/", "Volume mute toggled!")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
30
sessions.go
Normal file
30
sessions.go
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
package sonar
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// FlashAndRedirect to flash and redirect away.
|
||||||
|
func (s *Sonar) FlashAndRedirect(r *http.Request, w http.ResponseWriter, url string, flash string, v ...string) {
|
||||||
|
s.Flash(r, w, flash, v...)
|
||||||
|
w.Header().Add("Location", url)
|
||||||
|
w.WriteHeader(http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flash a message to the user.
|
||||||
|
func (s *Sonar) Flash(r *http.Request, w http.ResponseWriter, tmpl string, v ...string) {
|
||||||
|
session, _ := s.sessions.Get(r, s.Config.CookieName)
|
||||||
|
session.AddFlash(tmpl, v...)
|
||||||
|
session.Save(r, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFlashes retrieves the flashed messages.
|
||||||
|
func (s *Sonar) GetFlashes(r *http.Request, w http.ResponseWriter) []string {
|
||||||
|
var result []string
|
||||||
|
session, _ := s.sessions.Get(r, s.Config.CookieName)
|
||||||
|
if flashes := session.Flashes(); len(flashes) > 0 {
|
||||||
|
for _, flash := range flashes {
|
||||||
|
result = append(result, flash.(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.Save(r, w)
|
||||||
|
return result
|
||||||
|
}
|
60
sonar.go
Normal file
60
sonar.go
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
package sonar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/urfave/negroni"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Program constants.
|
||||||
|
const (
|
||||||
|
Version = "0.1.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sonar is the master app object.
|
||||||
|
type Sonar struct {
|
||||||
|
Debug bool
|
||||||
|
Player *Player
|
||||||
|
Config *Config
|
||||||
|
|
||||||
|
n *negroni.Negroni
|
||||||
|
router *mux.Router
|
||||||
|
sessions sessions.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates the Sonar app.
|
||||||
|
func New() *Sonar {
|
||||||
|
s := &Sonar{
|
||||||
|
Player: &Player{},
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Config = LoadConfig()
|
||||||
|
|
||||||
|
router := s.Register()
|
||||||
|
s.router = router
|
||||||
|
|
||||||
|
s.sessions = sessions.NewCookieStore([]byte("XXX DEBUG KEY")) // TODO
|
||||||
|
|
||||||
|
s.n = negroni.New(
|
||||||
|
negroni.NewRecovery(),
|
||||||
|
negroni.NewLogger(),
|
||||||
|
negroni.NewStatic(http.Dir("./www")),
|
||||||
|
)
|
||||||
|
s.n.UseHandler(router)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe starts the HTTP app.
|
||||||
|
func (s *Sonar) ListenAndServe(address string) error {
|
||||||
|
label := address
|
||||||
|
if strings.HasPrefix(label, ":") {
|
||||||
|
label = "0.0.0.0" + label
|
||||||
|
}
|
||||||
|
log.Info("Listening at http://%s", label)
|
||||||
|
|
||||||
|
return http.ListenAndServe(address, s.n)
|
||||||
|
}
|
BIN
www/css/.DS_Store
vendored
Normal file
BIN
www/css/.DS_Store
vendored
Normal file
Binary file not shown.
1912
www/css/bootstrap-grid.css
vendored
Normal file
1912
www/css/bootstrap-grid.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
www/css/bootstrap-grid.css.map
Normal file
1
www/css/bootstrap-grid.css.map
Normal file
File diff suppressed because one or more lines are too long
7
www/css/bootstrap-grid.min.css
vendored
Normal file
7
www/css/bootstrap-grid.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
www/css/bootstrap-grid.min.css.map
Normal file
1
www/css/bootstrap-grid.min.css.map
Normal file
File diff suppressed because one or more lines are too long
331
www/css/bootstrap-reboot.css
vendored
Normal file
331
www/css/bootstrap-reboot.css
vendored
Normal file
|
@ -0,0 +1,331 @@
|
||||||
|
/*!
|
||||||
|
* Bootstrap Reboot v4.1.3 (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2018 The Bootstrap Authors
|
||||||
|
* Copyright 2011-2018 Twitter, Inc.
|
||||||
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||||
|
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
|
||||||
|
*/
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: sans-serif;
|
||||||
|
line-height: 1.15;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-ms-overflow-style: scrollbar;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@-ms-viewport {
|
||||||
|
width: device-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
article, aside, figcaption, figure, footer, header, hgroup, main, nav, section {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #212529;
|
||||||
|
text-align: left;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[tabindex="-1"]:focus {
|
||||||
|
outline: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
box-sizing: content-box;
|
||||||
|
height: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title],
|
||||||
|
abbr[data-original-title] {
|
||||||
|
text-decoration: underline;
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
address {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
dl {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol ol,
|
||||||
|
ul ul,
|
||||||
|
ol ul,
|
||||||
|
ul ol {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dfn {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
position: relative;
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
background-color: transparent;
|
||||||
|
-webkit-text-decoration-skip: objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #0056b3;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not([href]):not([tabindex]) {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not([href]):not([tabindex]):focus {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
-ms-overflow-style: scrollbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
vertical-align: middle;
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
color: #6c757d;
|
||||||
|
text-align: left;
|
||||||
|
caption-side: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
outline: 1px dotted;
|
||||||
|
outline: 5px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
optgroup,
|
||||||
|
textarea {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
html [type="button"],
|
||||||
|
[type="reset"],
|
||||||
|
[type="submit"] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
button::-moz-focus-inner,
|
||||||
|
[type="button"]::-moz-focus-inner,
|
||||||
|
[type="reset"]::-moz-focus-inner,
|
||||||
|
[type="submit"]::-moz-focus-inner {
|
||||||
|
padding: 0;
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="radio"],
|
||||||
|
input[type="checkbox"] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="date"],
|
||||||
|
input[type="time"],
|
||||||
|
input[type="datetime-local"],
|
||||||
|
input[type="month"] {
|
||||||
|
-webkit-appearance: listbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
overflow: auto;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: inherit;
|
||||||
|
color: inherit;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type="number"]::-webkit-inner-spin-button,
|
||||||
|
[type="number"]::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type="search"] {
|
||||||
|
outline-offset: -2px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type="search"]::-webkit-search-cancel-button,
|
||||||
|
[type="search"]::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
template {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
1
www/css/bootstrap-reboot.css.map
Normal file
1
www/css/bootstrap-reboot.css.map
Normal file
File diff suppressed because one or more lines are too long
8
www/css/bootstrap-reboot.min.css
vendored
Normal file
8
www/css/bootstrap-reboot.min.css
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
/*!
|
||||||
|
* Bootstrap Reboot v4.1.3 (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2018 The Bootstrap Authors
|
||||||
|
* Copyright 2011-2018 Twitter, Inc.
|
||||||
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||||
|
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
|
||||||
|
*/*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}
|
||||||
|
/*# sourceMappingURL=bootstrap-reboot.min.css.map */
|
1
www/css/bootstrap-reboot.min.css.map
Normal file
1
www/css/bootstrap-reboot.min.css.map
Normal file
File diff suppressed because one or more lines are too long
9030
www/css/bootstrap.css
vendored
Normal file
9030
www/css/bootstrap.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
www/css/bootstrap.css.map
Normal file
1
www/css/bootstrap.css.map
Normal file
File diff suppressed because one or more lines are too long
7
www/css/bootstrap.min.css
vendored
Normal file
7
www/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
www/css/bootstrap.min.css.map
Normal file
1
www/css/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
147
www/index.gohtml
Normal file
147
www/index.gohtml
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>time.caskir.net</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta http-equiv="content-type" value="text/html; encoding=UTF-8">
|
||||||
|
<link rel="stylesheet" href="/css/bootstrap.min.css">
|
||||||
|
<style type="text/css">
|
||||||
|
button {
|
||||||
|
min-height: 4rem;
|
||||||
|
}
|
||||||
|
code { /* comment out to see debug output from fork command */
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h1>time.caskir.net</h1>
|
||||||
|
|
||||||
|
{{ range .Flashes }}
|
||||||
|
<div class="alert alert-info">{{ . }}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
Volume Control: <strong>{{ .Volume }}</strong>%
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-4">
|
||||||
|
<form method="POST" action="/volume/lower">
|
||||||
|
<button name="action" value="vol-lower" class="form-control btn btn-danger">➖</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<form method="POST" action="/volume/mute">
|
||||||
|
<button name="action" value="vol-mute" class="form-control btn btn-secondary">🚫</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<form method="POST" action="/volume/higher">
|
||||||
|
<button name="action" value="vol-higher" class="form-control btn btn-success">➕</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
Alan Watts Alarm Clock
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>
|
||||||
|
<strong>Status:</strong> {{ .PlaylistStatus }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST" action="/playlist/start">
|
||||||
|
<p>
|
||||||
|
<button name="action" value="playlist-start" class="form-control btn btn-success">Start Playlist</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" action="/playlist/stop">
|
||||||
|
<p>
|
||||||
|
<button name="action" value="playlist-stop" class="form-control btn btn-danger">Stop Playlist</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
Schedule
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>
|
||||||
|
<a href="/crontab.txt">See the current schedule</a>
|
||||||
|
</p>
|
||||||
|
<label for="time">Time:</label>
|
||||||
|
<input type="time" name="time" class="form-control mb-2" value="05:30">
|
||||||
|
|
||||||
|
<label>Days of week:</label>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="day" value="0"> Sunday
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="day" value="1" checked> Monday
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="day" value="2" checked> Tuesday
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="day" value="3" checked> Wednesday
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="day" value="4" checked> Thursday
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="day" value="5" checked> Friday
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="day" value="6"> Saturday
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button name="action" value="schedule" class="form-control btn btn-primary">Set Schedule</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
6461
www/js/bootstrap.bundle.js
vendored
Normal file
6461
www/js/bootstrap.bundle.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
www/js/bootstrap.bundle.js.map
Normal file
1
www/js/bootstrap.bundle.js.map
Normal file
File diff suppressed because one or more lines are too long
7
www/js/bootstrap.bundle.min.js
vendored
Normal file
7
www/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
www/js/bootstrap.bundle.min.js.map
Normal file
1
www/js/bootstrap.bundle.min.js.map
Normal file
File diff suppressed because one or more lines are too long
3944
www/js/bootstrap.js
vendored
Normal file
3944
www/js/bootstrap.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
www/js/bootstrap.js.map
Normal file
1
www/js/bootstrap.js.map
Normal file
File diff suppressed because one or more lines are too long
7
www/js/bootstrap.min.js
vendored
Normal file
7
www/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
www/js/bootstrap.min.js.map
Normal file
1
www/js/bootstrap.min.js.map
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user