Files
go2tv/internal/gui/main.go

724 lines
16 KiB
Go

//go:build !(android || ios)
// +build !android,!ios
package gui
import (
"context"
"errors"
"math"
"net/url"
"sort"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/lang"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"github.com/alexballas/go2tv/devices"
"github.com/alexballas/go2tv/soapcalls"
"github.com/alexballas/go2tv/soapcalls/utils"
"golang.org/x/time/rate"
)
type tappedSlider struct {
*widget.Slider
screen *FyneScreen
end string
mu sync.Mutex
}
type deviceList struct {
widget.List
}
func (c *deviceList) FocusGained() {}
func newDeviceList(dd *[]devType) *deviceList {
list := &deviceList{}
list.Length = func() int {
return len(*dd)
}
list.CreateItem = func() fyne.CanvasObject {
intListCont := container.NewHBox(widget.NewIcon(theme.NavigateNextIcon()), widget.NewLabel(""))
return intListCont
}
list.UpdateItem = func(i widget.ListItemID, o fyne.CanvasObject) {
o.(*fyne.Container).Objects[1].(*widget.Label).SetText((*dd)[i].name)
}
list.ExtendBaseWidget(list)
return list
}
func newTappableSlider(s *FyneScreen) *tappedSlider {
slider := &tappedSlider{
Slider: &widget.Slider{
Max: 100,
},
screen: s,
}
slider.ExtendBaseWidget(slider)
return slider
}
func (t *tappedSlider) Dragged(e *fyne.DragEvent) {
t.Slider.Dragged(e)
t.screen.sliderActive = true
if t.end == "" {
getSliderPos, err := t.screen.tvdata.GetPositionInfo()
if err != nil {
return
}
t.mu.Lock()
t.end = getSliderPos[0]
t.mu.Unlock()
// poor man's caching to reduce the amount of
// GetPositionInfo calls.
go func() {
time.Sleep(time.Second)
t.mu.Lock()
t.end = ""
t.mu.Unlock()
}()
}
total, err := utils.ClockTimeToSeconds(t.end)
if err != nil {
return
}
cur := (float64(total) * t.Slider.Value) / t.Slider.Max
roundedInt := int(math.Round(cur))
reltime, err := utils.SecondsToClockTime(roundedInt)
if err != nil {
return
}
end, err := utils.FormatClockTime(t.end)
if err != nil {
return
}
t.screen.EndPos.Set(end)
t.screen.CurrentPos.Set(reltime)
}
func (t *tappedSlider) DragEnd() {
// This ensures the slider functions correctly by addressing the race condition
// between the DragEnd action and the auto-refresh action.
// The auto-refresh action will reset this flag to false after the first iteration.
t.screen.sliderActive = true
if t.screen.State == "Playing" || t.screen.State == "Paused" {
getPos, err := t.screen.tvdata.GetPositionInfo()
if err != nil {
return
}
total, err := utils.ClockTimeToSeconds(getPos[0])
if err != nil {
return
}
cur := (float64(total) * t.screen.SlideBar.Value) / t.screen.SlideBar.Max
roundedInt := int(math.Round(cur))
reltime, err := utils.SecondsToClockTime(roundedInt)
if err != nil {
return
}
end, err := utils.FormatClockTime(getPos[0])
if err != nil {
return
}
t.screen.CurrentPos.Set(reltime)
t.screen.EndPos.Set(end)
if t.screen.tvdata.Transcode {
t.screen.ffmpegSeek = roundedInt
stopAction(t.screen)
playAction(t.screen)
}
if err := t.screen.tvdata.SeekSoapCall(reltime); err != nil {
return
}
}
}
func (t *tappedSlider) Tapped(p *fyne.PointEvent) {
// The auto-refresh action should reset this back to false
// after the first iterration.
t.screen.sliderActive = true
t.Slider.Tapped(p)
if t.screen.State == "Playing" || t.screen.State == "Paused" {
getPos, err := t.screen.tvdata.GetPositionInfo()
if err != nil {
return
}
total, err := utils.ClockTimeToSeconds(getPos[0])
if err != nil {
return
}
cur := (float64(total) * t.screen.SlideBar.Value) / t.screen.SlideBar.Max
roundedInt := int(math.Round(cur))
reltime, err := utils.SecondsToClockTime(roundedInt)
if err != nil {
return
}
end, err := utils.FormatClockTime(getPos[0])
if err != nil {
return
}
t.screen.CurrentPos.Set(reltime)
t.screen.EndPos.Set(end)
if t.screen.tvdata.Transcode {
t.screen.ffmpegSeek = roundedInt
stopAction(t.screen)
playAction(t.screen)
}
if err := t.screen.tvdata.SeekSoapCall(reltime); err != nil {
return
}
}
}
func mainWindow(s *FyneScreen) fyne.CanvasObject {
w := s.Current
var data []devType
list := newDeviceList(&data)
fynePE := &fyne.PointEvent{
AbsolutePosition: fyne.Position{
X: 10,
Y: 30,
},
Position: fyne.Position{
X: 10,
Y: 30,
},
}
w.Canvas().SetOnTypedKey(func(k *fyne.KeyEvent) {
if !s.Hotkeys {
return
}
if k.Name == "Space" || k.Name == "P" {
currentState := s.getScreenState()
switch currentState {
case "Playing":
go s.PlayPause.Tapped(fynePE)
case "Paused", "Stopped", "":
go s.PlayPause.Tapped(fynePE)
}
}
if k.Name == "S" {
go s.Stop.Tapped(fynePE)
}
if k.Name == "M" {
s.MuteUnmute.Tapped(fynePE)
}
if k.Name == "Prior" {
s.VolumeUp.Tapped(fynePE)
}
if k.Name == "Next" {
s.VolumeDown.Tapped(fynePE)
}
})
go func() {
var err error
data, err = getDevices(1)
if err != nil {
data = nil
}
sort.Slice(data, func(i, j int) bool {
return (data)[i].name < (data)[j].name
})
list.Refresh()
}()
mfiletext := widget.NewEntry()
sfiletext := widget.NewEntry()
mfile := widget.NewButton(lang.L("Select Media File"), func() {
go mediaAction(s)
})
mfiletext.Disable()
sfile := widget.NewButton(lang.L("Select Subtitles File"), func() {
go subsAction(s)
})
sfile.Disable()
sfiletext.Disable()
playpause := widget.NewButtonWithIcon(lang.L("Play"), theme.MediaPlayIcon(), func() {
go playAction(s)
})
stop := widget.NewButtonWithIcon(lang.L("Stop"), theme.MediaStopIcon(), func() {
go stopAction(s)
})
volumeup := widget.NewButtonWithIcon("", theme.ContentAddIcon(), func() {
go volumeAction(s, true)
})
muteunmute := widget.NewButtonWithIcon("", theme.VolumeUpIcon(), func() {
go muteAction(s)
})
volumedown := widget.NewButtonWithIcon("", theme.ContentRemoveIcon(), func() {
go volumeAction(s, false)
})
clearmedia := widget.NewButtonWithIcon("", theme.CancelIcon(), func() {
go clearmediaAction(s)
})
clearsubs := widget.NewButtonWithIcon("", theme.CancelIcon(), func() {
go clearsubsAction(s)
})
skipNext := widget.NewButtonWithIcon("", theme.MediaSkipNextIcon(), func() {
go skipNextAction(s)
})
sliderBar := newTappableSlider(s)
// previewmedia spawns external applications.
// Since there is no way to monitor the time it takes
// for the apps to load, we introduce a rate limit
// for the specific action.
throttle := rate.Every(3 * time.Second)
r := rate.NewLimiter(throttle, 1)
previewmedia := widget.NewButtonWithIcon("", theme.VisibilityIcon(), func() {
if !r.Allow() {
return
}
go previewmedia(s)
})
sfilecheck := widget.NewCheck(lang.L("Manual Subtitles"), func(b bool) {})
externalmedia := widget.NewCheck(lang.L("Media from URL"), func(b bool) {})
medialoop := widget.NewCheck(lang.L("Loop Selected"), func(b bool) {})
nextmedia := widget.NewCheck(lang.L("Auto-Play Next File"), func(b bool) {})
transcode := widget.NewCheck(lang.L("Transcode"), func(b bool) {})
mediafilelabel := canvas.NewText(lang.L("File")+":", nil)
subsfilelabel := canvas.NewText(lang.L("Subtitles")+":", nil)
devicelabel := canvas.NewText(lang.L("Select Device")+":", nil)
selectInternalSubs := widget.NewSelect([]string{}, func(item string) {
if item == "" {
return
}
s.SubsText.Text = ""
s.subsfile = ""
s.SubsText.Refresh()
sfilecheck.Checked = false
sfilecheck.Refresh()
sfile.Disable()
})
selectInternalSubs.PlaceHolder = lang.L("No Embedded Subs")
selectInternalSubs.Disable()
curPos := binding.NewString()
endPos := binding.NewString()
s.PlayPause = playpause
s.Stop = stop
s.MuteUnmute = muteunmute
s.CustomSubsCheck = sfilecheck
s.ExternalMediaURL = externalmedia
s.MediaText = mfiletext
s.SubsText = sfiletext
s.DeviceList = list
s.VolumeUp = volumeup
s.VolumeDown = volumedown
s.NextMediaCheck = nextmedia
s.SlideBar = sliderBar
s.CurrentPos = curPos
s.EndPos = endPos
s.SelectInternalSubs = selectInternalSubs
s.TranscodeCheckBox = transcode
curPos.Set("00:00:00")
endPos.Set("00:00:00")
sliderArea := container.NewBorder(nil, nil, widget.NewLabelWithData(curPos), widget.NewLabelWithData(endPos), sliderBar)
actionbuttons := container.New(&mainButtonsLayout{buttonHeight: 1.0, buttonPadding: theme.Padding()},
playpause,
volumedown,
muteunmute,
volumeup,
stop)
mrightwidgets := container.NewHBox(skipNext, previewmedia, clearmedia)
srightwidgets := container.NewHBox(selectInternalSubs, clearsubs)
checklists := container.NewHBox(externalmedia, sfilecheck, medialoop, nextmedia, transcode)
mediasubsbuttons := container.New(layout.NewGridLayout(2), mfile, sfile)
mfiletextArea := container.New(layout.NewBorderLayout(nil, nil, nil, mrightwidgets), mrightwidgets, mfiletext)
sfiletextArea := container.New(layout.NewBorderLayout(nil, nil, nil, srightwidgets), srightwidgets, sfiletext)
viewfilescont := container.New(layout.NewFormLayout(), mediafilelabel, mfiletextArea, subsfilelabel, sfiletextArea)
buttons := container.NewVBox(mediasubsbuttons, viewfilescont, checklists, sliderArea, actionbuttons, container.NewPadded(devicelabel))
content := container.New(layout.NewBorderLayout(buttons, nil, nil, nil), buttons, list)
// Widgets actions
list.OnSelected = func(id widget.ListItemID) {
playpause.Enable()
t, err := soapcalls.DMRextractor(context.Background(), data[id].addr)
check(s, err)
if err == nil {
s.selectedDevice = data[id]
s.controlURL = t.AvtransportControlURL
s.eventlURL = t.AvtransportEventSubURL
s.renderingControlURL = t.RenderingControlURL
s.connectionManagerURL = t.ConnectionManagerURL
if s.tvdata != nil {
s.tvdata.RenderingControlURL = s.renderingControlURL
}
}
}
transcode.OnChanged = func(b bool) {
if b {
s.Transcode = true
return
}
s.Transcode = false
}
sfilecheck.OnChanged = func(b bool) {
if b {
sfile.Enable()
return
}
sfile.Disable()
}
var mediafileOld, mediafileOldText string
externalmedia.OnChanged = func(b bool) {
if b {
nextmedia.SetChecked(false)
nextmedia.Disable()
mfile.Disable()
previewmedia.Disable()
skipNext.Disable()
// keep old values
mediafileOld = s.mediafile
mediafileOldText = s.MediaText.Text
// rename the label
mediafilelabel.Text = lang.L("URL") + ":"
mediafilelabel.Refresh()
// Clear the Media Text Area
clearmediaAction(s)
// Set some Media text defaults
// to indicate that we're expecting a URL
s.MediaText.SetPlaceHolder(lang.L("Enter URL here"))
s.MediaText.Enable()
s.SelectInternalSubs.PlaceHolder = lang.L("No Embedded Subs")
s.SelectInternalSubs.ClearSelected()
s.SelectInternalSubs.Disable()
return
}
if !nextmedia.Checked {
medialoop.Enable()
}
if !medialoop.Checked {
nextmedia.Enable()
}
mfile.Enable()
previewmedia.Enable()
skipNext.Enable()
mediafilelabel.Text = lang.L("File") + ":"
s.MediaText.SetPlaceHolder("")
s.MediaText.Text = mediafileOldText
s.mediafile = mediafileOld
mediafilelabel.Refresh()
s.MediaText.Disable()
if mediafileOld != "" {
subs, err := utils.GetSubs(s.ffmpegPath, mediafileOld)
if err != nil {
s.SelectInternalSubs.Options = []string{}
s.SelectInternalSubs.PlaceHolder = lang.L("No Embedded Subs")
s.SelectInternalSubs.ClearSelected()
s.SelectInternalSubs.Disable()
return
}
s.SelectInternalSubs.PlaceHolder = lang.L("Embedded Subs")
s.SelectInternalSubs.Options = subs
s.SelectInternalSubs.Enable()
}
}
medialoop.OnChanged = func(b bool) {
s.Medialoop = b
if b {
nextmedia.SetChecked(false)
nextmedia.Disable()
return
}
if !externalmedia.Checked {
nextmedia.Enable()
}
}
nextmedia.OnChanged = func(b bool) {
switch b {
case true:
medialoop.SetChecked(false)
medialoop.Disable()
case false:
medialoop.Enable()
}
go func() {
gaplessOption := fyne.CurrentApp().Preferences().StringWithFallback("Gapless", "Disabled")
if b {
if gaplessOption == "Enabled" {
switch s.State {
case "Playing", "Paused":
newTVPayload, err := queueNext(s, false)
if err == nil && s.GaplessMediaWatcher == nil {
s.GaplessMediaWatcher = gaplessMediaWatcher
go s.GaplessMediaWatcher(s.serverStopCTX, s, newTVPayload)
}
}
}
return
}
if s.tvdata != nil && s.tvdata.CallbackURL != "" {
_, err := queueNext(s, true)
if err != nil {
stopAction(s)
}
}
}()
}
// Device list auto-refresh.
// TODO: Add context to cancel
go refreshDevList(s, &data)
// Check mute status for selected device.
// TODO: Add context to cancel
go checkMutefunc(s)
// Keep track of the media progress and reflect that to the slide bar.
// TODO: Add context to cancel
go sliderUpdate(s)
return content
}
func refreshDevList(s *FyneScreen, data *[]devType) {
refreshDevices := time.NewTicker(5 * time.Second)
_, err := getDevices(2)
if err != nil && !errors.Is(err, devices.ErrNoDeviceAvailable) {
check(s, err)
}
for range refreshDevices.C {
newDevices, _ := getDevices(2)
outer:
for _, old := range *data {
oldAddress, _ := url.Parse(old.addr)
for _, device := range newDevices {
newAddress, _ := url.Parse(device.addr)
if newAddress.Host == oldAddress.Host {
continue outer
}
}
if utils.HostPortIsAlive(oldAddress.Host) {
newDevices = append(newDevices, old)
}
sort.Slice(newDevices, func(i, j int) bool {
return (newDevices)[i].name < (newDevices)[j].name
})
}
// check to see if the new refresh includes
// one of the already selected devices
var includes bool
u, _ := url.Parse(s.controlURL)
for _, d := range newDevices {
n, _ := url.Parse(d.addr)
if n.Host == u.Host {
includes = true
}
}
*data = newDevices
if !includes && !utils.HostPortIsAlive(u.Host) {
s.controlURL = ""
s.DeviceList.UnselectAll()
}
var found bool
for n, a := range *data {
if s.selectedDevice.addr == a.addr {
found = true
s.DeviceList.Select(n)
}
}
if !found {
s.DeviceList.UnselectAll()
}
s.DeviceList.Refresh()
}
}
func checkMutefunc(s *FyneScreen) {
checkMute := time.NewTicker(2 * time.Second)
var checkMuteCounter int
for range checkMute.C {
// Stop trying after 5 failures
// to get the mute status
if checkMuteCounter == 5 {
s.renderingControlURL = ""
checkMuteCounter = 0
}
if s.renderingControlURL == "" {
continue
}
if s.tvdata == nil {
s.tvdata = &soapcalls.TVPayload{RenderingControlURL: s.renderingControlURL}
}
isMuted, err := s.tvdata.GetMuteSoapCall()
if err != nil {
checkMuteCounter++
continue
}
checkMuteCounter = 0
switch isMuted {
case "1":
setMuteUnmuteView("Unmute", s)
case "0":
setMuteUnmuteView("Mute", s)
}
}
}
func sliderUpdate(s *FyneScreen) {
t := time.NewTicker(time.Second)
for range t.C {
if s.sliderActive {
s.sliderActive = false
continue
}
if (s.State == "Stopped" || s.State == "") && s.ffmpegSeek == 0 {
s.SlideBar.Slider.SetValue(0)
s.CurrentPos.Set("00:00:00")
s.EndPos.Set("00:00:00")
}
if s.State == "Playing" {
getPos, err := s.tvdata.GetPositionInfo()
if err != nil {
continue
}
total, err := utils.ClockTimeToSeconds(getPos[0])
if err != nil {
continue
}
current, err := utils.ClockTimeToSeconds(getPos[1])
if err != nil {
continue
}
switch {
case s.ffmpegSeek > 0:
current += s.ffmpegSeek
case s.tvdata != nil && s.tvdata.FFmpegSeek > 0:
current += s.tvdata.FFmpegSeek
}
s.ffmpegSeek = 0
valueToSet := float64(current) * s.SlideBar.Max / float64(total)
if !math.IsNaN(valueToSet) {
s.SlideBar.SetValue(valueToSet)
end, err := utils.FormatClockTime(getPos[0])
if err != nil {
return
}
currentClock, err := utils.SecondsToClockTime(current)
if err != nil {
return
}
s.CurrentPos.Set(currentClock)
s.EndPos.Set(end)
}
}
}
}