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) }