parent
95f247def8
commit
6465a1c5f2
15 changed files with 559 additions and 37 deletions
|
|
@ -819,6 +819,22 @@ Download one of the selected message's attachments.
|
||||||
|
|
||||||
Default: `"D"`
|
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
|
### keybinds.messages_list.yank_content
|
||||||
|
|
||||||
Yank (copy) the selected message's content/url/id.
|
Yank (copy) the selected message's content/url/id.
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -13,9 +13,9 @@ require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/alecthomas/chroma/v2 v2.27.0
|
github.com/alecthomas/chroma/v2 v2.27.0
|
||||||
github.com/andybalholm/brotli v1.2.6
|
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/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/fhttp v0.6.9
|
||||||
github.com/bogdanfinn/tls-client v1.16.0
|
github.com/bogdanfinn/tls-client v1.16.0
|
||||||
github.com/gdamore/tcell/v3 v3.6.0
|
github.com/gdamore/tcell/v3 v3.6.0
|
||||||
|
|
|
||||||
8
go.sum
8
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/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 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
|
||||||
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
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-20261012034659-5b006907b2de h1:bO825TKutshlGQ/AsH6uYpZVjHQhDLElctgrh/T90aU=
|
||||||
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/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 h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI=
|
||||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw=
|
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-20261012034741-d8b2704e2b8e h1:uCt61XkJ0DqRiBd5ozXZVd2xWlNFcA0rR2hew9lAZx0=
|
||||||
github.com/ayn2op/tview v0.0.0-20261011173501-8bd5978952e4/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
|
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 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||||
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,11 @@ type MessagesListKeybinds struct {
|
||||||
OpenInBrowser Keybind `toml:"open_in_browser"`
|
OpenInBrowser Keybind `toml:"open_in_browser"`
|
||||||
// Download one of the selected message's attachments.
|
// Download one of the selected message's attachments.
|
||||||
Download Keybind `toml:"download"`
|
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.
|
// Yank (copy) the selected message's content/url/id.
|
||||||
YankContent Keybind `toml:"yank_content"`
|
YankContent Keybind `toml:"yank_content"`
|
||||||
|
|
@ -280,6 +285,9 @@ func defaultKeybinds() Keybinds {
|
||||||
Open: key("open", "o"),
|
Open: key("open", "o"),
|
||||||
OpenInBrowser: key("open browser", "O"),
|
OpenInBrowser: key("open browser", "O"),
|
||||||
Download: key("download", "D"),
|
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"),
|
YankContent: key("copy text", "y"),
|
||||||
YankURL: key("copy url", "u"),
|
YankURL: key("copy url", "u"),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ func (m Model) ShortHelp() []keybind.Keybind {
|
||||||
if len(selectedMessage.Attachments) != 0 || len(messageURLs(*selectedMessage)) != 0 {
|
if len(selectedMessage.Attachments) != 0 || len(messageURLs(*selectedMessage)) != 0 {
|
||||||
help = append(help, cfg.Open.Keybind)
|
help = append(help, cfg.Open.Keybind)
|
||||||
}
|
}
|
||||||
|
if isVoiceMessage(*selectedMessage) {
|
||||||
|
help = append(help, cfg.Play.Keybind)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return help
|
return help
|
||||||
|
|
@ -54,6 +57,13 @@ func (m Model) FullHelp() [][]keybind.Keybind {
|
||||||
if len(message.Attachments) != 0 {
|
if len(message.Attachments) != 0 {
|
||||||
attachments = append(attachments, cfg.Download.Keybind)
|
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)
|
actions = append(actions, cfg.Cancel.Keybind)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,19 +15,25 @@ import (
|
||||||
|
|
||||||
"github.com/ayn2op/tview/layout"
|
"github.com/ayn2op/tview/layout"
|
||||||
|
|
||||||
|
"github.com/ayn2op/arikawa/v3/api"
|
||||||
"github.com/ayn2op/arikawa/v3/discord"
|
"github.com/ayn2op/arikawa/v3/discord"
|
||||||
md "github.com/ayn2op/arikawa/v3/markdown"
|
md "github.com/ayn2op/arikawa/v3/markdown"
|
||||||
"github.com/ayn2op/arikawa/v3/state"
|
"github.com/ayn2op/arikawa/v3/state"
|
||||||
|
"github.com/ayn2op/arikawa/v3/utils/httputil"
|
||||||
"github.com/ayn2op/discordo/internal/config"
|
"github.com/ayn2op/discordo/internal/config"
|
||||||
"github.com/ayn2op/discordo/internal/markdown"
|
"github.com/ayn2op/discordo/internal/markdown"
|
||||||
"github.com/ayn2op/discordo/internal/ui"
|
"github.com/ayn2op/discordo/internal/ui"
|
||||||
|
"github.com/ayn2op/discordo/internal/voice"
|
||||||
"github.com/ayn2op/ningen/v3"
|
"github.com/ayn2op/ningen/v3"
|
||||||
"github.com/ayn2op/tview"
|
"github.com/ayn2op/tview"
|
||||||
tviewimage "github.com/ayn2op/tview/image"
|
tviewimage "github.com/ayn2op/tview/image"
|
||||||
"github.com/ayn2op/tview/keybind"
|
"github.com/ayn2op/tview/keybind"
|
||||||
"github.com/ayn2op/tview/list"
|
"github.com/ayn2op/tview/list"
|
||||||
"github.com/ayn2op/tview/richtext"
|
"github.com/ayn2op/tview/richtext"
|
||||||
|
"github.com/ayn2op/tview/row"
|
||||||
"github.com/ayn2op/tview/scrollbar"
|
"github.com/ayn2op/tview/scrollbar"
|
||||||
|
"github.com/ayn2op/tview/slider"
|
||||||
|
"github.com/ayn2op/tview/text"
|
||||||
"github.com/ayn2op/tview/textview"
|
"github.com/ayn2op/tview/textview"
|
||||||
"github.com/ayn2op/tview/viewport"
|
"github.com/ayn2op/tview/viewport"
|
||||||
"github.com/gdamore/tcell/v3"
|
"github.com/gdamore/tcell/v3"
|
||||||
|
|
@ -57,6 +63,9 @@ type Model struct {
|
||||||
items []messageItem
|
items []messageItem
|
||||||
|
|
||||||
renderer *markdown.Renderer
|
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.
|
// kitty reports whether previews are drawn with kitty's graphics protocol.
|
||||||
// kittyImages counts the images sent to the terminal.
|
// kittyImages counts the images sent to the terminal.
|
||||||
|
|
@ -72,6 +81,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
state: state,
|
state: state,
|
||||||
renderer: markdown.NewRenderer(cfg),
|
renderer: markdown.NewRenderer(cfg),
|
||||||
|
voicePlayer: voice.NewPlayer(),
|
||||||
}
|
}
|
||||||
|
|
||||||
ml.scrollState.ScrollToEnd()
|
ml.scrollState.ScrollToEnd()
|
||||||
|
|
@ -241,6 +251,10 @@ func (m Model) buildItem(index int) tview.Widget {
|
||||||
view := &messageView{render: func(width int) richtext.Text {
|
view := &messageView{render: func(width int) richtext.Text {
|
||||||
return m.renderMessage(message, m.cfg.Theme.MessagesList.MessageStyle.Style, width)
|
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) {
|
for _, s := range previewSources(message) {
|
||||||
if preview, ok := item.previews[s.proxy]; ok {
|
if preview, ok := item.previews[s.proxy]; ok {
|
||||||
view.images = append(view.images, preview)
|
view.images = append(view.images, preview)
|
||||||
|
|
@ -251,9 +265,11 @@ func (m Model) buildItem(index int) tview.Widget {
|
||||||
return item.view
|
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 {
|
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
|
images []tviewimage.Widget
|
||||||
width int
|
width int
|
||||||
rendered bool
|
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 {
|
return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size {
|
||||||
v.at(limits.Max.Width)
|
v.at(limits.Max.Width)
|
||||||
rows := len(v.lines)
|
rows := len(v.lines)
|
||||||
|
if v.player != nil {
|
||||||
|
rows++
|
||||||
|
}
|
||||||
|
|
||||||
for _, preview := range v.images {
|
for _, preview := range v.images {
|
||||||
_, height := preview.Size()
|
_, height := preview.Size()
|
||||||
rows += height.Cells()
|
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) {
|
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)})
|
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)
|
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 {
|
for _, preview := range v.images {
|
||||||
_, height := preview.Size()
|
_, height := preview.Size()
|
||||||
preview.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: height.Cells()})
|
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.
|
// dateSeparator is a line with the date in the middle, filled to the width it is drawn at.
|
||||||
type dateSeparator struct {
|
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)
|
m.drawEmbeds(builder, message, baseStyle, width, contentRoot, contentSource)
|
||||||
|
|
||||||
attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style)
|
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 {
|
for _, a := range message.Attachments {
|
||||||
builder.NewLine()
|
builder.NewLine()
|
||||||
if m.cfg.Attachments.ShowLinks {
|
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) {
|
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 {
|
if len(message.Embeds) == 0 {
|
||||||
return
|
return
|
||||||
|
|
@ -855,6 +958,12 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
|
||||||
return m, m.open()
|
return m, m.open()
|
||||||
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.OpenInBrowser.Keybind):
|
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.OpenInBrowser.Keybind):
|
||||||
return m, m.openInBrowser()
|
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):
|
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Download.Keybind):
|
||||||
return m, m.download()
|
return m, m.download()
|
||||||
case keybind.Matches(msg, m.cfg.Keybinds.MessagesList.Reply.Keybind):
|
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.
|
// Invalidate the view so it is laid out with the preview.
|
||||||
m.items[index].view = nil
|
m.items[index].view = nil
|
||||||
return m, cmd
|
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:
|
case tview.ResizeMsg:
|
||||||
cols, rows := msg.Size()
|
cols, rows := msg.Size()
|
||||||
width, height := msg.PixelSize()
|
width, height := msg.PixelSize()
|
||||||
|
|
@ -1034,15 +1154,40 @@ func messageURLs(msg discord.Message) []string {
|
||||||
return urls
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadAttachment(attachment discord.Attachment, destination string) error {
|
// get requests the file at url, by a new link to it if that one expired.
|
||||||
resp, err := http.Get(attachment.URL)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
|
||||||
}
|
|
||||||
file, err := os.Create(destination)
|
file, err := os.Create(destination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/ayn2op/arikawa/v3/discord"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ package messageslist
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ayn2op/arikawa/v3/discord"
|
"github.com/ayn2op/arikawa/v3/discord"
|
||||||
"github.com/ayn2op/arikawa/v3/gateway"
|
"github.com/ayn2op/arikawa/v3/gateway"
|
||||||
|
|
@ -32,6 +34,19 @@ type olderMessagesLoadedMsg struct {
|
||||||
|
|
||||||
func (olderMessagesLoadedMsg) messagesList() {}
|
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.
|
// ReplyMsg asks to reply to Message by Name, mentioning its author if Mention is set.
|
||||||
type ReplyMsg struct {
|
type ReplyMsg struct {
|
||||||
Message discord.Message
|
Message discord.Message
|
||||||
|
|
@ -140,6 +155,63 @@ func (m Model) open() tview.Cmd {
|
||||||
return m.openWith(m.openAttachment)
|
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 {
|
func (m Model) openInBrowser() tview.Cmd {
|
||||||
return m.openWith(func(attachment discord.Attachment) tview.Cmd { return openURL(attachment.URL) })
|
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 {
|
if len(selectedMessage.Attachments) == 1 {
|
||||||
attachment := selectedMessage.Attachments[0]
|
attachment := selectedMessage.Attachments[0]
|
||||||
return m.confirmAttachment(attachment, saveAttachment(attachment))
|
return m.confirmAttachment(attachment, m.saveAttachment(attachment))
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make(picker.Items, len(selectedMessage.Attachments))
|
items := make(picker.Items, len(selectedMessage.Attachments))
|
||||||
for i, attachment := range 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)
|
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 {
|
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 {
|
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
|
return action
|
||||||
}
|
}
|
||||||
|
|
||||||
func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
|
func (m Model) openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
|
||||||
return func() tview.Msg {
|
return func() tview.Msg {
|
||||||
extension := filepath.Ext(attachment.Filename)
|
extension := filepath.Ext(attachment.Filename)
|
||||||
if extension == "" {
|
if extension == "" {
|
||||||
|
|
@ -233,7 +305,7 @@ func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd {
|
||||||
|
|
||||||
path := file.Name()
|
path := file.Name()
|
||||||
|
|
||||||
if err := downloadAttachment(attachment, path); err != nil {
|
if err := m.downloadAttachment(attachment, path); err != nil {
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
return ui.ErrorDialog("download attachment", err)
|
return ui.ErrorDialog("download attachment", err)
|
||||||
} else if err := open.Start(path); err != nil {
|
} 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 {
|
return func() tview.Msg {
|
||||||
destination, err := zenity.SelectFileSave(zenity.Filename(filepath.Base(attachment.Filename)), zenity.ConfirmOverwrite())
|
destination, err := zenity.SelectFileSave(zenity.Filename(filepath.Base(attachment.Filename)), zenity.ConfirmOverwrite())
|
||||||
if errors.Is(err, zenity.ErrCanceled) {
|
if errors.Is(err, zenity.ErrCanceled) {
|
||||||
|
|
@ -254,7 +326,7 @@ func saveAttachment(attachment discord.Attachment) tview.Cmd {
|
||||||
return ui.ErrorDialog("select attachment destination", err)
|
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 ui.ErrorDialog("download attachment", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"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.
|
// 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))
|
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...)
|
return tview.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadPreview yields an empty preview if loading fails.
|
// 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 {
|
return func() tview.Msg {
|
||||||
img, err := downloadPreview(proxy, bounds)
|
img, err := m.downloadPreview(proxy, bounds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to load preview", "err", err)
|
slog.Error("failed to load preview", "err", err)
|
||||||
img = &image.Alpha{}
|
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)
|
u, err := url.Parse(proxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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()))
|
query.Set("height", strconv.Itoa(bounds.Dy()))
|
||||||
u.RawQuery = query.Encode()
|
u.RawQuery = query.Encode()
|
||||||
|
|
||||||
resp, err := http.Get(u.String())
|
resp, err := m.get(u.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ func (m *Model) cycleFocus(step pane) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tview.Cmd {
|
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.
|
// Update returns m changed in response to msg and a command to run, or nil.
|
||||||
|
|
|
||||||
201
internal/voice/player.go
Normal file
201
internal/voice/player.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
39
internal/voice/player_test.go
Normal file
39
internal/voice/player_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
// 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()
|
ctx, err := audio()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -51,8 +51,8 @@ func startDevice(kind malgo.DeviceType, data func(output, input []byte)) (*malgo
|
||||||
config := malgo.DefaultDeviceConfig(kind)
|
config := malgo.DefaultDeviceConfig(kind)
|
||||||
config.SampleRate = sampleRate
|
config.SampleRate = sampleRate
|
||||||
config.PeriodSizeInMilliseconds = 10
|
config.PeriodSizeInMilliseconds = 10
|
||||||
config.Playback.Format, config.Playback.Channels = malgo.FormatS16, channels
|
config.Playback.Format, config.Playback.Channels = malgo.FormatS16, uint32(channels)
|
||||||
config.Capture.Format, config.Capture.Channels = malgo.FormatS16, channels
|
config.Capture.Format, config.Capture.Channels = malgo.FormatS16, uint32(channels)
|
||||||
|
|
||||||
device, err := malgo.InitDevice(ctx.Context, config, malgo.DeviceCallbacks{
|
device, err := malgo.InitDevice(ctx.Context, config, malgo.DeviceCallbacks{
|
||||||
Data: func(output, input []byte, _ uint32) { data(output, input) },
|
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) {
|
func setDevice(device **malgo.Device, on bool, kind malgo.DeviceType, data func(output, input []byte)) (err error) {
|
||||||
switch {
|
switch {
|
||||||
case on && *device == nil:
|
case on && *device == nil:
|
||||||
*device, err = startDevice(kind, data)
|
*device, err = startDevice(kind, channels, data)
|
||||||
case !on && *device != nil:
|
case !on && *device != nil:
|
||||||
(*device).Uninit()
|
(*device).Uninit()
|
||||||
*device = nil
|
*device = nil
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,17 @@ func (*Session) Latency() time.Duration { return 0 }
|
||||||
func (*Session) Leave() error { return nil }
|
func (*Session) Leave() error { return nil }
|
||||||
func (*Session) Toggle(bool) error { return nil }
|
func (*Session) Toggle(bool) error { return nil }
|
||||||
|
|
||||||
func (*Session) Join(discord.ChannelID) error {
|
var errNoVoice = errors.New("built without voice (build tag: no_voice)")
|
||||||
return errors.New("discordo was built without 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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
package voice
|
||||||
|
|
||||||
import "github.com/ayn2op/arikawa/v3/discord"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ayn2op/arikawa/v3/discord"
|
||||||
|
)
|
||||||
|
|
||||||
// Status is what a Session is doing.
|
// Status is what a Session is doing.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
|
|
@ -9,3 +13,10 @@ type Status struct {
|
||||||
ChannelID discord.ChannelID
|
ChannelID discord.ChannelID
|
||||||
Joined bool
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue