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