mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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:
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) } ?? [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user