Initial logic with Playlist and Volume Controls

master
Noah 4 years ago
parent 5fde7e15cb
commit 7795ce0a73
  1. 1
      .gitignore
  2. 39
      Makefile
  3. 33
      cmd/sonar/main.go
  4. 84
      config.go
  5. 95
      go-reload
  6. 14
      log.go
  7. 181
      music.go
  8. 75
      routes.go
  9. 30
      sessions.go
  10. 60
      sonar.go
  11. BIN
      www/css/.DS_Store
  12. 1912
      www/css/bootstrap-grid.css
  13. 1
      www/css/bootstrap-grid.css.map
  14. 7
      www/css/bootstrap-grid.min.css
  15. 1
      www/css/bootstrap-grid.min.css.map
  16. 331
      www/css/bootstrap-reboot.css
  17. 1
      www/css/bootstrap-reboot.css.map
  18. 8
      www/css/bootstrap-reboot.min.css
  19. 1
      www/css/bootstrap-reboot.min.css.map
  20. 9030
      www/css/bootstrap.css
  21. 1
      www/css/bootstrap.css.map
  22. 7
      www/css/bootstrap.min.css
  23. 1
      www/css/bootstrap.min.css.map
  24. 147
      www/index.gohtml
  25. 6461
      www/js/bootstrap.bundle.js
  26. 1
      www/js/bootstrap.bundle.js.map
  27. 7
      www/js/bootstrap.bundle.min.js
  28. 1
      www/js/bootstrap.bundle.min.js.map
  29. 3944
      www/js/bootstrap.js
  30. 1
      www/js/bootstrap.js.map
  31. 7
      www/js/bootstrap.min.js
  32. 1
      www/js/bootstrap.min.js.map

1
.gitignore vendored

@ -0,0 +1 @@
media/

@ -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

@ -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
}

@ -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)
}

@ -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

@ -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,
})
}

@ -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)
}

@ -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
}

@ -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
}

@ -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

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -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 */

File diff suppressed because one or more lines are too long

@ -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 */

File diff suppressed because one or more lines are too long

9030
www/css/bootstrap.css vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -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>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3944
www/js/bootstrap.js vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save