Let rows update their counts, drop fake dates, and own failed lookups

MAC6: a snapshot mutation carries its video count back to the list cache.
MAC8: playlist rows and cards are Buttons with the context menu attached,
so macOS accessibility can reach Edit and Delete.
MAC10: a published date stamped with the request's own time is dropped at
both API sentinels, and formattedPublishedDate refuses zero-interval output.
MAC11: a failed history hydration shows Details unavailable with a Try
Again action instead of keeping the raw video ID forever; an empty 200
body is a legible failure.
This commit is contained in:
Arkadiusz Fal
2026-09-11 20:28:35 +02:00
parent 3281840a8e
commit c0d38834a6
9 changed files with 268 additions and 41 deletions

View File

@@ -179,8 +179,12 @@ final class Bookmark {
}
/// Formatted published date, preferring parsed Date over API-provided text.
///
/// A date that would render as "in 0 seconds" is shown as nothing at all —
/// see ``Video/formattedPublishedDate``.
var formattedPublishedDate: String? {
if let publishedAt {
guard !RelativeDateFormatter.isEffectivelyNow(publishedAt) else { return nil }
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: publishedAt, relativeTo: Date())

View File

@@ -111,6 +111,29 @@ struct Video: Identifiable, Codable, Sendable {
)
}
/// The same standing-in video under wording that says the metadata could
/// not be fetched, for a row whose fetch has definitively failed.
///
/// Only what is shown changes: the video handed to the player and the queue
/// keeps its ID, which is the part that plays.
static func metadataUnavailable(for videoID: String, title: String) -> Video {
Video(
id: .global(videoID),
title: title,
description: nil,
author: Author(id: "", name: ""),
duration: 0,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
/// Whether the title is only the video's own ID standing in for one.
///
/// Anything showing this video beside details it fetched itself should
@@ -166,8 +189,13 @@ struct Video: Identifiable, Codable, Sendable {
}
/// Formatted published date, preferring parsed Date over API-provided text.
///
/// A date that would render as "in 0 seconds" is shown as nothing at all:
/// it is what an instance sends in place of an upload date it does not
/// have, and the metadata line is better one segment short than wrong.
var formattedPublishedDate: String? {
if let publishedAt {
guard !RelativeDateFormatter.isEffectivelyNow(publishedAt) else { return nil }
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: publishedAt, relativeTo: Date())

View File

@@ -1143,6 +1143,29 @@ private struct InvidiousAuthPlaylistVideo: Decodable, Sendable {
}
}
// MARK: - Invidious Sentinels
/// Invidious answers with the moment it replied where it has no upload date:
/// the row comes back as `"published": <now>, "publishedText": "0 seconds
/// ago"`, which renders as "in 0 seconds" beside a view count of zero. A
/// publication that has not happened yet is one the instance does not know.
enum InvidiousSentinel {
/// The date and the text Invidious actually has, both dropped where the
/// reading is that stamp.
///
/// A premiere is left alone: it is scheduled, so its `published` is meant
/// to be a date that has not arrived yet.
static func published(_ raw: Int64?, text: String?, isUpcoming: Bool) -> (date: Date?, text: String?) {
guard let raw else { return (nil, text) }
let date = Date(timeIntervalSince1970: TimeInterval(raw))
if !isUpcoming, RelativeDateFormatter.isStampedNow(date) {
return (nil, nil)
}
return (date, text)
}
}
// MARK: - Invidious Response Models
/// Response from the authenticated feed endpoint.
@@ -1174,14 +1197,20 @@ private struct InvidiousVideo: Decodable, Sendable {
let premiereTimestamp: Int64?
nonisolated func toVideo(baseURL: URL) -> Video {
Video(
let publishedReading = InvidiousSentinel.published(
published,
text: publishedText,
isUpcoming: isUpcoming ?? false
)
return Video(
id: .global(videoId),
title: title,
description: description,
author: Author(id: authorId, name: author),
duration: TimeInterval(lengthSeconds),
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
publishedText: publishedText,
publishedAt: publishedReading.date,
publishedText: publishedReading.text,
viewCount: viewCount,
likeCount: likeCount,
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],
@@ -1246,6 +1275,11 @@ private struct InvidiousVideoDetails: Decodable, Sendable {
nonisolated func toVideo(baseURL: URL) -> Video {
// Convert recommended videos, limiting to 12
let related: [Video]? = recommendedVideos?.prefix(12).map { $0.toVideo(baseURL: baseURL) }
let publishedReading = InvidiousSentinel.published(
published,
text: publishedText,
isUpcoming: isUpcoming ?? false
)
return Video(
id: .global(videoId),
@@ -1258,8 +1292,8 @@ private struct InvidiousVideoDetails: Decodable, Sendable {
subscriberCount: subscriberCount
),
duration: TimeInterval(lengthSeconds),
publishedAt: published.map { Date(timeIntervalSince1970: TimeInterval($0)) },
publishedText: publishedText,
publishedAt: publishedReading.date,
publishedText: publishedReading.text,
viewCount: viewCount,
likeCount: likeCount,
thumbnails: videoThumbnails?.map { $0.toThumbnail(baseURL: baseURL) } ?? [],

View File

@@ -819,9 +819,15 @@ enum PipedSentinel {
/// The upload date Piped actually has, or nil for its `-1` sentinel.
/// Piped sends this field as epoch **milliseconds**.
///
/// A stamp of the moment the instance answered is the other reading that
/// means nothing — it renders as "in 0 seconds" — and is dropped the same
/// way.
static func uploadedDate(_ raw: Int64?) -> Date? {
guard let raw, raw >= 0 else { return nil }
return Date(timeIntervalSince1970: TimeInterval(raw) / 1000)
let date = Date(timeIntervalSince1970: TimeInterval(raw) / 1000)
guard !RelativeDateFormatter.isStampedNow(date) else { return nil }
return date
}
}

View File

@@ -228,6 +228,16 @@ actor HTTPClient {
) async throws -> T {
let data = try await performRaw(request, customHeaders: customHeaders)
// A body with nothing in it is a failed answer however the status line
// reads: instances do answer `200` with zero bytes, and that is not a
// response any of these types can be decoded from.
guard !data.isEmpty else {
Task { @MainActor in
LoggingService.shared.error("API returned an empty response body", category: .api)
}
throw APIError.decodingError("Empty response body")
}
do {
return try decoder.decode(T.self, from: data)
} catch let error as DecodingError {

View File

@@ -300,6 +300,7 @@ final class RemotePlaylistsCache {
self.appliedSnapshotSequence[key] = sequence
self.snapshots[accountID, default: [:]][id] = snapshot
self.snapshotFetchDates[accountID, default: [:]][id] = Date()
self.carryCountToList(from: snapshot, id: id, for: accountID)
}
snapshotFetches[key] = task
@@ -312,6 +313,33 @@ final class RemotePlaylistsCache {
try await task.value
}
/// Carries a refetched playlist's video count into the account's list.
///
/// The list row counts the playlist's videos, and only `fetchList` rewrites
/// the list — so a video added or removed from the detail view left the row
/// counting what the playlist held before, until something happened to
/// refetch the whole list. Nothing is invented here and no request is
/// added: the count is the one the server just answered this snapshot with,
/// and a playlist the list does not carry is left out of it rather than
/// put in.
private func carryCountToList(from snapshot: RemotePlaylistSnapshot, id: String, for accountID: UUID) {
guard let index = lists[accountID]?.firstIndex(where: { $0.id.playlistID == id }),
let existing = lists[accountID]?[index],
existing.videoCount != snapshot.playlist.videoCount
else { return }
lists[accountID]?[index] = Playlist(
id: existing.id,
title: existing.title,
description: existing.description,
author: existing.author,
videoCount: snapshot.playlist.videoCount,
thumbnailURL: existing.thumbnailURL,
videos: existing.videos,
isLocal: existing.isLocal
)
}
// MARK: - Reconciliation
/// Runs a change and refetches the playlist it touched — and the list too,

View File

@@ -30,4 +30,23 @@ enum RelativeDateFormatter {
formatter.unitsStyle = unitsStyle
return formatter.localizedString(for: date, relativeTo: Date())
}
/// Whether a date is so close to now that a relative rendering of it reads
/// "in 0 seconds".
static func isEffectivelyNow(_ date: Date, tolerance: TimeInterval = 1) -> Bool {
abs(Date().timeIntervalSince(date)) < tolerance
}
/// Whether a publication date is the stamp an instance writes when it has
/// none to send.
///
/// Invidious answers `published` with the moment it replied — and
/// `publishedText` with "0 seconds ago" — for a video whose upload date it
/// could not read, which renders as "in 0 seconds"; a scraping Piped does
/// the same with `uploaded`. Read with a minute of slack, because the
/// clocks at the two ends of a request are not the same clock, and because
/// nothing reaches a listing within a minute of being published.
static func isStampedNow(_ date: Date, tolerance: TimeInterval = 60) -> Bool {
Date().timeIntervalSince(date) < tolerance
}
}

View File

@@ -23,8 +23,25 @@ struct HistoryListView: View {
/// Whether `video` is real metadata rather than the standing-in
/// placeholder a bare server ID starts as.
var isHydrated: Bool
/// Whether the metadata fetch was made and could not describe the
/// video, as opposed to never having been answered yet.
var metadataFailed = false
var id: String { videoID }
/// The video as the row shows it.
///
/// A row whose fetch failed says so instead of standing as its own ID
/// for the rest of the session, where nothing told a row still loading
/// apart from a dead one. Only the display changes: `video` — what
/// plays, and what the queue is handed — is untouched.
var displayVideo: Video {
guard metadataFailed, !isHydrated else { return video }
return .metadataUnavailable(
for: videoID,
title: String(localized: "home.history.metadataUnavailable")
)
}
}
/// The rows on screen, and the same rows' videos beside them.
@@ -534,7 +551,7 @@ struct HistoryListView: View {
@ViewBuilder
private func entryRowView(row: HistoryRow, index: Int) -> some View {
VideoRowView(
video: row.video,
video: row.displayVideo,
style: rowStyle,
watchProgress: row.entry.flatMap { watchProgress(for: $0) },
customMetadata: remainingMetadata(for: row)
@@ -542,14 +559,7 @@ struct HistoryListView: View {
.tappableVideo(
row.video,
startTime: row.entry?.watchedSeconds,
customActions: [
VideoContextAction(
String(localized: "home.history.remove"),
systemImage: "trash",
role: .destructive,
action: { remove(row) }
)
],
customActions: rowActions(for: row),
context: .history,
queueSource: historyQueueSource,
sourceLabel: String(localized: "queue.source.history"),
@@ -565,6 +575,36 @@ struct HistoryListView: View {
}
}
/// What a row offers in its menu: removal always, and a second go at the
/// metadata for a row that could not be described.
///
/// The retry is the only way back for such a row — a fetch is asked for once
/// per load, and the instance that failed to answer may well answer now.
private func rowActions(for row: HistoryRow) -> [VideoContextAction] {
var actions: [VideoContextAction] = []
if row.metadataFailed {
actions.append(
VideoContextAction(
String(localized: "home.history.metadataRetry"),
systemImage: "arrow.clockwise",
action: { retryHydration(row) }
)
)
}
actions.append(
VideoContextAction(
String(localized: "home.history.remove"),
systemImage: "trash",
role: .destructive,
action: { remove(row) }
)
)
return actions
}
/// How much of the video is left, when a local entry knows.
private func remainingMetadata(for row: HistoryRow) -> String? {
guard let entry = row.entry, !entry.isFinished, !entry.isLive else { return nil }
@@ -579,21 +619,14 @@ struct HistoryListView: View {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredRows.enumerated()), id: \.element.id) { index, row in
VideoCardView(
video: row.video,
video: row.displayVideo,
watchProgress: row.entry.flatMap { watchProgress(for: $0) },
isCompact: gridConfig.isCompactCards
)
.tappableVideo(
row.video,
startTime: row.entry?.watchedSeconds,
customActions: [
VideoContextAction(
String(localized: "home.history.remove"),
systemImage: "trash",
role: .destructive,
action: { remove(row) }
)
],
customActions: rowActions(for: row),
context: .history,
queueSource: historyQueueSource,
sourceLabel: String(localized: "queue.source.history"),
@@ -832,6 +865,23 @@ struct HistoryListView: View {
}
}
/// Asks again for a row the instance could not describe.
///
/// A fetch is only ever made once per row per load, so a row that failed has
/// nothing left that would ask for it. The failure is lifted first: the row
/// goes back to reading as one still being fetched, which is what it is.
private func retryHydration(_ row: HistoryRow) {
guard case let .server(account) = displayMode else { return }
hydrationAttempted.remove(row.videoID)
if let index = rows.firstIndex(where: { $0.videoID == row.videoID }) {
rows[index].metadataFailed = false
}
hydrate(row, account: account)
}
/// Asks for one row's metadata, as the row appears.
///
/// The request itself waits its turn: the row joins the queue, and the
@@ -874,10 +924,18 @@ struct HistoryListView: View {
if isShowing(accountID: accountID) {
apply(video, to: videoID)
}
} catch is CancellationError {
// A reload took the fetch over; the row is its to ask for.
} catch let error as APIError where error == .cancelled {
// The same, one layer down.
} catch {
// The row keeps its placeholder. A video the account watched
// is part of its history whether or not this instance can
// describe it right now.
// The row stays — a video the account watched is part of its
// history whether or not this instance can describe it — but
// it says it could not be described rather than standing as
// its own ID with nothing to tell it from one still loading.
if isShowing(accountID: accountID) {
markMetadataUnavailable(videoID)
}
}
// A finished fetch frees its slot for the next row — but only
@@ -916,12 +974,23 @@ struct HistoryListView: View {
rows[index].video = video
rows[index].isHydrated = true
rows[index].metadataFailed = false
if rowVideos.indices.contains(index) {
rowVideos[index] = video
}
}
/// Marks a row the instance could not describe.
///
/// It stays pending, so a reload owes it another fetch on its own; what
/// changes is that until one succeeds the row says it has no metadata
/// rather than showing the video ID as if it were a title.
private func markMetadataUnavailable(_ videoID: String) {
guard let index = rows.firstIndex(where: { $0.videoID == videoID }) else { return }
rows[index].metadataFailed = true
}
// MARK: - Live updates
/// Moves a video that was just recorded on the server to the front of the

View File

@@ -525,8 +525,9 @@ struct PlaylistsListView: View {
.swipeActions(actionsArray: swipeActions(for: item))
#endif
// Swipe is iOS-only, so a menu is the only way to reach a server-side
// playlist's actions on macOS and tvOS. Local rows keep exactly the
#if os(tvOS)
// There is no swipe here, so a menu is the only way to reach a
// server-side playlist's actions. Local rows keep exactly the
// affordances they had — and an account that may not change its
// playlists gets no menu rather than an empty one.
if case .remote = item, writableAccount != nil {
@@ -536,6 +537,11 @@ struct PlaylistsListView: View {
} else {
row
}
#else
// Everywhere else the menu hangs off the row's button instead — see
// `playlistRow(item:)`.
row
#endif
}
// MARK: - Grid Layout
@@ -567,13 +573,31 @@ struct PlaylistsListView: View {
.zoomTransitionSource(id: item.transitionID)
.focused($focusedPlaylistID, equals: item.id)
#else
rowContent(for: item)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
appEnvironment?.navigationCoordinator.navigate(to: .playlist(source(for: item)))
// A button rather than a tap gesture, and the menu hangs off the button
// itself: that is what carries a row's actions to the accessibility
// layer, exactly as a video row does. A bare shape with a gesture on it
// is not a control, offers no `AXShowMenu` and cannot be reached from
// the keyboard — and on macOS the menu is the only route to Edit and
// Delete, swipe actions being iOS-only.
let button = Button {
appEnvironment?.navigationCoordinator.navigate(to: .playlist(source(for: item)))
} label: {
rowContent(for: item)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.zoomTransitionSource(id: item.transitionID)
// Local rows keep exactly the affordances they had, and an account that
// may not change its playlists gets no menu rather than an empty one.
if case .remote = item, writableAccount != nil {
button.contextMenu {
rowActions(for: item)
}
.zoomTransitionSource(id: item.transitionID)
} else {
button
}
#endif
}
@@ -606,12 +630,17 @@ struct PlaylistsListView: View {
card
}
#else
let card = cardContent(for: item)
.frame(maxHeight: .infinity, alignment: .top)
.onTapGesture {
appEnvironment?.navigationCoordinator.navigate(to: .playlist(source(for: item)))
}
.zoomTransitionSource(id: item.transitionID)
// A button for the same reason the list row is one: the card's menu is
// only reachable by assistive tech and by the keyboard from a control.
let card = Button {
appEnvironment?.navigationCoordinator.navigate(to: .playlist(source(for: item)))
} label: {
cardContent(for: item)
.frame(maxHeight: .infinity, alignment: .top)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.zoomTransitionSource(id: item.transitionID)
if hasRowActions(item) {
card.contextMenu {