feat(core,app,android,ios): My List, synced, and New badges with new-episode rows

My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.

pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").

The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
This commit is contained in:
nvrl committed 2026-10-30 22:15:51 +01:00
1 parent cd9e8eea81
commit f66faf6b75
31 files changed
+1142 -70

No files matched your search

+15 -1
View File
@@ -480,7 +480,7 @@ final class AppModel {
if report.settingsChanged {
await AppearanceState.shared.reload()
}
if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty {
if report.applied > 0 || report.titles > 0 || report.settingsChanged || report.listChanged || !report.sharesRemoved.isEmpty {
await reloadNow()
}
// A crawl takes longer than the grace iOS gives a backgrounded app.
@@ -534,6 +534,20 @@ final class AppModel {
}
}
// MARK: - My List
/// Put a title on My List, or take it off.
func setOnList(_ title: TitleRecord, _ on: Bool) {
Task {
do {
try await run { try $0.setOnList(key: title.key, on: on) }
} catch {
reportError(error)
}
reload()
}
}
// MARK: - Watch state
/// Every episode of a title watched or unwatched, with an Undo: a whole
+1 -1
View File
@@ -78,7 +78,7 @@ struct SettingsStore {
nonmutating set { defaults.set(newValue, forKey: Key.newEpisodeNotifications) }
}
static let filters: [LibraryFilter] = [.all, .series, .films, .watching, .unwatched, .downloaded]
static let filters: [LibraryFilter] = [.all, .myList, .series, .films, .watching, .unwatched, .downloaded]
private static let sorts: [LibrarySort] = [.name, .recent, .added, .release, .rating, .popularity]
private func bool(_ key: String, default value: Bool) -> Bool {
+39 -1
View File
@@ -148,6 +148,9 @@ struct LibraryView: View {
onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex))
}
MenuRow("info.circle", "Open") { onTitle(title) }
MenuRow(title.onList ? "checkmark" : "plus", title.onList ? "Remove from My List" : "Add to My List") {
model.setOnList(title, !title.onList)
}
MenuRow("arrow.down.circle", "Download all") { pendingDownload = download(playlist, title.displayName) }
MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") {
model.setTitleWatched(title, !allWatched)
@@ -267,7 +270,9 @@ struct LibraryView: View {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 12) {
ForEach(shelf.titles, id: \.key) { title in
PosterTile(title: title)
// On a shelf, an airing show says when its
// next episode is out.
PosterTile(title: title, badge: title.badge ?? title.countdown())
.frame(width: 112)
.tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } }
}
@@ -479,6 +484,7 @@ extension LibraryFilter {
case .watching: "Watching"
case .unwatched: "Not started"
case .downloaded: "Downloaded"
case .myList: "My List"
}
}
}
@@ -596,6 +602,7 @@ private struct FeaturedPage: View {
let onPlay: (Int) -> Void
let onOpen: () -> Void
@Environment(\.scheme) private var scheme
@Environment(AppModel.self) private var model
@State private var mood: UInt32?
var body: some View {
@@ -637,6 +644,13 @@ private struct FeaturedPage: View {
}
.buttonStyle(.accent)
Button(action: onOpen) { Label("More info", systemImage: "info.circle") }.buttonStyle(.tonal)
// The icon alone: a third labelled button does not fit a
// phone's row.
Button { model.setOnList(title, !title.onList) } label: {
Image(systemName: title.onList ? "checkmark" : "plus")
}
.buttonStyle(.tonal)
.accessibilityLabel(title.onList ? "Remove from My List" : "Add to My List")
}
.padding(.top, 12)
if let episode = resume?.episode, episode.resumeAt != nil, let progress = episode.progress {
@@ -705,8 +719,16 @@ private struct ContinueCard: View {
struct PosterTile: View {
let title: TitleRecord
var folded = 0
/// `New`, `3 new`, or an airing show's countdown, at the top left.
var badge: String?
@Environment(\.scheme) private var scheme
init(title: TitleRecord, folded: Int = 0, badge: String? = nil) {
self.title = title
self.folded = folded
self.badge = badge ?? title.badge
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
RemoteArtwork(title.posterUrl ?? title.backdropUrl, title.displayName, fallback: title.thumbnailSource)
@@ -724,6 +746,16 @@ struct PosterTile: View {
.accessibilityLabel("and \(folded) more")
}
}
.overlay(alignment: .topLeading) {
if let badge {
Text(badge)
.textStyle(.labelSmall, semibold: true)
.foregroundStyle(scheme.onPrimary)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(scheme.primary, in: RoundedRectangle(cornerRadius: Corner.standard))
.padding(6)
}
}
Text(title.displayName).textStyle(.bodyMedium, semibold: true).foregroundStyle(scheme.onSurface)
.lineLimit(2).padding(.top, 6)
// Two lines, not one: at a large text size "2023 · 7 episodes"
@@ -736,6 +768,12 @@ struct PosterTile: View {
}
extension TitleRecord {
/// `Ep 8 · in 2 d` for a show whose next episode is still to air.
func countdown(now: Int64 = Int64(Date().timeIntervalSince1970)) -> String? {
guard let at = nextAiringAt, at > now, let episode = nextEpisode else { return nil }
return airingCountdown(episode: episode, at: at, now: now)
}
/// "2023 · 11 episodes", or "1988 · Film" — never "1 episodes".
var caption: String {
let count: String
+4
View File
@@ -251,6 +251,10 @@ struct TitleView: View {
.disabled(playlist.isEmpty)
.padding(.top, 16)
HStack(spacing: 0) {
TitleAction(icon: title.onList ? "checkmark" : "plus", label: "My List") {
model.setOnList(title, !title.onList)
}
.accessibilityValue(title.onList ? "On My List" : "Not on My List")
// Only where it would do something different: an unstarted show
// already starts at the beginning.
if upNext?.resumeAt != nil || title.watchedCount > 0 {