With "Partially Watched Videos" set to Ask, opening a live stream showed
the resume sheet with a playback timestamp. A live position is relative
to the live edge, not a fixed timeline, so it is meaningless as a
playhead.
Live views are still recorded in history, but carry no resume position:
- WatchEntry gains a persisted isLive flag plus recordLiveWatch(), which
stores no position and clears state a drifting HLS duration may have
left behind. progress is always 0 and toVideo() propagates the flag so
history rows render the LIVE badge.
- from(video:) clamps duration, keeping Piped's -1 live sentinel out of
storage.
- DataManager routes live saves through recordLiveWatch() and clears the
flag on the non-live path (before updateProgress, so the auto-finish
check is not suppressed by the hard-0 live progress), letting an entry
heal once the stream becomes a VOD.
- PlayerService skips completion saves, always starts at the live edge,
and drops any startTime passed for a live video.
- The resume prompt, the "Continue at" button label, thumbnail progress
bars, "X remaining", Continue Watching and tvOS Top Shelf all exclude
live entries.
- CloudKit syncs isLive with the rest of the watch state: the conflict
resolver carries it with watchedSeconds/isFinished and the sync engine
applies it when merging into an existing entry. It is read leniently,
so the schema version stays at 2 and older clients keep parsing these
records.
Sideloading tools re-sign the app with a team that cannot register the
iCloud.stream.yattee.app container, and CKContainer(identifier:) fatally
traps when the entitlement is missing. Detect availability by parsing
the embedded provisioning profile, skip creating any CloudKit objects
when the entitlement is absent, and show an explanation in iCloud
settings instead of the sync toggle.
CloudKit record names carry a source scope suffix so the same channel
or video ID can exist under different sources, but the apply path
matched local entities by bare ID: incoming records could merge into an
entity from a different scope, and a remote deletion of one scope
deleted every local entity with that ID and cascaded scoped deletions
for all other scopes back to CloudKit.
- Match local entities on the full mapper-derived record name when
merging incoming records, materializing records for upload, and
applying remote deletions.
- Remote deletions now remove only the matching entity and no longer
queue CloudKit deletions (no echo, no cross-scope cascade).
- Insert dedupe for CloudKit-applied records is scope-aware, so a
same-ID record from a different source inserts instead of being
silently dropped. Remotely added bookmarks also update the
fast-lookup cache immediately.
- When conflict resolution during a fetch keeps newer local data, the
merge result is queued for upload; previously the server (and other
devices) kept the stale version unless the entity was edited again.
Strict timestamp comparisons prevent re-upload ping-pong between
devices.
Register pending changes with the engine state instead of keeping
app-managed record arrays, and materialize records from local data at
send time in nextRecordZoneChangeBatch. This fixes several data-loss
paths:
- Pending changes now persist inside the engine state serialization,
so saves queued during the debounce window (or while offline) survive
app termination. Legacy record-name keys are migrated once.
- Transient CloudKit errors (zoneBusy, serviceUnavailable, rate limits,
network failures, limitExceeded, batchRequestFailed) re-register the
change for engine-scheduled retry instead of silently dropping it.
- Conflicts are resolved against the exact record that was sent; the
resolved record (carrying the server change tag) is cached and takes
precedence at the next send, and any newer local edit evicts it.
- Queue methods are now synchronous, removing a task-reordering race
where a fast delete/re-add could end up deleting the re-added entity.
- Records are built fresh at send time, so queued changes no longer
upload stale field snapshots, and batch size limits are handled by
the framework.
Previously CKSyncEngine was created with nil state serialization on
every launch, replaying the entire zone change history (hundreds of
merge/delete log lines and re-downloads each start). Now the saved
state is loaded so launches only fetch changes since the last session.
To make incremental sync safe:
- Clear persisted state once when the record schema version increases,
so records previously skipped as unsupported get re-delivered after
an app update.
- Handle remote zone deletion in fetchedDatabaseChanges by recreating
the zone and re-uploading local data, instead of leaving sync dead.
Account change and manual Refresh Sync still clear the state and force
a full fetch as before.
CloudKitRecordMapper was a standalone actor, so 'await recordMapper.toCKRecord'
from the @MainActor sync engine hopped off the main actor and read live
SwiftData @Model properties (e.g. LocalPlaylistItem.authorName) from the wrong
executor. SwiftData models are bound to the main ModelContext and are not
thread-safe, so this raced the backing store and crashed with EXC_BAD_ACCESS.
Make the mapper @MainActor (it must touch main-isolated models regardless) and
drop the now-redundant awaits. Fixes the crash for all synced model types.
Fixes an EXC_BAD_ACCESS seen in TestFlight build 261.
Pending deletes were lost across app restarts because
recoverPersistedPendingChanges() never reconstructed CKRecord.ID
objects from persisted record names. Additionally, incoming iCloud
records for deleted playlists were blindly applied, and orphaned
playlist items in CloudKit would recreate placeholder playlists.
- Rebuild pendingDeletes array from UserDefaults on recovery
- Guard applyRemoteRecord against records pending local deletion
- Skip deferred items whose parent playlist is pending deletion
- Queue all playlist item deletions when deleting a playlist
- Clean up placeholder playlists for pending-delete playlists