2023-12-05 14:36:45 +01:00
|
|
|
//go:generate fyne bundle -o bundle.go res/icon.png
|
|
|
|
//go:generate fyne bundle -o bundle.go -append res/header.png
|
|
|
|
|
2023-12-05 16:24:38 +01:00
|
|
|
/*
|
|
|
|
* Copyright 2023 José Carlos Cuevas
|
|
|
|
*
|
|
|
|
* This file is part of RadioSpiral Player.
|
|
|
|
* RadioSpiral Player is free software: you can redistribute it and/or modify it under the
|
|
|
|
* terms of the GNU General Public License as published by the Free Software Foundation,
|
|
|
|
* either version 3 of the License, or (at your option) any later version.
|
|
|
|
*
|
|
|
|
* RadioSpiral Player is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
|
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
|
|
|
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
|
|
*
|
|
|
|
* You should have received a copy of the GNU General Public License along with
|
|
|
|
* RadioSpiral Player. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
*
|
|
|
|
*/
|
|
|
|
|
2023-12-04 01:24:00 +01:00
|
|
|
package main
|
|
|
|
|
2023-12-04 11:34:14 +01:00
|
|
|
/*
|
2023-12-06 16:27:24 +01:00
|
|
|
* This app is basically using ffmpeg to read the stream, so we don't have to deal with
|
2023-12-04 11:34:14 +01:00
|
|
|
* complicated streaming stuff when there's usually a perfectly working program that will
|
|
|
|
* do this better than we can ever do.
|
|
|
|
*
|
2023-12-06 16:27:24 +01:00
|
|
|
* We use the Oto library (multiplatform!) to send the raw WAV data from ffmpeg to the audio
|
|
|
|
* system of the OS
|
2023-12-04 11:34:14 +01:00
|
|
|
*
|
2023-12-06 16:27:24 +01:00
|
|
|
* We keep control of oto and ffmpeg through the StreamPlayer object. We use pipe of stderr
|
|
|
|
* to read the text output of ffmpeg and watch for stream title changes, indside a goroutine
|
|
|
|
*
|
|
|
|
* There is also a goroutine that checks the broadcast information every ten minutes, updates
|
2023-12-04 11:34:14 +01:00
|
|
|
* the GUI with the currently playing information and also the next show coming up.
|
|
|
|
*/
|
|
|
|
|
2023-12-04 01:24:00 +01:00
|
|
|
import (
|
2023-12-04 11:15:05 +01:00
|
|
|
"encoding/json"
|
2023-12-05 15:57:36 +01:00
|
|
|
"flag"
|
2023-12-05 01:08:51 +01:00
|
|
|
"image"
|
2023-12-04 01:24:00 +01:00
|
|
|
"io"
|
2023-12-05 12:42:18 +01:00
|
|
|
"log"
|
2023-12-04 11:15:05 +01:00
|
|
|
"net/http"
|
2023-12-06 18:44:57 +01:00
|
|
|
"net/url"
|
2023-12-05 12:42:18 +01:00
|
|
|
"os"
|
2023-12-08 17:44:22 +01:00
|
|
|
"path/filepath"
|
2023-12-06 19:35:10 +01:00
|
|
|
"runtime"
|
2023-12-04 01:24:00 +01:00
|
|
|
"strings"
|
2023-12-04 11:15:05 +01:00
|
|
|
"time"
|
2023-12-04 01:24:00 +01:00
|
|
|
|
2023-12-04 11:15:05 +01:00
|
|
|
"fyne.io/fyne/v2"
|
2023-12-04 01:24:00 +01:00
|
|
|
"fyne.io/fyne/v2/app"
|
2023-12-05 01:08:51 +01:00
|
|
|
"fyne.io/fyne/v2/canvas"
|
2023-12-04 01:24:00 +01:00
|
|
|
"fyne.io/fyne/v2/container"
|
2023-12-04 11:15:05 +01:00
|
|
|
"fyne.io/fyne/v2/layout"
|
2023-12-04 01:56:39 +01:00
|
|
|
"fyne.io/fyne/v2/theme"
|
2023-12-04 01:24:00 +01:00
|
|
|
"fyne.io/fyne/v2/widget"
|
|
|
|
)
|
|
|
|
|
2023-12-08 01:52:40 +01:00
|
|
|
// Enums and constants
|
|
|
|
const RADIOSPIRAL_STREAM = "https://radiospiral.radio/stream.mp3"
|
|
|
|
const RADIOSPIRAL_JSON_ENDPOINT = "https://radiospiral.net/wp-json/radio/broadcast"
|
|
|
|
|
|
|
|
const (
|
|
|
|
Loading int = iota
|
|
|
|
Playing
|
|
|
|
Stopped
|
|
|
|
)
|
|
|
|
|
2023-12-04 01:24:00 +01:00
|
|
|
// helper
|
|
|
|
func check(err error) {
|
|
|
|
if err != nil {
|
2023-12-05 12:42:18 +01:00
|
|
|
log.Panic(err)
|
2023-12-04 01:24:00 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-04 11:15:05 +01:00
|
|
|
// JSON data we receive from the wp-json/radio/broadcast endpoint
|
|
|
|
type BroadcastResponse struct {
|
|
|
|
Broadcast BroadcastInfo `json:"broadcast"`
|
|
|
|
Updated int `json:"updated"`
|
|
|
|
Success bool `json:"success"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type BroadcastInfo struct {
|
|
|
|
NowPlaying NowPlayingInfo `json:"now_playing"`
|
|
|
|
NextShow NextShowInfo `json:"next_show"`
|
|
|
|
CurrentShow bool `json:"current_show"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type NowPlayingInfo struct {
|
|
|
|
Text string `json:"text"`
|
|
|
|
Artist string `json:"artist"`
|
|
|
|
Title string `json:"title"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type NextShowInfo struct {
|
|
|
|
Day string `json:"day"`
|
|
|
|
Date string `json:"date"`
|
|
|
|
Start string `json:"start"`
|
|
|
|
End string `json:"end"`
|
|
|
|
Show ShowData `json:"show"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type ShowData struct {
|
|
|
|
Name string `json:"name"`
|
|
|
|
AvatarUrl string `json:"avatar_url"`
|
|
|
|
ImageUrl string `json:"image_url"`
|
|
|
|
Hosts []HostsData `json:"hosts"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type HostsData struct {
|
|
|
|
Name string `json:"name"`
|
|
|
|
}
|
|
|
|
|
2023-12-05 01:08:51 +01:00
|
|
|
// Load images from URLs
|
|
|
|
func loadImageURL(url string) image.Image {
|
|
|
|
parts := strings.Split(url, "?")
|
|
|
|
resp, err := http.Get(parts[0])
|
|
|
|
check(err)
|
|
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
img, _, err := image.Decode(resp.Body)
|
|
|
|
check(err)
|
|
|
|
return img
|
|
|
|
}
|
|
|
|
|
2023-12-04 01:24:00 +01:00
|
|
|
func main() {
|
2023-12-06 16:27:24 +01:00
|
|
|
PLAYER_CMD := "ffmpeg"
|
2023-12-04 11:15:05 +01:00
|
|
|
|
2023-12-06 19:35:10 +01:00
|
|
|
if runtime.GOOS == "windows" {
|
|
|
|
log.Println("Detected Windows")
|
2023-12-08 17:44:22 +01:00
|
|
|
ex, err := os.Executable()
|
|
|
|
if err != nil {
|
|
|
|
log.Fatal("Couldn't get executable path")
|
|
|
|
}
|
|
|
|
exPath := filepath.Dir(ex)
|
|
|
|
PLAYER_CMD = filepath.Join(exPath, "ffmpeg.exe")
|
2023-12-06 19:35:10 +01:00
|
|
|
}
|
|
|
|
|
2023-12-05 15:57:36 +01:00
|
|
|
// Command line arguments parsing
|
|
|
|
loggingToFilePtr := flag.Bool("log", false, "Create a log file")
|
|
|
|
|
|
|
|
flag.Parse()
|
|
|
|
|
|
|
|
if *loggingToFilePtr {
|
|
|
|
logFile, err := os.OpenFile("radiospiral.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
|
|
|
check(err)
|
|
|
|
defer logFile.Close()
|
|
|
|
log.SetOutput(logFile)
|
|
|
|
}
|
2023-12-05 12:42:18 +01:00
|
|
|
|
|
|
|
log.Println("Starting the app")
|
|
|
|
|
2023-12-06 16:27:24 +01:00
|
|
|
// Create the status channel, to read from StreamPlayer and the pipe to send commands to it
|
2023-12-17 16:05:49 +01:00
|
|
|
// pipe_chan := make(chan io.ReadCloser)
|
2023-12-04 01:24:00 +01:00
|
|
|
|
2023-12-06 16:27:24 +01:00
|
|
|
// Create our StreamPlayer instance
|
2023-12-17 16:05:49 +01:00
|
|
|
streamPlayer := StreamPlayer{player_name: PLAYER_CMD}
|
2023-12-04 01:24:00 +01:00
|
|
|
|
2023-12-04 11:34:14 +01:00
|
|
|
// Create our app and window
|
2023-12-04 01:24:00 +01:00
|
|
|
app := app.New()
|
2023-12-05 12:58:10 +01:00
|
|
|
window := app.NewWindow("RadioSpiral Player")
|
2023-12-04 01:24:00 +01:00
|
|
|
|
2023-12-04 11:15:05 +01:00
|
|
|
window.Resize(fyne.NewSize(400, 600))
|
2023-12-05 14:36:45 +01:00
|
|
|
window.SetIcon(resourceIconPng)
|
2023-12-04 11:15:05 +01:00
|
|
|
|
2023-12-04 11:34:14 +01:00
|
|
|
// Keep the status of the player
|
2023-12-08 01:52:40 +01:00
|
|
|
playStatus := Stopped
|
2023-12-04 01:56:39 +01:00
|
|
|
|
2023-12-05 12:42:18 +01:00
|
|
|
// Placeholder avatar
|
2023-12-05 01:08:51 +01:00
|
|
|
radioSpiralAvatar := loadImageURL("https://radiospiral.net/wp-content/uploads/2018/03/Radio-Spiral-Logo-1.png")
|
2023-12-05 12:42:18 +01:00
|
|
|
|
|
|
|
// Header section
|
2023-12-05 15:57:36 +01:00
|
|
|
radioSpiralHeaderImage := canvas.NewImageFromResource(resourceHeaderPng)
|
|
|
|
radioSpiralHeaderImage.SetMinSize(fyne.NewSize(400, 120))
|
|
|
|
radioSpiralHeaderImage.FillMode = canvas.ImageFillContain
|
2023-12-05 12:42:18 +01:00
|
|
|
|
|
|
|
// Next show section
|
|
|
|
showAvatar := canvas.NewImageFromImage(radioSpiralAvatar)
|
|
|
|
showAvatar.SetMinSize(fyne.NewSize(200, 200))
|
|
|
|
showCard := widget.NewCard("RadioSpiral", "", showAvatar)
|
|
|
|
centerCardContainer := container.NewCenter(showCard)
|
|
|
|
|
2023-12-06 18:44:57 +01:00
|
|
|
// Player section
|
2023-12-05 12:42:18 +01:00
|
|
|
nowPlayingLabelHeader := widget.NewLabel("Now playing:")
|
2023-12-05 01:08:51 +01:00
|
|
|
nowPlayingLabel := widget.NewLabel("")
|
2023-12-05 12:42:18 +01:00
|
|
|
volumeDown := widget.NewButtonWithIcon("", theme.VolumeDownIcon(), func() {
|
2023-12-06 16:27:24 +01:00
|
|
|
streamPlayer.DecVolume()
|
2023-12-05 12:42:18 +01:00
|
|
|
})
|
|
|
|
volumeUp := widget.NewButtonWithIcon("", theme.VolumeUpIcon(), func() {
|
2023-12-06 16:27:24 +01:00
|
|
|
streamPlayer.IncVolume()
|
2023-12-05 12:42:18 +01:00
|
|
|
})
|
2023-12-06 16:28:49 +01:00
|
|
|
volumeMute := widget.NewButtonWithIcon("", theme.VolumeMuteIcon(), func() {
|
|
|
|
streamPlayer.Mute()
|
|
|
|
})
|
2023-12-05 12:42:18 +01:00
|
|
|
controlContainer := container.NewHBox(
|
|
|
|
nowPlayingLabelHeader,
|
|
|
|
layout.NewSpacer(),
|
|
|
|
volumeDown,
|
|
|
|
volumeUp,
|
2023-12-06 16:28:49 +01:00
|
|
|
volumeMute,
|
2023-12-05 12:42:18 +01:00
|
|
|
)
|
2023-12-04 11:15:05 +01:00
|
|
|
|
2023-12-05 01:08:51 +01:00
|
|
|
nowPlayingLabel.Alignment = fyne.TextAlignCenter
|
2023-12-05 12:42:18 +01:00
|
|
|
nowPlayingLabel.Wrapping = fyne.TextWrapWord
|
|
|
|
|
2023-12-08 01:52:40 +01:00
|
|
|
var playButton *widget.Button
|
|
|
|
|
|
|
|
playButton = widget.NewButtonWithIcon("", theme.MediaPlayIcon(), func() {
|
|
|
|
// Here we control each time the button is pressed and update its
|
|
|
|
// appearance anytime it is clicked. We make the player start playing
|
|
|
|
// or pause.
|
2023-12-17 16:05:49 +01:00
|
|
|
if !streamPlayer.IsPlaying() {
|
2023-12-11 10:57:23 +01:00
|
|
|
playButton.SetIcon(theme.MediaStopIcon())
|
2023-12-08 01:52:40 +01:00
|
|
|
playButton.SetText("(Buffering)")
|
|
|
|
streamPlayer.Load(RADIOSPIRAL_STREAM)
|
|
|
|
streamPlayer.Play()
|
|
|
|
playStatus = Loading
|
|
|
|
} else {
|
|
|
|
if playStatus == Playing {
|
2023-12-11 10:57:23 +01:00
|
|
|
playStatus = Stopped
|
2023-12-08 01:52:40 +01:00
|
|
|
playButton.SetIcon(theme.MediaPlayIcon())
|
2023-12-17 16:05:49 +01:00
|
|
|
streamPlayer.Stop()
|
2023-12-08 01:52:40 +01:00
|
|
|
} else {
|
|
|
|
playStatus = Loading
|
|
|
|
playButton.SetText("(Buffering)")
|
2023-12-11 10:57:23 +01:00
|
|
|
playButton.SetIcon(theme.MediaStopIcon())
|
2023-12-08 01:52:40 +01:00
|
|
|
streamPlayer.Load(RADIOSPIRAL_STREAM)
|
|
|
|
streamPlayer.Play()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
// Process the output of ffmpeg here in a separate goroutine
|
2023-12-05 12:42:18 +01:00
|
|
|
go func() {
|
|
|
|
for {
|
2023-12-17 16:05:49 +01:00
|
|
|
if streamPlayer.out != nil {
|
|
|
|
for {
|
|
|
|
var data [255]byte
|
|
|
|
_, err := streamPlayer.out.Read(data[:])
|
|
|
|
if err != nil {
|
|
|
|
log.Println(err)
|
|
|
|
break
|
2023-12-08 01:52:40 +01:00
|
|
|
}
|
2023-12-17 16:05:49 +01:00
|
|
|
lines := strings.Split(string(data[:]), "\n")
|
|
|
|
for _, line := range lines {
|
|
|
|
// Log, if enabled, the output of StreamPlayer
|
|
|
|
if *loggingToFilePtr {
|
|
|
|
log.Print("[" + streamPlayer.player_name + "] " + line)
|
|
|
|
}
|
|
|
|
if strings.Contains(line, "Output #0") {
|
|
|
|
playStatus = Playing
|
|
|
|
playButton.SetText("")
|
|
|
|
}
|
|
|
|
// Check if there's an updated title and reflect it on the
|
|
|
|
// GUI
|
|
|
|
if strings.Contains(line, "StreamTitle: ") {
|
|
|
|
log.Println("Found new stream title, updating GUI")
|
|
|
|
newTitleParts := strings.Split(line, "StreamTitle: ")
|
|
|
|
nowPlayingLabel.SetText(newTitleParts[1])
|
|
|
|
}
|
2023-12-05 12:42:18 +01:00
|
|
|
}
|
|
|
|
}
|
2023-12-17 16:05:49 +01:00
|
|
|
} else {
|
|
|
|
// To avoid high CPU usage, we wait some milliseconds before testing
|
|
|
|
// again for the change in streamPlayer.out from nil to ReadCloser
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
2023-12-05 12:42:18 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
2023-12-04 11:15:05 +01:00
|
|
|
|
2023-12-06 18:44:57 +01:00
|
|
|
rsUrl, err := url.Parse("https://radiospiral.net")
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
log.Println("Error generating RadioSpiral url")
|
|
|
|
}
|
|
|
|
|
2023-12-04 11:34:14 +01:00
|
|
|
// Layout the whole thing
|
2023-12-04 01:24:00 +01:00
|
|
|
window.SetContent(container.NewVBox(
|
2023-12-05 15:57:36 +01:00
|
|
|
radioSpiralHeaderImage,
|
2023-12-06 18:44:57 +01:00
|
|
|
container.NewCenter(widget.NewHyperlink("https://radiospiral.net", rsUrl)),
|
2023-12-05 15:57:36 +01:00
|
|
|
layout.NewSpacer(),
|
2023-12-05 01:08:51 +01:00
|
|
|
widget.NewLabel("Next show:"),
|
2023-12-05 12:42:18 +01:00
|
|
|
centerCardContainer,
|
2023-12-04 11:15:05 +01:00
|
|
|
layout.NewSpacer(),
|
2023-12-05 12:42:18 +01:00
|
|
|
controlContainer,
|
2023-12-05 01:08:51 +01:00
|
|
|
nowPlayingLabel,
|
|
|
|
playButton,
|
2023-12-04 01:24:00 +01:00
|
|
|
))
|
|
|
|
|
2023-12-04 11:34:14 +01:00
|
|
|
// Now that everything is laid out, we can start this
|
|
|
|
// small goroutine every minute, retrieve the stream data
|
|
|
|
// and the shows data, update the GUI accordingly
|
2023-12-04 11:15:05 +01:00
|
|
|
go func() {
|
|
|
|
for {
|
|
|
|
resp, err := http.Get(RADIOSPIRAL_JSON_ENDPOINT)
|
2023-12-05 12:42:18 +01:00
|
|
|
if err != nil {
|
|
|
|
// If we get an error fetching the data, await a minute and retry
|
|
|
|
log.Println("[ERROR] Error when querying broadcast endpoint")
|
|
|
|
log.Println(err)
|
|
|
|
time.Sleep(1 * time.Minute)
|
|
|
|
continue
|
|
|
|
}
|
2023-12-04 11:15:05 +01:00
|
|
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
2023-12-05 12:42:18 +01:00
|
|
|
if err != nil {
|
|
|
|
// We couldn't read the body, log the error, await a minute and retry
|
|
|
|
log.Println("[ERROR] Error when reading the body")
|
|
|
|
log.Println(err)
|
|
|
|
time.Sleep(1 * time.Minute)
|
|
|
|
continue
|
|
|
|
}
|
2023-12-04 11:15:05 +01:00
|
|
|
|
|
|
|
var broadcastResponse BroadcastResponse
|
2023-12-05 12:42:18 +01:00
|
|
|
|
2023-12-04 11:15:05 +01:00
|
|
|
json.Unmarshal(body, &broadcastResponse)
|
2023-12-05 12:42:18 +01:00
|
|
|
showCard.SetTitle(broadcastResponse.Broadcast.NextShow.Show.Name)
|
2023-12-05 01:08:51 +01:00
|
|
|
date := broadcastResponse.Broadcast.NextShow.Day + " " + broadcastResponse.Broadcast.NextShow.Date
|
2023-12-05 12:42:18 +01:00
|
|
|
host := "by: " + broadcastResponse.Broadcast.NextShow.Show.Hosts[0].Name
|
|
|
|
showCard.SetSubTitle(date + " " + host)
|
2023-12-05 01:08:51 +01:00
|
|
|
showAvatar.Image = loadImageURL(broadcastResponse.Broadcast.NextShow.Show.AvatarUrl)
|
|
|
|
showAvatar.Refresh()
|
2023-12-05 12:42:18 +01:00
|
|
|
time.Sleep(10 * time.Minute)
|
2023-12-04 11:15:05 +01:00
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
2023-12-04 11:34:14 +01:00
|
|
|
// Showtime!
|
2023-12-04 01:24:00 +01:00
|
|
|
window.ShowAndRun()
|
2023-12-08 01:52:40 +01:00
|
|
|
streamPlayer.Close()
|
2023-12-04 01:24:00 +01:00
|
|
|
}
|