Initial logic with Playlist and Volume Controls
parent
5fde7e15cb
commit
7795ce0a73
|
@ -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)
|
||||
}
|
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
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
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…
Reference in New Issue