511 lines
11 KiB
Go
511 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"code.rocketnine.space/tslocum/cview"
|
|
"github.com/faiface/beep"
|
|
"github.com/faiface/beep/speaker"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/mattn/go-runewidth"
|
|
)
|
|
|
|
const (
|
|
defaultLayout = "main,queue,playing"
|
|
defaultVolume = 100
|
|
)
|
|
|
|
var (
|
|
app *cview.Application
|
|
mainList *cview.List
|
|
queueList *cview.List
|
|
topstatusbuf *cview.TextView
|
|
bottomstatusbuf *cview.TextView
|
|
|
|
mainFiles []*libraryEntry
|
|
mainCursor = -1
|
|
mainDirectory string
|
|
mainAutoFocus string // Entry path to focus after loading display
|
|
|
|
mainBuffer bytes.Buffer
|
|
mainLock = new(sync.Mutex)
|
|
|
|
queueFiles []*libraryEntry
|
|
queueCursor = -1 // Focused entry
|
|
queuePlaying int // Playing entry
|
|
|
|
queueBuffer bytes.Buffer
|
|
queueLock = new(sync.Mutex)
|
|
|
|
queueFocused bool
|
|
|
|
seekStart, seekEnd int
|
|
volumeStart, volumeEnd int
|
|
|
|
screenWidth, screenHeight int
|
|
|
|
statusText string
|
|
statusBuffer bytes.Buffer
|
|
statusLock = new(sync.Mutex)
|
|
)
|
|
|
|
func initTUI() error {
|
|
/*cview.Styles.TitleColor = tcell.ColorDefault
|
|
cview.Styles.BorderColor = tcell.ColorDefault
|
|
cview.Styles.PrimaryTextColor = tcell.ColorDefault
|
|
cview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault*/
|
|
|
|
app = cview.NewApplication()
|
|
|
|
if !disableMouse {
|
|
app.EnableMouse(true)
|
|
}
|
|
|
|
app.SetAfterResizeFunc(handleResize)
|
|
|
|
app.SetMouseCapture(handleMouse)
|
|
|
|
app.SetInputCapture(inputConfig.Capture)
|
|
|
|
app.SetBeforeFocusFunc(handleBeforeFocus)
|
|
|
|
grid := cview.NewGrid()
|
|
grid.SetColumns(-1)
|
|
|
|
mainList = cview.NewList()
|
|
mainList.ShowSecondaryText(false)
|
|
mainList.SetScrollBarVisibility(cview.ScrollBarAlways)
|
|
mainList.SetHighlightFullLine(true)
|
|
mainList.SetSelectedTextColor(tcell.ColorBlack)
|
|
|
|
queueList = cview.NewList()
|
|
queueList.ShowSecondaryText(false)
|
|
queueList.SetScrollBarVisibility(cview.ScrollBarAlways)
|
|
queueList.SetHighlightFullLine(true)
|
|
queueList.SetSelectedTextColor(tcell.ColorBlack)
|
|
|
|
topstatusbuf = cview.NewTextView()
|
|
topstatusbuf.SetWrap(false)
|
|
topstatusbuf.SetWordWrap(false)
|
|
bottomstatusbuf = cview.NewTextView()
|
|
bottomstatusbuf.SetWrap(false)
|
|
bottomstatusbuf.SetWordWrap(false)
|
|
|
|
mainList.SetBorder(true)
|
|
mainList.SetTitleAlign(cview.AlignLeft)
|
|
mainList.SetSelectedFunc(handleMainSelection)
|
|
|
|
queueList.SetBorder(true)
|
|
queueList.SetTitleAlign(cview.AlignLeft)
|
|
queueList.SetTitle(" Queue ")
|
|
queueList.SetSelectedFunc(handleQueueSelection)
|
|
queueList.SetSelectedAlwaysCentered(true)
|
|
|
|
var i int
|
|
var rowHeights []int
|
|
ls := strings.Split(config.Layout, ",")
|
|
for _, l := range ls {
|
|
l = strings.ToLower(strings.TrimSpace(l))
|
|
switch l {
|
|
case "main":
|
|
grid.AddItem(mainList, i, 0, 1, 1, 0, 0, false)
|
|
rowHeights = append(rowHeights, -2)
|
|
case "queue":
|
|
grid.AddItem(queueList, i, 0, 1, 1, 0, 0, false)
|
|
rowHeights = append(rowHeights, -1)
|
|
case "playing":
|
|
grid.AddItem(topstatusbuf, i, 0, 1, 1, 0, 0, false)
|
|
grid.AddItem(bottomstatusbuf, i+1, 0, 1, 1, 0, 0, false)
|
|
rowHeights = append(rowHeights, 1, 1)
|
|
i++ // Use two rows
|
|
}
|
|
i++
|
|
}
|
|
grid.SetRows(rowHeights...)
|
|
|
|
app.SetRoot(grid, true)
|
|
|
|
focusUpdated(true)
|
|
|
|
return nil
|
|
}
|
|
|
|
func handleBeforeFocus(p cview.Primitive) bool {
|
|
focusMain := p == mainList
|
|
focusQueue := p == queueList
|
|
if focusMain || focusQueue {
|
|
queueFocused = focusQueue
|
|
focusUpdated(false)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func browseFolder(browse string) {
|
|
var err error
|
|
browse, err = filepath.Abs(browse)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if browse == mainDirectory {
|
|
mainAutoFocus = ""
|
|
return
|
|
}
|
|
if !strings.HasPrefix(browse, restrictLibrary) {
|
|
statusText = "failed to browse folder: permission denied"
|
|
go func() {
|
|
time.Sleep(5 * time.Second)
|
|
statusText = ""
|
|
go app.QueueUpdateDraw(updateMain)
|
|
}()
|
|
go app.QueueUpdateDraw(updateMain)
|
|
return
|
|
}
|
|
|
|
mainList.SetTitle(" " + cview.Escape(runewidth.Truncate(mainDirectory, screenWidth-4, "...")) + "... ")
|
|
|
|
placeCursorAtTop := mainList.GetCurrentItemIndex() == 0
|
|
mainFiles = scanFolder(browse)
|
|
if mainCursor == -1 {
|
|
if !placeCursorAtTop && len(mainFiles) > 0 {
|
|
mainCursor = 1
|
|
} else {
|
|
mainCursor = 0
|
|
}
|
|
}
|
|
|
|
mainDirectory = browse
|
|
if mainAutoFocus != "" {
|
|
autoSelectAbs, err := filepath.Abs(mainAutoFocus)
|
|
if err == nil && autoSelectAbs != mainDirectory {
|
|
autoSelect := -1
|
|
var entryPath string
|
|
for i, entry := range mainFiles {
|
|
if !entry.IsDir && entry.Mode&os.ModeSymlink == 0 {
|
|
continue
|
|
}
|
|
|
|
entryPath, err = filepath.Abs(path.Join(mainDirectory, entry.Name))
|
|
if err == nil {
|
|
if entryPath == autoSelectAbs {
|
|
autoSelect = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if autoSelect >= 0 {
|
|
mainCursor = autoSelect + 1
|
|
mainAutoFocus = ""
|
|
}
|
|
}
|
|
mainAutoFocus = ""
|
|
}
|
|
go app.QueueUpdateDraw(updateMain)
|
|
}
|
|
|
|
func browseParent() {
|
|
mainAutoFocus = mainDirectory
|
|
go browseFolder(path.Join(mainDirectory, ".."))
|
|
}
|
|
|
|
func updateMain() {
|
|
mainLock.Lock()
|
|
defer mainLock.Unlock()
|
|
|
|
mainBuffer.Reset()
|
|
var statusMessage string
|
|
if statusText != "" {
|
|
statusMessage = statusText
|
|
} else {
|
|
statusMessage = mainDirectory
|
|
}
|
|
|
|
truncated := false
|
|
widthRequirement := 4
|
|
for {
|
|
if runewidth.StringWidth(statusMessage) <= screenWidth-widthRequirement || !strings.ContainsRune(statusMessage, os.PathSeparator) {
|
|
break
|
|
}
|
|
|
|
statusMessage = statusMessage[strings.IndexRune(statusMessage, '/')+1:]
|
|
|
|
truncated = true
|
|
widthRequirement = 8
|
|
}
|
|
if truncated {
|
|
mainBuffer.WriteString(".../")
|
|
}
|
|
mainBuffer.WriteString(statusMessage)
|
|
|
|
mainList.SetTitle(" " + cview.Escape(runewidth.Truncate(mainBuffer.String(), screenWidth-4, "...")) + " ")
|
|
mainBuffer.Reset()
|
|
|
|
mainOffset := 0
|
|
if mainCursor == -1 {
|
|
mainCursor = mainList.GetCurrentItemIndex()
|
|
mainOffset, _ = mainList.GetOffset()
|
|
}
|
|
mainList.Clear()
|
|
|
|
var printed int
|
|
var line string
|
|
//var length string
|
|
|
|
if mainDirectory == "/" {
|
|
line = "./"
|
|
} else {
|
|
line = "../"
|
|
}
|
|
line = cview.Escape(line)
|
|
|
|
mainList.AddItem(cview.NewListItem(line))
|
|
|
|
printed++
|
|
|
|
for _, entry := range mainFiles {
|
|
//length = ""
|
|
if entry.IsDir || entry.Mode&os.ModeSymlink != 0 {
|
|
line = strings.TrimSpace(entry.Name) + "/"
|
|
} else {
|
|
line = entry.String()
|
|
|
|
if entry.Metadata.Length > 0 {
|
|
//m := entry.metadata.Length / time.Minute
|
|
//length = fmt.Sprintf(" %d:%02d", m, (entry.metadata.Length%(m*time.Minute))/time.Second)
|
|
}
|
|
}
|
|
line = cview.Escape(line)
|
|
|
|
mainList.AddItem(cview.NewListItem(line))
|
|
|
|
printed++
|
|
}
|
|
|
|
if mainCursor >= mainList.GetItemCount() {
|
|
mainList.SetCurrentItem(mainList.GetItemCount() - 1)
|
|
} else if mainCursor >= 0 {
|
|
mainList.SetCurrentItem(mainCursor)
|
|
}
|
|
|
|
mainList.SetOffset(mainOffset, 0)
|
|
mainCursor = -1
|
|
}
|
|
|
|
func updateQueue() {
|
|
queueLock.Lock()
|
|
defer queueLock.Unlock()
|
|
|
|
queueBuffer.Reset()
|
|
|
|
if queueCursor == -1 {
|
|
queueCursor = queueList.GetCurrentItemIndex()
|
|
}
|
|
queueList.Clear()
|
|
|
|
var printed int
|
|
var line string
|
|
//var length string
|
|
for _, entry := range queueFiles {
|
|
line = entry.String()
|
|
//lineWidth := runewidth.StringWidth(line)
|
|
line = cview.Escape(line)
|
|
|
|
queueList.AddItem(cview.NewListItem(line))
|
|
|
|
/*m := entry.metadata.Length / time.Minute
|
|
length = fmt.Sprintf(" %d:%02d", m, (entry.metadata.Length%(m*time.Minute))/time.Second)*/
|
|
|
|
printed++
|
|
}
|
|
|
|
if queueCursor >= queueList.GetItemCount() {
|
|
queueList.SetCurrentItem(queueList.GetItemCount() - 1)
|
|
} else if queueCursor >= 0 {
|
|
queueList.SetCurrentItem(queueCursor)
|
|
}
|
|
queueCursor = -1
|
|
}
|
|
|
|
func updateLists() {
|
|
updateMain()
|
|
updateQueue()
|
|
}
|
|
|
|
func updateStatus() {
|
|
app.QueueUpdateDraw(_updateStatus, topstatusbuf, bottomstatusbuf)
|
|
}
|
|
|
|
func _updateStatus() {
|
|
statusLock.Lock()
|
|
defer statusLock.Unlock()
|
|
|
|
var (
|
|
sampleRate beep.SampleRate
|
|
p time.Duration
|
|
l time.Duration
|
|
v float64
|
|
paused bool
|
|
silent bool
|
|
|
|
progressFormatted string
|
|
durationFormatted string
|
|
)
|
|
if playingStreamer != nil && volume != nil && ctrl != nil {
|
|
speaker.Lock()
|
|
silent = volume.Silent
|
|
paused = ctrl.Paused
|
|
sampleRate = playingFormat.SampleRate
|
|
p = playingFormat.SampleRate.D(playingStreamer.Position()).Truncate(time.Second)
|
|
l = playingFormat.SampleRate.D(playingStreamer.Len()).Truncate(time.Second)
|
|
v = volume.Volume
|
|
speaker.Unlock()
|
|
|
|
progressFormatted = formatDuration(p)
|
|
durationFormatted = formatDuration(l)
|
|
|
|
statusBuffer.Reset()
|
|
|
|
if paused {
|
|
statusBuffer.WriteString("Paused ")
|
|
}
|
|
statusBuffer.WriteString(fmt.Sprintf(" %dHz %s", sampleRate.N(time.Second), fileFormat(playingFileName)))
|
|
|
|
topStatusExtra := statusBuffer.String()
|
|
statusBuffer.Reset()
|
|
|
|
topStatusMaxLength := screenWidth - 2
|
|
|
|
printExtra := topStatusMaxLength >= (len(topStatusExtra)*2)+1
|
|
if printExtra {
|
|
topStatusMaxLength -= len(topStatusExtra)
|
|
}
|
|
|
|
statusBuffer.WriteRune(' ')
|
|
var trackInfo string
|
|
if playingFileInfo != "" {
|
|
trackInfo = runewidth.Truncate(playingFileInfo, topStatusMaxLength, "...")
|
|
} else {
|
|
trackInfo = runewidth.Truncate(playingFileName, topStatusMaxLength, "...")
|
|
}
|
|
statusBuffer.WriteString(trackInfo)
|
|
|
|
if printExtra {
|
|
padding := topStatusMaxLength - runewidth.StringWidth(trackInfo)
|
|
for i := 0; i < padding; i++ {
|
|
statusBuffer.WriteRune(' ')
|
|
}
|
|
|
|
statusBuffer.WriteString(topStatusExtra)
|
|
}
|
|
|
|
topstatusbuf.SetText(statusBuffer.String())
|
|
} else {
|
|
v = startingVolumeLevel
|
|
silent = startingVolumeSilent
|
|
|
|
progressFormatted = "--:--"
|
|
durationFormatted = "--:--"
|
|
|
|
statusBuffer.Reset()
|
|
|
|
trackInfo := fmt.Sprintf("ditty v%s", version)
|
|
|
|
topStatusMaxLength := screenWidth - 2
|
|
padding := (topStatusMaxLength - runewidth.StringWidth(trackInfo)) + 1
|
|
for i := 0; i < padding; i++ {
|
|
statusBuffer.WriteRune(' ')
|
|
}
|
|
|
|
statusBuffer.WriteString(trackInfo)
|
|
|
|
topstatusbuf.SetText(statusBuffer.String())
|
|
}
|
|
|
|
statusBuffer.Reset()
|
|
|
|
if silent {
|
|
statusBuffer.WriteString("Mut ")
|
|
|
|
for i := -7.5; i < 0.0; i += 0.5 {
|
|
statusBuffer.WriteRune(' ')
|
|
}
|
|
} else {
|
|
statusBuffer.WriteString("Vol ")
|
|
|
|
for i := -7.5; i < v-0.5; i += 0.5 {
|
|
statusBuffer.WriteRune(tcell.RuneHLine)
|
|
}
|
|
statusBuffer.WriteRune('▷')
|
|
for i := v; i < 0; i += 0.5 {
|
|
statusBuffer.WriteRune(' ')
|
|
}
|
|
}
|
|
|
|
bottomStatus := fmt.Sprintf("%s %s", durationFormatted, statusBuffer.String())
|
|
statusBuffer.Reset()
|
|
|
|
var progressIndicator string
|
|
if paused {
|
|
progressIndicator = "||"
|
|
} else {
|
|
progressIndicator = "▷"
|
|
}
|
|
|
|
padding := screenWidth - runewidth.StringWidth(bottomStatus) - len(formatDuration(p)) - runewidth.StringWidth(progressIndicator) - 3
|
|
position := int(float64(padding) * (float64(p) / float64(l)))
|
|
if position > padding-1 {
|
|
position = padding - 1
|
|
}
|
|
if paused && position > 0 {
|
|
position--
|
|
}
|
|
|
|
for i := 0; i < padding; i++ {
|
|
if i == position {
|
|
statusBuffer.WriteString(progressIndicator)
|
|
} else {
|
|
statusBuffer.WriteRune(tcell.RuneHLine)
|
|
}
|
|
}
|
|
|
|
seekStart = len(formatDuration(p)) + 2
|
|
seekEnd = seekStart + padding - 1
|
|
|
|
volumeStart = seekEnd + len(formatDuration(l)) + 4
|
|
volumeEnd = screenWidth - 2
|
|
|
|
bottomstatusbuf.SetText(" " + progressFormatted + " " + statusBuffer.String() + " " + bottomStatus)
|
|
statusBuffer.Reset()
|
|
}
|
|
|
|
func formatDuration(d time.Duration) string {
|
|
minutes := int(math.Floor(float64(d) / float64(time.Minute)))
|
|
seconds := int((d % time.Minute) / time.Second)
|
|
|
|
return fmt.Sprintf("%02d:%02d", minutes, seconds)
|
|
}
|
|
|
|
func handleResize(width int, height int) {
|
|
screenWidth, screenHeight = width, height
|
|
|
|
updateMain()
|
|
updateQueue()
|
|
updateStatus()
|
|
}
|
|
|
|
func handleMainSelection(i int, item *cview.ListItem) {
|
|
go listSelect(i)
|
|
}
|
|
|
|
func handleQueueSelection(i int, item *cview.ListItem) {
|
|
go queueSelect(i)
|
|
}
|