mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
cd9e8eea81
commit
f66faf6b75
31 files changed
+1142
-70
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user