From 679f87450f998f742fd350a015a2a660f7a85956 Mon Sep 17 00:00:00 2001 From: dericleeyy Date: Wed, 17 Jun 2026 00:16:49 +0800 Subject: [PATCH 1/4] feat(watch): sync playback progress between watch and phone on pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PCIOS-196: playback progress made via the Apple Watch (Watch play source) did not appear on the phone — and the reverse — without manually tapping "Refresh now" on both devices. On pause, each device now pushes its playback position directly to the other over WatchConnectivity and applies it to the local player/DB (last-write-wins on playedUpToModified, skipping the actively-playing device), so Now Playing/progress updates without a manual refresh. The existing server sync still propagates to other devices. Gated behind the new watchPlaybackProgressLocalSync feature flag (default on). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Feature Flags/FeatureFlag.swift | 6 +++ .../SessionManager+Send.swift | 13 +++++++ .../Data & Communication/SessionManager.swift | 24 ++++++++++++ .../WatchDataManager.swift | 4 ++ .../UI/WatchSyncManager.swift | 16 ++++++++ .../Watch Communication/WatchManager.swift | 37 +++++++++++++++++++ podcasts/WatchConstants.swift | 8 ++++ 7 files changed, 108 insertions(+) diff --git a/Modules/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift b/Modules/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift index 7c411e370d..a73078d7e0 100644 --- a/Modules/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift +++ b/Modules/Sources/PocketCastsUtils/Feature Flags/FeatureFlag.swift @@ -292,6 +292,10 @@ public enum FeatureFlag: String, CaseIterable { /// Remove the 50-episode limit when syncing Up Next to Apple Watch case unlimitedWatchUpNextSync + /// On pause, sync the playback position directly between the watch and phone over + /// WatchConnectivity (both directions) so progress appears on the other device without a manual refresh + case watchPlaybackProgressLocalSync + /// Ensure that tmp files are removed when no longer needed case cleanUpTmpFiles @@ -517,6 +521,8 @@ public enum FeatureFlag: String, CaseIterable { true case .unlimitedWatchUpNextSync: true + case .watchPlaybackProgressLocalSync: + true case .cleanUpTmpFiles: true case .displayErrorsOnPlayer: diff --git a/Pocket Casts Watch App/Data & Communication/SessionManager+Send.swift b/Pocket Casts Watch App/Data & Communication/SessionManager+Send.swift index 97dea29445..4c76321bbc 100644 --- a/Pocket Casts Watch App/Data & Communication/SessionManager+Send.swift +++ b/Pocket Casts Watch App/Data & Communication/SessionManager+Send.swift @@ -13,6 +13,19 @@ extension SessionManager { sendResponseless(messageType: WatchConstants.Messages.MinorSyncableUpdate.type) } + /// Pushes the latest playback position for an episode straight to the phone so it can update + /// without waiting on a server round-trip. Uses guaranteed delivery (sendMessage with a + /// transferUserInfo fallback) so the update still arrives if the phone is briefly unreachable. + func sendPlaybackProgress(episodeUuid: String, playedUpTo: TimeInterval, modifiedAt: Int64) { + let progressUpdate = [ + WatchConstants.Messages.messageType: WatchConstants.Messages.PlaybackProgressUpdate.type, + WatchConstants.Messages.PlaybackProgressUpdate.episodeUuid: episodeUuid, + WatchConstants.Messages.PlaybackProgressUpdate.playedUpTo: playedUpTo, + WatchConstants.Messages.PlaybackProgressUpdate.modifiedAt: modifiedAt + ] as [String: Any] + sendWithFallback(progressUpdate) + } + func play(episode: BaseEpisode, playlist: AutoplayHelper.Playlist?) { guard validateSessionActivated() else { return } if !WCSession.default.isReachable { return } diff --git a/Pocket Casts Watch App/Data & Communication/SessionManager.swift b/Pocket Casts Watch App/Data & Communication/SessionManager.swift index 4bd3c1f476..4040264a52 100644 --- a/Pocket Casts Watch App/Data & Communication/SessionManager.swift +++ b/Pocket Casts Watch App/Data & Communication/SessionManager.swift @@ -128,9 +128,33 @@ class SessionManager: NSObject, WCSessionDelegate { UserDefaults.standard.set(stateData, forKey: WatchConstants.UserDefaults.data) UserDefaults.standard.set(Date(), forKey: WatchConstants.UserDefaults.lastDataTime) updateFeatureFlags(stateData) + applyPhoneNowPlayingProgress() NotificationCenter.default.post(name: WatchConstants.Notifications.dataUpdated, object: nil) } + /// When the phone pauses an episode the watch also has loaded locally (Watch source), apply the + /// phone's position to the local player so the watch's Now Playing reflects it without a relaunch. + /// This is the reverse of the watch→phone fast-path. We only mirror the settled (paused) position + /// and never override an episode the watch is actively playing. `handleStateUpdate` has already + /// rejected stale updates, and we add a last-write-wins guard on `playedUpToModified`. + private func applyPhoneNowPlayingProgress() { + guard FeatureFlag.watchPlaybackProgressLocalSync.enabled, + !WatchDataManager.isPlaying(), + let phoneEpisode = WatchDataManager.playingEpisode(), + let localCurrent = PlaybackManager.shared.currentEpisode(), + localCurrent.uuid == phoneEpisode.uuid, + !PlaybackManager.shared.isActivelyPlaying(episodeUuid: localCurrent.uuid) else { return } + + let phonePlayedUpTo = WatchDataManager.currentTime() + guard WatchDataManager.nowPlayingPlayedUpToModified() > localCurrent.playedUpToModified, + Int64(localCurrent.playedUpTo) != Int64(phonePlayedUpTo) else { return } + + FileLog.shared.addMessage("SessionManager: applying phone playback progress \(phonePlayedUpTo) for \(localCurrent.uuid)") + DispatchQueue.main.async { + PlaybackManager.shared.seekToFromSync(time: phonePlayedUpTo, syncChanges: false, startPlaybackAfterSeek: false) + } + } + // MARK: - Offline watch messages func requestData() { diff --git a/Pocket Casts Watch App/Data & Communication/WatchDataManager.swift b/Pocket Casts Watch App/Data & Communication/WatchDataManager.swift index 1029983c3a..c834e898b3 100644 --- a/Pocket Casts Watch App/Data & Communication/WatchDataManager.swift +++ b/Pocket Casts Watch App/Data & Communication/WatchDataManager.swift @@ -76,6 +76,10 @@ class WatchDataManager { nowPlayingValue(key: WatchConstants.Keys.nowPlayingCurrentTime) as? TimeInterval ?? 0 } + class func nowPlayingPlayedUpToModified() -> Int64 { + (nowPlayingValue(key: WatchConstants.Keys.nowPlayingPlayedUpToModified) as? NSNumber)?.int64Value ?? 0 + } + class func duration() -> TimeInterval { nowPlayingValue(key: WatchConstants.Keys.nowPlayingDuration) as? TimeInterval ?? 0 } diff --git a/Pocket Casts Watch App/UI/WatchSyncManager.swift b/Pocket Casts Watch App/UI/WatchSyncManager.swift index d91b54cebb..84211f1d37 100644 --- a/Pocket Casts Watch App/UI/WatchSyncManager.swift +++ b/Pocket Casts Watch App/UI/WatchSyncManager.swift @@ -18,6 +18,7 @@ class WatchSyncManager { NotificationCenter.default.addObserver(self, selector: #selector(checkSubscriptionStatus), name: WatchConstants.Notifications.loginStatusUpdated, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(subscriptionStatusUpdated), name: Notification.Name(rawValue: ServerNotifications.subscriptionStatusChanged.rawValue), object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleContextUpdate), name: WatchConstants.Notifications.dataUpdated, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(playbackPaused), name: Constants.Notifications.playbackPaused, object: nil) } deinit { @@ -292,6 +293,21 @@ class WatchSyncManager { syncThenNotifyPhone(significantChange: false, syncRequired: true) } + /// When the watch pauses, push the new playback position straight to the phone so it updates + /// without a manual refresh. This is a cheap WatchConnectivity message — no extra server work: + /// `recordPlaybackPosition` already uploaded the position via `ApiServerHandler.saveUpTo`, and the + /// phone re-marks the episode dirty when it applies this, so other devices still converge normally. + @objc private func playbackPaused() { + guard FeatureFlag.watchPlaybackProgressLocalSync.enabled, + SyncManager.isUserLoggedIn(), + let episode = PlaybackManager.shared.currentEpisode() else { return } + + FileLog.shared.addMessage("WatchSync: pushing playback progress \(episode.playedUpTo) to phone for \(episode.uuid)") + SessionManager.shared.sendPlaybackProgress(episodeUuid: episode.uuid, + playedUpTo: episode.playedUpTo, + modifiedAt: episode.playedUpToModified) + } + @objc private func syncCompleted() { checkForUpNextAutoDownloads() sendPendingChangeMessage() diff --git a/podcasts/Watch Communication/WatchManager.swift b/podcasts/Watch Communication/WatchManager.swift index bf1ba156c5..7516832012 100644 --- a/podcasts/Watch Communication/WatchManager.swift +++ b/podcasts/Watch Communication/WatchManager.swift @@ -240,6 +240,8 @@ class WatchManager: NSObject, WCSessionDelegate { if DateUtil.hasEnoughTimePassed(since: ServerSettings.lastRefreshEndTime(), time: 30.minutes) { RefreshManager.shared.refreshPodcasts() } + } else if WatchConstants.Messages.PlaybackProgressUpdate.type == messageType { + handlePlaybackProgressUpdate(payload: payload) } else if WatchConstants.Messages.LoginDetailsRequest.type == messageType { // Watch is requesting login details but message was delivered without reply handler // This can happen with WatchConnectivity timing issues @@ -361,6 +363,40 @@ class WatchManager: NSObject, WCSessionDelegate { } } + /// Applies a playback position pushed directly from the watch (local fast-path) so the phone + /// reflects watch progress without waiting on a server round-trip. Mirrors the server sync's + /// last-write-wins rule via `playedUpToModified`, and re-marks the episode dirty so the phone + /// still uploads to the server for other devices. + private func handlePlaybackProgressUpdate(payload: [String: Any]) { + guard FeatureFlag.watchPlaybackProgressLocalSync.enabled, + let uuid = payload[WatchConstants.Messages.PlaybackProgressUpdate.episodeUuid] as? String, + let playedUpTo = (payload[WatchConstants.Messages.PlaybackProgressUpdate.playedUpTo] as? NSNumber)?.doubleValue, + let modifiedAt = (payload[WatchConstants.Messages.PlaybackProgressUpdate.modifiedAt] as? NSNumber)?.int64Value, + let episode = DataManager.sharedManager.findBaseEpisode(uuid: uuid) else { return } + + // Don't clobber a position the phone itself is actively producing. + if PlaybackManager.shared.isActivelyPlaying(episodeUuid: uuid) { return } + + // Last-write-wins: only apply if the watch's change is newer than what we already have. + guard modifiedAt > episode.playedUpToModified else { return } + + DataManager.sharedManager.saveEpisode(playedUpTo: playedUpTo, episode: episode, updateSyncFlag: SyncManager.isUserLoggedIn()) + DataManager.sharedManager.updateEpisodePlaybackInteractionDate(episode: episode) + FileLog.shared.addMessage("WatchManager: applied playback progress \(playedUpTo) from watch for \(uuid)") + + // If this episode is loaded in the phone's player (and paused), move the live position so the + // mini player / now playing updates immediately rather than only after a relaunch. This mirrors + // how the server sync applies a remote position (see SyncTask+ServerChanges). Otherwise just + // refresh any visible episode cell via the notification. + if PlaybackManager.shared.isNowPlayingEpisode(episodeUuid: uuid), !PlaybackManager.shared.playing() { + DispatchQueue.main.async { + PlaybackManager.shared.seekToFromSync(time: playedUpTo, syncChanges: false, startPlaybackAfterSeek: false) + } + } else { + NotificationCenter.postOnMainThread(notification: Constants.Notifications.playbackPositionSaved, object: uuid) + } + } + private func handleMarkPlayed(episodeUuid: String) { guard let episode = DataManager.sharedManager.findEpisode(uuid: episodeUuid) else { return } @@ -656,6 +692,7 @@ class WatchManager: NSObject, WCSessionDelegate { let duration = playbackManager.duration() let currentTime = playbackManager.currentTime() nowPlayingInfo[WatchConstants.Keys.nowPlayingCurrentTime] = currentTime + nowPlayingInfo[WatchConstants.Keys.nowPlayingPlayedUpToModified] = playingEpisode.playedUpToModified nowPlayingInfo[WatchConstants.Keys.nowPlayingDuration] = duration > 0 ? duration : 0 nowPlayingInfo[WatchConstants.Keys.nowPlayingUpNextCount] = playbackManager.queue.upNextCount() diff --git a/podcasts/WatchConstants.swift b/podcasts/WatchConstants.swift index 11bb07bde6..e93e1a2698 100644 --- a/podcasts/WatchConstants.swift +++ b/podcasts/WatchConstants.swift @@ -9,6 +9,7 @@ public enum WatchConstants { public static let nowPlayingEpisode = "episode" public static let nowPlayingStatus = "status" public static let nowPlayingCurrentTime = "upto" + public static let nowPlayingPlayedUpToModified = "uptoModified" public static let nowPlayingDuration = "duration" public static let nowPlayingColor = "color" public static let nowPlayingUpNextCount = "upcount" @@ -93,6 +94,13 @@ public enum WatchConstants { public static let type = "minSyncUpdate" } + enum PlaybackProgressUpdate { + public static let type = "playbackProgressUpdate" + public static let episodeUuid = "uuid" + public static let playedUpTo = "playedUpTo" + public static let modifiedAt = "modifiedAt" // playedUpToModified (UTC millis) + } + enum DataRequest { public static let type = "dataRequest" } From 01d355b4ff2509f384e2777c4a00cf079e1fabb5 Mon Sep 17 00:00:00 2001 From: dericleeyy Date: Wed, 17 Jun 2026 00:22:22 +0800 Subject: [PATCH 2/4] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2de85f06d9..b8ecd00d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ 8.15 ----- - Tag Ask AI widget tickets for dedicated inbox routing [#4227](https://github.com/Automattic/pocket-casts-ios/pull/4227) +- Sync Apple Watch and phone playback progress instantly when you pause [#4535](https://github.com/Automattic/pocket-casts-ios/pull/4535) 8.14 ----- From 6a99786800859c044135634c518623df73fed5a0 Mon Sep 17 00:00:00 2001 From: dericleeyy Date: Thu, 25 Jun 2026 00:13:07 +0800 Subject: [PATCH 3/4] Fix last-write-wins behavior Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- podcasts/Watch Communication/WatchManager.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/podcasts/Watch Communication/WatchManager.swift b/podcasts/Watch Communication/WatchManager.swift index 7516832012..a03374a09d 100644 --- a/podcasts/Watch Communication/WatchManager.swift +++ b/podcasts/Watch Communication/WatchManager.swift @@ -380,7 +380,9 @@ class WatchManager: NSObject, WCSessionDelegate { // Last-write-wins: only apply if the watch's change is newer than what we already have. guard modifiedAt > episode.playedUpToModified else { return } - DataManager.sharedManager.saveEpisode(playedUpTo: playedUpTo, episode: episode, updateSyncFlag: SyncManager.isUserLoggedIn()) + episode.playedUpTo = playedUpTo + episode.playedUpToModified = modifiedAt + DataManager.sharedManager.save(episode: episode) DataManager.sharedManager.updateEpisodePlaybackInteractionDate(episode: episode) FileLog.shared.addMessage("WatchManager: applied playback progress \(playedUpTo) from watch for \(uuid)") From ffe440cebc7c0f4bedda243f4ab443f26cdad051 Mon Sep 17 00:00:00 2001 From: dericleeyy Date: Thu, 2 Jul 2026 02:40:38 +0800 Subject: [PATCH 4/4] =?UTF-8?q?Log=20why=20phone=E2=86=92watch=20progress?= =?UTF-8?q?=20sync=20is=20skipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyPhoneNowPlayingProgress() previously bailed out of every precondition with a silent return, so when the phone's paused position failed to apply on the watch there was no way to tell which guard rejected it. Split the compound guards and log the specific reason (with the relevant values) on each reject path, while keeping the behaviour identical. The feature-off case stays silent as an intentional no-op. --- .../Data & Communication/SessionManager.swift | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/Pocket Casts Watch App/Data & Communication/SessionManager.swift b/Pocket Casts Watch App/Data & Communication/SessionManager.swift index 4040264a52..d768805ab4 100644 --- a/Pocket Casts Watch App/Data & Communication/SessionManager.swift +++ b/Pocket Casts Watch App/Data & Communication/SessionManager.swift @@ -138,16 +138,42 @@ class SessionManager: NSObject, WCSessionDelegate { /// and never override an episode the watch is actively playing. `handleStateUpdate` has already /// rejected stale updates, and we add a last-write-wins guard on `playedUpToModified`. private func applyPhoneNowPlayingProgress() { - guard FeatureFlag.watchPlaybackProgressLocalSync.enabled, - !WatchDataManager.isPlaying(), - let phoneEpisode = WatchDataManager.playingEpisode(), - let localCurrent = PlaybackManager.shared.currentEpisode(), - localCurrent.uuid == phoneEpisode.uuid, - !PlaybackManager.shared.isActivelyPlaying(episodeUuid: localCurrent.uuid) else { return } + // Feature-off is an intentional no-op, not a failure, so return without logging to avoid noise. + guard FeatureFlag.watchPlaybackProgressLocalSync.enabled else { return } + + // Each remaining condition logs why it bailed. Without this the apply fails silently and there + // is no way to tell from a device log which precondition rejected a phone→watch update. + guard !WatchDataManager.isPlaying() else { + FileLog.shared.addMessage("SessionManager: skipping phone progress - phone reports still playing") + return + } + guard let phoneEpisode = WatchDataManager.playingEpisode() else { + FileLog.shared.addMessage("SessionManager: skipping phone progress - no phone now-playing episode") + return + } + guard let localCurrent = PlaybackManager.shared.currentEpisode() else { + FileLog.shared.addMessage("SessionManager: skipping phone progress - no local current episode (phone episode \(phoneEpisode.uuid))") + return + } + guard localCurrent.uuid == phoneEpisode.uuid else { + FileLog.shared.addMessage("SessionManager: skipping phone progress - episode mismatch (local \(localCurrent.uuid), phone \(phoneEpisode.uuid))") + return + } + guard !PlaybackManager.shared.isActivelyPlaying(episodeUuid: localCurrent.uuid) else { + FileLog.shared.addMessage("SessionManager: skipping phone progress - watch is actively playing \(localCurrent.uuid)") + return + } let phonePlayedUpTo = WatchDataManager.currentTime() - guard WatchDataManager.nowPlayingPlayedUpToModified() > localCurrent.playedUpToModified, - Int64(localCurrent.playedUpTo) != Int64(phonePlayedUpTo) else { return } + let phoneModified = WatchDataManager.nowPlayingPlayedUpToModified() + guard phoneModified > localCurrent.playedUpToModified else { + FileLog.shared.addMessage("SessionManager: skipping phone progress - not newer (phone modified \(phoneModified) <= local \(localCurrent.playedUpToModified)) for \(localCurrent.uuid)") + return + } + guard Int64(localCurrent.playedUpTo) != Int64(phonePlayedUpTo) else { + FileLog.shared.addMessage("SessionManager: skipping phone progress - position unchanged (\(phonePlayedUpTo)) for \(localCurrent.uuid)") + return + } FileLog.shared.addMessage("SessionManager: applying phone playback progress \(phonePlayedUpTo) for \(localCurrent.uuid)") DispatchQueue.main.async {