diff --git a/go.mod b/go.mod index a3d1507..1a6ae4e 100644 --- a/go.mod +++ b/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 +) diff --git a/go.sum b/go.sum index 4e249cb..0cb7eb3 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index a2d5b5b..ca7c36e 100644 --- a/main.go +++ b/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,155 @@ 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() + } 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 +283,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 +299,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,10 +336,10 @@ 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() }) controlContainer := container.NewHBox( nowPlayingLabelHeader, @@ -275,17 +361,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 @@ -303,9 +390,10 @@ func main() { // 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 { + if !streamPlayer.IsPlaying() { playButton.SetIcon(theme.MediaPlayIcon()) - mplayer.Play(RADIOSPIRAL_STREAM) + streamPlayer.Load(RADIOSPIRAL_STREAM) + streamPlayer.Play() playStatus = true } else { if playStatus { @@ -315,7 +403,7 @@ func main() { playStatus = true playButton.SetIcon(theme.MediaPlayIcon()) } - mplayer.Pause() + streamPlayer.Pause() } })