Initial logic with Playlist and Volume Controls

master
Noah 2018-10-24 09:56:35 -07:00
parent 5fde7e15cb
commit 7795ce0a73
32 changed files with 22483 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
media/

39
Makefile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

1912
www/css/bootstrap-grid.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
www/css/bootstrap-grid.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

8
www/css/bootstrap-reboot.min.css vendored Normal file
View 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 */

File diff suppressed because one or more lines are too long

9030
www/css/bootstrap.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
www/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

147
www/index.gohtml Normal file
View 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 Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

7
www/js/bootstrap.bundle.min.js vendored Normal file

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 Normal file

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long