Commit Graph

282 Commits

Author SHA1 Message Date
Arkadiusz Fal
cdcbe51370 Stop decoding video during background playback on iOS
Pausing rendering in the background left mpv decoding every frame
(hardware decode plus copy back) only to drop it, keeping a 1080p video
at ~15% CPU with the screen off. After 5 s in the background without PiP
the video track is now disabled (vid=no); returning to the foreground
re-enables it and seeks exactly to the current position, which keeps
audio and video in sync (avsync 0.000 vs -0.044 and 22 drops without the
seek). Streams loaded while backgrounded start with video disabled.
Background CPU drops to ~3.7% on an iPhone 12 mini.
2026-10-27 23:25:52 +01:00
Arkadiusz Fal
c09e66cb5a Cut foreground CPU while playing and paused
- Send Now Playing elapsed time once per 5-second bucket; the modulo check
  fired for every frame of that second (12 updates/s on average).
- Skip mpv renders while paused unless mpv requests a redraw (seek, new
  frame, PiP change, new load) instead of redrawing the same frame at
  display rate.
- Read playback time untracked in the progress bar and time labels while
  the controls are hidden, so they are not re-rendered on every tick.
- Only clear the current SponsorBlock segment when one is set.
2026-10-26 19:35:12 +01:00
Arkadiusz Fal
728e3b7286 Cut foreground CPU during audio-only playback
- MPVRenderView keeps its display link paused while the stream has no
  video, instead of rendering and presenting 60 empty frames a second
- Chapter segments get clamped played/buffered fractions and are
  equatable, so a time update re-renders only the chapter under the
  playhead instead of every segment
- The mini player pauses its scrolling titles while the expanded player
  covers it (iOS/tvOS)
- Performance logging no longer flags the first sample after a
  foreground/background transition as high CPU

With the expanded player open in audio-only mode on an iPhone 12 mini,
app CPU dropped from ~30% to ~12% and wakeups from ~128/s to ~37/s.
2026-10-25 14:22:26 +01:00
Arkadiusz Fal
9412ca916c Add performance logging and background diagnostics
Performance Logging (Developer settings) logs a resource sample every 5s
in the foreground and background: CPU by area and thread, wakeups, GPU,
memory, disk, activity counters as rates (mpv property updates, player
time publishes, renders, saves, network, images, CloudKit events, log
entries, main run-loop wakeups), SwiftUI body evaluations of the player
views, and app/player/battery context. Each foreground/background
transition logs a summary of the period. MetricKit metrics and
diagnostics (CPU exceptions, hangs) are logged whenever logging is on.

Background diagnostics (iOS) log lifecycle, audio session and power
events with an app/player/render snapshot and a heartbeat that detects
suspension gaps and main-thread stalls. Logs are persisted per session
in Caches/Logs and the previous session is included in exports, so logs
survive the app being swiped away.
2026-10-24 19:36:51 +02:00
Arkadiusz Fal
21a6238a16 Stop re-rendering hidden player UI during background playback
Every mpv time-pos tick updated PlayerState.currentTime and bufferedTime,
re-rendering the invisible player controls, progress bar and chapter
segments several times per second while the app played in the
background, which drained the battery during long audio-only sessions.

- PlayerState keeps currentTime/bufferedTime current for readers but
  stops notifying observers while publishesTimeUpdates is off;
  PlayerService turns it off on entering the background and back on
  (with one catch-up notification) before returning to the foreground
- MarqueeText pauses its animation timeline outside the active scene
- Replace the 100 ms polling loops in RemoteControlCoordinator and
  ToastManager (iOS/tvOS) with observation-driven waits

Main-thread samples during 30 s of background audio-only playback on an
iPhone 12 mini went from 1289 to 248.
2026-10-24 17:32:34 +02:00
Arkadiusz Fal
2a49a5db68 Use production CloudKit environment for Xcode builds
Development-signed builds now opt into the Production CloudKit
environment via com.apple.developer.icloud-container-environment,
driven by a CLOUDKIT_ENV build setting. A new Debug-CKDev configuration
and "Yattee (CloudKit Dev)" scheme switch to the Development
environment for schema work.

The DEV badge and banner in iCloud settings now reflect the actual
environment (YatteeCloudKitEnvironment Info.plist key) instead of
#if DEBUG, and the sync engine logs the environment at startup.
2026-10-23 21:47:34 +02:00
Arkadiusz Fal
532e713322 Keep the separate macOS player window open when opening a link 2026-10-22 07:14:32 +02:00
Arkadiusz Fal
582aa56644 Add keyboard shortcuts to toggle corner layout panels
Playback menu items toggle the info panel below the video (⌥⌘I), the
side column (⌥⌘S) and both at once (⌥⌘P) while the corner layout is
active. Showing one region from the hidden state brings back only that
region; hiding the last visible one hides the whole panel.
2026-10-21 22:06:27 +02:00
Arkadiusz Fal
1fe7caa6bc Keep animated video resizes smooth on iOS
Every layout pass with a new size recreated the framebuffer and rendered
synchronously on the render queue, blocking the main thread behind the
in-flight render. Animated layout changes (corner layout panel toggles,
rotation) dropped to a few frames per second. Size changes arriving in
quick succession now let the layer stretch the last frame and recreate
the framebuffer once the size settles; one-off resizes still recreate
immediately.
2026-10-21 22:01:37 +02:00
Arkadiusz Fal
e276c99d0b Allow pinch-to-panscan on the video in the corner layout
The window-level pinch was blocked whenever the landscape panel was
pinned and visible (the corner layout always counts as pinned) or a
stale portrait-panel flag was set. In the corner layout the pinch now
starts when it begins inside the video region, and the panscan
control button is enabled there too.
2026-10-20 23:43:47 +02:00
Arkadiusz Fal
f9c448b0aa Show comment avatars from newer Invidious versions
Newer Invidious returns a single authorThumbnail URL on comments instead
of the authorThumbnails array, so every comment fell back to the
placeholder avatar. Decode authorThumbnail as a fallback.
2026-10-20 20:31:18 +02:00
Arkadiusz Fal
4d8ba7e028 Invalidate the subscription feed when subscriptions change (#968)
Subscribing or unsubscribing, locally or via iCloud, only posted a
notification, so the cached feed stayed valid and Yattee Server never
received the new channel list until the cache expired. Invalidate the
feed cache on every subscription add and removal, and skip the disk
write when it is already stale.
2026-10-19 22:54:55 +02:00
Arkadiusz Fal
433e57e68d Recover downloads from an invalidated URLSession (#975)
Toggling cellular downloads recreated the background session with the
same identifier before the old one finished invalidating, and nothing
ever replaced a dead session, so every download threw "Task created in
a session that has been invalidated" until relaunch. Wait for the old
session's invalidation before creating the new one, rebuild the session
when the delegate reports the live one invalid, and recreate it once
when task creation hits the invalidated exception.
2026-10-19 22:31:52 +02:00
Arkadiusz Fal
4c7ccb83a6 Keep unsent CloudKit changes across Sync Now (#978)
refreshSync() discarded the persisted engine state and the pending
buffer, and the engine state is where queued saves and deletions live,
so tapping Sync Now within the debounce window dropped history,
progress and bookmark changes. Carry the old engine's pending changes
into the buffer that the new engine adopts, then schedule a send.
2026-10-19 21:45:45 +02:00
Arkadiusz Fal
13eb89abc0 Report video as the Now Playing media type (#969)
NowPlayingService set the media-library key MPMediaItemPropertyMediaType,
which MediaRemote ignores, so external clients classified playback as
audio. Use MPNowPlayingInfoPropertyMediaType instead.
2026-10-19 18:02:42 +02:00
Arkadiusz Fal
1f7c471fcc Give the tvOS media-source-not-found screen an Open Sources button
A media source destination whose source was removed rendered a bare
ContentUnavailableView, so the remote had nothing to focus.
2026-10-16 23:59:09 +02:00
Arkadiusz Fal
9002b253d0 Give the tvOS downloads placeholders a focusable Done button
yattee://downloads still reaches the downloads destination on tvOS,
where it renders a bare ContentUnavailableView with nothing focusable,
leaving the remote stuck.
2026-10-16 23:58:58 +02:00
Arkadiusz Fal
4c31c0a348 Remove unused AppSettings, Instance and MediaSource CloudKit record types
Nothing creates or reads these records, and neither the development nor
the production CloudKit schema has these types.
2026-10-15 20:44:00 +02:00
Arkadiusz Fal
f2eacaf6d6 Give each thread its own libsmbclient talloc stackframe
One build 270 crash log aborts in talloc (talloc_abort_unknown_value)
inside libsmbclient name resolution while a media-source download opens
a file through SMBBridge, with FFmpeg reading an smb:// file on another
thread at the same time.

libsmbclient keeps its talloc stackframe behind a table of thread
functions, and none was installed (this build does not export
smbc_thread_posix()), so every thread shared one stackframe and the two
users freed each other's allocations. Install a pthread implementation
of smb_thread_functions at load time, before any libsmbclient use,
including FFmpeg's. The struct layout matches what talloc_stack and
smb_threads in the bundled library read (get_tls at +0x30, lock_mutex
at +0x10).
2026-10-14 22:30:34 +02:00
Arkadiusz Fal
7d769f3541 Skip mpv renders that land after the app went to the background
Four build 270 crash logs abort inside the GL driver
(GLDContextRec::flushContextInternal) from presentRenderbuffer, reached
from the immediate render queued by appDidBecomeActive/resumeRendering.
Only displayLinkFired checked isInBackground; renders dispatched
directly to the render queue could run, or present, after the app was
backgrounded again, where GPU work is not permitted.

Check isInBackground at the top of performRender and again before
presenting. PiP rendering is unaffected.
2026-10-14 20:55:41 +02:00
Arkadiusz Fal
eeee74f35f Stop the progress save from getting the app killed at suspension
17 of the build 270 crash logs are 0xdead10cc kills on the main thread
inside DataManager.save(), every one reached from the 5-second
PlayerService progress-save timer. During background audio the timer
keeps writing; when playback stops the app can be suspended mid-save,
still holding the SQLite lock in the shared app-group container.

Wrap the periodic save in a background-task assertion when the app is
not active, and skip the write when the position has not moved since
the last save, so a paused player stops writing every five seconds.

The remaining six 0xdead10cc logs die in DataManager.init recovering
the WAL during a background launch. They are most likely the aftermath
of these dirty kills, so store opening is left as is.
2026-10-14 18:08:32 +02:00
Arkadiusz Fal
fd6a97ce5c Keep the video info carousel aligned across window resizes
The carousel's lazy stack kept the old widths of cards it had laid out
off-screen, so after a resize (tab bar to split view sidebar on iPad,
rotation) every later card sat at a stale offset and the current card
landed beside the viewport. The stack now takes a new identity per
card width, and the existing re-snap aligns the current card.

Swapping between the tab bar and the sidebar also rebuilds the page,
which reset the carousel to the video it was opened on. The page's
position and continuation-loaded videos now live on its queue context,
fresh for every push, so a rebuilt page resumes where it was.
2026-10-14 08:22:58 +02:00
Arkadiusz Fal
97288b4497 Keep the split view mini player on pushed pages
The detail column absorbs its NavigationStack, so the mini player inset
on the stack only reached the root page and disappeared on push. Float
it over the split view, past the sidebar, and have every page reserve
its height as bottom safe area.
2026-10-13 23:58:49 +02:00
Arkadiusz Fal
f289905dd8 Keep the landscape panel hidden where the corner layout doesn't fit
The corner preset's landscape fallback opened the panel whenever the
device could host the corner layout. On the iPhone Duo exterior display
the player falls back to the widescreen layout, so the floating panel
opened on every rotation into landscape. Resolve the fallback from
whether the corner layout fits the current size instead.
2026-10-13 23:57:58 +02:00
Arkadiusz Fal
6e8835237d Add a Navigation setting to keep the tab bar on large screens
iPad and iPhone Duo users can pick Tab Bar instead of Automatic to opt
out of the sidebar layout. Switching rebuilds the navigation Settings
lives in, so the change asks for confirmation and lands on Home first.
2026-10-11 21:28:51 +02:00
Arkadiusz Fal
2c662b34e3 Hide Shorts in Invidious Popular too
Invidious serves /api/v1/popular from the same RSS-fed table as the
subscription feed, so Shorts there arrive with no length as well.
2026-10-11 19:19:51 +02:00
Arkadiusz Fal
8ade5a54d0 Add a Hide Shorts toggle to the view options
Videos carry isShort from Yattee Server and Piped, and Invidious feed
entries are taken as Shorts when they arrive with no length and aren't
live or upcoming: the feed comes from RSS, which lists Shorts, while
lengths come from the Videos tab, which doesn't. Subscriptions, search
and instance browse hide videos known to be Shorts when the toggle is
on; videos whose source can't tell are always shown.
2026-10-11 14:06:15 +02:00
Arkadiusz Fal
2d3ae1b0e5 Block the player dismiss pan from starting on side sliders
The window-level dismiss pan recognizes before SwiftUI's slider
DragGesture sets isAdjustingPlayerSliders, so dragging volume or
brightness often dismissed the player instead. Side sliders now publish
their frames and the pan refuses to begin from touches inside them.
2026-10-10 23:58:16 +02:00
Arkadiusz Fal
cf1acb1986 Apply volume and mute on the avfoundation renderer directly
mpv's volume/mute properties are software volume applied before samples
reach the AO, and AVSampleBufferAudioRenderer queues ~2s ahead, so slider
and mute changes were heard only after the queue drained. With the
avfoundation AO active, drive ao-volume/ao-mute (renderer volume, instant)
and keep software volume neutral; other AOs keep software volume. Re-apply
on every audio reconfig since a new renderer starts at full volume.
2026-10-10 23:58:03 +02:00
Arkadiusz Fal
8233ac26d7 Collapse the player for Video Info / Go to Channel from rows inside it
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.
2026-10-10 21:27:26 +02:00
Arkadiusz Fal
09a384763a Stretch the mini player's video preview to fill its frame
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.
2026-10-09 23:59:58 +02:00
Arkadiusz Fal
7b1a24a5b0 Morph the expanded player into the mini player on drag-to-dismiss
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).
2026-10-09 23:59:51 +02:00
Arkadiusz Fal
4770bbe3b9 Remove dead code found by Periphery on all platforms
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.
2026-10-08 23:57:56 +02:00
Arkadiusz Fal
45e19e6536 Open more YouTube link types and playlists from links
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.
2026-10-08 22:50:36 +02:00
Arkadiusz Fal
8a7218ed1b Show several opened links as mixed results
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.
2026-10-08 22:36:57 +02:00
Arkadiusz Fal
03be964c66 Load a single link from Open Link into its details screen
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.
2026-10-08 18:25:32 +02:00
Arkadiusz Fal
b9e7d88a12 Re-fetch everything from iCloud when the local store was recreated
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.
2026-10-07 23:59:56 +02:00
Arkadiusz Fal
bc6ad55f38 Ask link action confirmations in an alert on tvOS
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.
2026-10-07 23:59:44 +02:00
Arkadiusz Fal
33152f4371 Show toasts above shared sheets
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.
2026-10-07 23:59:43 +02:00
Arkadiusz Fal
95d3893f46 Resolve @handle channel links on Piped
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.
2026-10-07 23:59:07 +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
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