Commit Graph

219 Commits

Author SHA1 Message Date
Arkadiusz Fal
39041e4b39 Replace UIScreen.main with scene-local context
- List row hairlines read displayScale from the SwiftUI environment
- MPV render views take contentScaleFactor from traitCollection and
  re-apply it on UITraitDisplayScale changes
- ExpandedPlayerWindow scales against the presenting view's own height
- MPVPiPBridge sizes the PiP layer from the container view's window
  scene screen (UIScreen.main kept only as a nil-window fallback)
- WideScreenPlayerLayout falls back to the offered geometry size
- Remote control fullscreen state comes from the player sheet's layout
  pass via NavigationCoordinator.isWideScreenLayoutActive instead of
  screen bounds aspect
- Orientation TODOs for Dynamic Island side detection and
  prefersInterfaceOrientationLocked
2026-09-30 19:15:45 +02:00
Arkadiusz Fal
5af052b428 Jump panel pages to the top and clear stale chapters when the video changes
Tapping a related video kept the panel scrolled where it was, so the new
title was out of view, and the chapters section kept showing the previous
video's chapters until the new ones resolved.

The scroll config now carries the video identity and the shared page
scroll view scrolls to the top (without animation, resetting the reported
offset) whenever it changes. PlayerState.setCurrentVideo clears chapters
on a video id change, alongside comments and storyboards.
2026-09-25 21:44:43 +02:00
Arkadiusz Fal
b1625afb4a Collapse the corner side column by overdrag and fix the fast reveal glitch
Overdragging the side column's resize grabber past its minimum width now
slides the column off the trailing edge (the video grows with it), and a
swipe in from the trailing screen edge brings it back following the
finger. The seek pan skips that edge strip while the column is
collapsed. Showing the panel again restores every region hidden by its
own drag handle.

Swiping the below-video panel back up right after hiding it showed the
video clipped to a short centered box that sprang back to full size.
The hide flipped to the hidden layout in a `.logicallyComplete`
completion while the slide spring was still registered on the SwiftUI
host of the mpv render view; a reveal drag interrupting it made the
release spring start from a bogus shrunken rect. All slide-off
completions now wait for `.removed` and bail if a new drag took over.
The visibility-keyed `.animation` modifiers key on a toggle generation
bumped only by the animated show/hide paths, so drag flips never start
the toggle easing. The partnerless `matchedGeometryEffect` on the
widescreen video frame is dropped.
2026-09-24 22:21:47 +02:00
Arkadiusz Fal
ed2884d7ec Stop mpv render from blocking until frame target time
mpv_render_context_render slept until each frame's display time
(~40ms at 25fps) inside CAOpenGLLayer.display(), blocking main-thread
CoreAnimation commits and hitching UI scrolling during playback. Pass
MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME=0 and let CGL vsync pace
presentation instead.
2026-09-24 06:38:01 +02:00
Arkadiusz Fal
4a85f7355a Track the active chapter in PlayerState instead of per-tick time reads
Chapter lists highlighted the active row by reading currentTime in the
view body, re-rendering every row many times per second during
playback. PlayerState now stores a change-guarded activeChapterID
updated when time or chapters change, so views observe only chapter
transitions.
2026-09-23 23:47:21 +02:00
Arkadiusz Fal
52ec8c9f05 Block player dismiss pan from the panel resize grabber strip
Starting a column resize drag with a diagonal (downward) motion let the
window-level dismiss pan begin, cancelling the resize and dragging the
player down instead. The grabber now publishes its strip frame (with 8pt
slop on each side) to NavigationCoordinator, and the dismiss pan refuses
to begin from touches inside it — same exclusion pattern as the corner
layout's below-video panel frame.
2026-09-23 18:38:24 +02:00
Arkadiusz Fal
05a03723f6 Rework corner layout with portrait-style info panel below the video
The corner layout now always renders the info page as a portrait-style
panel below the video (collapsible description, drag to hide/expand),
while the side column holds tabs for the remaining pages. Key pieces:

- Below-video panel reuses PortraitDetailsPanel (no comments pill,
  square corners, chapters/related stay in the side column tabs)
- Drag down hides the panel with the video recentering in lockstep;
  swipe up on the video reveals it following the finger
- Fullscreen button toggles the panels in corner layout (controls hide
  first so their resize never plays visibly); status bar and home
  indicator hide only while the panels are hidden
- Panel visibility and column width are seeded at init from a warmed
  PlayerControlsLayout cache, so the first frame resolves corner mode
  without flashing the default layout or animating the panels in
- Dismiss pan is blocked inside the below-video panel frame
  (cornerBelowPanelFrame in NavigationCoordinator)
- Resize grabber slimmed to match the panel drag handle look
2026-09-23 07:25:02 +02:00
Arkadiusz Fal
ad01e113fc Fix video position jump when dismissing the portrait details panel
Two causes: the drag/dismiss animation targeted the content-area center
(screenHeight/2) while the resting position once the panel flips hidden
is fullScreenHeight/2 - top inset - the player window adds extra bottom
safe area, so the two centers differ and the flip snapped the video by
half that difference. And the hidden-state flip ran on a fixed 0.25s
timer that cut the 0.3-response spring short, snapping the video the
rest of the way. A shared hiddenVideoY is now the target everywhere
(drag interpolation, dismiss animation, swipe-up reveal), and the flip
runs in the spring's completion callback.

Also mark DEBUG os_log output public: without it the console shows
<private> for processes not launched by Xcode (e.g. USB syslog capture).
2026-09-22 23:55:54 +02:00
Arkadiusz Fal
a6d989c7af Add corner player layout on iPad and macOS
Preset-selected two-region layout: video in the top-left, a resizable
side column, and an optional region below the video. Pages are assigned
to regions per preset (Location picker in the page editor); the layout
falls back to the stacked widescreen layout below 900pt width, so Split
View and narrow windows degrade automatically.

- CornerPlayerLayout hosts the shared playerAreaForWidescreen builder,
  so the MPV hosting path is identical to the stacked layout
- Layout resolver in ExpandedPlayerSheet picks corner per layout pass;
  NavigationCoordinator.isCornerLayoutActive feeds the macOS window
  manager, which drops the aspect-ratio lock and auto-fit while corner
  is active (and restores them on exit, incl. fullscreen replay and
  window re-mounts)
- Side column width persisted via new cornerPanelColumnWidth setting
  (280-560pt, video column kept >= 480pt)
- Settings: layout mode picker (iPad/macOS) with automatic-fallback
  footer, per-page region picker when corner is on, region shown in
  the pages list
2026-09-21 23:52:09 +02:00
Arkadiusz Fal
c33d125553 Add details panel preset settings UI and default visibility
Phase 2 of the player panels redesign:

- New "Details Panel" screen in Player Controls settings: page
  reorder/enable list with per-page editor (info element toggles),
  and a default panel visibility picker (hidden/visible/remember).
- PlayerControlsSettingsViewModel gains panelSettings bindings with
  the usual debounced-save flow; the last enabled page cannot be
  disabled.
- Built-in presets bumped to v9 with explicit default panelSettings.
- The preset's default visibility now drives the wide-layout panel
  on player open and on entering landscape, replacing the hard
  hidden resets; "remember" restores the new detailsPanelLastVisible
  setting recorded on user toggles. Applied once per expanded player
  session (flag on NavigationCoordinator survives re-mounts).
- WideScreenPlayerLayout picks up external visibility writes so the
  async-loaded default reaches the mounted layout.
- Unit tests for PanelSettings Codable compatibility (round-trip,
  old JSON, unknown page types/discriminators/enum values); removed
  stale PlayerInfoTab tests left from Phase 1.
2026-09-21 23:25:10 +02:00
Arkadiusz Fal
8bb6137417 Lay groundwork for the player panels redesign
Phase 0 of the panels redesign, extraction only, no behavior changes:

- Unify comments loading on PlayerState: loadComments/loadMoreComments
  become the single loader, used by both the player sheet preload and
  the comments overlay (which previously had its own duplicate copy).
- Extract PanelResizeGrabber from FloatingDetailsPanel into the new
  Views/Player/Panels/ directory.
- Extract the shared queue list from QueueManagementSheet into
  QueueListView plus QueuePlaybackActions, reused by the tvOS panel.
- Remove the no-op wideLayoutPanelAlignment accessor from the settings
  view model; the player reads the panel side from SettingsManager, so
  the preset field was dead. The Codable field stays so old presets
  decode.
2026-09-21 23:04:42 +02:00
Arkadiusz Fal
6efd60a18e Play direct media links without Yattee Server by probing the URL first
Debrid and CDN download links (TorBox, Real-Debrid, presigned S3) hide the
file behind an opaque path, so the extension-based direct media check missed
them and every one was sent to Yattee Server for yt-dlp extraction, failing
outright when no server is configured.

Add DirectMediaProbe: a HEAD request classifies the response by Content-Type
and Content-Disposition, and when the type is generic or HEAD is refused, a
ranged GET sniffs the first bytes for container signatures (Matroska/WebM,
ISO BMFF, Ogg, FLAC, MP3, RIFF, FLV, ASF, MPEG-PS/TS, HLS and DASH
manifests). Anything playable goes straight to mpv with the server-reported
filename as the title; everything else falls back to extraction as before.

The probe runs ahead of the Yattee Server path in ExternalVideoView, both
OpenLinkSheet flows and the deep-link download path, and PlayerService
rebuilds direct_media streams itself so replays from history no longer
depend on a server either.
2026-09-21 20:03:06 +02:00
Arkadiusz Fal
53fda7574c Fix a crash when syncing watch entries whose video ID exceeds the CloudKit record name limit
Extracted media-source video IDs are full file paths, so closing a
long-named WebDAV video built a watch record name longer than 255
characters and CKRecord.ID threw a fatal CKException.

Scoped record names now fall back to a SHA-256 digest of the ID when
the verbatim form would exceed the limit. The sync engine's local
lookups for materializing pending saves and applying remote deletions
recognise the digest form and match it against all entities of that
type instead of the indexed ID lookup. Short IDs keep their existing
names, so records already in iCloud are unaffected.
2026-09-20 23:59:05 +02:00
Arkadiusz Fal
ea57378b00 Keep Apple TV awake during audio-only playback
Since b08974f4 audio-only playback re-enabled the idle timer on every
platform. On tvOS idle means screensaver and then device sleep, which
suspends the app, stops playback, and turns the TV off over HDMI-CEC.
Always prevent sleep while playing on tvOS; iOS and macOS still let the
screen sleep in audio mode.
2026-09-20 23:49:35 +02:00
Arkadiusz Fal
794b2b0181 Let accounts be set inactive without signing them out
An inactive account keeps its credential and syncs like a rename, but
drops out of every picker and cannot be used until activated again.
Deactivating the selected account switches to the local context as a
published choice, so other devices follow instead of falling back.
2026-09-19 23:58:24 +02:00
Arkadiusz Fal
101880d35f Route instance subscription imports through SubscriptionService with a destination picker
ImportSubscriptionsView wrote every imported channel straight into the
local SwiftData store via dataManager.subscribe(to:), regardless of the
selected account context. Imports now go through
SubscriptionService.subscribe(to:in:) aimed at a destination the user
picks — the local store or any logged-in account that supports
subscriptions, minus the source instance's own account, the same roster
the subscribe menus offer.

The already-subscribed marks are asked per destination through a new
SubscriptionService.subscribedChannelIDs(in:) helper (cold server
destinations fetch their list once, generation-fenced like the other
reads) and reload when the destination changes. Individual adds show a
per-row spinner while the server write is in flight, alreadySubscribed
counts as success, and Add All runs serially with an added/failed tally.

Also drop the "Invidious" wording from the import empty state — the
view serves Piped sources too.
2026-09-17 23:23:56 +02:00
Arkadiusz Fal
fe0a5e725c Fence stale-generation writes in Yattee Server feed error and progress paths
A stateless-server feed request that failed after an account switch could
write the previous account's error state over the new account's feed, and
a stale poller could flash the old context's partiallyLoaded counts. Guard
both writes on the captured generation, matching the existing completion
fence.
2026-09-16 23:37:00 +02:00
Arkadiusz Fal
753cda9661 Label the local context by where its data actually lives
"Yattee (iCloud)" was hardcoded for the local data context even with
iCloud sync disabled, when the data is purely on-device. The name now
comes from DataContextValidator.localContextName, which checks
iCloudSyncEnabled and falls back to "Yattee (This Device)".
2026-09-15 09:02:18 +02:00
Arkadiusz Fal
b917049243 Keep a fenced feed refresh from stranding the loading flag
The refresh captured its account generation before hopping onto the
detached task it runs in. That hop is a suspension point: a context
handler running in the gap - the wake-time status flap bumps the
generation there - left the request carrying a stale generation. Its
completion was then fenced out, and the fenced return deliberately
leaves isLoading alone, so nothing current owned the flag: it stayed
true, every later refresh skipped itself as already loading, and the
retry paths only watch for errors - the feed sat on a spinner nothing
would ever finish.

The generation and context are now read at dispatch time, so the
request that claims isLoading always carries the generation current at
that moment and its completion is the one that releases it. The
stateless-server poller's terminal writes get the same fence: a stale
poller must not hand a newer request's spinner an early ready.
2026-09-14 09:02:32 +02:00
Arkadiusz Fal
82ef5267c4 Let the sidebar's channels and playlists follow the data context 2026-09-14 07:11:28 +02:00
Arkadiusz Fal
b08185652e Let the switcher refuse the states only the user can fix
Picking a sign-in-required account landed on the local fallback with a
banner — the switch achieved nothing, and the fix lives in Settings.
isSwitchable now refuses a rejected or missing credential the same way
it already refused a switched-off source: the row stays listed with its
status in the title, disabled.

The line runs between states the user must act on and states that heal
on their own. Unreachable stays switchable — while the device itself is
offline every account reads unreachable, and refusing those would lock
the whole switcher — and so do checking and instance-pending, which are
refused nothing they may turn out to deserve.

Reaches every presentation through the shared rows, and withholds
"Use This Account" on the accounts screen through availableContexts.
The tests sign their usable accounts in now that a fresh account under
a resolved scope defaults to credentialMissing and is refused.
2026-09-12 23:55:07 +02:00
Arkadiusz Fal
839de238d3 Let account labels call the source by its title, not its host
An account with no user-set label was named username@host everywhere it
appears — the switcher menu, the settings rows, the playlist selector —
which overflows a menu row the moment the host has a subdomain. The
default name and the switcher's disambiguation suffixes now use the
instance's title when one is set, falling back to the host as before.
2026-09-12 23:44:35 +02:00
Arkadiusz Fal
03037e6769 Let a refused host count as unreachable and the switcher own every login
Residual round after the macOS fix verification, five closures:

A connection refusal or a failed DNS lookup arrived as APIError.unknown,
which no classifier reads — so an account pointed at a downed instance
never went unreachable, never fell back to local data, never armed a
retry. New APIError.connectionFailed carries the system's own sentence
and lands as unreachable/offline in AccountsManager, InstancesManager
and the credential probe; a refused host now classifies in seconds
instead of never.

Subscriptions' periodic retry armed only for networkError, while the
local fallback's yatteeServerRequired — shown exactly when the account's
server could not be reached — waited on a status expiry nothing was
watching. FeedLoadState.warrantsPeriodicRetry gates on the context's
unavailability, so that screen heals on its own too, and an account that
genuinely has no Yattee Server still never loops.

The switcher dropped every context of a switched-off source, leaving the
toolbar badge pointing at a menu its own selection had vanished from.
listedContexts keeps the rows with their status in the title ("— Source
switched off"), isSwitchable refuses the pick, and the current context
keeps its checkmark.

The sidebar footer's switcher row overflowed the 140 pt column minimum:
the width floor now sits on the menu itself and ViewThatFits drops the
caption — the part the icon already says — before the account name loses
characters.

The channel menu exposed a raw SF Symbol name as its accessibility
title; the label is now a real Label rendered iconOnly, so the AX bridge
hears "Subscription Options".
2026-09-12 17:10:58 +02:00
Arkadiusz Fal
c0d38834a6 Let rows update their counts, drop fake dates, and own failed lookups
MAC6: a snapshot mutation carries its video count back to the list cache.
MAC8: playlist rows and cards are Buttons with the context menu attached,
so macOS accessibility can reach Edit and Delete.
MAC10: a published date stamped with the request's own time is dropped at
both API sentinels, and formattedPublishedDate refuses zero-interval output.
MAC11: a failed history hydration shows Details unavailable with a Try
Again action instead of keeping the raw video ID forever; an empty 200
body is a legible failure.
2026-09-11 20:28:35 +02:00
Arkadiusz Fal
94b2dff75b Let Subscriptions settle on an answer and heal when the server returns
MAC3: an error the context banner answers now reaches a terminal Feed
Unavailable view instead of the spinner branch; an effective-context move
keeps the cached feed instead of purging it; the view retries on appear,
on activation, on a status lift, and every minute while a network error
is on screen.
2026-09-11 19:08:25 +02:00
Arkadiusz Fal
e661635079 Let every menu mark the current context and a disabled source admit it
MAC1: switcher rows are Toggles on macOS so the checkmark really renders,
and the settings row names its context via LabeledContent with a tooltip.
MAC4: accessible names are words, never raw SF Symbol identifiers.
MAC14: new displayStatus(for:) surfaces instanceDisabled on account rows,
badges, and the switcher tooltip without disturbing status(for:) consumers.
MAC16: disambiguated labels guard the username branch and fall back to the
source name only when rows would still collide.
Also carries the new Localizable strings for the whole macOS fix batch.
2026-09-11 08:02:26 +02:00
Arkadiusz Fal
b67b3472f6 Let a purge be answered once and a re-seed be answered until it sticks
The two answers to a deleted zone were sharing one lifecycle, and only one of
them deserved it. A re-seed only adds, so it is held open until the engine has
actually serialized the work and redone for as long as it has not. A purge
removes, and replaying it removes more than it was asked to — the account added
while the app kept running, the category switched back on, the reset that runs
its own upload afterwards. It is done the moment its durable half is, and the
comment that called both idempotent now says which is which.

The note itself was never scoped to an identity. A marker written under one
Apple account replayed against the next one's namespace, answering a wipe that
never happened there, and a purge would have taken that account's staged records
and its outbox with it. The account change forgets it now, the way it forgets
everything else it holds.

Retiring the marker trusted whichever state update arrived next, which is not
the same as one that carries the work: an update handed over before the recovery
registered anything can still reach the main actor after it, describing a state
in which none of it exists. Deliveries are counted where the engine hands them
over, and only a later one can retire what was armed.

A refused re-seed waited for the next launch. The accounts it could not speak
for announce themselves when they load, and the recovery is answered then —
outside the callback tree that announcement comes from.

A purge left two ways back for what it dropped: a pending zone save would have
recreated the zone on the next send, and the pre-engine pending-change keys
would have been rediscovered on a later launch. It clears both, and it now runs
after everything that puts work into the engine rather than before.

The account half fences before it destroys, like its upload twin, so a purge
arriving during the identity reload gap answers with nothing rather than with
somebody else's records.

And the reset that promises a re-upload is refused while sync is off, where
nothing would have performed one.

Re-entering the engine from inside its own callback is what started all of this,
and nothing in the code said so. A task-local marks the callback tree and the
two entry points assert on it in debug builds — a plain Task inherits it, which
is the mistake; Task.detached escapes it, which is the fix.
2026-09-09 23:59:59 +02:00
Arkadiusz Fal
a3e395fe4c Let a wiped zone be reseeded instead of crashing the messenger
A zone deleted from another device — a reset, an iCloud data purge — arrives
as an event on every other device, and the handler answered it by calling back
into the sync engine from inside the engine's own callback. CloudKit traps on
that, in release, so the app died a second after the push; and because the
handler destroyed the persisted sync state before doing the work, the relaunch
saw a zone that existed again, no local sign anything was lost, and never
noticed the wipe. Both devices then reported being in sync with an empty zone.

The handler now only writes a durable marker and mutates engine state: the zone
is recreated through pending database changes — which also gets past the zone
manager's memo — and the re-upload is registered, never sent. The engine sends
on its own schedule. The marker is written before any of it and retired only
after the engine serializes the state the recovery registered, so a crash
anywhere in the middle is redone on the next launch.

Accounts lead the re-seed now instead of trailing nine categories behind it.
They carry the logins, the tombstones and the selected context, and going last
is how a wipe took them with it.

A purge is not a reset: CloudKit requires that data deleted that way is not
sent back, so those reasons drop what was queued rather than republish it.
Local data stays — it is this app's store, not a cache of the zone.

And the reset that promises "your local data has been re-uploaded" now does it.
2026-09-09 23:59:58 +02:00
Arkadiusz Fal
0c6c7763db Let an untouched Save keep the name it was already showing 2026-09-09 23:59:57 +02:00
Arkadiusz Fal
2e1f411cc8 Let the label the account syncs be one the user can set 2026-09-09 23:52:54 +02:00
Arkadiusz Fal
2e6a464575 Let a login fixed on one device lift the rejection on the other 2026-09-09 21:36:08 +02:00
Arkadiusz Fal
bb6e98f9d2 Say what a timestamp tie actually does to the toggle
The tie test claimed the local record only repeated the server value, but
it fixtures opposing toggles stamped at the same instant, so name it for
the rule it proves. Note beside the strategy why that tie needs no
deterministic tiebreaker: no writer stamps these settings with a fixed
epoch date, unlike accounts and data contexts.
2026-09-08 23:49:43 +02:00
Arkadiusz Fal
e0f6b5e6d4 Let the newer channel notification toggle win its conflict 2026-09-08 23:27:06 +02:00
Arkadiusz Fal
0bfacaa497 Say why a matching secret can only be the one that was adopted 2026-09-08 23:10:27 +02:00
Arkadiusz Fal
bb46ac3e32 Settle a secret before handing over the account it belongs to 2026-09-08 23:09:57 +02:00
Arkadiusz Fal
f1e034111c Prune a record only once nothing of it is left behind 2026-09-08 22:23:30 +02:00
Arkadiusz Fal
87ab014467 Let the account that signs in claim what was made signed out 2026-09-07 23:46:12 +02:00
Arkadiusz Fal
02efc71e7c Tell the accounts apart, and let a disabled source come back 2026-09-07 18:53:21 +02:00
Arkadiusz Fal
5ac6b7188d Ask what a backend supports instead of what it is 2026-09-07 18:48:50 +02:00
Arkadiusz Fal
69aed50012 Keep a replay out of the playback that took its place 2026-09-07 06:41:54 +02:00
Arkadiusz Fal
cfc0547588 End the session with the watch that finished 2026-09-06 23:36:27 +02:00
Arkadiusz Fal
6bdcd51416 Let a replay resume only the watch it started 2026-09-06 23:12:13 +02:00
Arkadiusz Fal
ee4f458efe Let a replay start the watch it says it is 2026-09-06 22:47:48 +02:00
Arkadiusz Fal
5072fb0dce Start a session where the watch actually begins 2026-09-06 22:21:15 +02:00
Arkadiusz Fal
7bcc711c40 Mirror a watch into the account it was watched under 2026-09-06 11:06:15 +02:00
Arkadiusz Fal
3dc129dcfa Let the banner speak only for what it answers
Standing the feed error down under the context banner was aimed at one
sentence and hit every one. The fallback to local reads asks for a Yattee
Server nobody configured, and that question the banner already answers —
but a local refresh that failed on the way out answers nothing, and it was
being swallowed too. With the banner up, an offline refresh left the last
feed on screen with no explanation and no retry: the state said error, the
view said nothing.

The decision now names the errors the banner covers. `yatteeServerRequired`
and `notAuthenticated` are the account the banner is already talking about
and stand down; a `networkError` is news the banner does not carry and
keeps its own message and its retry button. It lives on `FeedLoadState` as
`displayedError(contextBannerVisible:)` so the rule is one function rather
than a guard in a view property, and so it can be tested.
2026-09-05 23:49:10 +02:00
Arkadiusz Fal
5abd314c88 Let an incoming context settle what a repair cannot argue with
A repair is a fallback, not a choice: it borrows the timestamp of the
selection it replaced so that a later choice still beats it, and it is
never published. But that borrowed rank also won arguments it had no
business winning. An incoming explicit context stamped at the same instant
that loses the `DataContext.tieBreakKey` tie was neither adopted — the
repair was not outranked — nor pushed back, because a repair pushes
nothing. Neither side moved, and the fallback stood against the server's
explicit value indefinitely. The tie is not theoretical: migrated contexts
are all stamped at the epoch, and a repair keeps the timestamp of whatever
it replaced, so two migrated-era values meet as equals.

A stored repair now never outranks an incoming context — it is replaced by
whatever arrived, older or tied, and nothing is ever enqueued for that.
Adopting is safe whatever the incoming value names: one naming an account a
tombstone dominates has already been turned back into a repair before the
comparison, so it stays unpublished and lands on the same target this
device's repair did.

Two more from the same trace:

- Staged records are applied by category, and contexts already come last so
  that a repair reads the accounts and the instance a dominated record left
  behind rather than falling to local past a signed-in sibling. That
  ordering was load-bearing and unremarked; it is now written down, along
  with what it does not cover — a context applied in an earlier batch than
  the account record that says where the deleted account lived still
  repairs without it, which the next explicit choice anywhere settles.

- An install that repaired its context before the repair bit existed reads
  back as having chosen it, so that one repair can still be published once.
  Accepted and documented at the load site: no shipped release ever
  persisted a data context, the window is interim builds only, and the next
  explicit choice heals it.
2026-09-05 23:30:40 +02:00
Arkadiusz Fal
ccf5c51460 Keep an automatic repair from ever reaching the server
A repair carries the timestamp of the selection it replaced, which is what
stops it outranking a deliberate choice made elsewhere — but it also makes
every ordering comparison read it as that choice. A fetch holding the
selected account's tombstone and an older server context applied them in
that order: the tombstone repaired the selection, and the older context
then lost to it, so the "the server holds the stale copy" path marked the
repair dirty and uploaded an automatic fallback under the deleted choice's
timestamp, over an explicit choice. The stored context now remembers
whether it is a repair, and a repair that wins an ordering pushes nothing
back: the server's context stands remotely, the fallback stays local, and
the next explicit choice on any device re-converges everyone. The bit is
persisted with the context, or a relaunch would forget and publish it on
the first stale record it read.

Three more from the same trace:

- The repair prefers a signed-in login on the same server, decided off
  statuses, and it commits — once the selection no longer names the deleted
  account there is nothing left for a later status to move. Foreground
  activation refreshes statuses ahead of it, but a deletion delivered by a
  silent push arrives outside that sequence, and a credential that reached
  this device over iCloud Keychain while it was in the background announces
  nothing. The refresh is now asked for by the repair itself, which closes
  every entry point rather than each fetch trigger having to remember one.

- A device that meets a tombstone before the account it kills cannot record
  which instance that account lived on, and the account record arriving
  afterwards was discarded — with the only copy of that fact still in it.
  Every later repair of a context naming it then fell to local past a
  usable sibling. The association is kept as the record is dropped.

- Removing an orphaned account deleted its credential before recording the
  deletion, so a crash in between brought the account back on the next
  launch as one nobody can sign into, owing no deletion to any device. The
  outbox goes first, here and in the sign-out and instance cascades; the
  scoped credential key needs only the account's ID, and the store is
  resolved through the instance before anything is removed.
2026-09-05 23:06:10 +02:00
Arkadiusz Fal
fbeae586b0 Only a deletion the app can point at moves the selection
`correctIfContextRemoved` read a locally missing account as a deleted one
and reset the context with `setContext(.local)` — a new timestamp and a
pending upload. Now that foreground activation calls it, it ran before
the fetch that delivers the account record: a perfectly good remote
choice was reset and then published over everybody else's. It now asks
`AccountsManager` for a repair keyed on the deletion tombstone, which is
the same repair a deletion performs — a signed-in login on the same
server when there is one, the replaced choice's timestamp, nothing
uploaded. The toast names where the selection actually landed.

Adopting a synced context that named a deleted account repaired it but
kept whatever upload the previous choice still owed, so the sync engine
published the automatic fallback under that choice's timestamp — exactly
what not stamping it "now" exists to prevent. The adoption clears the
flag, and only on adoption: a local choice that outranks the repair keeps
its own upload.

The channel button's subscription lookup is fenced to the channel and the
round it was asked in, so an answer that lands after the user has walked
on — or after the account behind it changed — no longer writes itself
into the button on screen. Context and credential changes ask again. A
destination toggle is compared against the effective context rather than
the raw selection: the menu offers Local precisely when the selected
account cannot be written to, and that toggle has to be the one the
button then shows.

The destination rows forget their checkmarks when the logins change. The
answers describe credentials that have been replaced, and the tvOS picker
deliberately stays open across exactly that.

The subscriptions settings screen counts and exports what it is actually
reading from. The fetch behind both already followed the effective
context while the branch and the footer followed the selection, so an
unavailable account meant local data described as the server's.

The legacy `subscriptionAccount` migration refused to adopt a context
whose account had not synced yet and was never retried, leaving a fresh
install on local until the next launch. A synced account arriving now
retries it; the migration's own guards keep it a one-way move that never
overrides a choice made in the meantime.

The remaining negative-able Piped counts — likes, channel subscribers and
videos, playlist video counts — go through `PipedSentinel.count` like the
views and upload dates already did.
2026-09-04 23:56:39 +02:00
Arkadiusz Fal
0c5745e4fb Let a subscribe name the account it lands on
Subscribing went wherever the selected account happened to be, and the
button offering it read the local store first — so under a server account
it claimed a channel was subscribed when only the device knew about it.
The button now asks the active provider and nothing else, except under a
local context, where the local store is that provider and can answer
without a hop.

The channel menu gains a destination list: local, plus every account
whose backend does subscriptions, whose instance is switched on, and
which has a credential on this device. A row names its own target, so it
deliberately does not care whether the selected context can be written
to — that is precisely the case where naming somewhere else is the only
way to subscribe at all. The main button is disabled instead, with the
reason beside it. tvOS reaches the same list through its own button and a
full-screen picker, because a menu cannot be driven with a remote.

Membership is asked for only once the rows are on screen: an unwarmed
provider answers by fetching that account's entire subscription list, and
asking every destination up front is a burst nobody requested. Answers
carry the round they were asked under, and "not asked yet" is kept apart
from "asked, not subscribed" — a checkmark the user acts on must not be a
guess.

SubscriptionService grows the destination-parameterized subscribe,
unsubscribe and membership calls the menu writes through, with the
context-implicit ones left as wrappers over the current context. A
membership answer that outlived its login now says nothing rather than
"no". Building a provider moves behind SubscriptionProviderFactory, which
is what lets a test see which destination a write was routed to without a
server behind it.

The unsubscribe-only screens are untouched: they act on the account being
browsed, which is the account whose list they are showing.
2026-09-04 23:18:04 +02:00