From c0d38834a6c1409af864968aff678271b0af7e09 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 11 Sep 2026 20:28:35 +0200 Subject: [PATCH] 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. --- Yattee/Data/Bookmark.swift | 4 + Yattee/Models/Video.swift | 28 +++++ Yattee/Services/API/InvidiousAPI.swift | 44 +++++++- Yattee/Services/API/PipedAPI.swift | 8 +- Yattee/Services/Networking/HTTPClient.swift | 10 ++ Yattee/Services/RemotePlaylistsCache.swift | 28 +++++ Yattee/Utilities/RelativeDateFormatter.swift | 19 ++++ Yattee/Views/Home/HistoryListView.swift | 111 +++++++++++++++---- Yattee/Views/Home/PlaylistsListView.swift | 57 +++++++--- 9 files changed, 268 insertions(+), 41 deletions(-) diff --git a/Yattee/Data/Bookmark.swift b/Yattee/Data/Bookmark.swift index 9c8c65a0..ee6d78ba 100644 --- a/Yattee/Data/Bookmark.swift +++ b/Yattee/Data/Bookmark.swift @@ -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()) diff --git a/Yattee/Models/Video.swift b/Yattee/Models/Video.swift index 0ef560c5..841e79b0 100644 --- a/Yattee/Models/Video.swift +++ b/Yattee/Models/Video.swift @@ -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()) diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index fa26360a..74ad743a 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -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": , "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) } ?? [], diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index f5528806..f97d4843 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -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 } } diff --git a/Yattee/Services/Networking/HTTPClient.swift b/Yattee/Services/Networking/HTTPClient.swift index af4e6ec9..5f4a109e 100644 --- a/Yattee/Services/Networking/HTTPClient.swift +++ b/Yattee/Services/Networking/HTTPClient.swift @@ -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 { diff --git a/Yattee/Services/RemotePlaylistsCache.swift b/Yattee/Services/RemotePlaylistsCache.swift index d485061d..dbbdca1a 100644 --- a/Yattee/Services/RemotePlaylistsCache.swift +++ b/Yattee/Services/RemotePlaylistsCache.swift @@ -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, diff --git a/Yattee/Utilities/RelativeDateFormatter.swift b/Yattee/Utilities/RelativeDateFormatter.swift index 776e8ecf..48e9fae4 100644 --- a/Yattee/Utilities/RelativeDateFormatter.swift +++ b/Yattee/Utilities/RelativeDateFormatter.swift @@ -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 + } } diff --git a/Yattee/Views/Home/HistoryListView.swift b/Yattee/Views/Home/HistoryListView.swift index 850360b3..68b3bb45 100644 --- a/Yattee/Views/Home/HistoryListView.swift +++ b/Yattee/Views/Home/HistoryListView.swift @@ -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 diff --git a/Yattee/Views/Home/PlaylistsListView.swift b/Yattee/Views/Home/PlaylistsListView.swift index 630b2ac0..aea23b91 100644 --- a/Yattee/Views/Home/PlaylistsListView.swift +++ b/Yattee/Views/Home/PlaylistsListView.swift @@ -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 {