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:
parent
657c48830c
commit
50f7d2c048
6 changed files with 207 additions and 28 deletions
20
README.md
20
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"] != "" {
|
||||
|
|
|
|||
14
difm/difm.go
14
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
164
main.go
164
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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue