Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
-----
- Tag Ask AI widget tickets for dedicated inbox routing [#4227](https://github.com/Automattic/pocket-casts-ios/pull/4227)
- Fix incorrect section heading when opening an episode on the Apple Watch [#4528](https://github.com/Automattic/pocket-casts-ios/pull/4528)
- Sync Apple Watch and phone playback progress instantly when you pause [#4535](https://github.com/Automattic/pocket-casts-ios/pull/4535)

8.14
-----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -517,6 +521,8 @@ public enum FeatureFlag: String, CaseIterable {
true
case .unlimitedWatchUpNextSync:
true
case .watchPlaybackProgressLocalSync:
true
case .cleanUpTmpFiles:
true
case .displayErrorsOnPlayer:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
16 changes: 16 additions & 0 deletions Pocket Casts Watch App/UI/WatchSyncManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions podcasts/Watch Communication/WatchManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
Copilot marked this conversation as resolved.
Outdated
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 }

Expand Down Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions podcasts/WatchConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
}
Expand Down