diff --git a/docs/config.md b/docs/config.md index 977dba4..af70a63 100644 --- a/docs/config.md +++ b/docs/config.md @@ -819,6 +819,22 @@ Download one of the selected message's attachments. Default: `"D"` +### keybinds.messages_list.play + +Play, pause or resume the selected voice message. + +Default: `"p"` + +### keybinds.messages_list.seek_backward + +Seek the voice message that is playing or paused. + +Default: `["h","left"]` + +### keybinds.messages_list.seek_forward + +Default: `["l","right"]` + ### keybinds.messages_list.yank_content Yank (copy) the selected message's content/url/id. diff --git a/go.mod b/go.mod index d9b73c5..dd4646a 100644 --- a/go.mod +++ b/go.mod @@ -13,9 +13,9 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/andybalholm/brotli v1.2.6 - github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa + github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e - github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4 + github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.6.0 diff --git a/go.sum b/go.sum index 36efd34..bf49586 100644 --- a/go.sum +++ b/go.sum @@ -10,12 +10,12 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa h1:uBfUPNrKAwrKweO+vzZSaI2mawNhRZ3QFZuH7JKenq4= -github.com/ayn2op/arikawa/v3 v3.0.0-20261011215609-c5c459e53baa/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw= +github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de h1:bO825TKutshlGQ/AsH6uYpZVjHQhDLElctgrh/T90aU= +github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw= -github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4 h1:ViehR1nggJ7caDHIj9bp2Z8VqS00wur0xrOo1E6YNGs= -github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E= +github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e h1:uCt61XkJ0DqRiBd5ozXZVd2xWlNFcA0rR2hew9lAZx0= +github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 49898c6..0c7d1ba 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -123,6 +123,11 @@ type MessagesListKeybinds struct { OpenInBrowser Keybind `toml:"open_in_browser"` // Download one of the selected message's attachments. Download Keybind `toml:"download"` + // Play, pause or resume the selected voice message. + Play Keybind `toml:"play"` + // Seek the voice message that is playing or paused. + SeekBackward Keybind `toml:"seek_backward"` + SeekForward Keybind `toml:"seek_forward"` // Yank (copy) the selected message's content/url/id. YankContent Keybind `toml:"yank_content"` @@ -280,6 +285,9 @@ func defaultKeybinds() Keybinds { Open: key("open", "o"), OpenInBrowser: key("open browser", "O"), Download: key("download", "D"), + Play: key("play", "p"), + SeekBackward: key("seek back", "h", "left"), + SeekForward: key("seek forward", "l", "right"), YankContent: key("copy text", "y"), YankURL: key("copy url", "u"), diff --git a/internal/ui/chat/messageslist/keymap.go b/internal/ui/chat/messageslist/keymap.go index 9e44fea..b7191db 100644 --- a/internal/ui/chat/messageslist/keymap.go +++ b/internal/ui/chat/messageslist/keymap.go @@ -23,6 +23,9 @@ func (m Model) ShortHelp() []keybind.Keybind { if len(selectedMessage.Attachments) != 0 || len(messageURLs(*selectedMessage)) != 0 { help = append(help, cfg.Open.Keybind) } + if isVoiceMessage(*selectedMessage) { + help = append(help, cfg.Play.Keybind) + } } return help @@ -54,6 +57,13 @@ func (m Model) FullHelp() [][]keybind.Keybind { if len(message.Attachments) != 0 { attachments = append(attachments, cfg.Download.Keybind) } + if isVoiceMessage(*message) { + attachments = append(attachments, cfg.Play.Keybind) + } + } + + if m.voiceStatus.ID.IsValid() { + attachments = append(attachments, cfg.SeekBackward.Keybind, cfg.SeekForward.Keybind) } actions = append(actions, cfg.Cancel.Keybind) diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index ede8379..ebea3a8 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -15,19 +15,25 @@ import ( "github.com/ayn2op/tview/layout" + "github.com/ayn2op/arikawa/v3/api" "github.com/ayn2op/arikawa/v3/discord" md "github.com/ayn2op/arikawa/v3/markdown" "github.com/ayn2op/arikawa/v3/state" + "github.com/ayn2op/arikawa/v3/utils/httputil" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/markdown" "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/discordo/internal/voice" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" tviewimage "github.com/ayn2op/tview/image" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/list" "github.com/ayn2op/tview/richtext" + "github.com/ayn2op/tview/row" "github.com/ayn2op/tview/scrollbar" + "github.com/ayn2op/tview/slider" + "github.com/ayn2op/tview/text" "github.com/ayn2op/tview/textview" "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" @@ -57,6 +63,9 @@ type Model struct { items []messageItem renderer *markdown.Renderer + // voicePlayer plays the voice message played last, and voiceStatus is what it last said of it. + voicePlayer *voice.Player + voiceStatus voice.PlayerStatus // kitty reports whether previews are drawn with kitty's graphics protocol. // kittyImages counts the images sent to the terminal. @@ -72,6 +81,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model { cfg: cfg, state: state, renderer: markdown.NewRenderer(cfg), + voicePlayer: voice.NewPlayer(), } ml.scrollState.ScrollToEnd() @@ -241,6 +251,10 @@ func (m Model) buildItem(index int) tview.Widget { view := &messageView{render: func(width int) richtext.Text { return m.renderMessage(message, m.cfg.Theme.MessagesList.MessageStyle.Style, width) }} + if isVoiceMessage(message) { + view.player = m.voiceMessageView(message.Attachments[0]) + } + for _, s := range previewSources(message) { if preview, ok := item.previews[s.proxy]; ok { view.images = append(view.images, preview) @@ -251,9 +265,11 @@ func (m Model) buildItem(index int) tview.Widget { return item.view } -// messageView renders a message followed by its image previews, caching the wrapped lines until the width changes. +// messageView renders a message followed by its voice message player, if it is one, and its image previews, caching the wrapped lines until the width changes. type messageView struct { - render func(width int) richtext.Text + render func(width int) richtext.Text + // player is a row high, or nil. + player tview.Widget images []tviewimage.Widget width int rendered bool @@ -278,6 +294,10 @@ func (v *messageView) Layout(limits layout.Limits) layout.Size { return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size { v.at(limits.Max.Width) rows := len(v.lines) + if v.player != nil { + rows++ + } + for _, preview := range v.images { _, height := preview.Size() rows += height.Cells() @@ -289,6 +309,11 @@ func (v *messageView) Layout(limits layout.Limits) layout.Size { func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) { v.at(area.Width).Draw(screen, tview.Rectangle{X: area.X, Y: area.Y, Width: area.Width, Height: len(v.lines)}) y := area.Y + len(v.lines) + if v.player != nil { + v.player.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: 1}) + y++ + } + for _, preview := range v.images { _, height := preview.Size() preview.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: height.Cells()}) @@ -296,7 +321,14 @@ func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) { } } -func (v *messageView) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg } +// Handle passes msg to the player, for its waveform to be clicked. +func (v *messageView) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { + if v.player == nil { + return msg + } + + return v.player.Handle(msg, tview.Rectangle{X: area.X, Y: area.Y + len(v.lines), Width: area.Width, Height: 1}) +} // dateSeparator is a line with the date in the middle, filled to the width it is drawn at. type dateSeparator struct { @@ -556,6 +588,11 @@ func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Mes m.drawEmbeds(builder, message, baseStyle, width, contentRoot, contentSource) attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style) + // A voice message is shown as its player. + if isVoiceMessage(message) { + return + } + for _, a := range message.Attachments { builder.NewLine() if m.cfg.Attachments.ShowLinks { @@ -568,6 +605,72 @@ func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Mes } } +const ( + symbolPlay = "▶" + symbolPause = "⏸" + // positionSeparator is between how much of a voice message was played and how long it is. + positionSeparator = " / " + // waveformBars is what the waveform of a voice message is drawn with, from quiet to loud. + waveformBars = "▁▂▃▄▅▆▇█" + // waveformWidth is how many bars a waveform is drawn with at most. + waveformWidth = 32 + // seekStep is how far a key seeks a voice message. + seekStep = time.Second +) + +// waveformRail returns the bars the waveform of a voice message is drawn with. +func waveformRail(waveform []byte) []string { + bars := []rune(waveformBars) + + var rail []string + for chunk := range slices.Chunk(waveform, len(waveform)/waveformWidth+1) { + rail = append(rail, string(bars[int(slices.Max(chunk))*len(bars)/256])) + } + + return rail +} + +func formatDuration(d time.Duration) string { + seconds := int(d.Round(time.Second).Seconds()) + return fmt.Sprintf("%d:%02d", seconds/60, seconds%60) +} + +// voiceMessageView returns the player of the voice message a: whether it is playing, its waveform with what was played of it, and how long it is. +func (m Model) voiceMessageView(a discord.Attachment) tview.Widget { + duration := time.Duration(a.DurationSecs * float64(time.Second)) + symbol, label := symbolPlay, formatDuration(duration) + + status := m.voiceStatus + loaded := status.ID == a.ID + if loaded { + label = formatDuration(status.Position) + positionSeparator + label + } + + if loaded && status.Playing { + symbol = symbolPause + } + + rail := waveformRail(a.Waveform) + style := m.cfg.Theme.MessagesList.AttachmentStyle.Style + // Only the voice message that is loaded can be sought. + var onSeek func(time.Duration) tview.Msg + if loaded { + onSeek = func(position time.Duration) tview.Msg { return seekMsg(position) } + } + + waveform := slider.New(0, duration, status.Position, onSeek). + Width(layout.Fixed(len(rail))). + Rail(func(cell, handle int) (string, tview.Style) { + if loaded && cell <= handle { + return rail[cell], style + } + + return rail[cell], style.Dim(true) + }) + + return row.New(text.New(symbol), waveform, text.New(label)).Spacing(1) +} + func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) { if len(message.Embeds) == 0 { return @@ -855,6 +958,12 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { return m, m.open() case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.OpenInBrowser.Keybind): return m, m.openInBrowser() + case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Play.Keybind): + return m, m.play() + case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.SeekBackward.Keybind): + return m, m.seek(m.voiceStatus.Position - seekStep) + case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.SeekForward.Keybind): + return m, m.seek(m.voiceStatus.Position + seekStep) case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Download.Keybind): return m, m.download() case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Reply.Keybind): @@ -903,6 +1012,17 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { // Invalidate the view so it is laid out with the preview. m.items[index].view = nil return m, cmd + case seekMsg: + return m, m.seek(time.Duration(msg)) + case voiceMsg: + m.voiceStatus = m.voicePlayer.Status() + for i := range m.items { + if isVoiceMessage(m.items[i].message) { + m.items[i].view = nil + } + } + + return m, m.Init() case tview.ResizeMsg: cols, rows := msg.Size() width, height := msg.PixelSize() @@ -1034,15 +1154,40 @@ func messageURLs(msg discord.Message) []string { return urls } -func downloadAttachment(attachment discord.Attachment, destination string) error { - resp, err := http.Get(attachment.URL) +// get requests the file at url, by a new link to it if that one expired. +func (m Model) get(url string) (*http.Response, error) { + resp, err := http.Get(url) + if err != nil || resp.StatusCode == http.StatusOK { + return resp, err + } + resp.Body.Close() + + var links struct { + URLs []struct { + Refreshed string `json:"refreshed"` + } `json:"refreshed_urls"` + } + body := map[string][]string{"attachment_urls": {url}} + if err := m.state.RequestJSONInto(&links, http.MethodPost, api.Endpoint+"attachments/refresh-urls", httputil.WithJSONBody(body)); err != nil { + return nil, err + } + + if len(links.URLs) != 0 { + if resp, err = http.Get(links.URLs[0].Refreshed); err != nil || resp.StatusCode == http.StatusOK { + return resp, err + } + resp.Body.Close() + } + + return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status) +} + +func (m Model) downloadAttachment(attachment discord.Attachment, destination string) error { + resp, err := m.get(attachment.URL) if err != nil { return err } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected HTTP status: %s", resp.Status) - } file, err := os.Create(destination) if err != nil { return err diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index 651221b..54594fe 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "path/filepath" "slices" + "strings" "testing" "github.com/ayn2op/arikawa/v3/discord" @@ -180,3 +181,12 @@ func TestModelSelectLastOwn(t *testing.T) { }) } } + +func TestWaveformRail(t *testing.T) { + waveform := make([]byte, 256) + waveform[255] = 255 + + if got, want := strings.Join(waveformRail(waveform), ""), strings.Repeat("▁", 28)+"█"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go index 2e85a89..fcfdba0 100644 --- a/internal/ui/chat/messageslist/msg.go +++ b/internal/ui/chat/messageslist/msg.go @@ -3,11 +3,13 @@ package messageslist import ( "context" "errors" + "io" "log/slog" "mime" "os" "path/filepath" "slices" + "time" "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/gateway" @@ -32,6 +34,19 @@ type olderMessagesLoadedMsg struct { func (olderMessagesLoadedMsg) messagesList() {} +// voiceMsg reports that the status of the voice message player changed. +type voiceMsg struct{} + +func (voiceMsg) messagesList() {} + +// Init waits for that. +func (m Model) Init() tview.Cmd { + return func() tview.Msg { + <-m.voicePlayer.Updates() + return voiceMsg{} + } +} + // ReplyMsg asks to reply to Message by Name, mentioning its author if Mention is set. type ReplyMsg struct { Message discord.Message @@ -140,6 +155,63 @@ func (m Model) open() tview.Cmd { return m.openWith(m.openAttachment) } +func isVoiceMessage(message discord.Message) bool { + return message.Flags&discord.IsVoiceMessage != 0 && len(message.Attachments) == 1 +} + +// play plays the selected voice message, or pauses or resumes it if it is the one playing. +func (m Model) play() tview.Cmd { + message, ok := m.selectedMessage() + if !ok || !isVoiceMessage(*message) { + return nil + } + + attachment := message.Attachments[0] + return func() tview.Msg { + if err := m.playVoiceMessage(attachment); err != nil { + return ui.ErrorDialog("play voice message", err) + } + + return nil + } +} + +func (m Model) playVoiceMessage(attachment discord.Attachment) error { + if toggled, err := m.voicePlayer.Toggle(attachment.ID); toggled || err != nil { + return err + } + + resp, err := m.get(attachment.URL) + if err != nil { + return err + } + defer resp.Body.Close() + + // It is read whole, for playing it not to wait for the network. + audio, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + return m.voicePlayer.Play(attachment.ID, audio) +} + +// seekMsg asks to seek to where the waveform of a voice message was clicked. +type seekMsg time.Duration + +func (seekMsg) messagesList() {} + +// seek moves the voice message that is playing or paused to position. +func (m Model) seek(position time.Duration) tview.Cmd { + return func() tview.Msg { + if err := m.voicePlayer.Seek(position); err != nil { + return ui.ErrorDialog("seek voice message", err) + } + + return nil + } +} + func (m Model) openInBrowser() tview.Cmd { return m.openWith(func(attachment discord.Attachment) tview.Cmd { return openURL(attachment.URL) }) } @@ -170,12 +242,12 @@ func (m Model) download() tview.Cmd { } if len(selectedMessage.Attachments) == 1 { attachment := selectedMessage.Attachments[0] - return m.confirmAttachment(attachment, saveAttachment(attachment)) + return m.confirmAttachment(attachment, m.saveAttachment(attachment)) } items := make(picker.Items, len(selectedMessage.Attachments)) for i, attachment := range selectedMessage.Attachments { - items[i] = picker.Item{Text: attachment.Filename, Reference: m.confirmAttachment(attachment, saveAttachment(attachment))} + items[i] = picker.Item{Text: attachment.Filename, Reference: m.confirmAttachment(attachment, m.saveAttachment(attachment))} } return m.showAttachmentsPicker(items) } @@ -196,7 +268,7 @@ func (m Model) showAttachmentsPicker(items picker.Items) tview.Cmd { } func (m Model) openAttachment(attachment discord.Attachment) tview.Cmd { - return m.confirmAttachment(attachment, openDownloadedAttachment(attachment)) + return m.confirmAttachment(attachment, m.openDownloadedAttachment(attachment)) } func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd { @@ -210,7 +282,7 @@ func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd return action } -func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd { +func (m Model) openDownloadedAttachment(attachment discord.Attachment) tview.Cmd { return func() tview.Msg { extension := filepath.Ext(attachment.Filename) if extension == "" { @@ -233,7 +305,7 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd { path := file.Name() - if err := downloadAttachment(attachment, path); err != nil { + if err := m.downloadAttachment(attachment, path); err != nil { os.Remove(path) return ui.ErrorDialog("download attachment", err) } else if err := open.Start(path); err != nil { @@ -244,7 +316,7 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd { } } -func saveAttachment(attachment discord.Attachment) tview.Cmd { +func (m Model) saveAttachment(attachment discord.Attachment) tview.Cmd { return func() tview.Msg { destination, err := zenity.SelectFileSave(zenity.Filename(filepath.Base(attachment.Filename)), zenity.ConfirmOverwrite()) if errors.Is(err, zenity.ErrCanceled) { @@ -254,7 +326,7 @@ func saveAttachment(attachment discord.Attachment) tview.Cmd { return ui.ErrorDialog("select attachment destination", err) } - if err := downloadAttachment(attachment, destination); err != nil { + if err := m.downloadAttachment(attachment, destination); err != nil { return ui.ErrorDialog("download attachment", err) } return nil diff --git a/internal/ui/chat/messageslist/preview.go b/internal/ui/chat/messageslist/preview.go index 6fb6241..f477fbd 100644 --- a/internal/ui/chat/messageslist/preview.go +++ b/internal/ui/chat/messageslist/preview.go @@ -6,7 +6,6 @@ import ( _ "image/jpeg" _ "image/png" "log/slog" - "net/http" "net/url" "slices" "strconv" @@ -96,16 +95,16 @@ func (m Model) loadPreviews() tview.Cmd { } // Reserve the preview's rows with a blank image of the same size, so the messages below do not shift when it loads. item.previews[s.proxy] = widget(image.NewAlpha(bounds)) - cmds = append(cmds, loadPreview(item.message.ID, s.proxy, bounds, widget)) + cmds = append(cmds, m.loadPreview(item.message.ID, s.proxy, bounds, widget)) } } return tview.Batch(cmds...) } // loadPreview yields an empty preview if loading fails. -func loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Rectangle, widget func(image.Image) tviewimage.Widget) tview.Cmd { +func (m Model) loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Rectangle, widget func(image.Image) tviewimage.Widget) tview.Cmd { return func() tview.Msg { - img, err := downloadPreview(proxy, bounds) + img, err := m.downloadPreview(proxy, bounds) if err != nil { slog.Error("failed to load preview", "err", err) img = &image.Alpha{} @@ -114,7 +113,7 @@ func loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Re } } -func downloadPreview(proxy discord.URL, bounds image.Rectangle) (image.Image, error) { +func (m Model) downloadPreview(proxy discord.URL, bounds image.Rectangle) (image.Image, error) { u, err := url.Parse(proxy) if err != nil { return nil, err @@ -124,7 +123,7 @@ func downloadPreview(proxy discord.URL, bounds image.Rectangle) (image.Image, er query.Set("height", strconv.Itoa(bounds.Dy())) u.RawQuery = query.Encode() - resp, err := http.Get(u.String()) + resp, err := m.get(u.String()) if err != nil { return nil, err } diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index 64ae006..f047c2e 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -190,7 +190,7 @@ func (m *Model) cycleFocus(step pane) { } func (m Model) Init() tview.Cmd { - return tview.Batch(openState(m.state), listen(m.events), m.guildsTree.Init(), tview.RequestTerminalInfo()) + return tview.Batch(openState(m.state), listen(m.events), m.guildsTree.Init(), m.messagesList.Init(), tview.RequestTerminalInfo()) } // Update returns m changed in response to msg and a command to run, or nil. diff --git a/internal/voice/player.go b/internal/voice/player.go new file mode 100644 index 0000000..3906467 --- /dev/null +++ b/internal/voice/player.go @@ -0,0 +1,201 @@ +//go:build !no_voice + +package voice + +import ( + "bytes" + "encoding/binary" + "io" + "sync" + "time" + + "github.com/ayn2op/arikawa/v3/discord" + "github.com/gen2brain/malgo" + "github.com/tphakala/go-opus/oggopus" +) + +const ( + // The sizes of the header of an Ogg page, and where in it its flags, its granule position and how many segments it has are. + oggHeaderSize = 27 + oggFlagsOffset = 5 + oggGranuleOffset = 6 + oggSegmentsOffset = 26 + // oggContinued is the flag of a page that begins with the rest of a packet. + oggContinued = 1 + + // progressSamples is how many samples are played between updates. + progressSamples = sampleRate / 10 +) + +// Player plays voice messages, one at a time. +type Player struct { + updates chan struct{} + + mu sync.Mutex + // device plays the voice message that is the attachment id, and is nil once it ended. + device *malgo.Device + id discord.AttachmentID + // audio is the Ogg Opus file, decoder what is left of it to play, and samples how much of it was played. + audio []byte + decoder *oggopus.Decoder + samples int64 +} + +func NewPlayer() *Player { + return &Player{updates: make(chan struct{}, 1)} +} + +// Updates is sent to when the status changes. +func (p *Player) Updates() <-chan struct{} { + return p.updates +} + +func (p *Player) update() { + select { + case p.updates <- struct{}{}: + default: + } +} + +func (p *Player) Status() PlayerStatus { + p.mu.Lock() + defer p.mu.Unlock() + + if p.device == nil { + return PlayerStatus{} + } + + return PlayerStatus{ID: p.id, Position: time.Duration(p.samples) * time.Second / sampleRate, Playing: p.device.IsStarted()} +} + +// Toggle pauses or resumes the voice message that is the attachment id, and reports whether it is the one that Play was last given and has not ended. +func (p *Player) Toggle(id discord.AttachmentID) (bool, error) { + p.mu.Lock() + defer p.mu.Unlock() + defer p.update() + + switch { + case p.device == nil || p.id != id: + return false, nil + case p.device.IsStarted(): + return true, p.device.Stop() + default: + return true, p.device.Start() + } +} + +// stop stops device if it is what is playing. +func (p *Player) stop(device *malgo.Device) { + if device != nil && p.device == device { + device.Uninit() + p.device = nil + p.update() + } +} + +// seek makes what is left to play start at position, by decoding from the last page that ends before it, as the decoder cannot seek. +func (p *Player) seek(position time.Duration) error { + sample := int64(max(position, 0) * sampleRate / time.Second) + + // headers is where the pages before the audio end, start the page to decode from, and first the sample it starts with. + var headers, start int + var first, last int64 + for offset := 0; offset+oggHeaderSize <= len(p.audio); { + page := p.audio[offset:] + segments := page[oggHeaderSize:][:min(int(page[oggSegmentsOffset]), len(page)-oggHeaderSize)] + size := oggHeaderSize + len(segments) + for _, segment := range segments { + size += int(segment) + } + + granule := int64(binary.LittleEndian.Uint64(page[oggGranuleOffset:])) + switch { + case granule == 0: + headers, start = offset+size, offset+size + case last <= sample && page[oggFlagsOffset]&oggContinued == 0: + start, first = offset, last + } + + // A page that no packet ends on has no position. + if granule > 0 { + last = granule + } + + offset += size + } + + decoder, err := oggopus.NewDecoder(io.MultiReader(bytes.NewReader(p.audio[:headers]), bytes.NewReader(p.audio[min(start, len(p.audio)):]))) + if err != nil { + return err + } + + // Nothing is left to play if position is past the end. + io.CopyN(io.Discard, decoder, (sample-first)*int64(decoder.Info().Channels)*2) + p.decoder, p.samples = decoder, sample + + return nil +} + +// Seek moves the voice message that is playing or paused to position. +func (p *Player) Seek(position time.Duration) error { + p.mu.Lock() + defer p.mu.Unlock() + defer p.update() + + if p.device == nil { + return nil + } + + return p.seek(position) +} + +// Play plays the voice message that is the attachment id, which is the Ogg Opus file audio, in place of the one that is playing. +func (p *Player) Play(id discord.AttachmentID, audio []byte) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.stop(p.device) + + p.audio = audio + if err := p.seek(0); err != nil { + return err + } + + var device *malgo.Device + ended := sync.OnceFunc(func() { + p.mu.Lock() + defer p.mu.Unlock() + + p.stop(device) + }) + + channels := p.decoder.Info().Channels + device, err := startDevice(malgo.Playback, channels, func(output, _ []byte) { + // What is played is left silent rather than wait for Seek, and for Play, which has not set the device yet. + if !p.mu.TryLock() { + return + } + defer p.mu.Unlock() + + if p.device != device { + return + } + + n, err := io.ReadFull(p.decoder, output) + played := p.samples + int64(n/channels/2) + if played/progressSamples != p.samples/progressSamples { + p.update() + } + + p.samples = played + + // A device cannot be stopped by what it calls. + if err != nil { + go ended() + } + }) + p.device, p.id = device, id + p.update() + + return err +} diff --git a/internal/voice/player_test.go b/internal/voice/player_test.go new file mode 100644 index 0000000..821c2c6 --- /dev/null +++ b/internal/voice/player_test.go @@ -0,0 +1,39 @@ +//go:build !no_voice + +package voice + +import ( + "bytes" + "io" + "testing" + "time" + + "github.com/tphakala/go-opus/oggopus" +) + +func TestPlayerSeek(t *testing.T) { + const seconds = 10 + + var audio bytes.Buffer + if err := oggopus.EncodeInterleaved(&audio, oggopus.Config{SampleRate: sampleRate, Channels: 1}, make([]byte, seconds*sampleRate*2)); err != nil { + t.Fatal(err) + } + + p := Player{audio: audio.Bytes()} + for _, position := range []time.Duration{0, 500 * time.Millisecond, 6 * time.Second, seconds * time.Second, time.Hour} { + if err := p.seek(position); err != nil { + t.Fatal(err) + } + + left, err := io.Copy(io.Discard, p.decoder) + if err != nil { + t.Fatal(err) + } + + // What is left is a packet longer at most, as the end is only trimmed when played from the start. + want := max(seconds*time.Second-position, 0) + if got := time.Duration(left/2) * time.Second / sampleRate; got < want || got > want+20*time.Millisecond { + t.Errorf("seek to %v: %v is left, want %v", position, got, want) + } + } +} diff --git a/internal/voice/session.go b/internal/voice/session.go index abcfcde..cff2a86 100644 --- a/internal/voice/session.go +++ b/internal/voice/session.go @@ -42,7 +42,7 @@ var audio = sync.OnceValues(func() (*malgo.AllocatedContext, error) { }) // startDevice starts a playback or capture device, which calls data with what to fill or with what was captured. -func startDevice(kind malgo.DeviceType, data func(output, input []byte)) (*malgo.Device, error) { +func startDevice(kind malgo.DeviceType, channels int, data func(output, input []byte)) (*malgo.Device, error) { ctx, err := audio() if err != nil { return nil, err @@ -51,8 +51,8 @@ func startDevice(kind malgo.DeviceType, data func(output, input []byte)) (*malgo config := malgo.DefaultDeviceConfig(kind) config.SampleRate = sampleRate config.PeriodSizeInMilliseconds = 10 - config.Playback.Format, config.Playback.Channels = malgo.FormatS16, channels - config.Capture.Format, config.Capture.Channels = malgo.FormatS16, channels + config.Playback.Format, config.Playback.Channels = malgo.FormatS16, uint32(channels) + config.Capture.Format, config.Capture.Channels = malgo.FormatS16, uint32(channels) device, err := malgo.InitDevice(ctx.Context, config, malgo.DeviceCallbacks{ Data: func(output, input []byte, _ uint32) { data(output, input) }, @@ -192,7 +192,7 @@ func (s *Session) setSpeaking(userID discord.UserID, speaking bool) { func setDevice(device **malgo.Device, on bool, kind malgo.DeviceType, data func(output, input []byte)) (err error) { switch { case on && *device == nil: - *device, err = startDevice(kind, data) + *device, err = startDevice(kind, channels, data) case !on && *device != nil: (*device).Uninit() *device = nil diff --git a/internal/voice/session_no_voice.go b/internal/voice/session_no_voice.go index 1338821..ab95624 100644 --- a/internal/voice/session_no_voice.go +++ b/internal/voice/session_no_voice.go @@ -22,6 +22,17 @@ func (*Session) Latency() time.Duration { return 0 } func (*Session) Leave() error { return nil } func (*Session) Toggle(bool) error { return nil } -func (*Session) Join(discord.ChannelID) error { - return errors.New("discordo was built without voice") -} +var errNoVoice = errors.New("built without voice (build tag: no_voice)") + +func (*Session) Join(discord.ChannelID) error { return errNoVoice } + +// Player plays nothing. +type Player struct{} + +func NewPlayer() *Player { return new(Player) } + +func (*Player) Updates() <-chan struct{} { return nil } +func (*Player) Status() PlayerStatus { return PlayerStatus{} } +func (*Player) Toggle(discord.AttachmentID) (bool, error) { return false, nil } +func (*Player) Play(discord.AttachmentID, []byte) error { return errNoVoice } +func (*Player) Seek(time.Duration) error { return nil } diff --git a/internal/voice/voice.go b/internal/voice/voice.go index 40e8e61..2ed11ab 100644 --- a/internal/voice/voice.go +++ b/internal/voice/voice.go @@ -1,7 +1,11 @@ -// Package voice joins voice channels, plays what is said in them and sends what the user says. Built with the no_voice tag, it does none of that and needs no C compiler. +// Package voice joins voice channels, plays what is said in them, sends what the user says, and plays voice messages. Built with the no_voice tag, it does none of that and needs no C compiler. package voice -import "github.com/ayn2op/arikawa/v3/discord" +import ( + "time" + + "github.com/ayn2op/arikawa/v3/discord" +) // Status is what a Session is doing. type Status struct { @@ -9,3 +13,10 @@ type Status struct { ChannelID discord.ChannelID Joined bool } + +// PlayerStatus is the voice message a Player is playing or has paused, if ID is valid. +type PlayerStatus struct { + ID discord.AttachmentID + Position time.Duration + Playing bool +}