Commit Graph

3272 Commits

Author SHA1 Message Date
Arkadiusz Fal
6da1cfc614 Keep the link action confirm button on one line
"Unsubscribe" wrapped on 5.4-inch screens.
2026-10-06 23:58:02 +02:00
Arkadiusz Fal
77b09ea802 Resolve @handle channel links on Invidious
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.
2026-10-06 23:43:03 +02:00
Arkadiusz Fal
9f4170ac00 Link the automation docs from the README 2026-10-06 23:39:09 +02:00
Arkadiusz Fal
2fa3027a42 Add App Intents for Shortcuts and Siri
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.
2026-10-06 08:05:54 +02:00
Arkadiusz Fal
c579ee63a2 Support x-callback-url for URL scheme actions
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.
2026-10-05 23:59:05 +02:00
Arkadiusz Fal
5ae07574cb Add URL scheme actions: play, queue, download, bookmark, subscribe, player controls
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.
2026-10-05 23:58:43 +02:00
Arkadiusz Fal
ac9fbec255 Move Open Link's video resolving and auto-download into LinkVideoResolver
No behaviour change; URL scheme actions will reuse it.
2026-10-05 23:54:44 +02:00
Arkadiusz Fal
498a282396 Open Settings from yattee://settings
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.
2026-10-05 23:37:14 +02:00
Arkadiusz Fal
dea4f590ec Restore 1.x yattee:// links and harden URL scheme routing
- 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
2026-10-05 23:33:48 +02:00
Arkadiusz Fal
1bb78d32bf Remove keychain provenance and identity scope simulator debug tools
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.
2026-10-05 09:01:59 +02:00
Arkadiusz Fal
cf90eb4d4e Dismiss sheets when navigation arrives from outside the app
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.
2026-10-04 23:50:39 +02:00
Arkadiusz Fal
4a4bcd6ca8 Keep a section's stack when the sidebar selection changes
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.
2026-10-04 23:32:44 +02:00
Arkadiusz Fal
5f31610489 Look up the foreground scene instead of the first connected one
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.
2026-10-04 23:27:18 +02:00
Arkadiusz Fal
1c7314f74a Present deep-link download options through the shared sheet host
The download deep link kept the video, streams and captions in separate
states next to a presented flag, and the sheet was declared only at the
app root, so it opened behind an expanded player. It is now a global
request for the download options AppSheet.
2026-10-04 22:56:05 +02:00
Arkadiusz Fal
3e561cc3b5 Present error details and Open Link through the shared sheet host
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.
2026-10-04 21:04:19 +02:00
Arkadiusz Fal
e3d3809f2f Present row sheets through the shared sheet host
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.
2026-10-04 09:07:05 +02:00
Arkadiusz Fal
28a75792e8 Present shared sheets through one host per window
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.
2026-10-03 23:45:08 +02:00
Arkadiusz Fal
c0495f3174 Show the sidebar layout on the iPhone Duo inner screen
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.
2026-10-03 23:26:15 +02:00
Arkadiusz Fal
b26b7d342f Keep navigation state in a router shared by both iOS layouts
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.
2026-10-03 23:24:50 +02:00
Arkadiusz Fal
79202667c0 Show mini player sheets already requested when the root appears
The root view mirrors the coordinator's mini-player queue and playlist
sheet flags into local state on change only, so a flag set before the
view first appeared was never shown. Read the initial value too.

Also remove the iOS Settings sheet driven by the .showSettings
notification, which nothing posts.
2026-10-03 23:22:08 +02:00
Arkadiusz Fal
6025933135 Exclude downloaded media from iCloud backups
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.
2026-10-03 22:45:07 +02:00
Arkadiusz Fal
86ff0d6a9e Prefer the avfoundation audio output on iOS and macOS
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+).
2026-10-03 11:33:48 +02:00
Arkadiusz Fal
8252dc0ea4 Return the tvOS details panel to the fixed Info/Comments tabs
Drop the preset-driven page list on tvOS (related, chapters, queue pages
and the Player Panels settings screen) and restore the segmented
Info/Comments picker, removing the now-unused strings.
2026-10-02 23:59:56 +02:00
Arkadiusz Fal
4e3d40ea98 Close the macOS PiP window when closing the video from the mini player
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.
2026-10-02 23:59:43 +02:00
Arkadiusz Fal
0a491cd2af Keep the macOS PiP video sized to the PiP window
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.
2026-10-02 23:59:27 +02:00
Arkadiusz Fal
30bc6840ba Cap the video info thumbnail width at 500pt 2026-10-02 23:59:15 +02:00
Arkadiusz Fal
0bd39f1f23 Mention swiping the thumbnail carousel in the play tip 2026-10-02 22:22:13 +02:00
Arkadiusz Fal
55722f6942 Keep the video info carousel out of the re-identified content subtree
The content VStack was re-identified with currentVideoIndex, so every
page change recreated the thumbnail carousel and reset its offset to 0
while the index already pointed at the next video. On the iPhone Duo
exterior display the recreated scroll view could not restore its
position, leaving the first card dimmed at peek opacity. Only the
sections below the header are re-identified now, and the carousel pages
use an explicit width instead of containerRelativeFrame so paging stays
aligned when the scroll view extends under a horizontal safe area.
2026-10-02 21:00:05 +02:00
Arkadiusz Fal
cd19262041 Allow pinch-to-panscan in the tabletop layout
Fill is computed against the upper half instead of the full screen, and
the hinge reflection is clipped to the lower half so a filled video's
mirror does not spill back over the hinge.
2026-10-01 23:54:01 +02:00
Arkadiusz Fal
6c199edc12 Read the player controls safe area from the hosting window
The safe area came from the first window of the first connected scene,
which on the iPhone Duo can be the exterior display's window with its
84pt vertical status bar inset. In Split View on the interior display
that inset became ~100pt of side padding on the top and bottom bars and
truncated the duration label. A HostWindowReader now captures the
window hosting the controls; the foreground scene's key window is the
fallback until it is known.
2026-10-01 22:49:43 +02:00
Arkadiusz Fal
40267573a9 Suspend the orientation lock while the Duo hinge is open
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.
2026-10-01 22:35:47 +02:00
Arkadiusz Fal
83895a1fd9 Reflect the video below the hinge in the tabletop layout
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.
2026-10-01 20:59:48 +02:00
Arkadiusz Fal
2fd52fff27 Support the iPhone Duo interior display in the player
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.
2026-10-01 08:53:37 +02:00
Arkadiusz Fal
2d65bc756f Animate the tabletop layout switch on hinge changes 2026-10-01 06:46:06 +02:00
Arkadiusz Fal
8a8f3a6000 Restore search field activation when selecting the search tab on iOS 27
iOS 27 changed the default TabSearchActivation so a role: .search tab no
longer focuses the search field on selection. Opt back into the iOS 26
behavior with .tabViewSearchActivation(.searchTabSelection).
2026-09-30 23:57:22 +02:00
Arkadiusz Fal
123be0b3ff Tighten the tabletop controls layout
The tabletop top bar sits below the hinge, not under the status bar, so
the top safe area padding is no longer added on top of the tabletop
offset. The side sliders reserve only the icon row and a small margin
from the lower-half center zone instead of the fullscreen allowance, and
the dimming scrim is skipped since no control overlaps the video.
2026-09-30 23:43:41 +02:00
Arkadiusz Fal
66d7d5b944 Keep tabletop video in the upper half while dragging the panel
The hidden video position was derived from the tabletop layout height,
which only applies while the panel is hidden. Dragging the visible panel
down therefore interpolated the video towards the full-screen center and
it snapped back to the upper half once the hidden state flipped. Derive
the hidden position from the hinge state directly so every drag, dismiss
and reveal targets the upper-half center.
2026-09-30 23:18:36 +02:00
Arkadiusz Fal
11a0dfddd0 Extend iPhone channel banner under horizontal safe area insets
On the iPhone Duo exterior display the vertical status bar adds an 84pt
trailing safe area inset, which left the channel banner short of the
screen edge. The banner and its gradient are now widened by the outer
container's horizontal insets on compact layouts and offset so each side
bleeds by its own inset; the avatar and name stay centered on the content
width. iPad/Mac keep using backgroundExtensionEffect.
2026-09-30 22:49:39 +02:00
Arkadiusz Fal
99d3d31031 Add iPhone Duo tabletop player layout and exterior display insets
Observe the foldable hinge with `onHingeChange` (iOS 27.1): while it is
partially open and the portrait panel is hidden, the video is fitted to
the upper half of the screen and every control (title bar, center
controls, side sliders, bottom bar) moves to the lower half. Pinch to
fill is disabled in that state. `YATTEE_FORCE_TABLETOP=1` in the launch
environment forces the layout on simulators without a hinge control.

The exterior display has a vertical status bar with a trailing safe
area inset, which squeezed the fullscreen portrait player into the
content width. The video and controls layers now span the full window
width (shifted under the leading inset, like the existing vertical
handling) and the controls pad their edges by the sheet's actual
asymmetric horizontal insets instead of the symmetric max.

Requires the iOS 27.1 SDK (Xcode 27.1).
2026-09-30 22:02:12 +02:00
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
3395847763 Use Command-Enter to open links on macOS 2026-09-29 23:34:56 +02:00
Arkadiusz Fal
1071fc56b3 Stop inline macOS playback when its window closes 2026-09-29 23:26:00 +02:00
Arkadiusz Fal
60222fa5f3 Fix inline macOS player aspect resizing 2026-09-29 23:08:16 +02:00
Arkadiusz Fal
6df08d2cbc Fix macOS related video carousel chrome 2026-09-29 23:06:52 +02:00
Arkadiusz Fal
0812485d0e Hide main window traffic lights with corner player controls 2026-09-29 22:20:28 +02:00
Arkadiusz Fal
423c310bd4 Show every video info action in the macOS toolbar
The toolbar layout on macOS now lists Play, Download, Share, Add to Playlist
and Bookmark as separate toolbar items, with a spacer setting Play apart from
the secondary actions. The primary action picker stays iOS-only, where the
pinned action plus the ⋯ menu layout is kept, and macOS gets its own settings
footer text.

The macOS items are written out explicitly rather than with ForEach: a ForEach
inside the toolbar builder is evaluated once and never re-renders on state
changes, which left the bookmark and download icons stale.
2026-09-28 23:48:17 +02:00
Arkadiusz Fal
e0a07e93d2 Hide the description section when a video has no description
Direct media links have no description, so the info page showed a divider
and a "No description available" placeholder under the channel row. Skip
the section entirely (divider included) unless there is a description or
details are still loading, and drop the unused player.noDescription string.
2026-09-28 23:47:59 +02:00
Arkadiusz Fal
309d17941d Keep the macOS player controls hoverable when the corner layout panels come back
Exiting native fullscreen from the corner layout restores the panels, and
the controls view read that as a floating details panel opening: it set a
hide override and ignored mouse movement for as long as the panel stayed
visible, so hovering the video no longer showed the controls. Only a
floating (unpinned) panel covers the video, so the flag now excludes
pinned panels, matching the existing widescreen rule. Corner panels are
pinned, so the fullscreen restore and the title-tap toggle keep the
controls responsive to hover.
2026-09-28 23:45:24 +02:00
Arkadiusz Fal
8d340048ea Hide the corner layout panels when the macOS player enters native fullscreen
The fullscreen button in the corner layout zoomed the whole video-plus-
panels arrangement onto the screen and left the panels there. Now the
button hides the panels first (the video grows over them inside the
current window), waits for that animation, then runs the native zoom, so
the fullscreen space shows the video alone. On exit the window returns to
its size first and the panels slide back in, unless the user re-showed
them while fullscreen. System-initiated transitions (green button, Window
menu, Mission Control) hide the panels as the zoom starts.

Hiding the panels re-arms the separate window's aspect lock; an entering
flag parks it during the transition and the restore on exit skips the
parked replay, so the window keeps its corner size instead of auto-
fitting to the video. The preset's remembered panel visibility is left
untouched by the fullscreen session.
2026-09-28 23:05:05 +02:00
Arkadiusz Fal
96eeadb479 Stop the macOS player window oscillating when a drag crosses the corner layout threshold
The corner layout resolves from the window width. Dragging the window
narrower than the threshold dropped the layout mid-drag, which re-locked
the aspect ratio and auto-fitted the window back to the video size. The
fitted width was above the threshold again, so the layout re-entered
corner mode and unlocked, and the cycle repeated on every layout pass for
the rest of the drag (visible as the window abruptly jumping in size).

Aspect lock/unlock requests made during a live resize are now parked and
applied in windowDidEndLiveResize, since changing contentAspectRatio or
contentResizeIncrements under the cursor makes AppKit's resize handler
switch modes and snap the frame. Auto-fit never runs during a live resize,
and the corner-layout change handler skips it when the change was caused
by the user's drag: the drag owns the size.

The free-resize size floor in windowWillResize now clamps width and height
independently. Preserving the proposed ratio (correct only under an aspect
lock) widened the window while dragging just the bottom edge past the
minimum height.
2026-09-28 23:00:40 +02:00