378 lines
11 KiB
Go
378 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/acaloiaro/di-tui/app"
|
|
"github.com/acaloiaro/di-tui/components"
|
|
"github.com/acaloiaro/di-tui/config"
|
|
"github.com/acaloiaro/di-tui/context"
|
|
"github.com/acaloiaro/di-tui/difm"
|
|
"github.com/acaloiaro/di-tui/mpris"
|
|
"github.com/acaloiaro/di-tui/views"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
"github.com/spf13/pflag"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
var ctx *context.AppContext
|
|
var favoritesDebouncer *time.Timer
|
|
|
|
const VERSION = "1.15.0"
|
|
|
|
func main() {
|
|
var err error
|
|
usernameFlag := pflag.String("username", "", "your di.fm username")
|
|
passwordFlag := pflag.String("password", "", "your di.fm password")
|
|
versionFlag := pflag.Bool("version", false, "print the current di-tui version")
|
|
networkFlag := pflag.String("network", viper.GetString("network.shortname"), "the audioaddict network to connect to")
|
|
pflag.Parse()
|
|
viper.BindPFlags(pflag.CommandLine)
|
|
|
|
if *versionFlag {
|
|
fmt.Printf("di-tui %s\n", VERSION)
|
|
return
|
|
}
|
|
network, err := difm.GetNetwork(*networkFlag)
|
|
if err != nil {
|
|
var networks []string
|
|
for network := range difm.Networks {
|
|
networks = append(networks, network)
|
|
}
|
|
fmt.Printf("Invalid network: %s \nPlease choose from the following: %s\n", *networkFlag, strings.Join(networks, ", "))
|
|
return
|
|
}
|
|
|
|
if *usernameFlag != "" && *passwordFlag != "" {
|
|
err = difm.Authenticate(ctx, *usernameFlag, *passwordFlag)
|
|
if err != nil {
|
|
fmt.Printf("unable to authenticate %s", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
token := config.GetToken()
|
|
if token == "" {
|
|
fmt.Println("Authenticate by running: di-tui --username USER --password PASSWORD\n\n" +
|
|
"Or, visit https://github.com/acaloiaro/di-tui#authenticate for other options.")
|
|
os.Exit(1)
|
|
}
|
|
|
|
ctx = context.CreateAppContext(views.CreateViewContext(network))
|
|
ctx.DifmToken = token
|
|
|
|
run()
|
|
}
|
|
|
|
func run() {
|
|
configureEventHandling()
|
|
updateScreenLayout()
|
|
FetchFavoritesAndChannels()
|
|
|
|
ctx.View.Keybindings.Bindings = views.GetKeybindings()
|
|
|
|
err := ctx.View.App.Run()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func updateScreenLayout() {
|
|
focusView := ctx.View.App.GetFocus()
|
|
|
|
main := tview.NewFlex()
|
|
main.SetDirection(tview.FlexRow)
|
|
|
|
favsAndChannels := tview.NewFlex().
|
|
AddItem(ctx.View.FavoriteList, 0, 1, false).
|
|
AddItem(ctx.View.ChannelList, 0, 2, false).
|
|
SetDirection(tview.FlexRow)
|
|
|
|
primaryView := tview.NewFlex()
|
|
primaryView.
|
|
AddItem(favsAndChannels, 30, 0, false).
|
|
AddItem(ctx.View.NowPlaying, 0, 4, false)
|
|
|
|
if ctx.ShowStatus {
|
|
main.AddItem(ctx.View.Status, 4, 0, false)
|
|
}
|
|
|
|
main.
|
|
AddItem(primaryView, 0, 3, false).
|
|
AddItem(ctx.View.Keybindings, 4, 0, false)
|
|
|
|
if focusView == nil {
|
|
focusView = ctx.View.FavoriteList
|
|
}
|
|
|
|
ctx.View.App.
|
|
SetRoot(main, true).
|
|
SetFocus(focusView)
|
|
}
|
|
|
|
// configureEventHandling handles key press events, and regular UI updates such as the currently playing track
|
|
func configureEventHandling() {
|
|
ctx.View.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
favoritesEmpty := len(ctx.FavoriteList) == 0
|
|
if favoritesEmpty {
|
|
ctx.View.App.SetFocus(ctx.View.ChannelList)
|
|
}
|
|
|
|
focus := ctx.View.App.GetFocus().(*tview.List)
|
|
switch event.Key() {
|
|
case tcell.KeyEnter:
|
|
current := focus.GetCurrentItem()
|
|
if focus != ctx.View.ChannelList && current < len(ctx.FavoriteList) {
|
|
highlightedFavorite := ctx.FavoriteList[current]
|
|
ctx.HighlightedChannel = difm.FavoriteItemChannel(ctx, highlightedFavorite)
|
|
} else if current < len(ctx.ChannelList) {
|
|
ctx.HighlightedChannel = &ctx.ChannelList[current]
|
|
}
|
|
app.Play(ctx)
|
|
case tcell.KeyRune:
|
|
switch event.Rune() {
|
|
case 'c':
|
|
if focus != ctx.View.ChannelList {
|
|
ctx.View.App.SetFocus(ctx.View.ChannelList)
|
|
}
|
|
case 'f':
|
|
if focus != ctx.View.FavoriteList {
|
|
ctx.View.App.SetFocus(ctx.View.FavoriteList)
|
|
}
|
|
case 'q':
|
|
ctx.View.App.Stop()
|
|
case 'j': // scroll down
|
|
current := focus.GetCurrentItem() + 1
|
|
focus.SetCurrentItem(current)
|
|
case 'k': // scroll up
|
|
current := focus.GetCurrentItem()
|
|
if current > 0 {
|
|
focus.SetCurrentItem(current - 1)
|
|
}
|
|
case 'J': // Shift+J: move favorite down
|
|
if focus == ctx.View.FavoriteList {
|
|
moveFavoriteDown(ctx, focus.GetCurrentItem())
|
|
}
|
|
case 'K': // Shift+K: move favorite up
|
|
if focus == ctx.View.FavoriteList {
|
|
moveFavoriteUp(ctx, focus.GetCurrentItem())
|
|
}
|
|
case 'F': // Shift+F: toggle favorite
|
|
current := focus.GetCurrentItem()
|
|
var channel *components.ChannelItem
|
|
if focus == ctx.View.FavoriteList && current < len(ctx.FavoriteList) {
|
|
channel = difm.FavoriteItemChannel(ctx, ctx.FavoriteList[current])
|
|
} else if focus == ctx.View.ChannelList && current < len(ctx.ChannelList) {
|
|
channel = &ctx.ChannelList[current]
|
|
}
|
|
if channel != nil {
|
|
toggleFavorite(ctx, channel)
|
|
}
|
|
case 'p', 32: // tcell has no constant for the space bar rune (32)
|
|
app.TogglePause(ctx)
|
|
}
|
|
}
|
|
|
|
return event
|
|
})
|
|
|
|
// keep 'now playing' up to date second-by-second
|
|
go func() {
|
|
c := time.Tick(1 * time.Second)
|
|
for range c {
|
|
// don't show elapsed time updates when playback is paused/not playing
|
|
if !ctx.IsPlaying {
|
|
continue
|
|
}
|
|
elapsed := time.Since(ctx.View.NowPlaying.Track.StartTime)
|
|
|
|
// If the current time is past the end of the track, then a new track is playing and the now playing track needs
|
|
// to be refreshed.
|
|
if ctx.View.NowPlaying.Track.Duration > 0 && ctx.View.NowPlaying.Track.Duration < elapsed.Seconds() {
|
|
app.UpdateNowPlaying(ctx, ctx.CurrentChannel)
|
|
}
|
|
|
|
if ctx.CurrentChannel != nil && elapsed.Seconds() > 0 {
|
|
ctx.View.NowPlaying.Elapsed = elapsed.Seconds()
|
|
}
|
|
|
|
ctx.View.App.QueueUpdateDraw(func() {})
|
|
}
|
|
}()
|
|
|
|
// display the status pane when new status messages arrive
|
|
go func() {
|
|
for {
|
|
status := <-ctx.StatusChannel
|
|
ctx.ShowStatus = true
|
|
ctx.View.Status.Message = status.Message
|
|
updateScreenLayout() // add the status pane to the screen
|
|
|
|
<-time.Tick(status.Duration)
|
|
ctx.ShowStatus = false
|
|
updateScreenLayout() // remove the status pane from the screen
|
|
}
|
|
}()
|
|
|
|
// Start the mpris server for d-bus support
|
|
mpris.Start(ctx)
|
|
}
|
|
|
|
func FetchFavoritesAndChannels() {
|
|
ctx.View.ChannelList.Clear()
|
|
ctx.View.FavoriteList.Clear()
|
|
|
|
channels := difm.ListChannels(ctx)
|
|
if len(channels) == 0 {
|
|
ctx.SetStatusMessage("Unable to get the channel list.")
|
|
return
|
|
}
|
|
ctx.ChannelList = channels
|
|
|
|
for _, chn := range channels {
|
|
ctx.View.ChannelList.AddItem(chn.Name, "", 0, func() {})
|
|
}
|
|
|
|
remoteFavs := difm.ListFavorites(ctx)
|
|
// Populate ChannelID on remote favorites by matching channel names
|
|
channelByName := make(map[string]*components.ChannelItem, len(channels))
|
|
for i := range channels {
|
|
channelByName[channels[i].Name] = &channels[i]
|
|
}
|
|
for i, rf := range remoteFavs {
|
|
if ch, ok := channelByName[rf.Name]; ok {
|
|
remoteFavs[i].ChannelID = ch.ID
|
|
}
|
|
}
|
|
|
|
favorites := mergeFavorites(remoteFavs, config.GetLocalFavorites())
|
|
for i, fav := range favorites {
|
|
ctx.View.FavoriteList.InsertItem(i, fav.Name, "", 0, func() {})
|
|
}
|
|
ctx.FavoriteList = favorites
|
|
|
|
if len(favorites) == 0 {
|
|
if len(channels) > 0 {
|
|
ctx.HighlightedChannel = &channels[0]
|
|
}
|
|
return
|
|
}
|
|
|
|
// default the highlighted channel to the first favorite; even before users select a channel manually. This way,
|
|
// when di-tui starts and the user presses the "Play" media key, di-tui will start playing the first favorite
|
|
// instead of requiring them to choose the channel to be played
|
|
ctx.HighlightedChannel = difm.FavoriteItemChannel(ctx, ctx.FavoriteList[0])
|
|
}
|
|
|
|
// mergeFavorites combines local config favorites with remote favorites.
|
|
// Local favorites define ordering; remote favorites not present locally are appended.
|
|
// Local entries with hidden=true suppress both themselves and their remote counterpart.
|
|
// If there are no local favorites, remote favorites are returned as-is.
|
|
func mergeFavorites(remoteFavs, localFavs []components.FavoriteItem) []components.FavoriteItem {
|
|
if len(localFavs) == 0 {
|
|
return remoteFavs
|
|
}
|
|
|
|
localIDs := make(map[int64]bool, len(localFavs))
|
|
result := make([]components.FavoriteItem, 0, len(localFavs)+len(remoteFavs))
|
|
for _, lf := range localFavs {
|
|
localIDs[lf.ChannelID] = true
|
|
if !lf.Hidden {
|
|
result = append(result, lf)
|
|
}
|
|
}
|
|
for _, rf := range remoteFavs {
|
|
if !localIDs[rf.ChannelID] {
|
|
result = append(result, rf)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func moveFavoriteUp(ctx *context.AppContext, current int) {
|
|
if current <= 0 || current >= len(ctx.FavoriteList) {
|
|
return
|
|
}
|
|
newCurrentIDx := current - 1
|
|
ctx.FavoriteList[current], ctx.FavoriteList[newCurrentIDx] = ctx.FavoriteList[newCurrentIDx], ctx.FavoriteList[current]
|
|
buildFavoriteList(ctx)
|
|
ctx.View.FavoriteList.SetCurrentItem(newCurrentIDx)
|
|
saveFavoritesDebounced(ctx)
|
|
}
|
|
|
|
func moveFavoriteDown(ctx *context.AppContext, current int) {
|
|
if current < 0 || current >= len(ctx.FavoriteList)-1 {
|
|
return
|
|
}
|
|
newCurrentIDx := current + 1
|
|
ctx.FavoriteList[current], ctx.FavoriteList[newCurrentIDx] = ctx.FavoriteList[newCurrentIDx], ctx.FavoriteList[current]
|
|
buildFavoriteList(ctx)
|
|
ctx.View.FavoriteList.SetCurrentItem(newCurrentIDx)
|
|
saveFavoritesDebounced(ctx)
|
|
}
|
|
|
|
// saveFavoritesDebounced defers saveFavorites by 200ms, resetting the timer on each call.
|
|
func saveFavoritesDebounced(ctx *context.AppContext) {
|
|
if favoritesDebouncer != nil {
|
|
favoritesDebouncer.Stop()
|
|
}
|
|
favoritesDebouncer = time.AfterFunc(200*time.Millisecond, func() { saveFavorites(ctx) })
|
|
}
|
|
|
|
// saveFavorites persists the current visible order while preserving hidden entries.
|
|
func saveFavorites(ctx *context.AppContext) {
|
|
var hidden []components.FavoriteItem
|
|
for _, lf := range config.GetLocalFavorites() {
|
|
if lf.Hidden {
|
|
hidden = append(hidden, lf)
|
|
}
|
|
}
|
|
config.SaveLocalFavorites(append(ctx.FavoriteList, hidden...))
|
|
}
|
|
|
|
func buildFavoriteList(ctx *context.AppContext) {
|
|
ctx.View.FavoriteList.Clear()
|
|
for i, fav := range ctx.FavoriteList {
|
|
ctx.View.FavoriteList.InsertItem(i, fav.Name, "", 0, func() {})
|
|
}
|
|
}
|
|
|
|
// toggleFavorite adds or removes channel from the favorites list.
|
|
// Removals are persisted as hidden=true so remote favorites for the same channel stay suppressed.
|
|
func toggleFavorite(ctx *context.AppContext, channel *components.ChannelItem) {
|
|
for i, fav := range ctx.FavoriteList {
|
|
if fav.ChannelID == channel.ID {
|
|
ctx.FavoriteList = append(ctx.FavoriteList[:i], ctx.FavoriteList[i+1:]...)
|
|
buildFavoriteList(ctx)
|
|
setLocalFavoriteHidden(channel.ID, channel.Name, true)
|
|
return
|
|
}
|
|
}
|
|
ctx.FavoriteList = append(ctx.FavoriteList, components.FavoriteItem{
|
|
ChannelID: channel.ID,
|
|
Name: channel.Name,
|
|
})
|
|
buildFavoriteList(ctx)
|
|
setLocalFavoriteHidden(channel.ID, channel.Name, false)
|
|
}
|
|
|
|
// setLocalFavoriteHidden updates or inserts a local favorite entry with the given hidden state.
|
|
func setLocalFavoriteHidden(channelID int64, name string, hidden bool) {
|
|
localFavs := config.GetLocalFavorites()
|
|
for i, lf := range localFavs {
|
|
if lf.ChannelID == channelID {
|
|
localFavs[i].Hidden = hidden
|
|
config.SaveLocalFavorites(localFavs)
|
|
return
|
|
}
|
|
}
|
|
config.SaveLocalFavorites(append(localFavs, components.FavoriteItem{
|
|
Name: name,
|
|
ChannelID: channelID,
|
|
Hidden: hidden,
|
|
}))
|
|
}
|