feat: use ffmpeg as stream backend and oto for sound output, GUI improvements #1
3 changed files with 164 additions and 58 deletions
7
go.mod
7
go.mod
|
@ -2,4 +2,9 @@ module radiospiral.net/m
|
|||
|
||||
go 1.16
|
||||
|
||||
require fyne.io/fyne/v2 v2.4.2
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.4.2
|
||||
github.com/ebitengine/oto/v3 v3.1.0 // indirect
|
||||
github.com/ebitengine/purego v0.5.1 // indirect
|
||||
golang.org/x/sys v0.15.0 // indirect
|
||||
)
|
||||
|
|
8
go.sum
8
go.sum
|
@ -69,6 +69,11 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/oto/v3 v3.1.0 h1:9tChG6rizyeR2w3vsygTTTVVJ9QMMyu00m2yBOCch6U=
|
||||
github.com/ebitengine/oto/v3 v3.1.0/go.mod h1:IK1QTnlfZK2GIB6ziyECm433hAdTaPpOsGMLhEyEGTg=
|
||||
github.com/ebitengine/purego v0.5.0/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
|
||||
github.com/ebitengine/purego v0.5.1 h1:hNunhThpOf1vzKl49v6YxIsXLhl92vbBEv1/2Ez3ZrY=
|
||||
github.com/ebitengine/purego v0.5.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
|
@ -505,8 +510,11 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
|
|
207
main.go
207
main.go
|
@ -21,14 +21,17 @@
|
|||
package main
|
||||
|
||||
/*
|
||||
* This app is basically a frontend using MPlayer to stream, so we don't have to deal with
|
||||
* This app is basically using ffmpeg to read the stream, so we don't have to deal with
|
||||
* complicated streaming stuff when there's usually a perfectly working program that will
|
||||
* do this better than we can ever do.
|
||||
*
|
||||
* We keep control of MPlayer through the MPlayer object and pipes to send it commands to
|
||||
* play, stop and which is the URL we want to stream (usually, RadioSpiral's).
|
||||
* We use the Oto library (multiplatform!) to send the raw WAV data from ffmpeg to the audio
|
||||
* system of the OS
|
||||
*
|
||||
* There is also a goroutine that checks the broadcast information every minute, updates
|
||||
* 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
|
||||
* the GUI with the currently playing information and also the next show coming up.
|
||||
*/
|
||||
|
||||
|
@ -45,6 +48,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ebitengine/oto/v3"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
|
@ -101,7 +106,9 @@ type HostsData struct {
|
|||
|
||||
// Radio player interface
|
||||
type RadioPlayer interface {
|
||||
Play(stream_url string)
|
||||
Load(stream_url string)
|
||||
IsPlaying() bool
|
||||
Play()
|
||||
Mute()
|
||||
Pause()
|
||||
IncVolume()
|
||||
|
@ -109,77 +116,156 @@ type RadioPlayer interface {
|
|||
Close()
|
||||
}
|
||||
|
||||
// MPlayer
|
||||
type MPlayer struct {
|
||||
player_name string
|
||||
is_playing bool
|
||||
stream_url string
|
||||
command *exec.Cmd
|
||||
in io.WriteCloser
|
||||
out io.ReadCloser
|
||||
pipe_chan chan io.ReadCloser
|
||||
// StreamPlayer
|
||||
type StreamPlayer struct {
|
||||
player_name string
|
||||
stream_url string
|
||||
command *exec.Cmd
|
||||
in io.WriteCloser
|
||||
out io.ReadCloser
|
||||
audio io.ReadCloser
|
||||
pipe_chan chan io.ReadCloser
|
||||
otoContext *oto.Context
|
||||
otoPlayer *oto.Player
|
||||
currentVolume float64
|
||||
paused bool
|
||||
}
|
||||
|
||||
func (player *MPlayer) Play(stream_url string) {
|
||||
if !player.is_playing {
|
||||
func (player *StreamPlayer) IsPlaying() bool {
|
||||
if player.otoPlayer == nil {
|
||||
log.Println("Player not loaded!")
|
||||
return false
|
||||
}
|
||||
|
||||
return player.otoPlayer.IsPlaying()
|
||||
}
|
||||
|
||||
func (player *StreamPlayer) Load(stream_url string) {
|
||||
if (player.otoPlayer == nil) || (!player.otoPlayer.IsPlaying()) {
|
||||
var err error
|
||||
is_playlist := strings.HasSuffix(stream_url, ".m3u") || strings.HasSuffix(stream_url, ".pls")
|
||||
if is_playlist {
|
||||
// TODO: Check ffmpeg's ability to deal with playlists
|
||||
// player.command = exec.Command(player.player_name, "-quiet", "-playlist", stream_url)
|
||||
player.command = exec.Command(player.player_name, "-v", "-playlist", stream_url)
|
||||
player.command = exec.Command(player.player_name, "-nodisp", "-loglevel", "verbose", "-playlist", stream_url)
|
||||
} else {
|
||||
player.command = exec.Command(player.player_name, "-v", stream_url)
|
||||
player.command = exec.Command(player.player_name, "-loglevel", "verbose", "-i", stream_url, "-f", "wav", "-")
|
||||
}
|
||||
|
||||
// In to send things over stdin to ffmpeg
|
||||
player.in, err = player.command.StdinPipe()
|
||||
check(err)
|
||||
player.out, err = player.command.StdoutPipe()
|
||||
// Out will be the wave data we will read and play
|
||||
player.audio, err = player.command.StdoutPipe()
|
||||
check(err)
|
||||
// Err is the output of ffmpeg, used to get stream title
|
||||
player.out, err = player.command.StderrPipe()
|
||||
check(err)
|
||||
|
||||
log.Println("Starting ffmpeg")
|
||||
err = player.command.Start()
|
||||
check(err)
|
||||
|
||||
player.is_playing = true
|
||||
player.stream_url = stream_url
|
||||
|
||||
op := &oto.NewContextOptions{
|
||||
SampleRate: 44100,
|
||||
ChannelCount: 2,
|
||||
Format: oto.FormatSignedInt16LE,
|
||||
}
|
||||
|
||||
if player.otoContext == nil {
|
||||
otoContext, readyChan, err := oto.NewContext(op)
|
||||
player.otoContext = otoContext
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
<-readyChan
|
||||
}
|
||||
|
||||
player.otoPlayer = player.otoContext.NewPlayer(player.audio)
|
||||
// Save current volume for the mute function
|
||||
player.currentVolume = player.otoPlayer.Volume()
|
||||
|
||||
player.paused = false
|
||||
|
||||
go func() {
|
||||
player.pipe_chan <- player.out
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (player *MPlayer) Close() {
|
||||
if player.is_playing {
|
||||
player.is_playing = false
|
||||
func (player *StreamPlayer) Play() {
|
||||
if player.otoPlayer == nil {
|
||||
log.Println("Stream not loaded")
|
||||
return
|
||||
}
|
||||
|
||||
player.in.Write([]byte("q"))
|
||||
if !player.otoPlayer.IsPlaying() {
|
||||
if player.command == nil {
|
||||
player.Load(player.stream_url)
|
||||
}
|
||||
player.otoPlayer.Play()
|
||||
}
|
||||
}
|
||||
|
||||
func (player *StreamPlayer) Close() {
|
||||
if player.IsPlaying() {
|
||||
err := player.otoPlayer.Close()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
player.in.Close()
|
||||
player.out.Close()
|
||||
player.command = nil
|
||||
player.audio.Close()
|
||||
// player.command.Cancel()
|
||||
|
||||
player.stream_url = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (player *MPlayer) Mute() {
|
||||
if player.is_playing {
|
||||
player.in.Write([]byte("m"))
|
||||
func (player *StreamPlayer) Mute() {
|
||||
if player.IsPlaying() {
|
||||
if player.otoPlayer.Volume() > 0 {
|
||||
player.currentVolume = player.otoPlayer.Volume()
|
||||
player.otoPlayer.SetVolume(0.0)
|
||||
} else {
|
||||
player.otoPlayer.SetVolume(player.currentVolume)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (player *MPlayer) Pause() {
|
||||
if player.is_playing {
|
||||
player.in.Write([]byte("p"))
|
||||
func (player *StreamPlayer) Pause() {
|
||||
if player.IsPlaying() {
|
||||
if !player.paused {
|
||||
log.Println("[oto] Pausing")
|
||||
player.paused = true
|
||||
player.otoPlayer.Pause()
|
||||
} else {
|
||||
log.Println("[oto] Playing")
|
||||
player.paused = false
|
||||
player.otoPlayer.Play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (player *MPlayer) IncVolume() {
|
||||
if player.is_playing {
|
||||
player.in.Write([]byte("*"))
|
||||
func (player *StreamPlayer) IncVolume() {
|
||||
if player.IsPlaying() {
|
||||
player.currentVolume += 0.05
|
||||
if player.currentVolume >= 1.0 {
|
||||
player.currentVolume = 1.0
|
||||
}
|
||||
player.otoPlayer.SetVolume(player.currentVolume)
|
||||
}
|
||||
}
|
||||
|
||||
func (player *MPlayer) DecVolume() {
|
||||
if player.is_playing {
|
||||
player.in.Write([]byte("/"))
|
||||
func (player *StreamPlayer) DecVolume() {
|
||||
if player.IsPlaying() {
|
||||
player.currentVolume -= 0.05
|
||||
if player.currentVolume <= 0.0 {
|
||||
player.currentVolume = 0.0
|
||||
}
|
||||
player.otoPlayer.SetVolume(player.currentVolume)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,6 +284,7 @@ func loadImageURL(url string) image.Image {
|
|||
func main() {
|
||||
RADIOSPIRAL_STREAM := "https://radiospiral.radio/stream.mp3"
|
||||
RADIOSPIRAL_JSON_ENDPOINT := "https://radiospiral.net/wp-json/radio/broadcast"
|
||||
PLAYER_CMD := "ffmpeg"
|
||||
|
||||
// Command line arguments parsing
|
||||
loggingToFilePtr := flag.Bool("log", false, "Create a log file")
|
||||
|
@ -213,14 +300,14 @@ func main() {
|
|||
|
||||
log.Println("Starting the app")
|
||||
|
||||
// Create the status channel, to read from MPlayer and the pipe to send commands to it
|
||||
// Create the status channel, to read from StreamPlayer and the pipe to send commands to it
|
||||
pipe_chan := make(chan io.ReadCloser)
|
||||
|
||||
// Create our MPlayer instance
|
||||
mplayer := MPlayer{player_name: "mplayer", is_playing: false, pipe_chan: pipe_chan}
|
||||
// Create our StreamPlayer instance
|
||||
streamPlayer := StreamPlayer{player_name: PLAYER_CMD, pipe_chan: pipe_chan}
|
||||
|
||||
// Make sure that MPlayer closes when the program ends
|
||||
defer mplayer.Close()
|
||||
// Make sure that StreamPlayer closes when the program ends
|
||||
defer streamPlayer.Close()
|
||||
|
||||
// Create our app and window
|
||||
app := app.New()
|
||||
|
@ -250,16 +337,20 @@ func main() {
|
|||
nowPlayingLabel := widget.NewLabel("")
|
||||
var playButton *widget.Button
|
||||
volumeDown := widget.NewButtonWithIcon("", theme.VolumeDownIcon(), func() {
|
||||
mplayer.DecVolume()
|
||||
streamPlayer.DecVolume()
|
||||
})
|
||||
volumeUp := widget.NewButtonWithIcon("", theme.VolumeUpIcon(), func() {
|
||||
mplayer.IncVolume()
|
||||
streamPlayer.IncVolume()
|
||||
})
|
||||
volumeMute := widget.NewButtonWithIcon("", theme.VolumeMuteIcon(), func() {
|
||||
streamPlayer.Mute()
|
||||
})
|
||||
controlContainer := container.NewHBox(
|
||||
nowPlayingLabelHeader,
|
||||
layout.NewSpacer(),
|
||||
volumeDown,
|
||||
volumeUp,
|
||||
volumeMute,
|
||||
)
|
||||
|
||||
nowPlayingLabel.Alignment = fyne.TextAlignCenter
|
||||
|
@ -275,17 +366,18 @@ func main() {
|
|||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Println("Reloading player")
|
||||
mplayer.Close()
|
||||
streamPlayer.Close()
|
||||
pipe_chan = make(chan io.ReadCloser)
|
||||
mplayer = MPlayer{player_name: "mplayer", is_playing: false, pipe_chan: pipe_chan}
|
||||
mplayer.Play(RADIOSPIRAL_STREAM)
|
||||
streamPlayer = StreamPlayer{player_name: PLAYER_CMD, pipe_chan: pipe_chan}
|
||||
streamPlayer.Load(RADIOSPIRAL_STREAM)
|
||||
streamPlayer.Play()
|
||||
playStatus = true
|
||||
playButton.SetIcon(theme.MediaPlayIcon())
|
||||
defer mplayer.Close()
|
||||
defer streamPlayer.Close()
|
||||
} else {
|
||||
// Log, if enabled, the output of MPlayer
|
||||
// Log, if enabled, the output of StreamPlayer
|
||||
if *loggingToFilePtr {
|
||||
log.Print("[mplayer] " + data)
|
||||
log.Print("[" + streamPlayer.player_name + "] " + data)
|
||||
}
|
||||
// Check if there's an updated title and reflect it on the
|
||||
// GUI
|
||||
|
@ -299,23 +391,24 @@ func main() {
|
|||
}
|
||||
}()
|
||||
|
||||
playButton = widget.NewButtonWithIcon("", theme.MediaStopIcon(), func() {
|
||||
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.
|
||||
if !mplayer.is_playing {
|
||||
playButton.SetIcon(theme.MediaPlayIcon())
|
||||
mplayer.Play(RADIOSPIRAL_STREAM)
|
||||
if !streamPlayer.IsPlaying() {
|
||||
playButton.SetIcon(theme.MediaPauseIcon())
|
||||
streamPlayer.Load(RADIOSPIRAL_STREAM)
|
||||
streamPlayer.Play()
|
||||
playStatus = true
|
||||
} else {
|
||||
if playStatus {
|
||||
playStatus = false
|
||||
playButton.SetIcon(theme.MediaPauseIcon())
|
||||
playButton.SetIcon(theme.MediaPlayIcon())
|
||||
} else {
|
||||
playStatus = true
|
||||
playButton.SetIcon(theme.MediaPlayIcon())
|
||||
playButton.SetIcon(theme.MediaPauseIcon())
|
||||
}
|
||||
mplayer.Pause()
|
||||
streamPlayer.Pause()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in a new issue