- 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
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.
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.
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
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
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.
The screen read local playlists and nothing else, so selecting an account
moved the subscriptions and the feed but left this list showing the
device's own playlists with no way to tell. It now follows the effective
context: local as before, or the account's list read live out of
RemotePlaylistsCache — never copied into state, because every change to a
server-side playlist goes through that cache and reading it is what makes
a rename or a delete show up.
Rows carry both the account's instance and the account's id, which have
to agree or UnifiedPlaylistDetailView drops ownership and the playlist
loses its edit and delete. Editing refetches first, exactly as the detail
view does: the form saves every field at once, and one started from a row
fetched minutes ago would put that row back over anything changed since.
Deleting from a server asks first; deleting a local playlist stays
unconfirmed as it always was.
Row identity, item mapping and the search filter move into
PlaylistListItem, where they can be tested — the transition id stays
separate from the row identity because it has to match what
PlaylistSource produces for the destination.
Local behaviour is unchanged throughout: the same rows, the same swipe
actions, the same unconfirmed delete, and the same synchronous read on
appear so the empty state never flashes.
Home feeds and subscription providers join the subscriptions feed in
dropping results that belong to a login the app has since moved off.
Clearing a cache does not cancel what is already awaiting the server,
and an Apple-account switch discarded providers without invalidating the
fetches holding them.
A sign-in whose credential the Keychain refused to store is now rolled
back rather than deleted. Creating an account also queues the permanent
marker that stops every other device synthesizing an account for that
instance's pre-revamp login; leaving it behind would suppress a still
valid legacy login everywhere on the strength of an account nobody has.
An account the server already confirmed is still deleted properly,
tombstone and all.
Caches with no recorded owner are discarded even when the identity
cannot be resolved at all — that is the same first launch after
upgrading, only offline.
An account whose credential is missing no longer hides a signed-in
sibling on the same instance, which made the whole instance report
itself as logged out. And connectivity returning recomputes statuses, so
an account marked unreachable retries instead of waiting for a
foreground transition it has no way to cause.
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
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
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
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
The fullscreen button did nothing in sheet mode: AppKit ignores
toggleFullScreen on a window attached as a sheet, and the old fallback
targeted the key window — the sheet itself. Two other strategies were
ruled out first: fullscreening the sheet's parent is silently refused
while any sheet is attached, and resizing the sheet's backing window to
screen size NaN-asserts inside AppKit's sheet positioning even without
animation.
Working approach: swap the sheet for a plain overlay in the main window
and native-fullscreen the now sheet-free main window.
- isMacPlayerFullScreenOverlay on NavigationCoordinator dismisses the
sheet (presentation binding excludes it; the setter ignores the
dismissal write-back) and mounts ExpandedPlayerSheet as a black-backed
overlay in ContentView
- ExpandedPlayerWindowManager fullscreens the parent after the sheet
detaches, verifies AppKit accepted it (rolls back if refused), hides
the window toolbar (NSWindow keeps it as a strip above the overlay
otherwise) and restores it on exit
- a didExitFullScreen observer restores the sheet on any exit path
(button, Esc, green button, Window menu); closing the player while
fullscreen exits the main window's fullscreen too
- scheduleSharedViewAdoptionRetry on entry re-homes the shared render
view once the dismissing sheet's window loses visibility — its steal
is declined mid-dismissal and nothing else retried, leaving the
overlay black until the next video load
Render subscriber count, Subscribe button, and (for subscribed
channels) description in the tvOS loading state instead of just
avatar + name + spinner. Seed the in-memory author cache when
navigating to a channel from a video so the first-time channel
view has a name and avatar to display immediately.
When the same video was already loaded (typically paused), opening it
again via the URL scheme, a deep link, or a remote-control loadVideo
command did nothing — the player just stayed paused. Now the same-video
early-return path resumes playback if paused and seeks to the supplied
startTime, so timestamps from URLs and remotes are honoured even when
the video is already loaded.
URLRouter gains a parseTimestamp helper that reads t/time/start query
params in plain-seconds and YouTube-style (1h2m3s) forms, and the deep
link handler now forwards that timestamp through to openVideo.
Tapping bit.ly/tinyurl/t.co/etc. in a description or comment previously
opened Safari even when the destination was a playable YouTube URL.
Added an opt-in "Resolve Short Links" toggle under YouTube Enhancements
(off by default) that follows the redirect on tap: if the target is a
YouTube/PeerTube/direct-media URL, open it in-app; otherwise prompt the
user before falling back to yt-dlp extraction or the browser.
Also added a confirmation dialog for non-shortener links that only
matched the loose .externalVideo yt-dlp fallback, so arbitrary web
pages in descriptions no longer silently kick off extraction.
Prompts live on NavigationCoordinator and are dual-hosted by YatteeApp
and ExpandedPlayerSheet so they remain visible whether or not the
expanded player is covering the main view.
Description links to YouTube videos, channels, playlists, and external
video URLs now open in Yattee instead of Safari. When a video is
already playing, tapping a video link surfaces the existing
QueueActionSheet (Play Now / Play Next / Add to Queue) — the sheet is
hosted both at the app root and inside ExpandedPlayerSheet so it
appears above whichever layer is on screen.
Reset isCommentsExpanded and commentsFrame on the NavigationCoordinator
directly when the portrait panel is dismissed, since PortraitDetailsPanel
owns its own @State that doesn't sync back through .onChange during dismiss.
Also track comments overlay frame via GeometryReader so the dismiss gesture
can allow swipes outside the comments area instead of blanket-blocking.