Commit Graph

130 Commits

Author SHA1 Message Date
Arkadiusz Fal
0c003c71cc Sync accounts through CloudKit records
A login created after the revamp got a random account UUID that never
left the device, so its credential was keyed to an ID no other device
had and the login was unusable there. Migrated accounts were fine only
because their ID is deterministically the instance ID.

Accounts, the permanent legacy-migration markers, the permanent deletion
tombstones and the selected data context now ride the existing
CKSyncEngine as four record types, with the rules the category needs:

- Absence proves nothing. A fetch resumes from a change token and never
  enumerates the zone, so deletions are records of their own and they
  dominate the account wherever they are seen — including the engine's
  retry-as-insert path, which is how a queued offline edit would
  otherwise resurrect a deleted account.
- Fetched records are staged durably before each delegate event returns
  and applied as a set, tombstones first, gated on the zone's own result
  rather than didFetchChanges, which fires after failed fetches too.
- Nothing activates for an identity that has not bootstrapped: a device
  left on the previous release consumed these record types as unknown
  and advanced its token past them, so each identity fetches the zone
  once from a nil token, outside the engine's own token.
- A disabled category defers records rather than discarding them; every
  persisted artifact is namespaced by identity, and async work is
  generation-checked so a fetch begun under one Apple account can never
  land in another's state.
- Uploads are ordered: a marker never outruns the account it describes,
  and a context naming an account is never published before that account
  is server-confirmed.
2026-08-25 23:56:58 +02:00
Arkadiusz Fal
defa6cf084 Make the account picker and feed route see account logins
Round three of review.

The subscription picker is fed by SubscriptionAccountValidator, which still
decided availability from the instance-keyed credential managers. A login
created after the revamp writes only an account-scoped key, so it never
appeared in the picker at all - which meant the account resolver added last
round could not even be reached. Availability now comes from AccountsManager
and account status.

SubscriptionFeedCache dispatched on the legacy setting and picked its
instance from it, so an account waiting on its credential produced
subscriptions from local data while the feed reported notAuthenticated, and
deleting the selected account could leave the route pointing at its server.
Both the route and the credential now come from the effective context, with
the legacy lookup kept only as a fallback for state that predates accounts.

Foreground activation re-resolved the identity scope but only recomputed
statuses when the scope actually moved. An iCloud Keychain credential
arriving after launch does not move it, so the account stayed
credentialMissing and reads stayed on the local fallback until relaunch.
Statuses are now recomputed on every activation in both scene-phase
branches.

Providers are bound to an account but never told it anything: a revoked SID
left the account signedIn, so the validator kept treating it as valid and
neither the banner nor the read fallback engaged. Server calls now record
their outcome. Conservatively - a raw 401 marks the credential rejected only
when no reverse-proxy basic auth is configured, since otherwise the 401 may
have come from the proxy. The two-stage probe that disambiguates the rest is
Phase 2; until then an ambiguous failure leaves the status alone rather than
wrongly telling the user to sign in again.
2026-08-25 22:40:47 +02:00
Arkadiusz Fal
bef119f70e Resolve contexts and credentials through real accounts
Round two of review on the accounts layer.

The settings picker mapped its legacy instance-keyed selection straight
onto DataContext(legacy:), which turns an instance UUID into an account
UUID. That identity only holds for migrated accounts - a login created
after the revamp has a random ID - so selecting a freshly added account
named one that does not exist and left the provider nil. Selections now
resolve through AccountsManager to an actual account on that instance,
preferring a signed-in one. The picker still lists one entry per instance
and so cannot show a second login; the Phase 3 switcher replaces it.

BackgroundFeedRefresher was missed in the previous sweep because it
resolves credentials differently: it routed on the legacy setting and
looked up "first enabled Invidious instance" with an instance-keyed
credential. After any new-style login that silently found nothing and
skipped the refresh. It now routes on the selected context and takes the
credential from that account.

Subscription reads used the raw stored context, so an account waiting on
its instance or credential rendered empty instead of falling back to local
data as the tri-state design specifies. Reads go through the validator's
effective context; writes deliberately do not, so a subscribe fails
visibly rather than landing in local data the user did not choose.

Account statuses are cleared when the identity namespace reloads, but
nothing recomputed them once a deferred scope resolution succeeded - a
synced account kept showing "sign in required" until relaunch. The scope
change now triggers a refresh, deferred by one turn so it cannot depend on
observer registration order relative to the store's own reload.
2026-08-24 23:59:46 +02:00
Arkadiusz Fal
2fded2e1dd Fix account defects found in review
Five findings against the accounts layer, all real.

A fresh login created a random-UUID account and then flipped
usesAccountLogin, which posts instancesDidChange and re-ran legacy
reconciliation immediately. That guard only looked for an account whose ID
equalled the instance ID, so it synthesized a second, credential-less
account alongside the real one. Signing in now marks the instance migrated,
and reconciliation treats any account on an instance as evidence its login
is already represented.

save() set hasStoredContext, so the save inside reconcileLegacyAccounts
marked the still-default .local context as explicitly chosen and the
legacy-context migration that ran immediately after did nothing. Upgrading
users with a selected server account silently lost it. Only setContext,
applySyncedContext and the migration itself record a choice now.

An Apple Account change re-resolved the credential scope but never told
AccountsManager to reload its identity-namespaced storage - the only reload
was a one-time launch task - so the previous identity's accounts, context
and outbox stayed live under the new one, as did cached per-account
subscription providers. The scope now posts when its resolved value
changes; the accounts store reloads and the provider cache is dropped.

Fourteen call sites still read the pre-revamp instance-keyed Keychain item,
which a new login no longer writes: reopening the source screen showed the
account as signed out, and feed, browse, home and import operations treated
it as unauthenticated. They now resolve the credential through the account
layer, preferring the selected context's account and otherwise any
signed-in account on that instance.

Signing in while the identity scope was still unresolved created the
account in a temporary namespace that reloadForCurrentIdentity later
discards, while credential storage silently refused the write and the UI
reported success. Login is gated on a resolved scope and verifies the
credential actually stored before reporting success, rolling back a
newly-created account if it did not.
2026-08-24 23:57:44 +02:00
Arkadiusz Fal
9065234606 Key subscription providers by account rather than backend type
SubscriptionService held one provider per backend and each resolved its
instance from settingsManager.subscriptionAccount, falling back to "the
first enabled Invidious instance" when that was unset. With several logins
possible on one instance that model breaks twice: two accounts would share
a single in-memory subscription cache, and the fallback could send an
account's request to a server it holds no credential for.

Providers are now built per account and cached by account ID, and each
resolves its instance and credential from the account it was built for -
no fallback. The protocol's accountType becomes a DataContext, which is
what actually identifies a provider now.

Views that branched on "is the subscription account local" read the context
instead, and the settings picker writes both stores. Keeping the legacy
setting in step is deliberate for now: several views still read it, and
letting the two diverge would show one account in the UI while serving
another's data. The Phase 3 switcher removes the last readers.

Signing out discards the account's provider so its cached subscriptions
cannot be served after the account is gone.
2026-08-24 23:56:40 +02:00
Arkadiusz Fal
66f721e3ac Add Account model, AccountsManager and multi-login UI
Yattee 2 could hold at most one login per instance: a Keychain item keyed by
the instance UUID and a Bool on the instance. This adds the account layer
the rest of the revamp builds on.

DataContext (.local or .account) replaces SubscriptionAccount as the thing
that selects a data set. The legacy value maps onto it as an identity
mapping, because migrated accounts deliberately reuse their instance UUID.
Contexts carry an app-managed changedAt, and a migrated one is stamped at
the epoch so any explicit choice on any device outranks it whatever order
they arrive in.

AccountCapabilities makes what a backend supports a single switch in
InstanceType.accountCapabilities, verified against each API surface -
Invidious has history and playlist privacy, Piped has neither. Call sites
ask for capabilities instead of switching on instance type, which is the
seam the planned per-user Yattee Server accounts land on.

AccountsManager owns accounts and the selected context, stored per identity
scope so a switch of Apple Account cannot show or upload the previous one's
list. Legacy single-logins become accounts through a repeatable
reconciliation rather than a one-shot at launch, because instances arrive
over iCloud whenever they arrive; a permanent marker set is what makes
re-running safe, since usesAccountLogin stays true on the instance forever
and would otherwise recreate a deliberately deleted account. A dirty-flag
outbox records what still needs uploading so an offline change survives a
crash. The legacy subscriptionAccount selection is migrated one way,
reading local defaults first and then the raw KVS value - the previous
release published that key to iCloud but never read it back, so on a fresh
install it exists only there.

DataContextValidator replaces SubscriptionAccountValidator, which corrected
any selection it could not immediately verify. That was safe when a context
was just "an instance I am logged into", but with credentials arriving
asynchronously it would discard the user's choice during a slow Keychain
sync. Validity is now tri-state: anything that might still resolve keeps
the selection and falls back to local for reads only, with server mutations
disabled rather than silently retargeted at local data; only an actual
deletion corrects the stored context.

EditSourceView's single log in/out pair becomes a list of accounts with
per-row status, sign-in-again and sign-out-and-remove, plus the explicit
"use saved login from this device" action - the one-tap adoption that
replaced automatic adoption after the Keychain provenance measurement. On
tvOS a credential-less account offers a local sign-in instead, since Apple
never syncs app Keychain items there and the state will not resolve on its
own.

InstanceLoginView now returns the identifier alongside the credential so
the caller can file it in the Keychain beside the secret, keeping login
identifiers out of synced metadata.
2026-08-24 23:52:01 +02:00
Arkadiusz Fal
e7476bec9b Archive the identity token by its guaranteed coding contract
Seventh review pass. No correctness findings this round; both are
availability.

The retry budget was only reset inside the tokenless branch of an identity
change, so an account that exhausted its four attempts left the next one
with none - its queued resolve() did no lookup at all and credentials
stayed unavailable until another foreground transition. The budget belongs
to the identity being resolved, so it now resets on every observed change.

ubiquityIdentityToken was archived with requiringSecureCoding: true, but
Foundation guarantees only NSCoding for it. Measured: a class adopting only
NSCoding does throw under secure archiving, and the `try?` there would have
silently produced no fingerprint - losing the scope cache, forcing a
CloudKit lookup every launch and leaving credentials unavailable offline.
This Mac's token happens to support secure coding, so nothing was failing
yet; the archive is only ever hashed, never decoded, so secure coding buys
nothing regardless. Also measured: the non-secure archive is deterministic,
which is what the fingerprint comparison depends on.

Note the digest differs between the two archiving modes, so any scope
cached before this change resolves once more against CloudKit and re-caches.
2026-08-24 23:30:24 +02:00
Arkadiusz Fal
00a298b09f Order tokenless scope resolution with an identity generation
Sixth review pass, both findings in the tokenless path the fourth pass
introduced.

The staleness guard compared fingerprints, which is useless when there is
no ubiquity token: nil before the switch and nil after it, so a user-record
lookup started under account A was accepted as current after CKAccountChanged
had already announced B. The scope then read as resolved, the pending-resolve
replay was skipped because it only runs while unresolved, and B was left
pointed at A's credential namespace. Every async path now captures a
generation counter that every observed account change bumps, and refuses to
apply a result across it.

Foreground revalidation also kept serving the old tokenless scope across
both awaits, and indefinitely when the account status came back
undetermined - so a switch that happened while the app was suspended could
hand A's retained credentials to B. The scope is now dropped before the
confirmation rather than compared after it, which also let the bespoke
revalidation logic collapse into resolve(), where the signed-out,
signed-in and undetermined cases are already handled. A tokenless setup
briefly reports "can't tell yet" on each activation; the tri-state UI
covers that, and the alternative traded a real cross-account disclosure for
the convenience.
2026-08-24 19:59:25 +02:00
Arkadiusz Fal
2d23820e14 Stop blind rollbacks in the Keychain delete paths
Fifth review pass, which prompted measuring whether SecItemDelete can be
conditioned on a value. It cannot: a delete issued with a deliberately
wrong kSecValueData still returns 0 and removes the item, so there is no
compare-and-delete and every check-then-delete is a race by construction.
That measurement decides both fixes here.

Adoption no longer rolls back its own write when the verification read
comes back empty. The rollback could only ever be a blind delete of
whatever occupied the key by then - including a credential synced from
another device in the gap - and it would propagate. Nothing is deleted; no
marker is written, so the account stays signed out and an ordinary sign-in
recovers it.

Legacy deletion re-reads immediately before deleting and aborts if the
value moved, shrinking the window to a single call gap and catching a
replacement that landed while the fingerprint was being computed. A
residual race remains and is documented at the call site: closing it is not
possible with these APIs, and never deleting would leave a credential the
user explicitly removed sitting in their iCloud Keychain.

The debug provenance report repeated the same nil-token ambiguity the scope
logic was fixed for, reporting "no Apple account signed in" when iCloud
Drive is merely off. It now reports the token as unavailable and shows
CloudKit's account status separately, so a re-run cannot draw a false
conclusion from it.
2026-08-23 23:59:59 +02:00
Arkadiusz Fal
d2e9264119 Revalidate a tokenless credential scope on account change
Fourth review pass, on a hole the previous commit opened.

Making a nil ubiquity token mean "unresolved" fixed the shared-local-scope
collision, but left a scope resolved from CloudKit alone with nothing local
to watch: with iCloud Drive off the token stays nil across a switch from
account A to B, so NSUbiquityIdentityDidChange never fires and the scope
kept pointing at A - and its retained credentials with it - until relaunch.

Two additions. CKAccountChanged is now observed alongside the ubiquity
notification, and a tokenless scope is dropped outright when either fires
because there is no fingerprint to re-derive it from. Foreground activation
also revalidates a tokenless scope against CloudKit, covering a switch
whose notification never arrived; it compares before replacing so the
common case never blanks out credentials, and an unreachable account leaves
the scope standing rather than flapping on every transient failure.

A token-derived scope is untouched by all of this - it changes only via the
notification, and a test pins that down.
2026-08-23 23:59:56 +02:00
Arkadiusz Fal
313403cc1e Stop a missing ubiquity token from meaning "signed out"
Third review pass.

adoptCurrentIdentity() treated a nil ubiquityIdentityToken as sign-out and
scoped credentials to "local". But Foundation also returns nil when iCloud
Drive is unavailable or switched off, and Yattee declares only the CloudKit
service with no ubiquity container, so the token tracks iCloud Drive state
rather than the account. In that configuration two different signed-in
Apple accounts both resolved to "local" and shared one credential
namespace - since Keychain items survive an account switch, account B could
then read account A's retained credential. That is the precise failure this
whole change exists to prevent.

A nil token now means unresolved, and CloudKit's account status settles it
during resolution: noAccount scopes to local, available proceeds to the
user-record lookup, anything else stays unresolved and retries. A scope
resolved without a token is not cached, because there is no key that could
be trusted to mean this account.

Adoption markers are now namespaced by Keychain service. Both credential
managers share one marker store and an instance UUID can outlive a change
of backend, so adopting an Invidious credential could suppress the Piped
offer for the same instance and point deletion at the wrong secret.

Writing the account-status branch also surfaced a Swift trap: an
unqualified `break` inside the switch left only the switch and fell through
to the user-record lookup, resolving a scope on an undetermined account
status. The loop is labelled now, and the test that caught it is included.
2026-08-23 23:56:31 +02:00
Arkadiusz Fal
f5e88df403 Stop adoption rollback from revoking a synced credential
Two more issues from a second review pass.

Adoption verified its write by reading the key back and deleted it on any
mismatch. But a mismatch can also mean iCloud Keychain delivered a real
credential for that key between the add and the read - and since the delete
spans both synchronizable forms, it would propagate and revoke that
credential on every device. A readable value that is not ours is now left
strictly alone; only an unreadable key (the add reported success but
produced nothing usable) is rolled back so the offer can be retried.

An identity switch arriving while a resolution was in flight was dropped:
the observer's resolve() returned early on the isResolving guard, and the
in-flight one then bailed on its fingerprint check without starting a
lookup for the new identity, leaving credentials disabled until a
foreground transition or relaunch. Requests that arrive mid-resolution are
now replayed once the running one finishes.

Adds a fingerprint provider and an injectable backoff so identity
resolution is testable without a real Apple account or real seconds, plus
tests for retry, foreground recovery, mid-flight switching, signed-out
scoping and the cached-scope fast path.
2026-08-23 23:56:28 +02:00
Arkadiusz Fal
c4053d5100 Close identity and adoption holes in credential scoping
Four issues from an external review of the previous commit.

The scope was resolved once at startup, so signing out or switching Apple
account while the app kept running left it pinned to the old identity -
reads and writes would then reach the previous account's retained
credentials, which is the exact leak scoping exists to prevent.
CredentialIdentityScope now observes NSUbiquityIdentityDidChange and
re-resolves.

Legacy deletion authority checked only the instance and the identity, not
the account. On an instance with several accounts, deleting any of them
satisfied the marker lookup and could retire the legacy item another
account had adopted, logging out devices still on the previous release.
The marker's accountID is now required to match.

A first-time resolution that failed (launched offline, CloudKit
unreachable) was never retried, disabling credentials for the rest of the
process. resolve() now retries with backoff, and resolveIfNeeded() runs on
foreground activation.

Adoption re-checked nothing at execution time, so a scoped credential
delivered by iCloud Keychain between rendering the offer and tapping it
would be overwritten with the older legacy secret. Adoption now re-verifies
eligibility and writes with a non-replacing add, leaving a duplicate
strictly alone.

Adds regression tests for the sibling-account deletion and the adoption
race.
2026-08-23 23:39:06 +02:00
Arkadiusz Fal
9fd0a71035 Add identity-scoped, account-keyed credential storage
Yattee 2 keys credentials by instance UUID with no notion of which Apple
account they belong to, and reads them back with SynchronizableAny. Apple
documents that Keychain data may stay on device when a user signs out and
chooses to keep it, so whoever signs in next would be served the previous
user's credential - guaranteed for migrated accounts, since local instances
survive the switch and the migrated account UUID is deterministically the
instance UUID.

Credentials are now keyed "<identityScope>|<accountID>", where the scope is
the CloudKit user record (identical across a user's devices, so iCloud
Keychain sync still works). Usernames get the same scope and live beside the
credential, keeping login identifiers out of synced metadata.

The legacy unscoped item is never adopted automatically. A strict
kSecAttrSynchronizable == true query was the candidate discriminator for
"this secret belongs to the current Apple account", but measured on iPadOS
27.0 a retained item keeps the attribute and still matches that query with
no Apple account signed in at all - the flag records what was once requested,
not live iCloud Keychain membership. Reads therefore never fall back to the
legacy key; adoption is an explicit user action that copies the item,
verifies the scoped read-back, then records a marker bound to the identity
and a salted fingerprint of the secret. The legacy item is retained, because
deleting a synchronizable item propagates and would log out devices still on
the previous release mid-rollout. Deleting it later needs all three of: a
marker on this device, matching identity, and the item still holding the
adopted secret.

The instance-keyed path is untouched, so no shipping call site changes
behavior. BasicAuthCredentialsManager stays instance-keyed - proxy auth
belongs to the instance, not to a user account.

While the scope is unresolved (first launch under a new Apple account,
CloudKit unreachable) reads report "can't tell yet" via isScopeResolved
rather than "signed out", and writes are refused rather than guessing a
scope. Login state is cached per scope so one identity's cache can never
answer for another.

Also adds the debug-only harness the measurement was taken with, under
Advanced > Developer > Experiments.
2026-08-23 21:20:30 +02:00
Ray Cheung
3ebae6d216 Fix no streams reported from Piped (#974)
Piped currently is having an issue it would only return a muxed 360p
stream, and it is rejected here because the audioCodec being set to nil.
2026-08-23 12:22:10 +02:00
Phil-Bastian Berndt
d46002e99a Chunk stateless feed requests to support more than 500 subscriptions (#967) 2026-08-23 12:22:07 +02:00
Yuri Chukhlib
3d2544b22d Fix deep link timestamp parsing accepting infinity as seek position (#965)
URLRouter.parseTimestampValue used TimeInterval(_:) for the plain-numeric
branch ("90", "90.5"). That initializer also accepts the special tokens
"inf"/"infinity" and "nan", and the value infinity compares as >= 0, so a
deep link or share URL carrying ?t=inf parsed to Double.infinity and was
forwarded to the player as a seek target. Seeking to infinity is undefined
and breaks playback startup for the affected link.

Reject non-finite values explicitly alongside the existing negative
check, so only finite non-negative seconds are accepted.

- Add isFinite guard to the plain-numeric branch of parseTimestampValue
- Cover inf/infinity/nan/negative rejection in URLRouterTests
2026-08-23 12:22:00 +02:00
Yuri Chukhlib
11d370b3b8 Fix playback rate display dropping meaningful digits (#963)
PlaybackRate.displayText/compactDisplayText used String(format: "%.2gx"), whose significant-figure notation dropped meaningful digits (1.25 -> "1.2x") and rounded others (1.75 -> "1.8x"). Replace with a fixed two-decimal format + trailing-zero strip so whole rates have no decimal (2 -> "2x"), halves show one (1.5 -> "1.5x"), and quarter-steps are preserved (1.25 -> "1.25x"). Adds a Swift Testing suite (PlaybackRateTests) proving red->green.
2026-08-23 12:21:54 +02:00
Yuri Chukhlib
63b625bcde Fix www.duckduckgo.com misrouting to yt-dlp external video extraction (#962) 2026-08-23 12:21:50 +02:00
Arkadiusz Fal
d624a09335 Fix timed links resuming at watch position instead of URL timestamp
Timed links arrive wrapped as yattee://open?url=... from the share
extension, but the timestamp was parsed from the wrapper URL, where a
single-? form like youtu.be/ID?t=N hides t inside the url query item.
With no startTime the player fell back to saved watch progress.

- Add URLRouter.unwrapped(_:) resolving the wrapper to the inner URL
  (raw remainder after ?url=, since the share extension does not encode
  & and URLComponents would drop &t= parts) and use it in handleDeepLink
- Thread forceStartTime through openVideo/playPreferringDownloaded/play
  so explicit link timestamps beat the 90%-watched restart threshold,
  clamped to just before the end; resume flows keep the old behavior
- Carry the parsed timestamp through OpenLinkSheet's play action, which
  dropped it entirely
- Cover timestamp parsing and wrapper unwrapping in NavigationTests
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
b08974f4dc Allow screen sleep during audio-only playback 2026-08-03 23:34:06 +02:00
Arkadiusz Fal
d17423fa5b Fix autoplay countdown showing in repeat one queue mode
When queue mode was set to repeat one, the "Playing in N seconds"
countdown still appeared after a video ended, showing the next queued
video - then restarting the current one anyway. The video-end path
never consulted the queue mode; only playNext() did, after the
countdown had already run.

- PlayerService.backendDidFinishPlaying: restart immediately in repeat
  one mode, regardless of queue contents, player visibility, background,
  or PiP. This also fixes repeat one never looping with an empty queue.
- ExpandedPlayerSheet.isAutoPlayEnabled: false in repeat one mode, which
  gates all countdown triggers (start on ended, overlay render, re-arm
  on appear) on iOS/macOS.
- TVPlayerView.handleVideoEnded: early-return in repeat one mode so
  tvOS shows neither the countdown nor the replay controls.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
908a916f76 Fix sending extracted videos (Twitch streams) to other devices
The remote control loadVideo command only carried the raw video ID and
an instance URL, so extracted videos (Twitch live streams and other
yt-dlp sites) failed on the receiver: it tried to fetch the ID from the
/api/v1/videos endpoint, which rejects non-YouTube IDs.

The command now carries the full ContentSource and title. The receiver
rebuilds the VideoID from the source and opens a placeholder Video -
the player then re-extracts streams and full metadata from the original
URL, same as when opening the video locally. Media-source extractors
(WebDAV/SMB/local) keep using the existing UUID:path branch.

Both fields are optional for protocol compatibility: old receivers
ignore them, commands from old senders keep the legacy behavior.

Also stop sending a start time for live streams - there is no shared
timeline, the receiver joins at the live edge.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
2c0c2e853a Apply thumbnail fallback everywhere a single URL was rendered
The previous fix rebuilt the quality chain when converting persisted
models back to videos, but many consumers discarded it again by
rendering bestThumbnail (usually a maxresdefault.jpg that 404s for
older videos) as a single URL with no fallback.

Extract the retry logic from VideoThumbnailView into FallbackLazyImage
and use it at every in-app site that can iterate: player thumbnails
(mini bar, expanded sheet loaders, autoplay previews, tvOS audio-mode
artwork - previously a silent black screen), video info card and tvOS
header, and the tvOS playlist cover.

Sites that fetch or send exactly one URL are rewritten to the
always-available hqdefault variant via Thumbnail.reliableURL (exposed
as Video.reliableThumbnailURL): Now Playing artwork, Top Shelf
snapshots, remote control state, the frozen transition thumbnail,
blurred info background, navigation covers, and playlist covers
derived from a video's first thumbnail in Invidious/Yattee Server
responses.

Also invert RecentPlaylist's upgrade helper, which rewrote covers *to*
maxresdefault, walk the quality chain when caching download thumbnails
for offline artwork instead of giving up after one 404, and expand the
remaining single-thumbnail Piped conversions into full chains.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
fd1029d3e0 Fix missing thumbnails for videos saved to library
Persistence models (local playlists, watch history, bookmarks,
downloads) store only the best advertised thumbnail URL - usually
maxresdefault.jpg, which doesn't exist for many older videos and 404s.
Live API results survive this because views fall back through the full
quality chain, but toVideo() rebuilt videos with that single dead URL,
leaving placeholder covers.

Reconstruct the quality fallback chain from the stored YouTube-style
URL at read time (Thumbnail.fallbackChain), and rewrite playlist list
covers to the always-available hqdefault variant
(Thumbnail.reliableURL), since those views render a single URL without
fallback. Also expand the fabricated maxres URL in PipedAPI into a
full chain.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
aa6e7fbce9 Stop tracking resume progress for live streams
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
8faf20c9e9 Add loading external subtitle files for media-source playback
Adds a "Load subtitle from file…" row to the Subtitles section on iOS
(document picker) and macOS (open panel), available when playing files
from local folder, SMB, or WebDAV sources. The picked file is copied
into the per-video temp subtitle directory, registered as a selectable
Caption (displayed by filename), and activated through the existing
loadCaption flow. The Subtitles tab and captions control button now
also appear for media-source files without any subtitles.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
9e546e29ee Label local-file video quality from mpv track info instead of Unknown
Streams built for local folder files carry no resolution/codec/fps
metadata (unknowable before demux), so the quality selector labeled
them "Unknown". Use the video track mpv reports (demux-w/h/fps, codec)
to fill those fields into a display-only copy of the stream — the
Video row and summary now show e.g. "720p · 30fps" with a codec badge.
Selection and tap handling keep using the original stream.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
67ed8c322b Add embedded audio/subtitle track selection for multi-track files
Files with multiple embedded tracks (e.g. MKV from media sources or
downloads) previously exposed none of them: mpv's track-list was never
read, aid was never set, and sid was only ever no/auto. The quality
selector showed no Audio or Subtitles tab for such files.

MPVClient now observes track-list/aid/sid and delivers a coalesced,
leniently decoded [MPVTrack] snapshot; selection state is derived from
the reported "selected" flags. Embedded tracks are switched live via
aid/sid (no reload), surfaced in the existing Audio/Subtitles sections
alongside external captions on iOS, tvOS and macOS. External sub-add/
audio-add tracks are filtered out to avoid double-listing. User picks
are sticky across same-video reloads (quality switch, audio mode,
retries) and preferred audio/subtitle languages auto-select a matching
embedded track once per load, with external captions taking precedence.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
cd01c08b5a Fix #960: scope subscription counts, import/export to active account
When an Invidious/Piped account is active, several views read the local
SwiftData subscription store instead of the account, so counts
contradicted each other (Home tile said 10 while the Channels list
showed the server's 2):

- Home Channels tile now uses the provider's count via new
  SubscriptionService.cachedSubscriptionCount (fetched once in the
  background when the server cache isn't populated yet)
- Export footer and export content use the active account's list;
  export runs async with a spinner and surfaces fetch errors as a toast
- CSV/OPML import routes through SubscriptionService.importSubscriptions,
  subscribing on the server for server accounts instead of silently
  writing to the invisible local store
- ChannelView subscribe-state is corrected via the provider after the
  optimistic local-store read; unused *Sync write helpers removed
- Server-account subscribe/unsubscribe/import now post
  subscriptionsDidChange so other views refresh
- New "Delete Local Subscription Data" section in Subscriptions
  settings (visible with a server account) clears the local store and
  queues CloudKit deletions so iCloud doesn't restore it
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
40f043b769 Fix startup crash in sideloaded builds without iCloud entitlements
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
c01b8f63e1 Guard PiP bridge geometry writes against non-finite rects
Two build 264 crashes on macOS 26.5.1 hit AppKit's non-finite-frame
trap (_NSViewValidateGeometry) on AVKit's PiP sample-buffer host view
during a routine layout pass. The NaN is produced inside AVKit from
geometry we feed it - either the manual PiP window resize around a
video switch or an infinite container rect copied into the sample
buffer layer frame.

Add a sanitizedGeometry helper that rejects NaN/infinite or degenerate
rects and apply it at every layer/window geometry write in the bridge
(updateLayerFrame variants, moveLayer, updateVideoAspectRatio,
resizePiPWindow, updateLayerFrameToMatchPiPWindow). Rejected writes are
logged with their call site so the actual source can be identified when
it reproduces, instead of crashing on the next CATransaction commit.
2026-07-23 23:12:23 +02:00
Arkadiusz Fal
ce7e9cbde7 Fix crash in CAOpenGLLayer shadow-copy init on macOS 27 beta
macOS 27 beta routes contentsScale changes (window moving between
screens) through Core Animation's implicit-animation path, which builds
a presentation copy of the layer via init(layer:). Our override re-ran
visual setters on that copy, and -[CAOpenGLLayer setColorspace:]
dereferences render state the shadow copy doesn't own, crashing with
EXC_BAD_ACCESS.

Drop the redundant setter block (super.init(layer:) already copies
presentation values) and disable implicit actions around the
contentsScale writes in MPVOGLView so the presentation-copy path is
never entered for scale updates.
2026-07-23 23:08:34 +02:00
Arkadiusz Fal
2ff248e262 Harden player view detach and time-update gate heal from #957
Guard the deinit detach by superview so a container whose render view
was stolen by a newer, not-yet-windowed container cannot rip the shared
view out of it. Narrow the loading-gate heal to .ready only: the old
video still playing during the details/streams fetch can emit .playing,
which would open the gate to stale time updates and break the
stale-error suppression in play()'s catch.
2026-07-23 23:06:48 +02:00
Arkadiusz Fal
faf0964746 Detect fragile GL driver by Metal GPU family instead of device model 2026-07-23 23:02:21 +02:00
Arkadiusz Fal
6994b9353a Show missing-credentials indicator for remote server sources
After reinstalling the app and importing sources from iCloud, remote
server instances with Keychain-only credentials showed no indication
that login is needed, unlike WebDAV/SMB sources.

- Show the orange key icon for Yattee Server instances without stored
  basic auth credentials (auth is always required for this type)
- Persist usesBasicAuth/usesAccountLogin flags on Instance (synced via
  iCloud) so missing Invidious/Piped/PeerTube credentials are detectable
  too; maintained on credential save/delete, login/logout, and legacy
  migration
- Centralize the check in AppEnvironment.needsCredentials(for:) used by
  SourcesListView, MediaSourcesView, and SourceRow
- Backfill the flags at launch from credentials currently in the
  Keychain so existing setups publish them without a re-login
2026-07-23 22:57:24 +02:00
Ramon
0db7b73d25 Fix black video, freezes, autoplay and clock on Apple TV HD (A8)
Fixes four bugs behind yattee#956 on Apple TV HD (AppleTV5,3, A8,
tvOS 26.5), where video played audio only with a permanently black
picture, never autoplayed, the clock stayed at 0:00, and navigating
during/after playback could freeze the whole app.

1. A8 GL driver deadlock (device-specific). glTexImage2D uploading
   float32 data into a float16 texture hangs in libGLImage's
   glgProcessPixelsWithProcessor (a dispatch_group_wait that never
   returns), permanently wedging mpv_render_context_render. mpv 0.37
   (commit 703f1588) turned on dithering (dither-depth=auto) and
   LUT-based scalers by default; on the A8 (no GL_EXT_texture_norm16)
   both upload float weights via that fatal conversion, and per-frame
   CPU plane uploads (videotoolbox-copy / software decode) hit the same
   path. Yattee 1.x shipped mpv 0.36 with these off, which is why the
   device worked there. Worked around on AppleTV5,3 only: dither-depth=no,
   hwdec=videotoolbox (zero-copy via IOSurface), and bilinear scalers.
   Confirmed by two on-device lldb backtraces.

2. Swift cooperative-pool starvation (all platforms). MPVClient's event
   loop ran as Task.detached and blocked a cooperative-pool thread for
   the client's entire lifetime. With two clients alive (active +
   pre-warmed) on a 2-core A8, both pool threads were held and every
   await in the app starved: the load pipeline froze at its first
   Task.sleep, killing autoplay, Now Playing and progress saving. Moved
   the event loop to a dedicated Thread; EAGLContext creation now bridges
   through GCD instead of Task.detached.

3. Leaked time-update gate (all platforms). PlayerService dropped all
   time updates while loadingVideoID was set; when a load task died
   mid-flight the flag leaked and the clock stayed at 0:00 during
   playback. Heal the gate on the ready/playing transition and leak-proof
   play() with a defer; log the previously silent load cancellation.

4. Orphaned player view (all platforms). Detach the shared render view in
   MPVContainerView.deinit when no successor container exists, and skip
   framebuffer recreation while the view is windowless, to stop a 100% CPU
   SwiftUI trait-update loop when opening a settings detail during playback.
2026-07-22 14:30:00 +02:00
Arkadiusz Fal
9c97036684 Make Enable Logging the master switch over all verbose logging
The verbose toggles (MPV, remote control) kept working with the master
"Enable Logging" toggle off: the settings UI hides them, but their
stored values - verboseMPVLogging is even iCloud-synced - were still
honored at runtime, producing console output and enabling the periodic
MPV stats collection while logging appeared disabled.

Enable Logging now takes precedence everywhere:

- LoggingService gates its DEBUG-build OSLog console output on the
  master toggle (it previously ran before the isEnabled guard, so the
  Xcode console was never silent)
- MPVLogging requires loggingEnabled && verboseMPVLogging, which also
  gates the 10s playback stats task via MPVLogging.verboseEnabled
- rcDebug in LocalNetworkService and RemoteControlCoordinator requires
  the master toggle alongside verboseRemoteControlLogging
2026-07-19 01:00:01 +02:00
Arkadiusz Fal
6aa5fd8ee7 Fix periodic tvOS playback stutter from render stalls on mpvQueue
Build 264 stutters for a split second every ~10 seconds. The periodic
playback-stats task fetches ~30 debug properties in one ~190ms block on
mpvQueue, and MPVClient.render() wrapped mpv_render_context_render in
mpvQueue.sync - so the render thread queued behind the fetch and stalled
~190ms, at exactly the stats interval. Build 261 predates the stats
task, which is why it was unaffected.

libmpv's render API is documented thread-safe relative to the client
API, so render-context calls no longer go through mpvQueue:

- New renderContextLock protects renderContext lifecycle vs use;
  render(), renderWithDepth(), renderSoftware(), reportSwap(),
  shouldRenderUpdateFrame() and hasRenderContext use it
- Render-update callbacks dispatch on a dedicated renderEventQueue so
  frame-ready notifications can't queue behind property/command work
- destroyRenderContext takes mpvQueue then the lock, still excluding
  in-flight renders during teardown
- The stats task skips its property fetch on iOS/tvOS unless verbose
  MPV logging is on (macOS keeps it for the render watchdog)
- performRender slow-frame warnings now break down render vs present
  time and report frame-delivery gaps for future triage
2026-07-19 00:53:06 +02:00
Arkadiusz Fal
4ec9b936a6 Fix background playback setting behavior across platforms
Hide the toggle on macOS, where apps are never suspended and playback
always continues regardless of the setting. Pause playback on iOS when
backgrounding with the setting disabled — the audio background mode
previously kept mpv playing, making the off state a no-op.

Move the continue-playing policy into PlayerService and make the
backend's background render pause unconditional (PiP-gated only):
it is a GPU optimization that should not depend on the setting.
Also ungate the player sheet visibility handlers, which manage
foreground mini player rendering unrelated to background playback.
2026-07-18 16:28:19 +02:00
Arkadiusz Fal
3a8f91f88c Reset player aspect ratio to 16:9 for audio-only playback
Audio-only streams never report a video size, so the aspect ratio of
the last played video stuck to the player area indefinitely once audio
mode was enabled. Reset it in play() when the selected stream is
audio-only, and let the macOS player window fall back to 16:9 instead
of keeping the previous lock.
2026-07-17 18:43:10 +02:00
Arkadiusz Fal
c2554cee04 Apply audio mode to downloaded videos
Downloads bypass stream selection and reach play() as ready-made local
file streams, so audio mode never affected them. Apply the mode at that
choke point instead: swap a local video stream for the separately
downloaded audio track, or play a muxed file with the video track
disabled (vid set per-load in MPVClient, never toggled live). The
transform is bidirectional so stored audio-only queue/history entries
restore full video when the mode is off, and setAudioMode now reloads
downloaded content at the current position in both directions.
2026-07-17 09:27:09 +02:00
Arkadiusz Fal
c7ad4fad2e Make CloudKit apply path scope-aware and re-upload local-newer merges
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.
2026-07-15 20:15:03 +02:00
Arkadiusz Fal
10ca000387 Fix conflict resolver field names for recent channels and playlists
The recent-channel resolver copied avatarURLString/providerName and the
recent-playlist resolver copied authorID/providerName - fields that do
not exist in the records the mapper writes - while missing the real
ones (thumbnailURLString, subscriberCount, isVerified, videoCount). As
a result, when the local side won a conflict, those fields silently
kept the older server values.

Also drop the notificationsEnabled preservation in the subscription
resolver: the subscription mapper never writes that field since
notification preferences moved to their own record type.
2026-07-14 23:50:04 +02:00
Arkadiusz Fal
959641b642 Sync watch progress on natural video completion and backgrounding
Naturally finished videos only saved their 100% progress locally
(play() deliberately skips the sync-on-switch when videoEndedNaturally
is set), so the watched state never reached other devices in the
autoplay flow. Save completion through updateWatchProgress, which
queues the iCloud sync.

Also queue the current playback position when the app enters
background, so backgrounding mid-video hands the position off to other
devices instead of only syncing on explicit stop or video switch.
2026-07-14 23:08:39 +02:00
Arkadiusz Fal
9467ac4dba Migrate CloudKit sync to state-driven CKSyncEngine pattern
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.
2026-07-13 23:25:00 +02:00
Arkadiusz Fal
06087220ef Add audio-only music mode
Persistent per-platform mode that plays only the audio track to save
bandwidth and speed up loads. When enabled, stream selection picks the
best audio-only stream (preferred language, codec, bitrate) and never
fetches the video URL; toggling mid-video reloads at the current
position using the quality-switch path (live vid=no toggling is avoided
due to A/V desync).

- Toggle row in the player quality/settings sheet on iOS, macOS and
  tvOS, next to lock controls
- New audioMode control button type (red when active) for player
  controls and mini player layouts
- Picking a video quality explicitly turns the mode off; audio track
  picks keep it on and now work while audio-only is playing
- Stream URL refresh preserves audio-only playback instead of
  resurrecting video
- Queue preloads and history entries resolved before a toggle are
  discarded so selection re-runs with the current mode
- tvOS player shows the video thumbnail instead of a black screen
  during audio-only playback
- PiP button hidden for audio-only streams
2026-07-13 19:06:01 +02:00
Arkadiusz Fal
afa73e6bcb Prefer avfoundation AO on macOS 27 to avoid mpv hotplug listener crash
On macOS 27 beta, mpv's coreaudio AO fails to initialize (channel layout
set rejected with paramErr -50). The existing ao=coreaudio,avfoundation
fallback restored audio, but exposed an mpv bug: init() registers a HAL
hotplug listener (AudioObjectAddPropertyListener on the system object,
with the raw ao pointer as user data) before the step that fails, and
the coreaudio_error path never unregisters it. mpv then frees the ao
and falls back to avfoundation, leaving a dangling listener per
playback start. The next audio device change (AirPods connect,
sleep/wake device republish) invokes the listener with the freed
pointer and crashes with a use-after-free SIGSEGV on the
HALC_ProxyNotification Call Listener Queue (observed in build 262;
faulting address was reused "texture_" shader string memory).

Gate the AO order by OS version: on macOS 27+ use
avfoundation,coreaudio so the failing coreaudio init never runs; older
macOS keeps the native coreaudio first with avfoundation as fallback.
Update the read-only defaults mirror in MPV options settings to match.
2026-07-07 23:51:34 +02:00
Arkadiusz Fal
8d4f472348 Unify macOS content view backgrounds with opaque window background
Views without an explicit background sat on the translucent window
material, which picks up a wallpaper tint (bluish in dark mode) and
clashed with views drawing windowBackgroundColor, making the video
info header gradient fade visibly mismatch. Add opaqueWindowBackground()
and apply it centrally in NavigationDestinationHandlerModifier to both
stack roots and pushed destinations, plus VideoInfoView for
presentations outside navigation stacks.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-06 09:36:49 +02:00
Arkadiusz Fal
ecb6a9dd7a Replace macOS inline player sheet with full-window overlay
When "Play in a separate window" is disabled, the expanded player was a
native sheet attached to the main window. AppKit refuses fullscreen with
an attached sheet, so pressing F swapped the sheet for an in-window
overlay before fullscreening — a visible two-step transition.

Present the inline player as the full-window overlay from the start, so
fullscreen is just the window's native toggle. This removes the whole
sheet-swap machinery (toggleSheetFullScreen with its detach timing,
SheetWindowResizer, sheet sizing helpers).

- beginInlineOverlay/endInlineOverlay hide/restore the main window
  toolbar and mirror the window's fullscreen state into the renamed
  isMacInlinePlayerFullScreen flag via NSWindow notifications, so exits
  through Esc/green button/Mission Control stay in sync
- Collapsing exits fullscreen only if it was entered for the video
- Esc in the windowed overlay collapses to the mini bar via a new
  onCollapse callback (replacing the sheet's default Esc-dismiss)
- ContentView registers its hosting window (MainContentWindowReader) so
  the overlay targets the right window even when the setting is toggled
  while the Settings window has focus
2026-07-03 21:57:55 +02:00