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 {