feat: local favorites support

Favorites are now merged between local list and the remote service and
stored in di-tui's config fle. If no local favorites are configured the
behavior is unchanged; once the user interacts, the config becomes the
source of truth for ordering and visibility.

Merge rules:
  - Local entries appear first, sorted by the user's preference
  - Hidden local entries suppress matching remote entries
  - Remote favorites with no local entry are appended to the end, in order

Reorder saves are debounced 200ms so rapid key presses produce a single
config write. Toggling a favorite saves immediately.
This commit is contained in:
Adriano Caloiaro 2026-05-02 08:52:45 -06:00
parent 657c48830c
commit 50f7d2c048
No known key found for this signature in database
6 changed files with 207 additions and 28 deletions

View file

@ -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

View file

@ -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

View file

@ -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"] != "" {

View file

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

164
main.go
View file

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

View file

@ -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