The corner layout's below-video host is the portrait info panel, not a
page host: it can render only Info, plus Comments as the panel's pill on
iOS. The page editor still offered "Below Video" for every page, so
assigning Related there was accepted, stored, listed as such, and then
silently rendered in the side column.
- The Layout section gains a "Below Video" picker in corner mode:
Nothing / Info / Info and Comments (macOS offers the first two). It
writes the info/comments regions; the per-page Location section and
its strings are gone.
- PanelSettings.normalizedPages clamps regions to what the host can
render (only info/comments below, comments only together with info),
so older presets self-correct. effectiveRegion(for:) resolves the
platform-specific placement and is used by both the pages list labels
and CornerPlayerLayout, replacing its own region checks.
- The layout schematic drops the below-video block for "Nothing"; the
corner-mode footer explains the constraint.
- Tests cover the picker round-trip and the clamping.
On iPad both orientations show the video next to a panel, the corner
layout hosts the portrait panel in landscape, and Split View windows make
"portrait" mean "narrow", so the portrait/landscape visibility split never
matched what the user saw. PillVisibility now treats any mode except Never
as visible on iPad, and the pill editor shows a Show pill toggle there
(writes Both/Never, reads synced orientation modes as on). iPhone keeps
the four-way picker.
ExpandedPlayerSheet's bottomPillsOverlay was unreferenced; the floating
panel renders its own pill row.
The pin now sits as an icon-only pill at the leading edge of the page
bar, before the page pills, instead of floating on the resize grabber.
It stays fixed while the pages scroll and fills with the accent color
while pinned. The page bar is kept visible when a pin action is present
even if only one page is available, so the control never disappears.
iPhone (floating-only) still shows no pin.
Removes PanelPinButton, which had no other users.
Pages list
- Info is pinned first and always enabled in every layout: normalized
pages force it to the front and enabled (older presets self-correct),
disabling it is a no-op, and any move that would displace it snaps
back. The iOS/macOS list renders it outside the reorderable ForEach
(no drag handle, nothing drops above it) and the editors hide its
enabled toggle; tvOS also hides its move buttons. The "at least one
page must stay enabled" guard and its strings are gone since Info
always counts.
- iPhone never renders queue or comments as pages, so its Sections list
hides them (PanelSettings.hiddenSettingsPageTypes). Reordering goes
through moveVisiblePages, which keeps hidden entries in their stored
slots. Comments get their own group on the Details Panel screen
(pill mode + avatars) and the overlay hosts pass the avatars flag.
Default visibility
- "When Player Opens" is now configured per orientation
(portraitDefaultVisibility / landscapeDefaultVisibility). The legacy
single value is still decoded and seeds both until set individually;
presets that never set it default to portrait Visible, landscape
Hidden. macOS shows one picker (no portrait layout).
- "Remember Last" no longer leaks across orientations: separate
remembered flags, written by the portrait toggles and by the wide and
corner layouts respectively, with the old shared flag read once as a
fallback. The corner layout seeded from the portrait resolution
before; it now uses landscape.
Section options (iOS inline-sections hosts)
- Chapters and Related editors gain "Expanded by Default" (on by
default). The Info editor gains a Description group with "Expanded by
Default" (off) and a "Collapsed Lines" stepper (1–10, default 3).
Applied when the panel appears and on each video change.
- Expanding a collapsed section scrolls its header to the top of the
panel: PanelPageScrollView can hand its scroll proxy to page content
and each inline section is wrapped in one anchored container.
- The portrait panel's bottom fade sized its touch blocker from a stale
"expanded comments pill" answer that ignored collapse on scroll, so a
collapsed section header (the last item) sat under it and could not
be tapped. The predicate now tracks the pill's actual collapse state.
iPhone landscape now uses the floating panel only, always on the right;
the pin and side-toggle buttons on the grabber are gone and the pinned
setting resolves to false on phone.
iPad and macOS default to the pinned side-by-side layout, keep the pin
button to switch to floating, and pick the panel side in the preset's
panel settings instead of an on-screen arrow. The pinned state persists
as a layout preference: closing the player or the hidden default
visibility no longer resets it.
Removes PanelAlignmentButton, the global floatingDetailsPanelSide setting
and its key, FloatingPanelSide.opposite, and the legacy
wideLayoutPanelAlignment preset field.
WideScreenPlayerLayout looked up safe area insets and window bounds only
from a foregroundActive scene. Notification Center and Control Center move
the scene to foregroundInactive, so the lookup failed, insets dropped to
zero and the panel and video jumped to the screen edge. Fall back to an
inactive foreground scene (and then any scene) before giving up.
The "more" label covered the end of the last line with a
systemBackground gradient, which rendered as a black block over the
floating panel's translucent material in landscape. The text is now
alpha-masked so the fade works on any host background.
iPhone has no page switcher: the portrait panel already rendered one info
page with chapters and related videos as collapsible inline sections. The
landscape floating panel now renders the same panel (no page bar, comments
stay on the pill/overlay), and the settings screen calls the list
"Sections" there, with the page bar labels toggle hidden.
The inline sections were hardcoded to chapters then related and ignored
the configured order, enablement, and thumbnail toggles. The info page now
receives the enabled section pages from PanelSettings.inlineSections(in:)
and renders them in list order with their own settings.
The unpinned floating details panel forces a dark color scheme on its
content so it reads over the video. The play now / play next and resume
sheets opened from a related video inherited that scheme, giving white
text on the light system sheet background.
The panel now publishes the real system scheme via a
presentationColorScheme environment value and the sheets reapply it.
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.
The quality selector sheet already covers quality, audio, subtitles and
rate, so the embedded tracks page duplicated it. Drop the page type, its
per-page settings, the editor toggles, and the visibleSections plumbing
in QualitySelectorView.
The handler picked the first scroll view under the nearest ancestor,
which in the corner layout is one of the side column pages, so pulling
down on the expanded below-video panel never collapsed it. Match the
background view's window frame instead and re-attach when the match
changes.
The fullscreen video layer extends up into the status bar strip and is
shifted with an offset; `.clipped()` came after the offset, so it clipped
to the unshifted frame and cut the top strip (title row, avatar) off. Clip
before the offset instead.
The player controls read the main window's safe area only on geometry
changes. With the corner layout keeping its geometry fixed across the
status bar hide, the value went stale and the controls kept the status bar
height as top padding in fullscreen. Refresh it when the status bar
visibility changes, with a few delayed re-reads to cover the fade.
The fullscreen toggle used to slide both panels off screen while the video
frame grew into the freed space. Panels now stay at their home positions
as layers under the video, and the video frame grows over them to hide and
shrinks back to reveal. Both panels stay in the hierarchy while covered, so
scroll position and loaded content survive toggles; hit testing,
accessibility and the dismiss-pan exclusion frames are switched off while
covered (PanelResizeGrabber gets a publishesFrame flag for this).
Side column gestures (grabber overdrag, trailing edge swipe) drive the
video's trailing edge over the static column. The below panel's handle drag
keeps sliding the panel off the bottom, with the video growing in lockstep
so the edges coincide. The below panel is raised above the video only while
expanding over it, and drops back under it after the collapse spring has
fully settled.
Hiding the status bar in the fullscreen state changed the top safe area and
reflowed the whole layout mid-transition. The layout now ignores the top
safe area and pads by a fixed status bar height (remembered by
StatusBarVisibilityController while the bar is visible); the fullscreen
video grows up into that strip as part of the same animation.
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.
The corner layout side column puts a 12pt resize grabber strip before the
panel, and every page then added the standard 16pt horizontal padding, so
content sat ~28pt from the left edge but 16pt from the right.
Add a PanelHorizontalInsets environment value with a panelHorizontalPadding()
modifier that the info, comments, related and chapters pages use instead of
.padding(.horizontal). The side column trims the leading inset by the grabber
width so content is 16pt from the column's outer edge on both sides; other
hosts keep the defaults. Queue and tracks are List-backed and get the same
leading delta as a scroll content margin. The page bar follows the same insets
so the first pill lines up with the content.
Section headers in the inline info panel now toggle their content with a
rotating chevron. Collapsed state resets when the video changes. Also let
the collapsed description text take full width.
The below-video panel now renders only when the info page is assigned
to that region; comments assigned below become the portrait panel's
pill (iOS), and every other below-video page falls back to the side
column instead of disappearing. Page selection snaps to the first
configured page when the current one is disabled or reordered away,
and the page bar highlight follows the actually rendered page. The
related tab stays visible with a spinner while video details load
instead of vanishing until they arrive.
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.
The pill mode now lives on CommentsPageSettings and is edited in the
comments page editor instead of a standalone settings section. The
legacy value on PlayerPillSettings is kept for decoding old presets
and migrated by effectivePanelSettings. Comments are loaded when
either the comments page is enabled or the pill is not disabled.
The panel content wrapper added a 10pt top padding that pushed the
scroll view down, sitting the title 6pt lower and hard-clipping
scrolled content below the drag handle instead of fading it through
the full top gradient. Drop the outer padding and restore the scroll
top inset to 10pt to match the pre-redesign look.
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
The grabber hit area was cut to 14pt because it blocked the page tabs;
portrait no longer shows the page bar, so bring back the 50pt area
covering the title strip.
Hide the page bar in the portrait details panel and fold chapters and
related videos into the info page as inline sections below a collapsible
description. The description clamps to three lines with an inline
baseline-aligned "more" fading into the last line; the whole collapsed
area is tappable to expand. The comments pill keeps its previous
behavior (teaser row, collapsing to a button on scroll) and opens the
comments overlay.
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).
The preset's When Player Opens setting only drove the landscape/floating
panel state, so on iPhone in portrait the details panel reappeared on
every player expansion regardless of the setting. Mirror the resolved
visibility into the portrait panel state, and seed it into the sheet's
initial state at all creation sites so the first frame already renders
the correct layout - applying it in onAppear animated a visible
top-to-center video jump during expansion. Portrait panel toggles now
also persist the last visibility so Remember Last works there too.
The invisible 50pt drag-grabber overlay covered the page bar and
swallowed taps on the info/comments/related/queue tabs. Limit the
grabber hit area to the 14pt strip above the page bar and exclude
the gradient from hit testing.
Phase 5 of the player panels redesign:
- Per-page element toggles now rendered and editable for every page:
comments (author avatars), related (thumbnails), chapters (thumbnails),
queue (previously played section), tracks (video/audio/subtitles/speed
sections)
- Page bar labels toggle (icons-only bar shows the selected page's name)
- Layout mode schematic diagram in the details panel settings
- NEW badge machinery for future page types (versionAdded on PanelPageType)
- Typed per-page settings accessors on PanelPageConfiguration and VM update
helpers for each page kind
- Unit tests for per-page settings round-trip and accessor fallbacks
The standalone quality selector sheet is unaffected: section gating defaults
to everything visible and only the embedded tracks page passes preset
settings through.
TVDetailsPanel now renders the active tvOS preset's panel pages (info,
comments, related, chapters, queue; tracks stays with the quality panel)
with runtime hiding of empty pages, a segmented picker for up to four
pages and a focusable capsule row above that, and Info element toggles
honored. Comments unify on the PlayerState loader; the tvOS queue list
is extracted from QueueManagementSheet into TVQueueListView and reused
as the queue page. New related/chapters focus-based pages: selecting a
related video plays it and dismisses the panel, chapters seek and keep
the panel open.
New minimal Settings > Player Panels screen edits the active tvOS
preset via the existing service: duplicate-to-edit, page toggles with
last-page guard, Move Up/Down reordering, and Info element toggles.
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.
Phase 1 of the player panels redesign:
- New PanelSettings model (pages, regions, layout mode, default
visibility) stored as an optional field on PlayerControlsLayout with
an effectivePanelSettings accessor; lenient Codable so future page
types and settings payloads never corrupt saved presets.
- New Views/Player/Panels content engine: PlayerPanelContentView
renders a page bar plus the selected page (info, comments, related,
chapters, queue, tracks), with runtime hiding of empty pages.
- PortraitDetailsPanel and FloatingDetailsPanel now host the shared
content instead of their duplicated info bodies; drag machinery,
pills, and fades are untouched.
- Comments list extracted into CommentsListContent shared by the
overlay and the comments page; comments pill routes to the comments
page (expanding the portrait panel) when the page is enabled, and
falls back to the overlay when it is not.
- Tracks page embeds QualitySelectorView via a new isEmbedded mode.
- Deleted the unused video info bodies in ExpandedPlayerSheet+VideoInfo
and the dead PlayerInfoTab model.
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.
Materials render nearly transparent over the video layer on Apple TV HD,
making the material-only focus difference invisible. Focused buttons now
get a solid white fill with a black icon, AVKit-style.
The player and its overlays (details panel, quality/queue panels,
error cover) always sit over dark video and hardcode white text, but
system controls — the Info/Comments segmented picker and the View
Channel bordered button — followed the device color scheme, rendering
near-invisible dark-on-dark under the light scheme.
The video title and TVScrollableDescription hardcoded white text, which
was unreadable on light backgrounds. Description colors now adapt to the
color scheme by default; the player details panel opts back into white
via overlaysDarkBackground since it always sits over a dark backdrop.
When enabled (the default), share links to YouTube videos point to the
active source's web watch page instead of youtube.com. Invidious serves
its frontend at the API URL; Piped uses its configured frontend URL.
Videos the active source can't serve (PeerTube, extracted sources) and
sources without a web frontend keep their original links.
The toggle lives in Settings > Layout & Navigation > Sharing on iOS and
macOS, and is shown only when an enabled source has a web watch page.
The fullscreen button routes through a portrait-video check (toggling
the details panel instead of rotating), but the double-tap gesture
called onToggleFullscreen directly and always rotated to landscape.
Extract the shared decision into PlayerControlsActions.performFullscreenTap()
and use it from both the button and the tap gesture handler.
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.
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.
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.
Media-source streams from WebDAV/SMB were invisible in the Video list:
the filter required remote URLs to carry a resolution, which
MediaFile.toStream never sets. Include non-adaptive remote streams
without resolution (only media sources produce those), extend the
mpv-track display enrichment to the currently playing stream regardless
of URL scheme, and classify metadata-less streams as recommended — a
nil codec previously counted as software-decoded, hiding the row behind
advanced mode with a spurious warning. The warning now uses the
enriched codec.
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.
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.
Aspect-driven window sizing (contentAspectRatio + setFrame) ran even while
the separate player window was in native fullscreen, making AppKit fit and
center the content inside the fullscreen space with black bars around it.
Defer the aspect lock and auto-resize while fullscreen by parking the ratio,
then replay it in windowDidExitFullScreen so the window is ratio-locked (and
auto-fitted when that setting is on) once back in windowed mode.
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.