Show a history from the account that recorded it

This commit is contained in:
Arkadiusz Fal
2026-09-06 16:23:28 +02:00
parent 7bcc711c40
commit 0e289ae920
5 changed files with 779 additions and 133 deletions

View File

@@ -3896,6 +3896,28 @@
}
}
},
"history.remote.delete.error" : {
"comment" : "Removing a video from the account's server-side history failed",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Could not remove from history"
}
}
}
},
"history.remote.load.error" : {
"comment" : "Loading the account's server-side history failed",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Could not load history"
}
}
}
},
"history.search.placeholder" : {
"localizations" : {
"en" : {
@@ -14787,6 +14809,17 @@
}
}
},
"settings.privacy.history.account.footer %@" : {
"comment" : "Footer telling the user watched videos are mirrored to the selected account's server history",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Watched videos are also recorded to the history of %@ on its server. Turning off Save Watch History, or using Incognito, stops both."
}
}
}
},
"settings.privacy.incognito.footer" : {
"localizations" : {
"en" : {

View File

@@ -0,0 +1,62 @@
//
// HistoryDisplayMode.swift
// Yattee
//
// Whose watch history the history screen is showing.
//
import Foundation
/// Which history the screen reads — the device's own, or an account's.
///
/// A value rather than a scattering of checks, so the list, the row actions and
/// the clear menu can never disagree about which store they are looking at: the
/// rows come from one of them, and a deletion goes to the same one.
enum HistoryDisplayMode: Equatable {
/// The device's own ``WatchEntry`` store.
case local
/// The server-side history of this account.
case server(Account)
/// The account whose history is on screen, or `nil` when it is the device's.
var account: Account? {
switch self {
case .local: nil
case let .server(account): account
}
}
var isLocal: Bool {
if case .local = self { return true }
return false
}
/// Which history a screen opened right now would show.
///
/// Follows the *effective* context, so a selected account that cannot be
/// used falls back to the local list — with the banner above it saying why —
/// rather than to an empty server list nobody can explain.
///
/// An account whose backend has no history to read is local too, and
/// deliberately so: Piped stores watch history on the device only, so the
/// screen there is the same screen it has always been rather than a feature
/// that appears to be missing.
///
/// - Parameters:
/// - account: the account `effectiveContext` names, when it resolves.
/// - capabilities: what that account's backend can do.
static func resolve(
effectiveContext: DataContext,
account: Account?,
capabilities: AccountCapabilities?
) -> HistoryDisplayMode {
switch effectiveContext {
case .local:
return .local
case .account:
guard let account, capabilities?.supportsWatchHistory == true else { return .local }
return .server(account)
}
}
}

View File

@@ -10,11 +10,58 @@ import SwiftUI
struct HistoryListView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Namespace private var sheetTransition
@State private var history: [WatchEntry] = []
@State private var videos: [Video] = [] // Cached Video conversions to avoid repeated toVideo() calls
/// One row, from whichever history the screen is reading.
///
/// The local entry travels with it rather than being looked up again: it is
/// what supplies the progress bar and the resume point, and a server row
/// gets one whenever the same video was also watched on this device.
private struct HistoryRow: Identifiable {
let videoID: String
var video: Video
var entry: WatchEntry?
/// Whether `video` is real metadata rather than the standing-in
/// placeholder a bare server ID starts as.
var isHydrated: Bool
var id: String { videoID }
}
/// The rows on screen, and the same rows' videos beside them.
///
/// The videos are kept as their own array because every row hands the whole
/// list to the queue: deriving it per row would walk the history once per
/// visible row instead of once per load.
@State private var rows: [HistoryRow] = []
@State private var rowVideos: [Video] = []
@State private var showingClearConfirmation = false
@State private var selectedClearOption: ClearHistoryOption?
// MARK: - Server state
/// The effective context the rows on screen belong to, so a status change
/// that does not move it does not force a reload.
@State private var loadedContext: DataContext?
/// Which load the state belongs to. Cancelling the previous task is not
/// enough on its own: a request that already came back races the
/// cancellation, and its rows would be appended to a history nobody is
/// looking at any more.
@State private var loadGeneration = 0
@State private var loadTask: Task<Void, Never>?
/// The page to ask for next; 1-based, like the server's own numbering.
@State private var nextPage = 1
@State private var hasMorePages = false
@State private var isLoadingPage = false
@State private var serverErrorMessage: String?
/// Videos a metadata fetch has already been started for, so a row that
/// scrolls in and out again does not ask twice — including the ones whose
/// fetch failed, which would otherwise retry on every pass.
@State private var hydrationAttempted: Set<String> = []
/// How many IDs one page of server history asks for.
private static let serverPageSize = 60
// View options (persisted)
@AppStorage("history.layout") private var layout: VideoListLayout = .list
@AppStorage("history.rowStyle") private var rowStyle: VideoRowStyle = .regular
@@ -37,24 +84,66 @@ struct HistoryListView: View {
private var dataManager: DataManager? { appEnvironment?.dataManager }
/// History entries filtered by search text.
private var filteredHistory: [WatchEntry] {
guard !searchText.isEmpty else { return history }
// MARK: - Context
/// The account whose history is on screen, or `nil` when it is the device's.
///
/// The *effective* context, so a selected account that cannot be used right
/// now shows the local history — with the banner saying why — instead of an
/// empty list nobody can explain.
private var contextAccount: Account? {
guard let appEnvironment,
case let .account(id) = appEnvironment.dataContextValidator.effectiveContext
else { return nil }
return appEnvironment.accountsManager.account(id: id)
}
/// Which history the rows, the row actions and the clear menu all belong to.
///
/// Read live rather than stored: it follows the effective context, and the
/// same value gates every affordance so none of them can be offering an
/// action against a store the list is not showing.
private var displayMode: HistoryDisplayMode {
guard let appEnvironment else { return .local }
let account = contextAccount
return HistoryDisplayMode.resolve(
effectiveContext: appEnvironment.dataContextValidator.effectiveContext,
account: account,
capabilities: account.flatMap { appEnvironment.accountsManager.capabilities(for: $0) }
)
}
private var contextUnavailability: DataContextUnavailability? {
guard appEnvironment?.accountsManager.currentContext.isLocal == false else { return nil }
return appEnvironment?.dataContextValidator.unavailability
}
// MARK: - Rows
/// History rows filtered by search text.
private var filteredRows: [HistoryRow] {
guard !searchText.isEmpty else { return rows }
let query = searchText.lowercased()
return history.filter { entry in
entry.title.lowercased().contains(query) ||
entry.authorName.lowercased().contains(query) ||
entry.videoID.lowercased().contains(query)
return rows.filter { row in
row.video.title.lowercased().contains(query) ||
row.video.author.name.lowercased().contains(query) ||
row.videoID.lowercased().contains(query)
}
}
/// Pre-computed Video objects for filtered history entries.
/// The filtered rows' videos, in the same order — what a row hands the queue.
private var filteredVideos: [Video] {
guard !searchText.isEmpty else { return videos }
let filteredIDs = Set(filteredHistory.map { $0.videoID })
return zip(history, videos)
.filter { filteredIDs.contains($0.0.videoID) }
.map { $0.1 }
guard !searchText.isEmpty else { return rowVideos }
return filteredRows.map(\.video)
}
/// Nothing fetched yet and no reason given — the spinner is for the first
/// page only, a later page loads under the rows that are already there.
private var isLoadingFirstServerPage: Bool {
guard case .server = displayMode else { return false }
return rows.isEmpty && isLoadingPage
}
/// Gets the watch progress (0.0-1.0) for a watch entry, or nil if finished.
@@ -113,7 +202,9 @@ struct HistoryListView: View {
Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3")
}
if !history.isEmpty {
// The server has no clear-all, so this is the device's
// history only — the same menu it has always been.
if displayMode.isLocal, !rows.isEmpty {
Menu {
ForEach(ClearHistoryOption.allCases, id: \.self) { option in
Button(role: .destructive) {
@@ -127,6 +218,8 @@ struct HistoryListView: View {
Label(String(localized: "home.history.clear"), systemImage: "trash")
}
}
DataContextSwitcherButton()
}
.focusSection()
.padding(.horizontal, 48)
@@ -134,43 +227,26 @@ struct HistoryListView: View {
.padding(.bottom, 20)
// Content
Group {
if filteredHistory.isEmpty {
emptyView
} else {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
.focusSection()
content
.focusSection()
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#else
Group {
if filteredHistory.isEmpty {
// Empty state
emptyView
} else {
// Content based on layout
switch layout {
case .list:
listContent
case .grid:
gridContent
}
content
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
}
.onChange(of: geometry.size.width, initial: true) { _, newWidth in
viewWidth = newWidth
}
#endif
}
// Above the list rather than inside it: the rows are local data while
// this is showing, and nothing else on screen says so.
.safeAreaInset(edge: .top, spacing: 0) {
if let contextUnavailability {
DataContextUnavailableBanner(reason: contextUnavailability)
}
}
#if !os(tvOS)
.navigationTitle(String(localized: "home.history.title"))
.toolbarTitleDisplayMode(.inlineLarge)
@@ -183,6 +259,9 @@ struct HistoryListView: View {
ToolbarSpacer(.flexible, placement: .primaryAction)
}
#endif
ToolbarItem(placement: viewOptionsPlacement) {
DataContextSwitcherMenu()
}
// View options button (always visible)
ToolbarItem(placement: viewOptionsPlacement) {
Button {
@@ -198,8 +277,9 @@ struct HistoryListView: View {
#endif
}
// Clear history menu (only when not empty)
if !history.isEmpty {
// Clear history menu (local history only, and only when not empty):
// the server offers no way to clear a history in one call.
if displayMode.isLocal, !rows.isEmpty {
ToolbarItem(placement: .primaryAction) {
Menu {
ForEach(ClearHistoryOption.allCases, id: \.self) { option in
@@ -237,10 +317,58 @@ struct HistoryListView: View {
}
.presentationCompactAdaptation(.sheet)
.onAppear {
loadHistory()
startLoad()
}
.onReceive(NotificationCenter.default.publisher(for: .watchHistoryDidChange)) { _ in
loadHistory()
// The device's own history changed. The account's is the server's to
// change, and a local write is not news about it.
if displayMode.isLocal {
loadHistory()
}
}
// Selecting another account — or local — changes whose history this is,
// and the previous rows may not be shown for a moment longer.
.onReceive(NotificationCenter.default.publisher(for: .dataContextDidChange)) { _ in
startLoad()
}
// The same, without the context moving: the credential behind the
// account was replaced, so the history may now be someone else's.
.onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in
startLoad()
}
// A status, an account or an instance changing moves reads between the
// account and local without the stored context moving at all.
.onReceive(NotificationCenter.default.publisher(for: .accountStatusesDidChange)) { _ in
reloadIfEffectiveContextMoved()
}
.onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in
reloadIfEffectiveContextMoved()
}
.onReceive(NotificationCenter.default.publisher(for: .instancesDidChange)) { _ in
reloadIfEffectiveContextMoved()
}
}
// MARK: - Content
@ViewBuilder
private var content: some View {
if isLoadingFirstServerPage {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let serverErrorMessage, rows.isEmpty {
serverErrorView(serverErrorMessage)
} else if filteredRows.isEmpty {
// Empty state
emptyView
} else {
// Content based on layout
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
@@ -261,61 +389,94 @@ struct HistoryListView: View {
}
}
private func serverErrorView(_ message: String) -> some View {
ContentUnavailableView {
Label(String(localized: "history.remote.load.error"), systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button(String(localized: "common.retry")) {
startLoad()
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - List Layout
private var listContent: some View {
VideoListContainer(listStyle: listStyle, rowStyle: rowStyle) {
// Header spacer for top padding
Spacer()
.frame(height: 16)
} content: {
ForEach(Array(filteredHistory.enumerated()), id: \.element.videoID) { index, entry in
let video = filteredVideos[index]
VideoListRow(
isLast: index == filteredHistory.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
entryRowView(entry: entry, index: index)
VideoListContainer(
listStyle: listStyle,
rowStyle: rowStyle,
header: {
// Header spacer for top padding
Spacer()
.frame(height: 16)
},
content: {
ForEach(Array(filteredRows.enumerated()), id: \.element.id) { index, row in
VideoListRow(
isLast: index == filteredRows.count - 1,
rowStyle: rowStyle,
listStyle: listStyle
) {
entryRowView(row: row, index: index)
}
#if !os(tvOS)
.videoSwipeActions(
video: row.video,
fixedActions: [
SwipeAction(
symbolImage: "trash.fill",
tint: .white,
background: .red
) { reset in
remove(row)
reset()
}
]
)
#endif
}
#if !os(tvOS)
.videoSwipeActions(
video: video,
fixedActions: [
SwipeAction(
symbolImage: "trash.fill",
tint: .white,
background: .red
) { reset in
removeEntry(entry)
reset()
}
]
)
#endif
},
footer: {
loadMoreFooter
}
)
}
/// The trigger that asks for the next page, and the spinner while it is out.
///
/// Server mode only — the device's history is read in one go and has no
/// second page to ask for.
@ViewBuilder
private var loadMoreFooter: some View {
if case let .server(account) = displayMode {
LoadMoreTrigger(isLoading: isLoadingPage, hasMore: hasMorePages) {
beginPageLoad(nextPage, account: account)
}
}
}
/// Reusable row view for a history entry.
@ViewBuilder
private func entryRowView(entry: WatchEntry, index: Int) -> some View {
let video = filteredVideos[index]
private func entryRowView(row: HistoryRow, index: Int) -> some View {
VideoRowView(
video: video,
video: row.video,
style: rowStyle,
watchProgress: watchProgress(for: entry),
customMetadata: entry.isFinished || entry.isLive ? nil : String(localized: "home.history.remaining \(entry.remainingTime)")
watchProgress: row.entry.flatMap { watchProgress(for: $0) },
customMetadata: remainingMetadata(for: row)
)
.tappableVideo(
video,
startTime: entry.watchedSeconds,
row.video,
startTime: row.entry?.watchedSeconds,
customActions: [
VideoContextAction(
String(localized: "home.history.remove"),
systemImage: "trash",
role: .destructive,
action: { removeEntry(entry) }
action: { remove(row) }
)
],
context: .history,
@@ -325,39 +486,54 @@ struct HistoryListView: View {
videoIndex: index,
loadMoreVideos: loadMoreHistoryCallback
)
.onAppear {
hydrateIfNeeded(row)
}
}
/// 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 }
return String(localized: "home.history.remaining \(entry.remainingTime)")
}
// MARK: - Grid Layout
private var gridContent: some View {
ScrollView {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredHistory.enumerated()), id: \.element.videoID) { index, entry in
let video = filteredVideos[index]
VideoCardView(
video: video,
watchProgress: watchProgress(for: entry),
isCompact: gridConfig.isCompactCards
)
.tappableVideo(
video,
startTime: entry.watchedSeconds,
customActions: [
VideoContextAction(
String(localized: "home.history.remove"),
systemImage: "trash",
role: .destructive,
action: { removeEntry(entry) }
)
],
context: .history,
queueSource: historyQueueSource,
sourceLabel: String(localized: "queue.source.history"),
videoList: filteredVideos,
videoIndex: index,
loadMoreVideos: loadMoreHistoryCallback
)
VStack(spacing: 0) {
VideoGridContent(columns: gridConfig.effectiveColumns) {
ForEach(Array(filteredRows.enumerated()), id: \.element.id) { index, row in
VideoCardView(
video: row.video,
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) }
)
],
context: .history,
queueSource: historyQueueSource,
sourceLabel: String(localized: "queue.source.history"),
videoList: filteredVideos,
videoIndex: index,
loadMoreVideos: loadMoreHistoryCallback
)
.onAppear {
hydrateIfNeeded(row)
}
}
}
loadMoreFooter
}
}
#if os(tvOS)
@@ -377,14 +553,258 @@ struct HistoryListView: View {
return String(localized: "home.history.clear.confirm.timeRange \(option.localizedTitle)")
}
private func loadHistory() {
history = dataManager?.watchHistory(limit: 10000) ?? []
videos = history.map { $0.toVideo() } // Pre-compute all Video conversions once
// MARK: - Loading
private func startLoad() {
loadTask?.cancel()
loadTask = nil
guard let account = beginLoad() else { return }
beginPageLoad(1, account: account)
}
private func removeEntry(_ entry: WatchEntry) {
dataManager?.removeFromHistory(videoID: entry.videoID)
loadHistory()
private func reloadIfEffectiveContextMoved() {
guard appEnvironment?.dataContextValidator.effectiveContext != loadedContext else { return }
startLoad()
}
/// Records which history the rows belong to and reads the local one when
/// that is what is being shown, returning the account whose pages still have
/// to be fetched.
///
/// Local data is read synchronously and stays that way: an await before it
/// would put the empty state on screen for a frame every time this view
/// appears.
private func beginLoad() -> Account? {
guard let appEnvironment else { return nil }
// Recorded on every load, not only on a context change: the first load
// of a session has to establish what is on screen, or the first status
// change would look like a move and reload for nothing.
loadedContext = appEnvironment.dataContextValidator.effectiveContext
loadGeneration += 1
serverErrorMessage = nil
isLoadingPage = false
hydrationAttempted.removeAll()
nextPage = 1
guard case let .server(account) = displayMode else {
hasMorePages = false
loadHistory()
return nil
}
setRows([])
hasMorePages = true
return account
}
/// Starts one page fetch, marking the load as in flight before the task is
/// even scheduled.
///
/// The flag has to be set here rather than inside the task: the trigger at
/// the end of the list can appear twice before a task body first runs, and
/// both would ask for the same page.
private func beginPageLoad(_ page: Int, account: Account) {
guard !isLoadingPage else { return }
isLoadingPage = true
let generation = loadGeneration
loadTask = Task { await loadPage(page, account: account, generation: generation) }
}
private func loadPage(_ page: Int, account: Account, generation: Int) async {
guard let appEnvironment else { return }
defer {
if generation == loadGeneration {
isLoadingPage = false
}
}
do {
let ids = try await appEnvironment.accountService.watchHistory(
page: page,
maxResults: Self.serverPageSize,
for: account
)
guard generation == loadGeneration else { return }
append(ids)
nextPage = page + 1
// A page the server could not fill is the last one there is; so is
// an empty one, which is what asking past the end returns.
hasMorePages = ids.count >= Self.serverPageSize
} catch is CancellationError {
// Another load is taking over — it owns the state now.
} catch let error as APIError where error == .cancelled {
// The same, one layer down.
} catch {
guard generation == loadGeneration else { return }
hasMorePages = false
serverErrorMessage = error.localizedDescription
}
}
/// Appends a page of server IDs, in the order the server gave them.
///
/// Never re-sorted: the server's order *is* the history, newest first, and
/// the local entries some of these rows have were written at times that say
/// nothing about when the account watched them.
private func append(_ ids: [String]) {
var known = Set(rows.map(\.videoID))
var appended: [HistoryRow] = []
for id in ids where !known.contains(id) {
known.insert(id)
appended.append(makeRow(id: id))
}
guard !appended.isEmpty else { return }
rows.append(contentsOf: appended)
rowVideos.append(contentsOf: appended.map(\.video))
}
/// A row for one server-side ID, from local data where there is some.
///
/// A video watched on this device is already known — title, thumbnail,
/// progress and all — so the row is complete before anything is fetched.
/// Everything else starts as a placeholder and is filled in as it appears.
private func makeRow(id: String) -> HistoryRow {
if let entry = dataManager?.watchEntry(for: id) {
return HistoryRow(videoID: id, video: entry.toVideo(), entry: entry, isHydrated: true)
}
return HistoryRow(videoID: id, video: Self.placeholder(for: id), entry: nil, isHydrated: false)
}
/// The row a server ID stands as until its metadata arrives — or instead of
/// it, when the fetch fails.
///
/// The entry is never dropped for want of a title: the account watched this
/// video, and a history missing rows it cannot describe is a wrong history.
private static func placeholder(for videoID: String) -> Video {
Video(
id: .global(videoID),
title: videoID,
description: nil,
author: Author(id: "", name: ""),
duration: 0,
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
// MARK: - Hydration
private func hydrateIfNeeded(_ row: HistoryRow) {
guard case let .server(account) = displayMode else { return }
hydrate(row, account: account)
}
/// Fetches one row's metadata, as the row appears.
///
/// Through the account's own instance: every ID the server returns is a
/// YouTube ID, and the account's instance is the one that has it.
private func hydrate(_ row: HistoryRow, account: Account) {
guard !row.isHydrated,
!hydrationAttempted.contains(row.videoID),
let appEnvironment,
let instance = appEnvironment.accountsManager.instance(for: account)
else { return }
hydrationAttempted.insert(row.videoID)
let videoID = row.videoID
let generation = loadGeneration
Task {
do {
let video = try await appEnvironment.contentService.video(id: videoID, instance: instance)
guard generation == loadGeneration else { return }
apply(video, to: videoID)
} 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.
}
}
}
private func apply(_ video: Video, to videoID: String) {
guard let index = rows.firstIndex(where: { $0.videoID == videoID }) else { return }
rows[index].video = video
rows[index].isHydrated = true
if rowVideos.indices.contains(index) {
rowVideos[index] = video
}
}
// MARK: - Reading
private func loadHistory() {
let entries = dataManager?.watchHistory(limit: 10000) ?? []
setRows(entries.map { HistoryRow(videoID: $0.videoID, video: $0.toVideo(), entry: $0, isHydrated: true) })
}
private func setRows(_ newRows: [HistoryRow]) {
rows = newRows
rowVideos = newRows.map(\.video)
}
// MARK: - Removing
private func remove(_ row: HistoryRow) {
switch displayMode {
case .local:
dataManager?.removeFromHistory(videoID: row.videoID)
loadHistory()
case let .server(account):
removeFromServer(row.videoID, account: account)
}
}
/// Removes one video from the account's server history, and from the rows.
///
/// The server's history only — the local ``WatchEntry`` behind the row, if
/// there is one, is the device's own record and is cleared from the local
/// history view.
private func removeFromServer(_ videoID: String, account: Account) {
guard let appEnvironment else { return }
let generation = loadGeneration
Task {
do {
try await appEnvironment.accountService.removeFromWatchHistory(videoID: videoID, for: account)
// The rows may belong to another account by now; the removal
// still happened, but this list is not the one to take it out of.
guard generation == loadGeneration else { return }
removeRow(videoID)
} catch {
appEnvironment.toastManager.showError(
String(localized: "history.remote.delete.error"),
subtitle: error.localizedDescription
)
}
}
}
private func removeRow(_ videoID: String) {
rows.removeAll { $0.videoID == videoID }
rowVideos = rows.map(\.video)
}
private func clearHistory() {

View File

@@ -54,28 +54,64 @@ struct PrivacySettingsView: View {
@ViewBuilder
private var historySection: some View {
if let settingsManager = appEnvironment?.settingsManager {
SettingsFormSection("settings.behavior.historyRetention.header", footer: "settings.behavior.historyRetention.footer") {
Toggle(
String(localized: "settings.privacy.saveWatchHistory"),
isOn: Bindable(settingsManager).saveWatchHistory
)
PlatformMenuPicker(
String(localized: "settings.behavior.historyRetention"),
selection: Binding(
get: { settingsManager.historyRetentionDays },
set: { settingsManager.historyRetentionDays = $0 }
SettingsFormSection(
"settings.behavior.historyRetention.header",
content: {
Toggle(
String(localized: "settings.privacy.saveWatchHistory"),
isOn: Bindable(settingsManager).saveWatchHistory
)
) {
ForEach(historyRetentionOptions, id: \.self) { days in
Text(labelForHistoryRetentionDays(days))
.tag(days)
PlatformMenuPicker(
String(localized: "settings.behavior.historyRetention"),
selection: Binding(
get: { settingsManager.historyRetentionDays },
set: { settingsManager.historyRetentionDays = $0 }
)
) {
ForEach(historyRetentionOptions, id: \.self) { days in
Text(labelForHistoryRetentionDays(days))
.tag(days)
}
}
},
footer: {
VStack(alignment: .leading, spacing: 6) {
Text(String(localized: "settings.behavior.historyRetention.footer"))
// Only while an account that has a server-side history
// is the selected source: that is exactly when watching
// something writes to two places, and the two settings
// above are what stop both.
if let name = serverHistoryAccountName {
Text(String(localized: "settings.privacy.history.account.footer \(name)"))
}
}
}
}
)
}
}
/// The account watched videos are being mirrored to, if any.
///
/// Resolved the same way the history screen decides whose history it is
/// showing, so the note and the screen can never disagree about it.
private var serverHistoryAccountName: String? {
guard let appEnvironment else { return nil }
let context = appEnvironment.dataContextValidator.effectiveContext
let account = context.accountID.flatMap { appEnvironment.accountsManager.account(id: $0) }
let mode = HistoryDisplayMode.resolve(
effectiveContext: context,
account: account,
capabilities: account.flatMap { appEnvironment.accountsManager.capabilities(for: $0) }
)
guard let mirrored = mode.account else { return nil }
return appEnvironment.dataContextValidator.displayName(for: mirrored)
}
private func labelForHistoryRetentionDays(_ days: Int) -> String {
switch days {
case 0:

View File

@@ -0,0 +1,95 @@
//
// HistoryDisplayModeTests.swift
// YatteeTests
//
// Tests for whose watch history the history screen shows.
//
import Testing
import Foundation
@testable import Yattee
@Suite("History Display Mode")
struct HistoryDisplayModeTests {
// MARK: - Helpers
private static let account = Account(instanceID: UUID())
private static func capabilities(watchHistory: Bool) -> AccountCapabilities {
AccountCapabilities(
supportsSubscriptions: true,
supportsFeed: true,
supportsPlaylistReads: true,
supportsPlaylistWrites: true,
supportsWatchHistory: watchHistory,
supportsPlaylistPrivacy: false
)
}
private static func resolve(
effectiveContext: DataContext,
account: Account? = account,
capabilities: AccountCapabilities? = capabilities(watchHistory: true)
) -> HistoryDisplayMode {
HistoryDisplayMode.resolve(
effectiveContext: effectiveContext,
account: account,
capabilities: capabilities
)
}
// MARK: - Local
@Test("The device's own context shows the local history")
func localContextShowsLocalHistory() {
#expect(Self.resolve(effectiveContext: .local, account: nil, capabilities: nil) == .local)
}
/// The fallback case: the selected account is unusable, reads have moved to
/// local, and the history on screen is the device's — with the banner above
/// it saying why.
@Test("A context that fell back to local shows the local history")
func fallbackShowsLocalHistory() {
#expect(Self.resolve(effectiveContext: .local) == .local)
}
/// Piped keeps no server-side history, so its accounts get exactly the
/// screen they have always had rather than an empty server list.
@Test("An account whose backend has no history shows the local one")
func withoutWatchHistorySupportShowsLocalHistory() {
#expect(
Self.resolve(
effectiveContext: .account(Self.account.id),
capabilities: Self.capabilities(watchHistory: false)
) == .local
)
}
@Test("An account whose capabilities are unknown shows the local history")
func unknownCapabilitiesShowLocalHistory() {
#expect(Self.resolve(effectiveContext: .account(Self.account.id), capabilities: nil) == .local)
}
@Test("An account context that resolves to no account shows the local history")
func unresolvedAccountShowsLocalHistory() {
#expect(Self.resolve(effectiveContext: .account(Self.account.id), account: nil) == .local)
}
// MARK: - Server
@Test("An account with watch history shows that account's history")
func accountContextShowsServerHistory() {
#expect(Self.resolve(effectiveContext: .account(Self.account.id)) == .server(Self.account))
}
// MARK: - Identity
@Test("Each mode names the store it reads")
func modeNamesItsStore() {
#expect(HistoryDisplayMode.local.account == nil)
#expect(HistoryDisplayMode.local.isLocal)
#expect(HistoryDisplayMode.server(Self.account).account == Self.account)
#expect(HistoryDisplayMode.server(Self.account).isLocal == false)
}
}