//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) } } } }