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
|
||||||
|
|
||||||
Favorites must be edited on the web player (e.g. at [DI.fm](https://di.fm)) and cannot be
|
Favorites are a merge of your remote favorites and an optional local list in
|
||||||
edited via this app. It used to be possible to edit favorites from this app, but DI.fm
|
`~/.config/di-tui/config.yml`. If no local favorites are configured, the remote list is
|
||||||
introduced limitations on their API.
|
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
|
## Dependencies
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,12 @@ type ChannelItem struct {
|
||||||
Playlist string `json:"playlist"`
|
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 {
|
type FavoriteItem struct {
|
||||||
Name string
|
ChannelID int64 `yaml:"channel_id" mapstructure:"channel_id"`
|
||||||
PlaylistURL string
|
Hidden bool `yaml:"hidden" mapstructure:"hidden"`
|
||||||
|
Name string `yaml:"name" mapstructure:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentlyPlaying contains the currently playing metadata for a di.fm channel
|
// 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
|
// 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.
|
// using individual `ReadType` functions from viper. Migrating all `Get*` methods to use `Config` instead.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Theme map[string]string
|
Favorites []components.FavoriteItem `yaml:"favorites" mapstructure:"favorites"`
|
||||||
|
Theme map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var C Config
|
var C Config
|
||||||
|
|
@ -105,6 +106,29 @@ func SaveNetwork(network *components.Network) {
|
||||||
saveConfig()
|
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
|
// HasTheme returns true if the di-tui config has a Theme set
|
||||||
func HasTheme() bool {
|
func HasTheme() bool {
|
||||||
if C.Theme["primary_color"] != "" {
|
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
|
k := i + 1
|
||||||
origFavoriteName := cfg.Section(sec).Key(fmt.Sprintf("Title%d", k)).String()
|
origFavoriteName := cfg.Section(sec).Key(fmt.Sprintf("Title%d", k)).String()
|
||||||
favoriteName := strings.Replace(origFavoriteName, networkNamePrefix, "", 1)
|
favoriteName := strings.Replace(origFavoriteName, networkNamePrefix, "", 1)
|
||||||
favorites = append(favorites, components.FavoriteItem{
|
favorites = append(favorites, components.FavoriteItem{Name: favoriteName})
|
||||||
Name: favoriteName,
|
|
||||||
PlaylistURL: cfg.Section(sec).Key(fmt.Sprintf("File%d", k)).String(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
slices.SortFunc(favorites, func(a components.FavoriteItem, b components.FavoriteItem) int {
|
slices.SortFunc(favorites, func(a components.FavoriteItem, b components.FavoriteItem) int {
|
||||||
return strings.Compare(a.Name, b.Name)
|
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) {
|
func FavoriteItemChannel(ctx *context.AppContext, favorite components.FavoriteItem) (channel *components.ChannelItem) {
|
||||||
for _, chn := range ctx.ChannelList {
|
for i, chn := range ctx.ChannelList {
|
||||||
if chn.Name == favorite.Name {
|
if (favorite.ChannelID != 0 && chn.ID == favorite.ChannelID) || chn.Name == favorite.Name {
|
||||||
channel = &chn
|
channel = &ctx.ChannelList[i]
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
164
main.go
164
main.go
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/acaloiaro/di-tui/app"
|
"github.com/acaloiaro/di-tui/app"
|
||||||
|
"github.com/acaloiaro/di-tui/components"
|
||||||
"github.com/acaloiaro/di-tui/config"
|
"github.com/acaloiaro/di-tui/config"
|
||||||
"github.com/acaloiaro/di-tui/context"
|
"github.com/acaloiaro/di-tui/context"
|
||||||
"github.com/acaloiaro/di-tui/difm"
|
"github.com/acaloiaro/di-tui/difm"
|
||||||
|
|
@ -19,6 +20,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var ctx *context.AppContext
|
var ctx *context.AppContext
|
||||||
|
var favoritesDebouncer *time.Timer
|
||||||
|
|
||||||
const VERSION = "1.13.4"
|
const VERSION = "1.13.4"
|
||||||
|
|
||||||
|
|
@ -151,6 +153,25 @@ func configureEventHandling() {
|
||||||
if current > 0 {
|
if current > 0 {
|
||||||
focus.SetCurrentItem(current - 1)
|
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)
|
case 'p', 32: // tcell has no constant for the space bar rune (32)
|
||||||
app.TogglePause(ctx)
|
app.TogglePause(ctx)
|
||||||
}
|
}
|
||||||
|
|
@ -210,31 +231,148 @@ func FetchFavoritesAndChannels() {
|
||||||
ctx.SetStatusMessage("Unable to get the channel list.")
|
ctx.SetStatusMessage("Unable to get the channel list.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ctx.ChannelList = channels
|
||||||
|
|
||||||
for _, chn := range 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)
|
remoteFavs := difm.ListFavorites(ctx)
|
||||||
for i, favorite := range favorites {
|
// Populate ChannelID on remote favorites by matching channel names
|
||||||
ctx.View.FavoriteList.InsertItem(i, favorite.Name, "", 0, func() {})
|
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
|
ctx.FavoriteList = favorites
|
||||||
|
|
||||||
if len(channels) == 0 && len(favorites) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(favorites) == 0 {
|
if len(favorites) == 0 {
|
||||||
ctx.HighlightedChannel = &channels[0]
|
if len(channels) > 0 {
|
||||||
|
ctx.HighlightedChannel = &channels[0]
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// default the highlighted channel to the first favorite; even before users select a channel manually. This way,
|
// 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
|
// 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
|
// instead of requiring them to choose the channel to be played
|
||||||
highlightedFavorite := ctx.FavoriteList[0]
|
ctx.HighlightedChannel = difm.FavoriteItemChannel(ctx, ctx.FavoriteList[0])
|
||||||
ctx.HighlightedChannel = difm.FavoriteItemChannel(ctx, highlightedFavorite)
|
}
|
||||||
|
|
||||||
|
// 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: "p", Description: "Pause", Func: func() {}},
|
||||||
{Shortcut: "Space", Description: "Pause", Func: func() {}},
|
{Shortcut: "Space", Description: "Pause", Func: func() {}},
|
||||||
{Shortcut: "Enter", Description: "Play", 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
|
return bindings
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue