Related and queue rows in the player use the default menu context, so the
pushed screen landed underneath the still-expanded player. Collapse on
player state instead of the .player context. Recommendations without an
authorId no longer offer Go to Channel, which opened an empty channel.
Wide videos showed letterbox bars in the tiny iOS mini player preview right
after the dismiss morph, which lands the video filling the preview. The
container that takes the shared player view now sets mpv's keepaspect, so
the mini player fills its frame and every other surface keeps the aspect
ratio.
Releasing the dismiss drag now shrinks the player window onto its video and
flies it into the mini player's preview instead of sliding it off screen and
showing the mini player afterwards. The mini player stays mounted under the
expanded player and reports its preview frame, and the window is masked down
to the video while its root view is transformed onto that frame (with the
safe area the transform drops added back, so the content doesn't jump).
The handoff of the shared MPV view is made seamless: a freshly made player
container waits for its first layout before taking a view that is on screen
elsewhere, sizes and redraws it there, and the window hides in that same
frame. Resizing the render view draws the current frame into the recreated
framebuffer right away instead of leaving it blank until the next tick.
Falls back to the slide when no preview target is available (rotated
session, video preview hidden, different window size).
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
Route YouTube search results links to search and nocookie embeds to the
video, match YouTube hosts exactly, and report YouTube pages without a
video as unsupported instead of sending them to yt-dlp. A watch link
inside a playlist shows both the video and the playlist when loaded, and
plays the video with the playlist queued around it. URL scheme play,
queue and bookmark actions accept playlist links.
Load All in Open Link opens an Opened Links page that resolves each link
into a video, playlist or channel row like search results, in input
order. Video rows keep the usual context menu and swipe actions, and the
page can play, queue, bookmark or download all resolved videos.
Open Link gets a Load action that resolves one link and shows its video
details page, playlist or channel screen instead of only playing or
downloading it. Play and Download stay as shortcuts for video links.
Timed links start at their timestamp from the details page, the Show
Details link action opens the details page, and a short extraction cache
avoids re-extracting the same link for details and playback.
On tvOS SwiftData keeps the store in the app group's Library/Caches,
which the system may purge. The CKSyncEngine tokens live in UserDefaults
and survive, so the next launch opened an empty store and fetched only
records changed since the old tokens; subscriptions and bookmarks that
had not changed since never came back.
DataManager now records whether the store file existed when the
container opened, and the sync engine clears its persisted state once
per launch when it did not, so the first sync after a loss is a full
fetch. Also log the store path and a persistent-store failure that
previously fell back to in-memory storage silently.
A tvOS sheet fills most of the screen, far too much for a two-button
question. The shared sheet host now presents link action confirmations
as an alert there, answering cancel when it goes away without a choice.
A toast shown while a shared sheet was up, such as a failed link action
over Open Link, appeared behind the sheet. Sheets from .appSheetHost now
carry their own toast overlay, and only the top-most overlay of a scope
shows its toasts.
Piped's /channel/{id} rejects handles, so channel pages, tabs and
subscribe links for youtube.com/@name failed. Resolve the handle
through /c/@handle once per instance and use the channel ID.
Invidious' channel endpoints take only UC… IDs and return 500 for a
youtube.com/@handle, so channels opened from handle links (and the
subscribe link action) failed with "Could not get channel info". Resolve
the handle through /api/v1/resolveurl first, cached per instance.
Play Video, Add Video to Queue, Download Video, Bookmark Video, Subscribe
to / Unsubscribe from Channel, Control Player, Seek, Set Playback Speed,
Get Now Playing, Open Section and Search Videos. Each runs the same
LinkAction as the matching yattee:// link through LinkActionPerformer,
without the confirmation sheet (the person built the shortcut). Failures
surface their message in Shortcuts.
App Shortcuts: control player, now playing, open section, search.
yattee://x-callback-url/{verb}?… runs the same action as yattee://{verb}?…
and then opens x-success with the result's values appended, x-error with
errorCode/errorMessage, or x-cancel when a confirmation is declined (x-error
with errorCode=cancelled when no x-cancel is given). Unknown verbs report
unsupportedAction. Callbacks to file:, javascript: and data: URLs are ignored.
Adds yattee://player/state, which reports what is playing (title, videoID,
channel, playing, position, duration, rate) without changing anything.
yattee://play|play-next|queue|download|bookmark|subscribe|unsubscribe?url={link}
and yattee://player/play|pause|toggle|next|previous|close|seek|rate run through
one LinkActionPerformer that reports a LinkActionResult, ready for
x-callback-url. An unencoded url= keeps its own query; only the action's own
parameters end it.
Actions that change saved state ask first through a confirmation sheet
presented by the top-most sheet host, unless the new 'Run Link Actions
Without Asking' setting is on. Download skips the question when the quality
picker will show anyway. Player commands leave sheets and the player alone.
SettingsView owns its navigation stack, so pushing it onto a section's
stack rendered nothing. Select the Settings section where the layout has
one, otherwise present Home's settings sheet; open the Settings window on
macOS.
- Route legacy yattee://watch?v=, yattee://youtube.com/..., yattee://https://...
links like their web equivalents (only natively handled destinations)
- Unwrap yattee://open?url= inside route() so every caller keeps the inner
URL's own query items
- Never route file:// URLs as direct media
- Support source/instance on yattee://playlist and PeerTube /w/p/ and
/video-playlists/ URLs
- Leave open sheets alone and show a toast for links that go nowhere
Both were DEBUG-only Developer settings entries used while building
the accounts revamp and are no longer needed. The simulator's
account-sync suspension barrier in AccountSyncCoordinator existed only
to keep invented scopes away from the real zone, so it goes with it.
Tapping a new-videos notification while Home's Settings sheet was up
switched to the Subscriptions feed underneath the sheet, so the app
came back still showing Settings. The existing dismissSettings() hook
was never called.
NavigationCoordinator.dismissPresentations() bumps a generation that
the main window's root sheet host and screen sheets (Home's Settings,
Remote Devices, Customize Home; Subscriptions' view options and data)
observe via .dismissesOnExternalNavigation(_:). The notification,
deep links and Handoff call it; in-app navigation and layout swaps do
not. The expanded player keeps its own sheets.
The notification also collapses the expanded player so the feed it
opens is visible.
In the split-view sidebar layout (macOS, and now iPad and the iPhone
Duo), the detail NavigationStack that a selection change replaces
writes an empty path through its binding on the way out, so going to
another section and back always landed on the section's root. macOS
already behaved this way before the router.
Only the selected section's stack is on screen and can pop, so path
writes for any other section are ignored.
The swipe-action share sheet, the expanded player's safe area and the
Dynamic Island side check took connectedScenes.first, which ignores
which scene is active (on the iPhone Duo it can be the other panel's)
and presented the share sheet from the main window, behind an expanded
player.
UIApplication.foregroundWindowScene/foregroundWindow resolve the
foreground-active scene (else foreground-inactive, else any) and its
key window, the order the widescreen layout already used. The share
sheet now presents from the key window, which is the player's while it
is up. The tvOS display mode manager uses the same lookup.
Error details were declared in the video info view and the expanded
player, and Open Link three times: at the app root for deep links and
for the File menu command, and on Home. All of them are now AppSheets;
the deep link and menu requests go through the global request, so a
deep link that arrives while the player is expanded no longer opens the
sheet behind it.
The four sign-in modals that were a full-screen cover on tvOS and a
sheet elsewhere, each written out twice, use a platformSheet helper.
Each video row carried its own sheets: tappableVideo added the queue
action and resume sheets, the context menu and swipe actions added
add-to-playlist and download options, so a row could declare up to six
sheets, owned by a row that a list may recycle.
Rows now present those through the nearest sheet host, as does the
video info view's resume prompt. The queue action sheet for a tapped
description link becomes a global request instead of being declared
at both the app root and the expanded player.
Sheets take the color scheme where their host sits, so the floating
details panel's dark override no longer reaches them and the
presentationColorScheme workaround is gone.
The add-to-playlist, download options and queue sheets were declared in
every view that could open them: the root view (twice, mirrored from
coordinator flags), the expanded player, its portrait and floating
panels, the player controls and the video info view.
AppSheet lists the shared sheets, and .appSheetHost() declares them
once. The app root and the expanded player each host them, and every
presented sheet hosts the next level. Views present through the
presentAppSheet environment action, which reaches the nearest host;
code without a view (and the expanded player itself, whose host sits
in its own body) uses PresentationRouter.request, taken by the
top-most host: the player while it is up, the root otherwise.
The mini player's queue and playlist sheets now go through the host,
replacing the isMiniPlayer*SheetPresented flags.
The sidebar layout was gated on iPad idiom. It now appears wherever there
is room for it: regular width and regular height, or an iPad with
regular width (a short Stage Manager window keeps it). That covers the
Duo inner screen in both orientations, while large iPhones in landscape
and the Duo outer screen keep the tab bar. The subscriptions feed's
channel sidebar follows the same rule, and Sidebar settings are offered
on devices with a hinge.
SwiftUI's sidebarAdaptable TabView never shows a sidebar on iPhone
idiom, so the iOS sidebar layout now uses the NavigationSplitView with a
list sidebar that macOS already used, on iPad as well. The sidebar
layout shows the floating mini player instead of the tab bar accessory.
The tab bar and sidebar layouts each kept their own navigation paths and
selection, so swapping between them (resizing an iPad Stage Manager
window across the size-class breakpoint) lost every pushed screen and
re-applied the startup tab.
NavigationRouter now holds the selected section, a typed stack per
section keyed by SidebarItem.id, the search text and the sidebar
manager. Both layouts select with SidebarItem and render the router's
state; navigate(to:) pushes onto it directly instead of going through
an observed pendingNavigation, so a request made before a layout
appears is not lost, and a destination pushed before startup keeps its
section. When a layout can't show the selection (a sidebar channel in
the tab bar, a section hidden by customization) it lands on the section
that can host it with that screen pushed first.
This also fixes keyboard section commands in the tab bar layout and the
coordinator's stale tab on iPad. AppTab, pendingNavigation,
selectedSidebarItem and the unused SheetDestination/path API are gone,
and the iOS 18 and 26.1 root views are merged into AdaptiveTabView.
Downloads lived in Documents/Downloads and were included in device
backups. Mark the directory as excluded on every launch, and run a
one-time migration flagging files downloaded by earlier versions.
Download records stay in UserDefaults and are restored with a backup
while the files are not, so active downloads now reset finished phases
whose files are missing instead of completing with a dangling file.
AVSampleBufferAudioRenderer is the system media path, so it gets Spatial
Audio / Spatialize Stereo and head tracking, which audiounit and coreaudio
output never do. iOS now uses avfoundation,audiounit like tvOS, and all
macOS versions use avfoundation,coreaudio (previously only macOS 27+).
On macOS 27 AVKit can ignore a programmatic stopPictureInPicture while
PiP is active: no delegate calls, and the PiP window stays on screen
after the video is closed. If AVKit hasn't begun stopping 0.5 s after the
request, release the controller (which closes the window), run the
didStop cleanup and create a fresh controller.
Once PiP starts the player's container view collapses to zero, and
AVSampleBufferDisplayLayer stops laying out its internal video sublayer,
so resizing the PiP window left the video at its starting size in the
bottom-left corner. Force the layer's layout after each resize.
The in-app orientation lock pinned the player to the orientation it
opened in even where programmatic rotation is refused, so rotating the
half-open device did nothing. OrientationManager now ignores lock
requests and drops the current lock while canRotateProgrammatically is
false. The YATTEE_FORCE_TABLETOP override only applies on the interior
display size and flags the same state, matching a real .partiallyOpen
hinge instead of forcing the layout on the folded exterior display.
The iPhone Duo tabletop layout mirrors every presented frame into the
lower half, blurred and fading out, like a screen standing on a glossy
table. The render view blits a quarter-resolution copy of the display
framebuffer into a ring of IOSurface-backed buffers (no Y flip, so the
surface is already mirrored) and hands it to a CALayer hosted by
MPVReflectionView; the simulator's software renderer feeds the same
layer a flipped CGImage. Off during PiP, for audio-only streams, and
via the new Playback → Foldable → "Reflection Below Video" setting.
The interior display refuses programmatic rotation
(UISceneErrorDomain 101: "The current windowing mode does not allow for
programmatic changes to interface orientation"), so the rotate/fullscreen
button did nothing there and the failure was swallowed.
- OrientationManager tracks `canRotateProgrammatically` (from the hinge
state, cleared when a geometry request is refused) and is @Observable.
- The fullscreen button takes the iPad path when rotation is unavailable:
details toggle in portrait, floating panel toggle in landscape, icon
follows the panel state; orientation lock is hidden. The refused request
itself falls back to the details toggle. Accelerometer-driven rotation
requests are gated the same way.
- Corner layout gate is size-only on iOS (≥900×600 including safe areas,
so the status bar toggling with the layout can't oscillate it); the
layout-mode setting is exposed on foldables via a hinge presence check.
- Corner layout on iPhone always hides the status bar (the interior's
landscape bar is a ~57pt column) and pads by the live top inset only,
instead of the transient 82pt inset seen while unfolding.
- The player hosting controller nudges its safe area after size
transitions so fold/unfold doesn't leave SwiftUI with stale insets.
- 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
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.
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.
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.
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.
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
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).
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 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.
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.
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.
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.
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.
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.