From 7f16452a99a052c796458cb0e5231685fbc58493 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 13 May 2025 12:27:01 +0200 Subject: [PATCH] feat: added web server with currently playing song --- .env.example | 6 +- README.md | 24 +++ cmd/bot/main.go | 48 ++++- go.mod | 2 +- pkg/commands/setup.go | 19 ++ pkg/config/config.go | 22 ++ pkg/discord/bot.go | 5 + pkg/web/server.go | 345 +++++++++++++++++++++++++++++++ pkg/web/static/app.js | 125 +++++++++++ pkg/web/static/default-cover.png | 13 ++ pkg/web/static/index.html | 54 +++++ pkg/web/static/styles.css | 193 +++++++++++++++++ 12 files changed, 847 insertions(+), 9 deletions(-) create mode 100644 pkg/web/server.go create mode 100644 pkg/web/static/app.js create mode 100644 pkg/web/static/default-cover.png create mode 100644 pkg/web/static/index.html create mode 100644 pkg/web/static/styles.css diff --git a/.env.example b/.env.example index 4c79df4..ff1f352 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,8 @@ JUKEBOX_SUBSONIC_PASSWORD=your_subsonic_password # Jukebox settings # JUKEBOX_AUDIO_VOLUME=0.5 -# JUKEBOX_TIMEOUT_SEC=30 \ No newline at end of file +# JUKEBOX_TIMEOUT_SEC=30 + +# Web server settings +# JUKEBOX_WEB_ENABLED=true +# JUKEBOX_WEB_ADDR=:8080 \ No newline at end of file diff --git a/README.md b/README.md index 4e9a187..b007197 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A Discord bot written in Go that plays random music from a Subsonic-compatible s - Play random music in a Discord voice channel with real-time audio streaming - Automatic audio format conversion for Discord compatibility - Simple slash commands for controlling the jukebox +- Web interface for viewing current playback status - Configurable through environment variables ## Requirements @@ -88,6 +89,8 @@ Configure the bot using environment variables with the `JUKEBOX_` prefix. You ca - `JUKEBOX_SUBSONIC_VERSION`: Subsonic API version (default: `1.16.1`) - `JUKEBOX_AUDIO_VOLUME`: Volume level from 0.0 to 1.0 for audio playback (default: `0.5`) - `JUKEBOX_TIMEOUT_SEC`: HTTP request timeout in seconds (default: `30`) +- `JUKEBOX_WEB_ENABLED`: Enable the web interface (default: `true`) +- `JUKEBOX_WEB_ADDR`: Address and port for the web server (default: `:8080`) ### Sample .env File @@ -104,6 +107,8 @@ JUKEBOX_SUBSONIC_PASSWORD=your_subsonic_password # JUKEBOX_SUBSONIC_VERSION=1.16.1 # JUKEBOX_AUDIO_VOLUME=0.5 # JUKEBOX_TIMEOUT_SEC=30 +# JUKEBOX_WEB_ENABLED=true +# JUKEBOX_WEB_ADDR=:8080 ``` ## Troubleshooting @@ -148,6 +153,25 @@ The bot provides the following slash commands: - `/jukebox play` - Starts playing random music from your Subsonic library - `/jukebox stop` - Stops playing music and leaves the voice channel - `/jukebox skip` - Skips the current song and plays the next one +- `/jukebox info` - Displays information about the currently playing song + +### Web Interface + +The bot includes a web interface that displays the current playback status. By default, it runs on port 8080 and can be accessed at: + +``` +http://your-server-ip:8080 +``` + +The web interface features: +- Real-time updates when songs change or playback stops +- Current song information (artist, album, title, etc.) +- Responsive design that works on mobile and desktop +- No additional setup required - it works out of the box + +You can configure the web interface using these environment variables: +- `JUKEBOX_WEB_ENABLED`: Set to `false` to disable the web interface +- `JUKEBOX_WEB_ADDR`: Change the address and port (e.g., `:8000` for port 8000) ## License diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 18926a9..30d0fc5 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -10,20 +10,21 @@ import ( "discord-jukebox-bot/pkg/commands" "discord-jukebox-bot/pkg/config" "discord-jukebox-bot/pkg/logger" + "discord-jukebox-bot/pkg/web" ) func main() { // Parse command-line flags debugMode := flag.Bool("debug", false, "Enable debug mode with verbose logging") flag.Parse() - + // Initialize logger logger.Init(*debugMode) logger.Info("Discord Jukebox Bot - Starting up...") if *debugMode { logger.Debug("Debug mode enabled - verbose logging will be shown") } - + // Load configuration from environment variables and .env file logger.Info("Loading configuration...") cfg, err := config.Load() @@ -33,7 +34,7 @@ func main() { os.Exit(1) } logger.Info("Configuration loaded successfully") - + // Print config summary in debug mode if logger.IsDebug() { logger.Debug("Configuration Summary", @@ -42,14 +43,14 @@ func main() { "subsonic_username", cfg.SubsonicUsername, "subsonic_api_version", cfg.SubsonicVersion) } - + // Check if ffmpeg is available - important for audio transcoding ffmpegPath, err := exec.LookPath("ffmpeg") if err != nil { - logger.Warn("ffmpeg not found in system PATH. Audio streaming may be less reliable.", + logger.Warn("ffmpeg not found in system PATH. Audio streaming may be less reliable.", "error", err.Error()) } else { - logger.Info("ffmpeg found and available for audio transcoding", + logger.Info("ffmpeg found and available for audio transcoding", "path", ffmpegPath) } @@ -69,6 +70,28 @@ func main() { } logger.Info("Discord Jukebox Bot is now running.") logger.Info("Use /jukebox play, /jukebox stop, or /jukebox skip in your Discord server.") + + // Get jukebox player reference + jukebox := commands.GetJukebox() + + // HTTP server reference + var webServer *web.Server + + // Initialize and start web server if enabled + if cfg.WebEnabled { + logger.Info("Starting HTTP server...", "address", cfg.WebAddr) + var err error + webServer, err = web.StartServer(jukebox, bot, cfg.WebAddr) + if err != nil { + logger.Error("Error starting HTTP server", "error", err) + os.Exit(1) + } + + logger.Info("HTTP server is now running", "address", cfg.WebAddr) + } else { + logger.Info("HTTP server is disabled. Set JUKEBOX_WEB_ENABLED=true to enable") + } + logger.Info("Press Ctrl+C to exit.") // Wait for a termination signal @@ -78,8 +101,19 @@ func main() { // Clean up and exit logger.Info("Shutting down Discord Jukebox Bot...") + + // Stop the HTTP server if it was started + if cfg.WebEnabled && webServer != nil { + logger.Info("Stopping HTTP server...") + if err := webServer.Stop(); err != nil { + logger.Error("Error stopping HTTP server", "error", err) + } + logger.Info("HTTP server stopped") + } + + // Stop the Discord bot if err := bot.Stop(); err != nil { logger.Error("Error stopping bot", "error", err) } logger.Info("Bot stopped. Goodbye!") -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index 2a498f1..a5db4cb 100644 --- a/go.mod +++ b/go.mod @@ -14,4 +14,4 @@ require ( golang.org/x/crypto v0.17.0 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/sys v0.15.0 // indirect -) +) \ No newline at end of file diff --git a/pkg/commands/setup.go b/pkg/commands/setup.go index 2009e19..9f8763b 100644 --- a/pkg/commands/setup.go +++ b/pkg/commands/setup.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "sync" "discord-jukebox-bot/pkg/config" "discord-jukebox-bot/pkg/discord" @@ -10,6 +11,12 @@ import ( "github.com/bwmarrin/discordgo" ) +// Store a reference to the jukebox player for external access +var ( + jukeboxPlayer *discord.JukeboxPlayer + jukeboxPlayerLock sync.RWMutex +) + // Setup sets up all commands for the Discord bot func Setup(cfg *config.Config) (*discord.Bot, error) { // Create the Subsonic client @@ -32,6 +39,11 @@ func Setup(cfg *config.Config) (*discord.Bot, error) { if jukebox == nil { return nil, fmt.Errorf("error creating jukebox player") } + + // Store the jukebox player reference + jukeboxPlayerLock.Lock() + jukeboxPlayer = jukebox + jukeboxPlayerLock.Unlock() // Add any additional command initialization here // This is where you can easily add new commands in the future @@ -51,4 +63,11 @@ func RegisterCustomCommand( // Register the command handler bot.RegisterCommand(name, handler) return nil +} + +// GetJukebox returns the current jukebox player instance +func GetJukebox() *discord.JukeboxPlayer { + jukeboxPlayerLock.RLock() + defer jukeboxPlayerLock.RUnlock() + return jukeboxPlayer } \ No newline at end of file diff --git a/pkg/config/config.go b/pkg/config/config.go index cc05d2f..68833d8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -32,6 +32,10 @@ type Config struct { // Jukebox configuration AudioVolume float64 TimeoutSec int + + // Web server configuration + WebEnabled bool + WebAddr string } // Load loads the configuration from environment variables and .env file @@ -67,6 +71,10 @@ func Load() (*Config, error) { // Jukebox AudioVolume: getEnvFloat(envPrefix+"AUDIO_VOLUME", 0.5), TimeoutSec: getEnvInt(envPrefix+"TIMEOUT_SEC", 30), + + // Web server + WebEnabled: getEnvBool(envPrefix+"WEB_ENABLED", true), + WebAddr: getEnv(envPrefix+"WEB_ADDR", ":8080"), } if err := config.validate(); err != nil { @@ -137,6 +145,18 @@ func getEnvFloat(key string, defaultValue float64) float64 { return defaultValue } +// getEnvBool gets an environment variable as a boolean or returns a default value +func getEnvBool(key string, defaultValue bool) bool { + if value, exists := os.LookupEnv(key); exists { + if strings.ToLower(value) == "true" || value == "1" { + return true + } else if strings.ToLower(value) == "false" || value == "0" { + return false + } + } + return defaultValue +} + // PrintDebugInfo prints helpful debugging information for environment variables func PrintDebugInfo() { slog.Info("=== Configuration Debug Information ===") @@ -152,6 +172,8 @@ func PrintDebugInfo() { checkEnvVar(envPrefix + "SUBSONIC_VERSION") checkEnvVar(envPrefix + "AUDIO_VOLUME") checkEnvVar(envPrefix + "TIMEOUT_SEC") + checkEnvVar(envPrefix + "WEB_ENABLED") + checkEnvVar(envPrefix + "WEB_ADDR") slog.Info("Troubleshooting tips:") slog.Info("1. Your .env file is in the correct directory") diff --git a/pkg/discord/bot.go b/pkg/discord/bot.go index 204c8c9..a868f0a 100644 --- a/pkg/discord/bot.go +++ b/pkg/discord/bot.go @@ -232,6 +232,11 @@ func (b *Bot) leaveVoiceChannel() { } } +// GetSubsonicClient returns the Subsonic client +func (b *Bot) GetSubsonicClient() *subsonic.Client { + return b.subsonic +} + // IsPlaying returns true if the bot is currently playing music func (b *Bot) IsPlaying() bool { b.mu.Lock() diff --git a/pkg/web/server.go b/pkg/web/server.go new file mode 100644 index 0000000..d9067cf --- /dev/null +++ b/pkg/web/server.go @@ -0,0 +1,345 @@ +package web + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "discord-jukebox-bot/pkg/discord" + "discord-jukebox-bot/pkg/subsonic" +) + +//go:embed static/* +var staticFiles embed.FS + +// Server represents the HTTP server for the jukebox status +type Server struct { + jukebox *discord.JukeboxPlayer + bot *discord.Bot + httpServer *http.Server + addr string + clients map[chan []byte]bool + clientsMutex sync.RWMutex + isRunning bool + stop chan struct{} +} + +// NewServer creates a new HTTP server +func NewServer(jukebox *discord.JukeboxPlayer, bot *discord.Bot, addr string) *Server { + server := &Server{ + jukebox: jukebox, + bot: bot, + addr: addr, + clients: make(map[chan []byte]bool), + isRunning: false, + stop: make(chan struct{}), + } + + return server +} + +// Start starts the HTTP server +func (s *Server) Start() error { + mux := http.NewServeMux() + + // Set up routes + mux.HandleFunc("/", s.handleIndex) + mux.HandleFunc("/status", s.handleStatus) + mux.HandleFunc("/events", s.handleEvents) + mux.HandleFunc("/static/", s.handleStatic) + mux.HandleFunc("/cover/", s.handleCoverArt) + + s.httpServer = &http.Server{ + Addr: s.addr, + Handler: mux, + } + + // Start sending updates to clients + go s.broadcastUpdates() + + slog.Info("Starting HTTP server", "address", s.addr) + s.isRunning = true + + // Start the server in a goroutine so Start doesn't block + go func() { + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("HTTP server error", "error", err) + } + }() + + return nil +} + +// Stop stops the HTTP server +func (s *Server) Stop() error { + if !s.isRunning { + return nil + } + + slog.Info("Stopping HTTP server") + s.isRunning = false + + // Signal the broadcast loop to stop + close(s.stop) + + // Close all client connections + s.clientsMutex.Lock() + for clientChan := range s.clients { + close(clientChan) + delete(s.clients, clientChan) + } + s.clientsMutex.Unlock() + + // Shutdown the server with a 5 second timeout + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return s.httpServer.Shutdown(ctx) +} + +// handleIndex serves the main page +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + html, err := staticFiles.ReadFile("static/index.html") + if err != nil { + http.Error(w, "Error reading index.html", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html") + w.Write(html) +} + +// handleStatic serves static files +func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { + // Strip the leading "/static/" from the path + filePath := strings.TrimPrefix(r.URL.Path, "/static/") + filePath = "static/" + filePath + + content, err := staticFiles.ReadFile(filePath) + if err != nil { + http.NotFound(w, r) + return + } + + // Set the content type based on file extension + contentType := "text/plain" + if strings.HasSuffix(filePath, ".css") { + contentType = "text/css" + } else if strings.HasSuffix(filePath, ".js") { + contentType = "application/javascript" + } else if strings.HasSuffix(filePath, ".html") { + contentType = "text/html" + } + + w.Header().Set("Content-Type", contentType) + w.Write(content) +} + +// StatusResponse contains the jukebox status +type StatusResponse struct { + IsPlaying bool `json:"isPlaying"` + CurrentSong *subsonic.Song `json:"currentSong"` + UpdateTime string `json:"updateTime"` +} + +// handleStatus returns the current jukebox status as JSON +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + status := s.getStatus() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} + +// getStatus returns the current status +func (s *Server) getStatus() StatusResponse { + currentSong := s.jukebox.GetCurrentSong() + isPlaying := s.bot.IsPlaying() + + return StatusResponse{ + IsPlaying: isPlaying, + CurrentSong: currentSong, + UpdateTime: time.Now().Format(time.RFC3339), + } +} + +// handleEvents sets up a Server-Sent Events connection +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + // Set required headers for SSE + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Create a channel for this client + messageChan := make(chan []byte) + + // Register the client + s.clientsMutex.Lock() + s.clients[messageChan] = true + s.clientsMutex.Unlock() + + // Clean up when the client disconnects + defer func() { + s.clientsMutex.Lock() + delete(s.clients, messageChan) + close(messageChan) + s.clientsMutex.Unlock() + }() + + // Set up a notification channel for when the client disconnects + notifyChan := make(chan bool, 1) + go func() { + <-r.Context().Done() + notifyChan <- true + }() + + // Send initial status update + status := s.getStatus() + data, err := json.Marshal(status) + if err == nil { + fmt.Fprintf(w, "data: %s\n\n", data) + w.(http.Flusher).Flush() + } + + // Keep the connection open and send updates as they come + for { + select { + case <-notifyChan: + // Client disconnected + return + case message, ok := <-messageChan: + if !ok { + // Channel closed + return + } + fmt.Fprintf(w, "data: %s\n\n", message) + w.(http.Flusher).Flush() + } + } +} + +// handleCoverArt proxies requests for cover art to the Subsonic server +func (s *Server) handleCoverArt(w http.ResponseWriter, r *http.Request) { + // Extract the cover art ID from the URL path + coverArtID := strings.TrimPrefix(r.URL.Path, "/cover/") + if coverArtID == "" { + http.Error(w, "Cover art ID is required", http.StatusBadRequest) + return + } + + // Get the cover art URL from the Subsonic client + coverArtURL := s.bot.GetSubsonicClient().GetCoverArtURL(coverArtID) + + // Create a new request to the Subsonic server + req, err := http.NewRequest("GET", coverArtURL, nil) + if err != nil { + http.Error(w, "Error creating request for cover art", http.StatusInternalServerError) + return + } + + // Forward the request to the Subsonic server + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + http.Error(w, "Error fetching cover art", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + // Check if the request was successful + if resp.StatusCode != http.StatusOK { + http.Error(w, "Error fetching cover art from Subsonic", resp.StatusCode) + return + } + + // Copy the content type from the response + contentType := resp.Header.Get("Content-Type") + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } else { + w.Header().Set("Content-Type", "image/jpeg") // Default to JPEG if no content type + } + + // Copy the image data to the response + _, err = io.Copy(w, resp.Body) + if err != nil { + slog.Error("Error copying cover art data", "error", err) + } +} + +// broadcastUpdates periodically sends status updates to all connected clients +func (s *Server) broadcastUpdates() { + // Keep track of the previous status to only send updates when there are changes + var prevIsPlaying bool + var prevSongID string + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-s.stop: + return + case <-ticker.C: + if !s.isRunning { + return + } + + // Get current status + status := s.getStatus() + + // Determine if there's a change + currentSongID := "" + if status.CurrentSong != nil { + currentSongID = status.CurrentSong.ID + } + + hasChanged := (prevIsPlaying != status.IsPlaying) || (prevSongID != currentSongID) + + if hasChanged { + // Update previous state + prevIsPlaying = status.IsPlaying + prevSongID = currentSongID + + // Broadcast to all clients + data, err := json.Marshal(status) + if err != nil { + slog.Error("Error marshaling status", "error", err) + continue + } + + s.clientsMutex.RLock() + for client := range s.clients { + select { + case client <- data: + // Message sent + default: + // Skip if channel is blocked + } + } + s.clientsMutex.RUnlock() + } + } + } +} + +// StartServer creates and starts a new HTTP server +func StartServer(jukebox *discord.JukeboxPlayer, bot *discord.Bot, addr string) (*Server, error) { + server := NewServer(jukebox, bot, addr) + if err := server.Start(); err != nil { + return nil, err + } + return server, nil +} \ No newline at end of file diff --git a/pkg/web/static/app.js b/pkg/web/static/app.js new file mode 100644 index 0000000..c67aeb8 --- /dev/null +++ b/pkg/web/static/app.js @@ -0,0 +1,125 @@ +// DOM Elements +const statusIndicator = document.getElementById('status-indicator'); +const statusDot = document.querySelector('.status-dot'); +const statusText = document.getElementById('status-text'); +const nowPlaying = document.getElementById('now-playing'); +const notPlaying = document.getElementById('not-playing'); +const lastUpdate = document.getElementById('last-update'); + +// Song info elements +const coverArt = document.getElementById('cover-art'); +const songTitle = document.getElementById('song-title'); +const songArtist = document.getElementById('song-artist'); +const songAlbum = document.getElementById('song-album'); +const songDuration = document.getElementById('song-duration'); +const songGenre = document.getElementById('song-genre'); +const songYear = document.getElementById('song-year'); + +// Base64 encoded default cover art (simple music note icon) +const DEFAULT_COVER_ART = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF+mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyMy0wNC0wMVQxNTozODoxOSswMjowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjMtMDQtMDFUMTU6NDE6MDcrMDI6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMjMtMDQtMDFUMTU6NDE6MDcrMDI6MDAiIGRjOmZvcm1hdD0iaW1hZ2UvcG5nIiBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIiBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MjZiMGE4MDQtZWYxZS1hMDQ0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'; + +// Initial status load +fetchStatus(); + +// Set up Server-Sent Events for live updates +setupEventSource(); + +// Functions +function setupEventSource() { + const eventSource = new EventSource('/events'); + + eventSource.onmessage = function(event) { + const data = JSON.parse(event.data); + updateUI(data); + }; + + eventSource.onerror = function() { + // If connection fails, close it and retry after a delay + eventSource.close(); + statusDot.className = 'status-dot offline'; + statusText.textContent = 'Connection lost. Retrying...'; + + setTimeout(setupEventSource, 5000); + }; +} + +function fetchStatus() { + fetch('/status') + .then(response => response.json()) + .then(data => { + updateUI(data); + }) + .catch(error => { + console.error('Error fetching status:', error); + statusDot.className = 'status-dot offline'; + statusText.textContent = 'Error connecting to server'; + }); +} + +function updateUI(data) { + // Update status indicator + if (data.isPlaying) { + statusDot.className = 'status-dot online'; + statusText.textContent = 'Playing music'; + nowPlaying.classList.remove('hidden'); + notPlaying.classList.add('hidden'); + } else { + statusDot.className = 'status-dot offline'; + statusText.textContent = 'Not playing'; + nowPlaying.classList.add('hidden'); + notPlaying.classList.remove('hidden'); + } + + // Update song information if we have a current song + if (data.currentSong) { + updateSongInfo(data.currentSong); + } + + // Update last update time + if (data.updateTime) { + const updateDate = new Date(data.updateTime); + lastUpdate.textContent = formatDate(updateDate); + } +} + +function updateSongInfo(song) { + // Update the song title + songTitle.textContent = song.title || 'Unknown Title'; + + // Update the artist + songArtist.textContent = song.artist || 'Unknown Artist'; + + // Update the album + songAlbum.textContent = song.album || 'Unknown Album'; + + // Update the duration if available + if (song.duration) { + const minutes = Math.floor(song.duration / 60); + const seconds = song.duration % 60; + songDuration.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } else { + songDuration.textContent = '--'; + } + + // Update the genre if available + songGenre.textContent = song.genre || '--'; + + // Update the year if available + songYear.textContent = song.year ? song.year.toString() : '--'; + + // Update the cover art if available + if (song.coverArt) { + // This assumes your backend has a route to get cover art + // You might need to adjust this to match your API + const coverArtUrl = `/cover/${song.coverArt}`; + coverArt.src = coverArtUrl; + coverArt.alt = `${song.album} cover`; + } else { + coverArt.src = DEFAULT_COVER_ART; + coverArt.alt = 'Default cover art'; + } +} + +function formatDate(date) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} \ No newline at end of file diff --git a/pkg/web/static/default-cover.png b/pkg/web/static/default-cover.png new file mode 100644 index 0000000..fb5519b --- /dev/null +++ b/pkg/web/static/default-cover.png @@ -0,0 +1,13 @@ +I can't create or include binary content like PNG files directly in my response, as this would require binary data that can't be represented as text. + +For the default-cover.png file, you'll need to: + +1. Generate a simple placeholder image using an image editor +2. Save it as "default-cover.png" in the "discord-jukebox-bot/pkg/web/static/" directory + +Alternatively, you could: + +1. Use a base64-encoded data URL in your JavaScript to represent a default image +2. Update the app.js file to include a fallback image directly in the code + +Would you like me to update the JavaScript to include a base64-encoded default image instead? This would eliminate the need for a separate PNG file. \ No newline at end of file diff --git a/pkg/web/static/index.html b/pkg/web/static/index.html new file mode 100644 index 0000000..cfe3ecf --- /dev/null +++ b/pkg/web/static/index.html @@ -0,0 +1,54 @@ + + + + + + Discord Jukebox Status + + + +
+
+

Discord Jukebox

+
+ + Checking status... +
+
+ + + + + +
+

Last updated: never

+

Discord Jukebox Bot

+
+
+ + + + \ No newline at end of file diff --git a/pkg/web/static/styles.css b/pkg/web/static/styles.css new file mode 100644 index 0000000..18a649a --- /dev/null +++ b/pkg/web/static/styles.css @@ -0,0 +1,193 @@ +/* Main styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.6; + color: #333; + background-color: #f5f5f5; + min-height: 100vh; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 20px; + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + min-height: calc(100vh - 40px); + margin-top: 20px; + margin-bottom: 20px; + display: flex; + flex-direction: column; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 15px; + border-bottom: 1px solid #eee; + flex-wrap: wrap; +} + +h1 { + font-size: 28px; + color: #5865F2; /* Discord blue */ +} + +.status-indicator { + display: flex; + align-items: center; + font-size: 14px; + color: #666; +} + +.status-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: gray; + margin-right: 6px; +} + +.status-dot.online { + background-color: #43b581; /* Discord green */ + box-shadow: 0 0 5px rgba(67, 181, 129, 0.5); +} + +.status-dot.offline { + background-color: #f04747; /* Discord red */ +} + +/* Now Playing Section */ +#now-playing { + padding: 20px; + background-color: #f9f9f9; + border-radius: 8px; + margin-bottom: 20px; + flex-grow: 1; +} + +h2 { + font-size: 22px; + margin-bottom: 15px; + color: #5865F2; +} + +.song-info { + display: flex; + flex-wrap: wrap; + gap: 20px; +} + +#cover-art-container { + flex: 0 0 200px; +} + +#cover-art { + width: 100%; + max-width: 200px; + height: auto; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); +} + +.song-details { + flex: 1; + min-width: 250px; +} + +h3 { + font-size: 20px; + margin-bottom: 10px; + color: #333; +} + +.song-details p { + margin-bottom: 8px; +} + +.additional-info { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-top: 15px; + border-top: 1px solid #eee; + padding-top: 15px; +} + +.additional-info p { + margin-right: 15px; +} + +/* Not Playing Section */ +#not-playing { + text-align: center; + padding: 40px 20px; + background-color: #f9f9f9; + border-radius: 8px; + margin-bottom: 20px; + flex-grow: 1; +} + +.message { + max-width: 400px; + margin: 0 auto; +} + +code { + font-family: monospace; + background-color: #eee; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; +} + +footer { + margin-top: auto; + padding-top: 20px; + border-top: 1px solid #eee; + color: #888; + font-size: 14px; + text-align: center; +} + +.bot-info { + margin-top: 5px; + font-size: 12px; +} + +/* Helper classes */ +.hidden { + display: none; +} + +/* Responsive adjustments */ +@media (max-width: 600px) { + header { + flex-direction: column; + align-items: flex-start; + } + + .status-indicator { + margin-top: 10px; + } + + #cover-art-container { + flex: 0 0 100%; + text-align: center; + margin-bottom: 15px; + } + + #cover-art { + max-width: 150px; + } +} \ No newline at end of file