Skip to content

Commit

Permalink
Merge pull request #38 from braheezy/file-browser
Browse files Browse the repository at this point in the history
File browser
  • Loading branch information
braheezy authored May 25, 2024
2 parents f19eca4 + 190a172 commit cf723c9
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 58 deletions.
33 changes: 26 additions & 7 deletions cmd/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ package cmd
import "github.com/charmbracelet/bubbles/key"

type helpKeyMap struct {
togglePlay key.Binding
quit key.Binding
seek key.Binding
seekBack key.Binding
seekForward key.Binding
togglePlay key.Binding
quit key.Binding
seek key.Binding
seekBack key.Binding
seekForward key.Binding
selectSong key.Binding
previousSong key.Binding
nextSong key.Binding
pickSong key.Binding
}

var helpKeys = helpKeyMap{
Expand All @@ -29,14 +33,29 @@ var helpKeys = helpKeyMap{
seekForward: key.NewBinding(
key.WithKeys("right", "l"),
),
selectSong: key.NewBinding(
key.WithKeys("up", "k", "down", "j"),
key.WithHelp("up/k/down/j", "choose song"),
),
previousSong: key.NewBinding(
key.WithKeys("up", "k"),
),
nextSong: key.NewBinding(
key.WithKeys("down", "j"),
),
pickSong: key.NewBinding(
key.WithKeys("enter", "j"),
key.WithHelp("enter", "pick song"),
),
}

func (k helpKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.togglePlay, k.seek, k.quit}
}
func (k helpKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.togglePlay, k.seek}, // first column
{k.quit}, // second column
{k.togglePlay, k.seek},
{k.selectSong, k.pickSong},
{k.quit},
}
}
16 changes: 15 additions & 1 deletion cmd/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,22 @@ const (
qoaRed = "#7b2165"
qoaPink = "#dd81c7"
black = "#191724"

greenLight = "#56949f"
greenDark = "#9ccfd8"
)

var (
accent = lipgloss.AdaptiveColor{Dark: "#81dd97", Light: "#217b37"}
accent = lipgloss.AdaptiveColor{Dark: greenDark, Light: greenLight}
main = lipgloss.AdaptiveColor{Dark: qoaPink, Light: qoaRed}

listStyle = lipgloss.NewStyle().
Padding(1, 2).
Margin(1).
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(accent)
listTitleStyle = lipgloss.NewStyle().
Padding(0, 1).
Foreground(main).
Bold(true)
)
136 changes: 107 additions & 29 deletions cmd/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/braheezy/goqoa/pkg/qoa"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
Expand Down Expand Up @@ -37,6 +38,7 @@ func tickCmd() tea.Cmd {
type model struct {
// filenames is a list of filenames to play.
filenames []string
fileList list.Model
// currentIndex is the index of the current song playing
currentIndex int
// qoaPlayer is the QOA player
Expand All @@ -54,6 +56,14 @@ type model struct {
terminalHeight int
}

type item struct {
title, desc string
}

func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }

// qoaPlayer handles playing QOA audio files and showing progress.
type qoaPlayer struct {
// qoaData is the raw QOA encoded audio bytes.
Expand Down Expand Up @@ -91,17 +101,38 @@ func initialModel(filenames []string) *model {
}
// Create the help bubble
help := help.New()
help.ShowAll = true

// Create the progress bubble
prog := progress.New(progress.WithGradient(qoaRed, qoaPink))
prog.ShowPercentage = false
prog.Width = maxWidth

items := make([]list.Item, len(filenames))
for i, filename := range filenames {
qoaBytes := openFile(filename)
qoaMetadata, err := qoa.DecodeHeader(qoaBytes)
if err != nil {
logger.Fatalf("Error decoding QOA header: %v", err)
}
desc := formatDuration(calcSongLength(qoaMetadata))
items[i] = item{title: filename, desc: desc}
}
delegate := list.NewDefaultDelegate()
delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Copy().Foreground(main)
listModel := list.New(items, delegate, 0, 0)
listModel.SetShowHelp(false)
listModel.Title = "goqoa"
listModel.SetStatusBarItemName("song", "songs")
listModel.InfiniteScrolling = true
listModel.Styles.Title = listTitleStyle

// Wait for the audio context to be ready
<-ready

m := &model{
filenames: filenames,
fileList: listModel,
currentIndex: 0,
ctx: ctx,
help: help,
Expand All @@ -114,8 +145,7 @@ func initialModel(filenames []string) *model {
return m
}

// newQOAPlayer creates a new QOA player for the given filename.
func newQOAPlayer(filename string, ctx *oto.Context) *qoaPlayer {
func openFile(filename string) []byte {
_, err := qoa.IsValidQOAFile(filename)
if err != nil {
logger.Fatalf("Error validating QOA file: %v", err)
Expand All @@ -126,13 +156,19 @@ func newQOAPlayer(filename string, ctx *oto.Context) *qoaPlayer {
logger.Fatalf("Error reading QOA file: %v", err)
}

return qoaBytes
}

// newQOAPlayer creates a new QOA player for the given filename.
func newQOAPlayer(filename string, ctx *oto.Context) *qoaPlayer {
qoaBytes := openFile(filename)
qoaMetadata, qoaAudioData, err := qoa.Decode(qoaBytes)
if err != nil {
logger.Fatalf("Error decoding QOA data: %v", err)
}

// Calculate length of song in nanoseconds
totalLength := time.Duration((int64(qoaMetadata.Samples) * int64(time.Second)) / int64(qoaMetadata.SampleRate))
totalLength := calcSongLength(qoaMetadata)

reader := qoa.NewReader(qoaAudioData)
player := ctx.NewPlayer(reader)
Expand All @@ -149,6 +185,12 @@ func newQOAPlayer(filename string, ctx *oto.Context) *qoaPlayer {
}
}

func calcSongLength(qoaMetadata *qoa.QOA) time.Duration {
return time.Duration((int64(qoaMetadata.Samples) * int64(time.Second)) / int64(qoaMetadata.SampleRate))
}

// initialView returns the initial view of the application.

// togglePlayPause toggles the playing state of the player.
func (qp *qoaPlayer) togglePlayPause() tea.Cmd {
if qp.player.IsPlaying() {
Expand Down Expand Up @@ -231,6 +273,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.progress.Width > maxWidth {
m.progress.Width = maxWidth
}

listHeight := len(m.fileList.Items()) * 5
if msg.Height < listHeight {
listHeight = msg.Height
}
m.fileList.SetSize(msg.Width/3, listHeight)
return m, m.checkRepaint(msg)
// Handle key presses
case tea.KeyMsg:
Expand All @@ -249,6 +297,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.seekBack):
newPercent := m.qoaPlayer.seekBack()
return m, m.progress.SetPercent(newPercent)
case key.Matches(msg, m.keys.pickSong):
m.loadSong(m.fileList.Index())
}
// Update the progress. This is called periodically, so also handle songs that are over.
case tickMsg:
Expand All @@ -268,23 +318,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.progress = progressModel.(progress.Model)
return m, cmd
}
return m, nil

var cmd tea.Cmd
m.fileList, cmd = m.fileList.Update(msg)
return m, cmd
}

// nextSong changes to the next song in the filenames list, wrapping around to 0 if needed.
func (m *model) nextSong() {
func (m *model) loadSong(index int) {
if m.qoaPlayer != nil {
m.qoaPlayer.player.Close()
}

// Select next song in filenames list, but wrap around to 0 if at end
nextIndex := (m.currentIndex + 1) % len(m.filenames)
nextFile := m.filenames[nextIndex]
nextFile := m.filenames[index]

// Create a new QOA player for the next song
m.qoaPlayer = newQOAPlayer(nextFile, m.ctx)
m.qoaPlayer.player.Play()
m.currentIndex = nextIndex
m.fileList.Select(m.currentIndex)
m.currentIndex = index
}

// nextSong changes to the next song in the filenames list, wrapping around to 0 if needed.
func (m *model) nextSong() {
// Select next song in filenames list, but wrap around to 0 if at end
nextIndex := (m.currentIndex + 1) % len(m.filenames)
m.loadSong(nextIndex)
}

func (m *model) checkRepaint(msg tea.WindowSizeMsg) tea.Cmd {
Expand All @@ -311,34 +368,41 @@ func (m *model) checkRepaint(msg tea.WindowSizeMsg) tea.Cmd {
// ==========================================
// View renders the current state of the application.
func (m model) View() string {
var view strings.Builder
var mainView strings.Builder

view.WriteString(m.renderTitle())
view.WriteRune('\n')
mainView.WriteString(m.renderTitle())
mainView.WriteRune('\n')

// Render the stats
view.WriteString(m.renderStats())
view.WriteRune('\n')
mainView.WriteString(m.renderStats())
mainView.WriteRune('\n')

// Song progress
view.WriteString(m.progress.View())
view.WriteString(m.renderTime())
view.WriteRune('\n')
mainView.WriteString(m.progress.View())
mainView.WriteString(m.renderTime())
mainView.WriteRune('\n')

mainView.WriteRune('\n')
mainView.WriteString(m.help.View(m.keys))
mainView.WriteRune('\n')

view.WriteRune('\n')
view.WriteString(m.help.View(m.keys))
view.WriteRune('\n')
fileListView := listStyle.Render(m.fileList.View())

return lipgloss.PlaceHorizontal(m.terminalWidth, lipgloss.Center, view.String())
view := lipgloss.JoinHorizontal(lipgloss.Top, fileListView, mainView.String())

return lipgloss.PlaceHorizontal(m.terminalWidth, lipgloss.Center, view)
}

func formatDuration(d time.Duration) string {
if d < time.Second {
return fmt.Sprintf("%dms", d/time.Millisecond) // Fixed width for milliseconds
}
if d < time.Minute {
return fmt.Sprintf("%ds", d/time.Second) // Show seconds only if less than one minute
return fmt.Sprintf("%ds", d/time.Second) // Fixed width for seconds
}
min := d / time.Minute
sec := (d % time.Minute) / time.Second
return fmt.Sprintf("%dm%02ds", min, sec) // Show minutes and seconds if one minute or more
return fmt.Sprintf("%dm%ds", min, sec) // Fixed width for minutes and seconds
}
func (m model) renderTitle() string {
playingStyle := lipgloss.NewStyle().
Expand Down Expand Up @@ -366,17 +430,31 @@ func (m model) renderStats() string {
return statsStyle.Render(stats)
}
func (m model) renderTime() string {
timeStyle := lipgloss.NewStyle().
Padding(0, 1).
Foreground(accent)

// Convert seconds to time.Duration
currentDuration := time.Duration(m.qoaPlayer.currentSeconds * float64(time.Second))
totalDuration := time.Duration(m.qoaPlayer.totalLength.Seconds() * float64(time.Second))

// Format durations using time.Duration's String method, customized for display
currentTimeStr := formatDuration(currentDuration)
totalTimeStr := formatDuration(totalDuration)
// Format the entire string

// Calculate the widths of the time strings
currentTimeWidth := lipgloss.Width(currentTimeStr)
totalTimeWidth := lipgloss.Width(totalTimeStr)
separatorWidth := 3 // Width of " / "
totalWidth := currentTimeWidth + separatorWidth + totalTimeWidth

// Set a fixed width for the display (e.g., 20 characters)
fixedWidth := 12
leftPadding := fixedWidth - totalWidth

timeStyle := lipgloss.NewStyle().
Padding(0, 1).
Foreground(accent).
MarginLeft(leftPadding)

// Ensure the entire string is fixed width
timeProgress := fmt.Sprintf("%s / %s", currentTimeStr, totalTimeStr)
return timeStyle.Render(timeProgress)
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ require (
)

require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
Expand All @@ -40,6 +41,7 @@ require (
github.com/muesli/termenv v0.15.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sync v0.7.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
Expand Down Expand Up @@ -41,6 +43,8 @@ github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHix
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
github.com/jszwec/csvutil v1.5.1/go.mod h1:Rpu7Uu9giO9subDyMCIQfHVDuLrcaC36UA4YcJjGBkg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
Expand Down Expand Up @@ -71,6 +75,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
Expand Down
Loading

0 comments on commit cf723c9

Please sign in to comment.