feat(messageslist): play voice messages by click and seek by dragging

This commit is contained in:
ayn2op 2026-10-13 16:46:10 -04:00
parent bba502cba8
commit 7e5c3dc62c
5 changed files with 56 additions and 18 deletions

2
go.mod
View file

@ -15,7 +15,7 @@ require (
github.com/andybalholm/brotli v1.2.6
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-20261013011207-c8c627013874
github.com/ayn2op/tview v0.0.0-20261013221052-78efeee1ac7c
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.6.0

4
go.sum
View file

@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de h1:bO825TKutshlG
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-20261013011207-c8c627013874 h1:zXM8mKeDfXx+dzGEVTTZ0Xfo9vCXNBiX2qEWz/6KMV0=
github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
github.com/ayn2op/tview v0.0.0-20261013221052-78efeee1ac7c h1:WgEp8OeS15d15kjPVPOPpnSsaKYJZYjTIKxBbr8b5kI=
github.com/ayn2op/tview v0.0.0-20261013221052-78efeee1ac7c/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=

View file

@ -26,6 +26,7 @@ import (
"github.com/ayn2op/discordo/internal/voice"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/button"
"github.com/ayn2op/tview/column"
tviewimage "github.com/ayn2op/tview/image"
"github.com/ayn2op/tview/keybind"
@ -69,6 +70,8 @@ type Model struct {
// voicePlayer plays the voice message played last, and voiceStatus is what it last said of it.
voicePlayer *voice.Player
voiceStatus voice.PlayerStatus
// seeking reports whether the waveform of the voice message is dragged, which is sought once it is let go of.
seeking bool
// kitty reports whether previews are drawn with kitty's graphics protocol.
// kittyImages counts the images sent to the terminal.
@ -682,6 +685,9 @@ func (m Model) voiceMessageView(a discord.Attachment) tview.Widget {
}
waveform := slider.New(0, duration, status.Position, onSeek).
OnPress(func(position time.Duration) tview.Msg { return seekPressMsg(position) }).
OnRelease(seekReleaseMsg{}).
Dragging(loaded && m.seeking).
Width(layout.Fixed(len(rail))).
Rail(func(cell, handle int) (string, tview.Style) {
if loaded && cell <= handle {
@ -691,7 +697,10 @@ func (m Model) voiceMessageView(a discord.Attachment) tview.Widget {
return rail[cell], style.Dim(true)
})
return row.New(text.New(symbol), waveform, text.New(label)).Spacing(1).Height(layout.Fixed(1))
width, _ := text.New(symbol).Size()
play := button.New().Label(symbol).Width(width).OnClick(playMsg(a))
return row.New(play, waveform, text.New(label)).Spacing(1).Height(layout.Fixed(1))
}
func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) {
@ -980,7 +989,9 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
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()
if message, ok := m.selectedMessage(); ok && isVoiceMessage(*message) {
return m, m.play(message.Attachments[0])
}
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):
@ -1038,10 +1049,31 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.items[index].previews[msg.proxy] = preview
return m, cmd
case playMsg:
return m, m.play(discord.Attachment(msg))
case seekPressMsg:
m.seeking = true
m.voiceStatus.Position = time.Duration(msg)
return m, nil
case seekMsg:
return m, m.seek(time.Duration(msg))
m.voiceStatus.Position = time.Duration(msg)
if m.seeking {
return m, nil
}
return m, m.seek(m.voiceStatus.Position)
case seekReleaseMsg:
m.seeking = false
return m, m.seek(m.voiceStatus.Position)
case voiceMsg:
m.voiceStatus = m.voicePlayer.Status()
status := m.voicePlayer.Status()
// The waveform shows where it is dragged to rather than what is playing, while that voice message is the one loaded.
m.seeking = m.seeking && status.ID == m.voiceStatus.ID
if m.seeking {
status.Position = m.voiceStatus.Position
}
m.voiceStatus = status
return m, m.Init()
case tview.ResizeMsg:
cols, rows := msg.Size()

View file

@ -159,14 +159,13 @@ 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
}
// playMsg asks to play the voice message whose play symbol was clicked.
type playMsg discord.Attachment
attachment := message.Attachments[0]
func (playMsg) messagesList() {}
// play plays the voice message that is attachment, or pauses or resumes it if it is the one playing.
func (m Model) play(attachment discord.Attachment) tview.Cmd {
return func() tview.Msg {
if err := m.playVoiceMessage(attachment); err != nil {
return ui.ErrorDialog("play voice message", err)
@ -196,10 +195,16 @@ func (m Model) playVoiceMessage(attachment discord.Attachment) error {
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
// seekPressMsg is where the waveform of a voice message was pressed, seekMsg where it was dragged to after, and seekReleaseMsg that it was let go of.
type (
seekPressMsg time.Duration
seekMsg time.Duration
seekReleaseMsg struct{}
)
func (seekMsg) messagesList() {}
func (seekPressMsg) messagesList() {}
func (seekMsg) messagesList() {}
func (seekReleaseMsg) messagesList() {}
// seek moves the voice message that is playing or paused to position.
func (m Model) seek(position time.Duration) tview.Cmd {

View file

@ -367,7 +367,8 @@ func (p paneWidget) Draw(screen tview.Screen, area tview.Rectangle) {
func (p paneWidget) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
out := p.child.Handle(msg, area)
if mouse, ok := msg.(tview.MouseMsg); ok && area.Contains(mouse.Position()) {
// Mouse input the pane leaves, but for a press, goes on to the other panes, for a slider dragged out of one to follow the pointer.
if mouse, ok := msg.(tview.MouseMsg); ok && area.Contains(mouse.Position()) && (out != msg || mouse.Action == tview.MouseLeftDown) {
return paneMsg{pane: p.pane, msg: out, focus: mouse.Action == tview.MouseLeftDown}
}
// Keys and paste reach only the focused pane, so what it makes of them goes to that pane through route.