diff --git a/cmd/curd/main.go b/cmd/curd/main.go
index 66922d0..34ffccb 100644
--- a/cmd/curd/main.go
+++ b/cmd/curd/main.go
@@ -79,6 +79,7 @@ func main() {
flag.StringVar(&userCurdConfig.DiscordClientId, "discord-client-id", userCurdConfig.DiscordClientId, "Discord client ID for Rich Presence")
flag.BoolVar(&userCurdConfig.VimKeys, "vim-keys", userCurdConfig.VimKeys, "Enable vim motions in selection menus (j/k/h/l, / search) (true/false)")
flag.BoolVar(&userCurdConfig.CheckUpdates, "check-updates", userCurdConfig.CheckUpdates, "Check for curd updates in the background when idle (true/false)")
+ flag.BoolVar(&userCurdConfig.ShowNewEpisodes, "show-new-episodes", userCurdConfig.ShowNewEpisodes, "Show new episode indicators in currently watching list (true/false)")
continueLast := flag.Bool("c", false, "Continue last episode")
addNewAnime := flag.Bool("new", false, "Add new anime")
rofiSelection := flag.Bool("rofi", false, "Open selection in rofi")
diff --git a/internal/anilist.go b/internal/anilist.go
index 469b308..fd1e8ce 100755
--- a/internal/anilist.go
+++ b/internal/anilist.go
@@ -534,6 +534,10 @@ func GetUserData(token string, userID int) (map[string]interface{}, error) {
native
}
status
+ nextAiringEpisode {
+ episode
+ timeUntilAiring
+ }
}
status
score
@@ -597,6 +601,10 @@ func GetUserDataPreview(token string, userID int) (map[string]interface{}, error
native
}
status
+ nextAiringEpisode {
+ episode
+ timeUntilAiring
+ }
}
status
score
@@ -1171,6 +1179,13 @@ func ParseAnimeList(input map[string]interface{}) AnimeList {
animeEntry.CoverImage = safeString(coverImage["large"])
}
+ if nextEp, ok := media["nextAiringEpisode"].(map[string]interface{}); ok && nextEp != nil {
+ animeEntry.Media.NextAiringEpisode = &NextAiringEpisodeInfo{
+ Episode: toInt(nextEp["episode"]),
+ TimeUntilAiring: toInt(nextEp["timeUntilAiring"]),
+ }
+ }
+
// Defense in depth: never insert the same media twice even if AniList
// returns the entry under multiple non-custom lists.
if animeEntry.Media.ID != 0 {
diff --git a/internal/anilist_cache.go b/internal/anilist_cache.go
index 5f26277..92fc014 100644
--- a/internal/anilist_cache.go
+++ b/internal/anilist_cache.go
@@ -269,9 +269,16 @@ func buildCategorySelectionOptions(list AnimeList, category string) []SelectionO
for _, entry := range getEntriesByCategory(list, category) {
title := mediaDisplayTitle(entry.Media, userCurdConfig)
+ hasNew := false
+ if userCurdConfig.ShowNewEpisodes && entry.Media.NextAiringEpisode != nil {
+ nextWatched := nextEpisodeFromProgress(entry.Progress)
+ hasNew = entry.Media.NextAiringEpisode.Episode > nextWatched
+ }
+
options = append(options, SelectionOption{
- Key: strconv.Itoa(entry.Media.ID),
- Label: title,
+ Key: strconv.Itoa(entry.Media.ID),
+ Label: title,
+ HasNewEpisodes: hasNew,
})
}
@@ -285,9 +292,16 @@ func buildCategoryPreviewOptions(list AnimeList, category string) map[string]Rof
for _, entry := range getEntriesByCategory(list, category) {
title := mediaDisplayTitle(entry.Media, userCurdConfig)
+ hasNew := false
+ if userCurdConfig.ShowNewEpisodes && entry.Media.NextAiringEpisode != nil {
+ nextWatched := nextEpisodeFromProgress(entry.Progress)
+ hasNew = entry.Media.NextAiringEpisode.Episode > nextWatched
+ }
+
options[strconv.Itoa(entry.Media.ID)] = RofiSelectPreview{
- Title: title,
- CoverImage: entry.CoverImage,
+ Title: title,
+ CoverImage: entry.CoverImage,
+ HasNewEpisodes: hasNew,
}
}
diff --git a/internal/config.go b/internal/config.go
index 44c514e..e45c98c 100755
--- a/internal/config.go
+++ b/internal/config.go
@@ -75,6 +75,7 @@ type CurdConfig struct {
MyAnimeListClientSecret string `config:"MyAnimeListClientSecret"`
MyAnimeListImported bool `config:"MyAnimeListImported"`
MyAnimeListImportDismissed bool `config:"MyAnimeListImportDismissed"`
+ ShowNewEpisodes bool `config:"ShowNewEpisodes"`
}
const DefaultMpvPlaybackStartTimeout = 20
@@ -134,6 +135,7 @@ func defaultConfigMap() map[string]string {
"MyAnimeListClientSecret": "",
"MyAnimeListImported": "false",
"MyAnimeListImportDismissed": "false",
+ "ShowNewEpisodes": "true",
}
}
diff --git a/internal/selection_menu.go b/internal/selection_menu.go
index 71c36e7..b6b60f9 100644
--- a/internal/selection_menu.go
+++ b/internal/selection_menu.go
@@ -78,6 +78,11 @@ var (
quitHintStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFD700")) // Gold
+
+ newEpisodeItemStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#4CAF50")) // Green
+
+ rofiNewEpisodeColor = "#4CAF50"
)
// Init initializes the model
@@ -318,10 +323,18 @@ func (m Model) View() string {
// Render the options within the visible range
for i := start; i < end; i++ {
+ label := m.filteredKeys[i].Label
+
if i == m.selected {
- b.WriteString(selectedItemStyle.Render(m.filteredKeys[i].Label) + "\n")
+ if m.filteredKeys[i].HasNewEpisodes {
+ b.WriteString(newEpisodeItemStyle.Render(" [NEW]") + selectedItemStyle.Render(label) + "\n")
+ } else {
+ b.WriteString(selectedItemStyle.Render(label) + "\n")
+ }
+ } else if m.filteredKeys[i].HasNewEpisodes {
+ b.WriteString(newEpisodeItemStyle.Render(" [NEW]") + regularItemStyle.Render(label) + "\n")
} else {
- b.WriteString(regularItemStyle.Render(m.filteredKeys[i].Label) + "\n")
+ b.WriteString(regularItemStyle.Render(label) + "\n")
}
}
}
@@ -339,11 +352,19 @@ func (m Model) visibleItemsCount() int {
return count
}
+func displayLabel(opt SelectionOption) string {
+ if opt.HasNewEpisodes {
+ return "[NEW]" + opt.Label
+ }
+ return opt.Label
+}
+
// filterOptions filters and sorts options based on the search term
func (m *Model) filterOptions() {
m.filteredKeys = nil
for _, opt := range m.allOptions {
- if strings.Contains(strings.ToLower(opt.Label), strings.ToLower(m.filter)) {
+ // Small function to also consider new episode from list
+ if strings.Contains(strings.ToLower(displayLabel(opt)), strings.ToLower(m.filter)) {
m.filteredKeys = append(m.filteredKeys, opt)
}
}
@@ -498,8 +519,9 @@ func previewOptionsToSortedSelection(options map[string]RofiSelectPreview) []Sel
selectionOptions := make([]SelectionOption, 0, len(options))
for id, opt := range options {
selectionOptions = append(selectionOptions, SelectionOption{
- Label: opt.Title,
- Key: id,
+ Label: opt.Title,
+ Key: id,
+ HasNewEpisodes: opt.HasNewEpisodes,
})
}
@@ -531,18 +553,22 @@ func DynamicSelectPreviewWithRefresh(options map[string]RofiSelectPreview, addne
Log(fmt.Sprintf("Error caching image: %v", err))
continue
}
- rofiInput.WriteString(fmt.Sprintf("%s\x00icon\x1f%s\n", opt.Label, cachePath))
+ label := opt.Label
+ if opt.HasNewEpisodes {
+ label = fmt.Sprintf("[NEW] %s ", rofiNewEpisodeColor, opt.Label)
+ }
+ rofiInput.WriteString(fmt.Sprintf("%s\x00icon\x1f%s\n", label, cachePath))
}
if addnewoption {
-
rofiInput.WriteString("Add new anime\n")
}
rofiInput.WriteString("Back\n")
rofiInput.WriteString("Quit\n")
configPath := filepath.Join(GetStoragePath(), "selectanimepreview.rasi")
- cmd := exec.Command("rofi", "-dmenu", "-theme", configPath, "-show-icons", "-p", "Select Anime", "-i", "-no-custom")
+ // NOTE: Need `-markup-rows` to enable pango
+ cmd := exec.Command("rofi", "-dmenu", "-theme", configPath, "-show-icons", "-markup-rows", "-p", "Select Anime", "-i", "-no-custom")
cmd.Stdin = strings.NewReader(rofiInput.String())
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
@@ -613,6 +639,9 @@ func preDownloadImages(options map[string]RofiSelectPreview, count int) {
func parsePreviewSelection(rawSelection string, selectionOptions []SelectionOption) (SelectionOption, error) {
selected := strings.TrimSpace(rawSelection)
+ selected = strings.TrimSpace(pangoStrip.ReplaceAllString(selected, ""))
+ selected = strings.TrimPrefix(selected, "[NEW] ")
+ selected = strings.TrimSpace(selected)
switch selected {
case "":
@@ -641,7 +670,7 @@ func downloadToCache(imageURL string) (string, error) {
}
cacheDir := os.ExpandEnv("${HOME}/.cache/curd/images")
- if err := os.MkdirAll(cacheDir, 0755); err != nil {
+ if err := os.MkdirAll(cacheDir, 0o755); err != nil {
return "", fmt.Errorf("failed to create cache directory: %w", err)
}
@@ -725,8 +754,7 @@ func rofiSelectInternal(options []SelectionOption, isHomeMenu bool, refreshConfi
for {
optionsString := buildRofiOptionsString(currentOptions, isHomeMenu)
configPath := filepath.Join(GetStoragePath(), "selectanime.rasi")
- // -markup: enable Pango in -mesg (and prompts where supported)
- args := []string{"-dmenu", "-theme", configPath, "-i", "-markup", "-p", prompt}
+ args := []string{"-dmenu", "-theme", configPath, "-i", "-markup", "-markup-rows", "-p", prompt}
if msg := strings.TrimSpace(message); msg != "" {
args = append(args, "-mesg", msg)
}
@@ -894,7 +922,11 @@ func dynamicSelectInternal(options []SelectionOption, refreshConfig *SelectionRe
func buildRofiOptionsString(options []SelectionOption, isHomeMenu bool) string {
optionsList := make([]string, 0, len(options)+2)
for _, opt := range options {
- optionsList = append(optionsList, opt.Label)
+ if opt.HasNewEpisodes {
+ optionsList = append(optionsList, fmt.Sprintf("[NEW] %s", rofiNewEpisodeColor, opt.Label))
+ } else {
+ optionsList = append(optionsList, opt.Label)
+ }
}
if !isHomeMenu {
@@ -917,8 +949,12 @@ func parseRofiSelection(err error, rawSelection string, options []SelectionOptio
}
selected := strings.TrimSpace(rawSelection)
- // Strip accidental Pango/markup noise if a theme echoes it.
- selected = strings.TrimSpace(ansiStrip.ReplaceAllString(selected, ""))
+ // strip accidental pango noise if a theme echoes it.
+ selected = strings.TrimSpace(pangoStrip.ReplaceAllString(
+ ansiStrip.ReplaceAllString(selected, ""), "",
+ ))
+ selected = strings.TrimPrefix(selected, "[NEW] ")
+ selected = strings.TrimSpace(selected)
switch {
case selected == "":
if isHomeMenu {
diff --git a/internal/structs.go b/internal/structs.go
index 1902a8b..5858cc8 100755
--- a/internal/structs.go
+++ b/internal/structs.go
@@ -71,6 +71,11 @@ type NextEpisode struct {
Mode string
}
+type NextAiringEpisodeInfo struct {
+ Episode int
+ TimeUntilAiring int
+}
+
// StreamPlaybackHint carries MPV playback metadata for a resolved stream URL.
type StreamPlaybackHint struct {
Referrer string
@@ -127,13 +132,14 @@ type ResponseData struct {
}
type Media struct {
- Duration int `json:"duration"`
- Episodes int `json:"episodes"`
- Format string `json:"format"`
- ID int `json:"id"`
- MalID int `json:"mal_id"`
- Title AnimeTitle `json:"title"`
- Status string `json:"status"`
+ Duration int `json:"duration"`
+ Episodes int `json:"episodes"`
+ Format string `json:"format"`
+ ID int `json:"id"`
+ MalID int `json:"mal_id"`
+ Title AnimeTitle `json:"title"`
+ Status string `json:"status"`
+ NextAiringEpisode *NextAiringEpisodeInfo `json:"nextAiringEpisode,omitempty"`
}
type Entry struct {
@@ -159,8 +165,9 @@ type AnimeList struct {
}
type RofiSelectPreview struct {
- Title string `json:"title"`
- CoverImage string `json:"coverImage"`
+ Title string `json:"title"`
+ CoverImage string `json:"coverImage"`
+ HasNewEpisodes bool `json:"-"`
}
type SelectionOptionImage struct {
@@ -171,9 +178,10 @@ type SelectionOptionImage struct {
// SelectionOption holds the label and the internal key
type SelectionOption struct {
- Title string
- Label string
- Key string
- Thumbnail string
- ExtraData interface{}
+ Title string
+ Label string
+ Key string
+ Thumbnail string
+ ExtraData interface{}
+ HasNewEpisodes bool
}
diff --git a/internal/update.go b/internal/update.go
index 6709cc9..ea30305 100644
--- a/internal/update.go
+++ b/internal/update.go
@@ -287,6 +287,7 @@ var (
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
mdURLRe = regexp.MustCompile(`https?://[^\s<>\]]+`)
ansiStrip = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+ pangoStrip = regexp.MustCompile(`<[^>]*>`)
)
// markdownToPango turns common GitHub release markdown into Rofi-friendly Pango.
diff --git a/rofi/selectanimepreview.rasi b/rofi/selectanimepreview.rasi
index f381f79..c6aa276 100644
--- a/rofi/selectanimepreview.rasi
+++ b/rofi/selectanimepreview.rasi
@@ -6,7 +6,7 @@
text-color: #BBBBBB; /* Default text color (light gray) */
text-color-selected: #FFFFFF; /* Text color when selected (white) */
primary: rgba(53, 132, 228, 0.75); /* Blusish primary color */
- important: rgba(53, 132, 228, 0.75); /* Bluish primary color */
+ important: rgba(53, 132, 228, 0.75); /* Bluish primary color */
}
configuration {