diff --git a/README.md b/README.md index 5333012..9c0b66a 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,23 @@ DI.fm is the default network, but other audio addict networks can be chosen with ### Favorites -Favorites must be edited on the web player (e.g. at [DI.fm](https://di.fm)) and cannot be -edited via this app. It used to be possible to edit favorites from this app, but DI.fm -introduced limitations on their API. +Favorites are a merge of your remote favorites and an optional local list in +`~/.config/di-tui/config.yml`. If no local favorites are configured, the remote list is +used as-is. + +Use `Shift+F` to toggle a channel as a favorite from either pane, and `Shift+K`/`Shift+J` +to reorder. Changes are written to config immediately. + +Local favorites can also be defined manually: + +```yml +favorites: + - name: Trance + channel_id: 123 +``` + +Channels removed via `Shift+F` are stored with `hidden: true`, which suppresses them even +if they appear in your remote favorites. ## Dependencies diff --git a/components/components.go b/components/components.go index f8dbfee..6d5f67c 100644 --- a/components/components.go +++ b/components/components.go @@ -19,10 +19,12 @@ type ChannelItem struct { Playlist string `json:"playlist"` } -// FavoriteItem contains a di.fm favorite channel +// FavoriteItem contains a favorite channel. It is used both as a UI component and as the +// serialized representation in the di-tui config file. type FavoriteItem struct { - Name string - PlaylistURL string + ChannelID int64 `yaml:"channel_id" mapstructure:"channel_id"` + Hidden bool `yaml:"hidden" mapstructure:"hidden"` + Name string `yaml:"name" mapstructure:"name"` } // CurrentlyPlaying contains the currently playing metadata for a di.fm channel diff --git a/config/config.go b/config/config.go index ab24ce2..f0e830e 100644 --- a/config/config.go +++ b/config/config.go @@ -12,7 +12,8 @@ import ( // TODO This application is migrating to having all of its configuration read into this `Config` struct, rather than // using individual `ReadType` functions from viper. Migrating all `Get*` methods to use `Config` instead. type Config struct { - Theme map[string]string + Favorites []components.FavoriteItem `yaml:"favorites" mapstructure:"favorites"` + Theme map[string]string } var C Config @@ -105,6 +106,29 @@ func SaveNetwork(network *components.Network) { saveConfig() } +// GetLocalFavorites returns the user's locally configured favorites +func GetLocalFavorites() []components.FavoriteItem { + return C.Favorites +} + +// SaveLocalFavorites persists the ordered favorites list to the config file +func SaveLocalFavorites(favorites []components.FavoriteItem) { + data := make([]map[string]any, len(favorites)) + for i, f := range favorites { + entry := map[string]any{ + "name": f.Name, + "channel_id": f.ChannelID, + } + if f.Hidden { + entry["hidden"] = true + } + data[i] = entry + } + viper.Set("favorites", data) + C.Favorites = favorites + saveConfig() +} + // HasTheme returns true if the di-tui config has a Theme set func HasTheme() bool { if C.Theme["primary_color"] != "" { diff --git a/difm/difm.go b/difm/difm.go index e611407..8b9b2aa 100644 --- a/difm/difm.go +++ b/difm/difm.go @@ -206,10 +206,7 @@ func ListFavorites(ctx *context.AppContext) (favorites []components.FavoriteItem k := i + 1 origFavoriteName := cfg.Section(sec).Key(fmt.Sprintf("Title%d", k)).String() favoriteName := strings.Replace(origFavoriteName, networkNamePrefix, "", 1) - favorites = append(favorites, components.FavoriteItem{ - Name: favoriteName, - PlaylistURL: cfg.Section(sec).Key(fmt.Sprintf("File%d", k)).String(), - }) + favorites = append(favorites, components.FavoriteItem{Name: favoriteName}) } slices.SortFunc(favorites, func(a components.FavoriteItem, b components.FavoriteItem) int { return strings.Compare(a.Name, b.Name) @@ -286,11 +283,12 @@ func Stream(url string, ctx *context.AppContext, streamCtx c.Context) { } } -// FavoriteItemChannel identifies the ChannelItem that corresponds with a FavoriteItem +// FavoriteItemChannel identifies the ChannelItem that corresponds with a FavoriteItem. +// Matches by ChannelID when set, falling back to name. func FavoriteItemChannel(ctx *context.AppContext, favorite components.FavoriteItem) (channel *components.ChannelItem) { - for _, chn := range ctx.ChannelList { - if chn.Name == favorite.Name { - channel = &chn + for i, chn := range ctx.ChannelList { + if (favorite.ChannelID != 0 && chn.ID == favorite.ChannelID) || chn.Name == favorite.Name { + channel = &ctx.ChannelList[i] return } } diff --git a/main.go b/main.go index 9166567..dbc3eb5 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "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" @@ -19,6 +20,7 @@ import ( ) var ctx *context.AppContext +var favoritesDebouncer *time.Timer const VERSION = "1.13.4" @@ -151,6 +153,25 @@ func configureEventHandling() { 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) } @@ -210,31 +231,148 @@ func FetchFavoritesAndChannels() { ctx.SetStatusMessage("Unable to get the channel list.") return } + ctx.ChannelList = channels for _, chn := range channels { - ctx.View.ChannelList.AddItem(chn.Name, "", 0, func() { - }) + ctx.View.ChannelList.AddItem(chn.Name, "", 0, func() {}) } - favorites := difm.ListFavorites(ctx) - for i, favorite := range favorites { - ctx.View.FavoriteList.InsertItem(i, favorite.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.ChannelList = channels ctx.FavoriteList = favorites - if len(channels) == 0 && len(favorites) == 0 { - return - } - if len(favorites) == 0 { - ctx.HighlightedChannel = &channels[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 - highlightedFavorite := ctx.FavoriteList[0] - ctx.HighlightedChannel = difm.FavoriteItemChannel(ctx, highlightedFavorite) + 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, + })) } diff --git a/views/views.go b/views/views.go index 59dcbcc..8f50460 100644 --- a/views/views.go +++ b/views/views.go @@ -260,6 +260,9 @@ func GetKeybindings() (bindings []UIKeybinding) { {Shortcut: "p", Description: "Pause", Func: func() {}}, {Shortcut: "Space", Description: "Pause", Func: func() {}}, {Shortcut: "Enter", Description: "Play", Func: func() {}}, + {Shortcut: "S-f", Description: "Toggle Favorite", Func: func() {}}, + {Shortcut: "S-j", Description: "Move Fav Down", Func: func() {}}, + {Shortcut: "S-k", Description: "Move Fav Up", Func: func() {}}, } return bindings