diff --git a/cmd/helpers.go b/cmd/helpers.go new file mode 100644 index 0000000..08d7aff --- /dev/null +++ b/cmd/helpers.go @@ -0,0 +1,12 @@ +package cmd + +import "time" + +func videoPercentageWatched(pos time.Duration, dur time.Duration) float64 { + var percentage float64 + if dur.Milliseconds() > 0 { + percentage = (float64(pos.Milliseconds()) / float64(dur.Milliseconds())) * 100 + } + + return percentage +} diff --git a/cmd/player.go b/cmd/player.go new file mode 100644 index 0000000..0b41fe6 --- /dev/null +++ b/cmd/player.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + playerMaxTitleLength = 30 +) + +var ( + regexpPosDur = regexp.MustCompile(`AV: (\d+):(\d+):(\d+) \/ (\d+):(\d+):(\d+)`) +) + +type playerModel struct { + entry *feedEntry + isPlayingVideo bool + + // video information + videoDuration time.Duration + videoPosition time.Duration + + // TODO: make this smarter + // finished entries to send messages about + finishedEntries []int64 + + sync.RWMutex +} + +func (m *playerModel) Init() tea.Cmd { + return nil +} + +func (m *playerModel) playEntry(f feedEntry) tea.Cmd { + if m.isPlayingVideo == true { + return nil + } + + m.entry = &f + + if m.entry != nil && m.isPlayingVideo == false { + go m.playVideo(context.Background(), m.entry.Link) + } else { + // TODO: we are already playing something, we should not allow this + // return MsgTick(time.Now()) + + } + + return nil +} + +func (m *playerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case MsgPlayEntry: + f := feedEntry(msg) + return m, m.playEntry(f) + } + + // send message because it is finished + m.Lock() + defer m.Unlock() + if len(m.finishedEntries) > 0 { + watchedEntryID := m.finishedEntries[0] + m.finishedEntries = m.finishedEntries[1:] + return m, func() tea.Msg { return MsgWatchedEntry(watchedEntryID) } + } + + return m, nil +} + +func (m *playerModel) View() string { + if m.isPlayingVideo && m.entry != nil { + timePos := time.Time{}.Add(m.videoPosition) + timeDur := time.Time{}.Add(m.videoDuration) + + // truncate name if needed + name := m.entry.Name + if len(name) > playerMaxTitleLength { + name = name[0:playerMaxTitleLength] + "..." + } + + return fmt.Sprintf("Playing: %s (%s/%s %.1f%%)", + name, + timePos.Format(time.TimeOnly), + timeDur.Format(time.TimeOnly), + videoPercentageWatched(m.videoPosition, m.videoDuration), + ) + + } + + return "" +} + +func (m *playerModel) finishedVideo() { + m.Lock() + defer m.Unlock() + + m.isPlayingVideo = false + + if videoPercentageWatched(m.videoPosition, m.videoDuration) > 90.0 { + m.finishedEntries = append(m.finishedEntries, m.entry.ID) + } +} + +func (m *playerModel) playVideo(ctx context.Context, url string) (bool, error) { + m.Lock() + m.isPlayingVideo = true + m.Unlock() + + defer m.finishedVideo() + + cmd := exec.CommandContext(ctx, "mpv", url) + output, err := cmd.StdoutPipe() + if err != nil { + return false, err + } + + err = cmd.Start() + if err != nil { + return false, err + } + + s := bufio.NewScanner(output) + go func(s *bufio.Scanner) { + var finishedVideo bool + for s.Scan() { + t := s.Text() + if strings.Contains(t, "End of file") { + finishedVideo = true + } else if strings.HasPrefix(t, "AV: ") { + matches := regexpPosDur.FindStringSubmatch(t) + posh, _ := strconv.Atoi(matches[1]) + posm, _ := strconv.Atoi(matches[2]) + poss, _ := strconv.Atoi(matches[3]) + durh, _ := strconv.Atoi(matches[4]) + durm, _ := strconv.Atoi(matches[5]) + durs, _ := strconv.Atoi(matches[6]) + + m.videoPosition = time.Duration((posh*60*60 + posm*60 + poss) * int(time.Second)) + m.videoDuration = time.Duration((durh*60*60 + durm*60 + durs) * int(time.Second)) + } + } + _ = finishedVideo + }(s) + + return false, cmd.Wait() +}