From 5072fb0dce1f8a2ffafc2e45929674132b481c25 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 6 Sep 2026 22:21:15 +0200 Subject: [PATCH] Start a session where the watch actually begins --- Yattee/Services/Player/PlayerService.swift | 40 ++-- Yattee/Services/WatchHistorySyncService.swift | 65 ++++++- Yattee/Views/Home/HistoryListView.swift | 112 ++++++++++- Yattee/Views/Home/ServerHistoryPaging.swift | 34 ++++ .../Player/ExpandedPlayerSheet+Overlays.swift | 6 +- YatteeTests/ServerHistoryPagingTests.swift | 59 ++++++ YatteeTests/WatchHistorySyncTests.swift | 174 ++++++++++++++++++ 7 files changed, 463 insertions(+), 27 deletions(-) create mode 100644 Yattee/Views/Home/ServerHistoryPaging.swift create mode 100644 YatteeTests/ServerHistoryPagingTests.swift diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index f9f67a42..be8fc621 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -264,13 +264,14 @@ final class PlayerService { saveProgressAndSync() } - // Mirror this watch into the active account's server history. Only for - // a genuinely new video: an audio-mode toggle or a quality switch - // reloads the same one, and starting a session for those would post it - // to the server a second time. - if isNewVideo { - watchHistorySyncService?.beginSession(video: video) - } + // Mirror this watch into the active account's server history. + // Unconditional, and the service decides: `isNewVideo` is already false + // for most openings, because openVideo() puts the video into state + // before this task runs. The service compares against the session it is + // holding instead, which keeps a reload of the same video — an + // audio-mode toggle, a quality switch, a stream retry — from posting it + // a second time. + watchHistorySyncService?.beginSessionIfNeeded(video: video) // Reset flag for the new video videoEndedNaturally = false @@ -660,14 +661,30 @@ final class PlayerService { } else if state.playbackState == .ended { // Restart from beginning when video has ended Task { - await seek(to: 0) - resume() + await replayCurrentVideo() } } else { resume() } } + /// Plays the current video again from the beginning, as a new watch. + /// + /// Every way a *finished* video is started again comes through here: the + /// replay button, the end-of-queue overlay, repeat-one, play/pause on an + /// ended video. Each of those is a second watch of the same video, so the + /// mirroring session is started fresh and the video is posted to the + /// account's history again — which is what moves it back to the front. + /// Seeking to the start mid-video is not this, and does not pass here. + func replayCurrentVideo() async { + if let video = state.currentVideo { + watchHistorySyncService?.beginSession(video: video) + } + + await seek(to: 0) + resume() + } + /// Stops playback and clears the player. func stop() { sleepPreventionService.allowSleep() @@ -807,9 +824,8 @@ final class PlayerService { // Handle queue mode behavior switch state.queueMode { case .repeatOne: - // Restart current video - await seek(to: 0) - resume() + // Restart current video — a second watch of it, not a continuation + await replayCurrentVideo() return case .repeatAll: diff --git a/Yattee/Services/WatchHistorySyncService.swift b/Yattee/Services/WatchHistorySyncService.swift index 293b7efb..4f9e3982 100644 --- a/Yattee/Services/WatchHistorySyncService.swift +++ b/Yattee/Services/WatchHistorySyncService.swift @@ -56,11 +56,25 @@ final class WatchHistorySyncService { // MARK: - Session lifecycle - /// Starts a mirroring session for a video that is about to play. + /// Starts a mirroring session unless this video is already the one being + /// mirrored. /// - /// Called only for a genuinely new video. Reloads of the same one — the - /// audio-mode toggle, a quality switch, a stream retry — must not reach - /// here, or each of them would post again. + /// What playback calls on every load, because a load is not by itself a new + /// watch: an audio-mode toggle, a quality switch or a stream retry all come + /// through the same path with the same video, and starting a session for + /// each of them would post the video again every time. A session already + /// standing for this video is left exactly as it is — including its + /// `posted` flag, which is what makes the reload free. + func beginSessionIfNeeded(video: Video) { + if let currentSession, currentSession.videoID == video.id.videoID { return } + beginSession(video: video) + } + + /// Starts a mirroring session for a video, replacing whatever was running. + /// + /// For the moments that are a new watch of a video already on screen — + /// replaying one that finished, repeat-one coming round again — where the + /// point is precisely to post it a second time. func beginSession(video: Video) { currentSession = nil @@ -130,6 +144,7 @@ final class WatchHistorySyncService { do { try await accountService.markWatched(videoID: videoID, for: account) + announceUpdate(videoID: videoID, accountID: accountID) return } catch is CancellationError { // The account was discarded or the app is going away. Not a @@ -144,7 +159,13 @@ final class WatchHistorySyncService { ) return } catch { - guard attempt == 0 else { + // Only what a second attempt could plausibly answer differently: + // a timeout, a dropped connection, a rate limit, a server that + // fell over. A 400 for an ID the server will never accept is + // just as wrong the second time. + let isRetryable = (error as? APIError)?.isRetryable == true + + guard attempt == 0, isRetryable else { LoggingService.shared.debug( "Watch history mirror dropped for \(videoID): \(error)", category: .api @@ -155,6 +176,23 @@ final class WatchHistorySyncService { } } + /// Tells whoever is showing this account's server history that a video just + /// reached the front of it. + /// + /// The view has no other way to know: the local write it also does is not + /// news about the server, and the account's history is only ever read by + /// asking for it. + private func announceUpdate(videoID: String, accountID: UUID) { + NotificationCenter.default.post( + name: .serverWatchHistoryDidUpdate, + object: nil, + userInfo: [ + ServerWatchHistoryUpdate.accountIDKey: accountID, + ServerWatchHistoryUpdate.videoIDKey: videoID + ] + ) + } + // MARK: - Draining /// Waits for every post still in flight. @@ -189,3 +227,20 @@ final class WatchHistorySyncService { } } } + +// MARK: - Announcement + +extension Notification.Name { + /// A video was recorded in an account's server-side history. The userInfo + /// carries which account and which video, so a list showing another + /// account's history can ignore it. + static let serverWatchHistoryDidUpdate = Notification.Name("stream.yattee.serverWatchHistoryDidUpdate") +} + +/// The userInfo keys of ``Notification/Name/serverWatchHistoryDidUpdate``. +enum ServerWatchHistoryUpdate { + /// `UUID` — the account whose history gained the video. + static let accountIDKey = "accountID" + /// `String` — the video that was recorded. + static let videoIDKey = "videoID" +} diff --git a/Yattee/Views/Home/HistoryListView.swift b/Yattee/Views/Home/HistoryListView.swift index 3c7356cb..1d38d1f6 100644 --- a/Yattee/Views/Home/HistoryListView.swift +++ b/Yattee/Views/Home/HistoryListView.swift @@ -49,8 +49,13 @@ struct HistoryListView: View { /// looking at any more. @State private var loadGeneration = 0 @State private var loadTask: Task? - /// The page to ask for next; 1-based, like the server's own numbering. - @State private var nextPage = 1 + /// The lowest page still worth asking for; 1-based, like the server's own + /// numbering. + /// + /// Normally the page number comes from the rows themselves. This is the + /// floor under it, raised past any page that turned out to hold nothing new, + /// so a page that cannot move the list on is never asked for twice. + @State private var pageFloor = 1 @State private var hasMorePages = false @State private var isLoadingPage = false @State private var serverErrorMessage: String? @@ -62,6 +67,19 @@ struct HistoryListView: View { /// How many IDs one page of server history asks for. private static let serverPageSize = 60 + /// The page to ask the server for next. + /// + /// Read from the rows that are loaded rather than counted up from the last + /// request, so a deletion — which shifts every server offset after it — + /// cannot make the next request skip the item that moved back across the + /// page boundary. + private var nextServerPage: Int { + max( + ServerHistoryPaging.nextPage(loadedRowCount: rows.count, pageSize: Self.serverPageSize), + pageFloor + ) + } + // View options (persisted) @AppStorage("history.layout") private var layout: VideoListLayout = .list @AppStorage("history.rowStyle") private var rowStyle: VideoRowStyle = .regular @@ -347,6 +365,17 @@ struct HistoryListView: View { .onReceive(NotificationCenter.default.publisher(for: .instancesDidChange)) { _ in reloadIfEffectiveContextMoved() } + // Something was just watched and mirrored to the account whose history + // this is. The server has moved it to the front; so does the list, + // rather than waiting to be left and come back to. + .onReceive(NotificationCenter.default.publisher(for: .serverWatchHistoryDidUpdate)) { notification in + applyServerUpdate(notification) + } + // Searching reads titles, and a server row starts with only an ID. + .onChange(of: searchText) { _, newValue in + guard !newValue.isEmpty, case let .server(account) = displayMode else { return } + hydrateAllLoaded(account: account) + } } // MARK: - Content @@ -454,7 +483,7 @@ struct HistoryListView: View { private var loadMoreFooter: some View { if case let .server(account) = displayMode { LoadMoreTrigger(isLoading: isLoadingPage, hasMore: hasMorePages) { - beginPageLoad(nextPage, account: account) + beginPageLoad(nextServerPage, account: account) } } } @@ -586,7 +615,7 @@ struct HistoryListView: View { serverErrorMessage = nil isLoadingPage = false hydrationAttempted.removeAll() - nextPage = 1 + pageFloor = 1 guard case let .server(account) = displayMode else { hasMorePages = false @@ -631,11 +660,24 @@ struct HistoryListView: View { guard generation == loadGeneration else { return } - append(ids) - nextPage = page + 1 + let appended = append(ids) + + // A page that held nothing this list did not already have cannot be + // what moves it on, however many times it is asked for. + if appended == 0 { + pageFloor = 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 + + // A search is matching on titles, and rows that arrived without one + // would sit out of the results until somebody scrolled them into + // view — which, being filtered out, they never are. + if !searchText.isEmpty { + hydrateAllLoaded(account: account) + } } catch is CancellationError { // Another load is taking over — it owns the state now. } catch let error as APIError where error == .cancelled { @@ -652,7 +694,9 @@ struct HistoryListView: View { /// 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]) { + /// - Returns: how many of the IDs were rows this list did not already have. + @discardableResult + private func append(_ ids: [String]) -> Int { var known = Set(rows.map(\.videoID)) var appended: [HistoryRow] = [] @@ -661,10 +705,11 @@ struct HistoryListView: View { appended.append(makeRow(id: id)) } - guard !appended.isEmpty else { return } + guard !appended.isEmpty else { return 0 } rows.append(contentsOf: appended) rowVideos.append(contentsOf: appended.map(\.video)) + return appended.count } /// A row for one server-side ID, from local data where there is some. @@ -710,6 +755,19 @@ struct HistoryListView: View { hydrate(row, account: account) } + /// Fetches metadata for every loaded row that has none, whether or not it is + /// on screen. + /// + /// For searching, which reads titles: a row still standing as its own video + /// ID matches nothing anybody would type, and the usual trigger — the row + /// appearing — cannot fire for a row the search has filtered out. Bounded by + /// what is loaded, and each row is still only ever asked for once. + private func hydrateAllLoaded(account: Account) { + for row in rows where !row.isHydrated { + 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 @@ -750,6 +808,44 @@ struct HistoryListView: View { } } + // MARK: - Live updates + + /// Moves a video that was just recorded on the server to the front of the + /// list. + /// + /// Only for the account the rows on screen were loaded for — `loadedContext` + /// rather than the live one, which is the same guard the generation gives an + /// awaited result: a post that lands as the context moves belongs to the + /// history that is on its way out, not the one arriving. + private func applyServerUpdate(_ notification: Notification) { + guard case let .server(account) = displayMode, + let accountID = notification.userInfo?[ServerWatchHistoryUpdate.accountIDKey] as? UUID, + accountID == account.id, + loadedContext == .account(accountID), + let videoID = notification.userInfo?[ServerWatchHistoryUpdate.videoIDKey] as? String + else { return } + + bumpToFront(videoID) + } + + /// Puts a video at the top of the rows, wherever it was — or was not. + /// + /// What the server does with a repeated post: the video moves to the front + /// of the history rather than appearing twice in it. A video not among the + /// loaded rows is new to the front of the list, and arrives with whatever + /// the device already knows about it, which for something just watched here + /// is everything. + private func bumpToFront(_ videoID: String) { + if let index = rows.firstIndex(where: { $0.videoID == videoID }) { + guard index != 0 else { return } + rows.insert(rows.remove(at: index), at: 0) + } else { + rows.insert(makeRow(id: videoID), at: 0) + } + + rowVideos = rows.map(\.video) + } + // MARK: - Reading private func loadHistory() { diff --git a/Yattee/Views/Home/ServerHistoryPaging.swift b/Yattee/Views/Home/ServerHistoryPaging.swift new file mode 100644 index 00000000..fac6dbd8 --- /dev/null +++ b/Yattee/Views/Home/ServerHistoryPaging.swift @@ -0,0 +1,34 @@ +// +// ServerHistoryPaging.swift +// Yattee +// +// Which page of an account's server history to ask for next. +// + +import Foundation + +/// Works out the next page number of a server history from what is on screen. +/// +/// Its own type so the arithmetic can be reasoned about — and tested — apart +/// from the view that scrolls. +enum ServerHistoryPaging { + /// The page to request next, given how many rows are loaded. + /// + /// Derived from the rows rather than counted up from the last request, + /// because the server pages by offset and a deletion shifts every offset + /// after it. Counting up would ask for the page after the one just read and + /// step straight over the item that moved back across the boundary — gone + /// for the rest of the session, one more for every further deletion. Asking + /// for the page the loaded rows actually end in re-reads that boundary + /// instead, and the caller's de-duplication drops what is already there. + /// + /// With nothing deleted this is the same number counting up would give. + /// + /// - Parameters: + /// - loadedRowCount: how many rows of this account's history are held. + /// - pageSize: how many IDs one request asks for. + static func nextPage(loadedRowCount: Int, pageSize: Int) -> Int { + guard pageSize > 0 else { return 1 } + return max(0, loadedRowCount) / pageSize + 1 + } +} diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift index 55f4b9ec..b50e94ed 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift @@ -22,12 +22,14 @@ extension ExpandedPlayerSheet { } /// Restarts playback from the beginning. + /// + /// The video this offers has finished, so playing it again is a new watch — + /// which is the player's to know about, not the overlay's. func restartPlayback() { guard let playerService else { return } Task { - await playerService.seek(to: 0) - playerService.resume() + await playerService.replayCurrentVideo() } } diff --git a/YatteeTests/ServerHistoryPagingTests.swift b/YatteeTests/ServerHistoryPagingTests.swift new file mode 100644 index 00000000..5cda9eaa --- /dev/null +++ b/YatteeTests/ServerHistoryPagingTests.swift @@ -0,0 +1,59 @@ +// +// ServerHistoryPagingTests.swift +// YatteeTests +// +// Which page of a server history is asked for next, once rows have been +// deleted out from under the offsets. +// + +import Testing +import Foundation +@testable import Yattee + +@Suite("Server History Paging") +struct ServerHistoryPagingTests { + private static let pageSize = 60 + + private static func nextPage(_ loadedRowCount: Int) -> Int { + ServerHistoryPaging.nextPage(loadedRowCount: loadedRowCount, pageSize: pageSize) + } + + @Test("Nothing loaded asks for the first page") + func nothingLoadedAsksForTheFirstPage() { + #expect(Self.nextPage(0) == 1) + } + + @Test("A full page loaded asks for the second") + func aFullPageAsksForTheSecond() { + #expect(Self.nextPage(Self.pageSize) == 2) + } + + @Test("Two full pages loaded ask for the third") + func twoFullPagesAskForTheThird() { + #expect(Self.nextPage(Self.pageSize * 2) == 3) + } + + @Test("A row deleted out of the first page puts the boundary back in reach") + func aDeletionRereadsTheBoundary() { + // The server closed the gap: what was the first item of page 2 is now + // the last of page 1, and asking for page 2 would step over it. + #expect(Self.nextPage(Self.pageSize - 1) == 1) + } + + @Test("Deletions across two pages come back to the page they left short") + func deletionsAcrossPagesRereadThatPage() { + #expect(Self.nextPage(Self.pageSize * 2 - 1) == 2) + } + + @Test("A partly filled page still asks for the page it is in") + func aPartialPageAsksForItself() { + #expect(Self.nextPage(1) == 1) + #expect(Self.nextPage(Self.pageSize + 1) == 2) + } + + @Test("Nonsense counts still name a page that exists") + func degenerateInputsStayValid() { + #expect(ServerHistoryPaging.nextPage(loadedRowCount: -5, pageSize: Self.pageSize) == 1) + #expect(ServerHistoryPaging.nextPage(loadedRowCount: 10, pageSize: 0) == 1) + } +} diff --git a/YatteeTests/WatchHistorySyncTests.swift b/YatteeTests/WatchHistorySyncTests.swift index 1aa8aa6a..f1e1e604 100644 --- a/YatteeTests/WatchHistorySyncTests.swift +++ b/YatteeTests/WatchHistorySyncTests.swift @@ -102,6 +102,36 @@ private final class Flag { var isSet = false } +/// Collects what ``Notification/Name/serverWatchHistoryDidUpdate`` announced. +@MainActor +private final class UpdateAnnouncements { + private(set) var received: [(accountID: UUID, videoID: String)] = [] + private var observer: NSObjectProtocol? + + init() { + // No queue: delivered synchronously, on the main actor the post is made + // from, so a test can look at it the moment the flush returns. + observer = NotificationCenter.default.addObserver( + forName: .serverWatchHistoryDidUpdate, + object: nil, + queue: nil + ) { [weak self] notification in + guard let accountID = notification.userInfo?[ServerWatchHistoryUpdate.accountIDKey] as? UUID, + let videoID = notification.userInfo?[ServerWatchHistoryUpdate.videoIDKey] as? String + else { return } + + MainActor.assumeIsolated { + self?.received.append((accountID: accountID, videoID: videoID)) + } + } + } + + func stop() { + if let observer { NotificationCenter.default.removeObserver(observer) } + observer = nil + } +} + private func makeVideo(_ id: String) -> Video { Video( id: .global(id), @@ -209,6 +239,85 @@ struct WatchHistorySyncTests { #expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"]) } + @Test("A finished video played again is a new watch and posts again") + func aFreshSessionForTheSameVideoPostsAgain() async throws { + let harness = WatchHistorySyncHarness() + defer { harness.tearDown() } + + harness.service.beginSession(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + + // The replay button, or repeat-one coming round: the same video, still + // loaded, watched a second time. + harness.service.beginSession(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + await harness.service.flushPending() + + #expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"]) + } + + // MARK: - Reloads of the video already playing + + @Test("Reloading the video already playing does not post it again") + func reloadingTheSameVideoKeepsTheSession() async throws { + let harness = WatchHistorySyncHarness() + defer { harness.tearDown() } + + harness.service.beginSessionIfNeeded(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + + // An audio-mode toggle, a quality switch, a stream retry: the same + // video loaded again, and the same watch. + harness.service.beginSessionIfNeeded(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + await harness.service.flushPending() + + #expect(await harness.transport.markedVideoIDs == ["aaa"]) + } + + @Test("Moving to another video starts that video's session") + func anotherVideoReplacesTheSession() async throws { + let harness = WatchHistorySyncHarness() + defer { harness.tearDown() } + + harness.service.beginSessionIfNeeded(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + + harness.service.beginSessionIfNeeded(video: makeVideo("bbb")) + harness.service.recordProgress(videoID: "bbb") + await harness.service.flushPending() + + #expect(await harness.transport.markedVideoIDs == ["aaa", "bbb"]) + } + + @Test("The first video of a playback starts a session with nothing to compare to") + func theFirstVideoStartsASession() async throws { + let harness = WatchHistorySyncHarness() + defer { harness.tearDown() } + + harness.service.beginSessionIfNeeded(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + await harness.service.flushPending() + + #expect(await harness.transport.markedVideoIDs == ["aaa"]) + } + + @Test("A video opened again after the player stopped is watched again") + func theSameVideoAfterAnEndedSessionPostsAgain() async throws { + let harness = WatchHistorySyncHarness() + defer { harness.tearDown() } + + harness.service.beginSessionIfNeeded(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + harness.service.endSession() + + harness.service.beginSessionIfNeeded(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + await harness.service.flushPending() + + #expect(await harness.transport.markedVideoIDs == ["aaa", "aaa"]) + } + @Test("Progress for a video the session is not about is ignored") func progressForAnotherVideoIsIgnored() async throws { let harness = WatchHistorySyncHarness() @@ -305,6 +414,37 @@ struct WatchHistorySyncTests { #expect(await harness.transport.callCount == 2) } + @Test("A post the server refused on its merits is not tried again") + func aNonRetryableFailureIsNotRetried() async throws { + let harness = WatchHistorySyncHarness() + defer { harness.tearDown() } + + // An ID this server will not take is not going to take it the second + // time either. + await harness.transport.setError(APIError.httpError(statusCode: 400, message: "Invalid video id.")) + + harness.service.beginSession(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + await harness.service.flushPending() + try await harness.transport.settle() + + #expect(await harness.transport.callCount == 1) + } + + @Test("A post the transport itself refused is not tried again") + func aServiceFailureFromTheTransportIsNotRetried() async throws { + let harness = WatchHistorySyncHarness() + defer { harness.tearDown() } + await harness.transport.setError(AccountServiceError.notSupported) + + harness.service.beginSession(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + await harness.service.flushPending() + try await harness.transport.settle() + + #expect(await harness.transport.callCount == 1) + } + @Test("A post that could not be resolved at all is not retried") func aResolutionFailureIsNotRetried() async throws { let harness = WatchHistorySyncHarness() @@ -337,6 +477,40 @@ struct WatchHistorySyncTests { #expect(await harness.transport.callCount == 0) } + // MARK: - Announcing + + @Test("A post that landed says so, naming the account and the video") + func aSuccessfulPostIsAnnounced() async throws { + let harness = WatchHistorySyncHarness() + defer { harness.tearDown() } + let announcements = UpdateAnnouncements() + defer { announcements.stop() } + + harness.service.beginSession(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + await harness.service.flushPending() + + #expect(announcements.received.count == 1) + #expect(announcements.received.first?.accountID == harness.account.id) + #expect(announcements.received.first?.videoID == "aaa") + } + + @Test("A post that never landed announces nothing") + func aFailedPostIsNotAnnounced() async throws { + let harness = WatchHistorySyncHarness() + defer { harness.tearDown() } + let announcements = UpdateAnnouncements() + defer { announcements.stop() } + await harness.transport.setError(APIError.timeout) + + harness.service.beginSession(video: makeVideo("aaa")) + harness.service.recordProgress(videoID: "aaa") + await harness.service.flushPending() + try await harness.transport.settle() + + #expect(announcements.received.isEmpty) + } + // MARK: - Draining and disowning @Test("A flush returns only once the post it is waiting for has landed")