Compare commits

..

566 Commits

Author SHA1 Message Date
github-actions[bot]
9306bafbea Bump build number to 270 2026-08-23 11:04:21 +00:00
Arkadiusz Fal
0a7c056595 Update CHANGELOG 2026-08-23 12:46:11 +02:00
Ray Cheung
3ebae6d216 Fix no streams reported from Piped (#974)
Piped currently is having an issue it would only return a muxed 360p
stream, and it is rejected here because the audioCodec being set to nil.
2026-08-23 12:22:10 +02:00
Phil-Bastian Berndt
d46002e99a Chunk stateless feed requests to support more than 500 subscriptions (#967) 2026-08-23 12:22:07 +02:00
Yuri Chukhlib
c41016185c Fix description timestamp links seeking to wrong position for out-of-range values (#966)
DescriptionText.parseTimestamp turned a matched timestamp string into a
clickable seek link by computing seconds from its colon-separated parts
with no range validation. The link regex (\d{1,2}:\d{2}(?::\d{2})?)
matches strings that are not valid clock positions, such as 1:99 or
0:60, and parseTimestamp happily computed 1*60+99 = 159 for 1:99 — so
tapping such a link seeked the player to 2:39 instead of being ignored.

This also diverged from ChapterParser, which rejects seconds/minutes
>= 60; the same timestamp string could be dropped as a chapter but
still seek as a description link.

- Validate seconds < 60 in the MM:SS branch and minutes < 60, seconds
  < 60 in the H:MM:SS branch; return nil otherwise
- Change parseTimestamp to return Int? and skip building the seek link
  for nil, so out-of-range matches are left as plain text
- Add DescriptionTextTests with valid, boundary, and out-of-range cases
2026-08-23 12:22:03 +02:00
Yuri Chukhlib
3d2544b22d Fix deep link timestamp parsing accepting infinity as seek position (#965)
URLRouter.parseTimestampValue used TimeInterval(_:) for the plain-numeric
branch ("90", "90.5"). That initializer also accepts the special tokens
"inf"/"infinity" and "nan", and the value infinity compares as >= 0, so a
deep link or share URL carrying ?t=inf parsed to Double.infinity and was
forwarded to the player as a seek target. Seeking to infinity is undefined
and breaks playback startup for the affected link.

Reject non-finite values explicitly alongside the existing negative
check, so only finite non-negative seconds are accepted.

- Add isFinite guard to the plain-numeric branch of parseTimestampValue
- Cover inf/infinity/nan/negative rejection in URLRouterTests
2026-08-23 12:22:00 +02:00
Yuri Chukhlib
6c5c9915fe Fix audio sample rate label dropping the kHz decimal (#964)
MPVTrack.detailText built its sample-rate label with integer division
(`sampleRate / 1000`), which truncates the fractional kHz. The
second-most-common audio rate — 44100 Hz (CD quality, music videos) —
showed as "44 kHz", a label that denotes 44000 Hz (a different rate),
instead of the conventional "44.1 kHz" used by VLC, mpv and every DAW.
The same defect hit 88200 -> "88 kHz", 176400 -> "176 kHz" and
22050 -> "22 kHz". Whole-kHz rates (48000, 96000, ...) were already
correct and stay unchanged. The label is live in the quality selector's
advanced details, where EmbeddedTrackRowView renders track.detailText.

- Factor the formatting into MPVTrack.formatSampleRate(_:), a pure
  static helper: divide as Double, drop the decimal for whole kHz,
  otherwise keep 1-2 meaningful digits with the trailing zero stripped
  (44.1, 88.2, 22.05).
- detailText now appends Self.formatSampleRate(sampleRate); its structure
  (codec / channelCount parts, separator, nil-when-empty) is unchanged.
- Add YatteeTests/MPVTrackFormatTests covering whole-kHz rates, the
  44.1 kHz family (the bug), half-decimal rates and the detailText
  integration.
2026-08-23 12:21:57 +02:00
Yuri Chukhlib
11d370b3b8 Fix playback rate display dropping meaningful digits (#963)
PlaybackRate.displayText/compactDisplayText used String(format: "%.2gx"), whose significant-figure notation dropped meaningful digits (1.25 -> "1.2x") and rounded others (1.75 -> "1.8x"). Replace with a fixed two-decimal format + trailing-zero strip so whole rates have no decimal (2 -> "2x"), halves show one (1.5 -> "1.5x"), and quarter-steps are preserved (1.25 -> "1.25x"). Adds a Swift Testing suite (PlaybackRateTests) proving red->green.
2026-08-23 12:21:54 +02:00
Yuri Chukhlib
63b625bcde Fix www.duckduckgo.com misrouting to yt-dlp external video extraction (#962) 2026-08-23 12:21:50 +02:00
github-actions[bot]
f4cf9e8b8d Update AltStore source for 2.0.0 (269) 2026-08-03 21:54:39 +00:00
github-actions[bot]
dda7d6d5ef Bump build number to 269 2026-08-03 21:54:04 +00:00
Arkadiusz Fal
82b48b29f4 Update CHANGELOG.md 2026-08-03 23:34:06 +02:00
Arkadiusz Fal
b0f573be82 Fix double-tap fullscreen gesture rotating on portrait videos
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
01cf9c953d Fix project.pbxproj 2026-08-03 23:34:06 +02:00
Arkadiusz Fal
d624a09335 Fix timed links resuming at watch position instead of URL timestamp
Timed links arrive wrapped as yattee://open?url=... from the share
extension, but the timestamp was parsed from the wrapper URL, where a
single-? form like youtu.be/ID?t=N hides t inside the url query item.
With no startTime the player fell back to saved watch progress.

- Add URLRouter.unwrapped(_:) resolving the wrapper to the inner URL
  (raw remainder after ?url=, since the share extension does not encode
  & and URLComponents would drop &t= parts) and use it in handleDeepLink
- Thread forceStartTime through openVideo/playPreferringDownloaded/play
  so explicit link timestamps beat the 90%-watched restart threshold,
  clamped to just before the end; resume flows keep the old behavior
- Carry the parsed timestamp through OpenLinkSheet's play action, which
  dropped it entirely
- Cover timestamp parsing and wrapper unwrapping in NavigationTests
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
b08974f4dc Allow screen sleep during audio-only playback 2026-08-03 23:34:06 +02:00
Arkadiusz Fal
d17423fa5b Fix autoplay countdown showing in repeat one queue mode
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
25f15a1b3e Add view options to playlists list view
List/grid layout toggle with row size and grid columns options,
persisted per-view. PlaylistRowView now scales with VideoRowStyle;
new LocalPlaylistCardView provides the grid card with edit/delete
context menu.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
3f3709a37e Add search to playlists list view 2026-08-03 23:34:06 +02:00
Arkadiusz Fal
908a916f76 Fix sending extracted videos (Twitch streams) to other devices
The remote control loadVideo command only carried the raw video ID and
an instance URL, so extracted videos (Twitch live streams and other
yt-dlp sites) failed on the receiver: it tried to fetch the ID from the
/api/v1/videos endpoint, which rejects non-YouTube IDs.

The command now carries the full ContentSource and title. The receiver
rebuilds the VideoID from the source and opens a placeholder Video -
the player then re-extracts streams and full metadata from the original
URL, same as when opening the video locally. Media-source extractors
(WebDAV/SMB/local) keep using the existing UUID:path branch.

Both fields are optional for protocol compatibility: old receivers
ignore them, commands from old senders keep the legacy behavior.

Also stop sending a start time for live streams - there is no shared
timeline, the receiver joins at the live edge.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
2c0c2e853a Apply thumbnail fallback everywhere a single URL was rendered
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
fd1029d3e0 Fix missing thumbnails for videos saved to library
Persistence models (local playlists, watch history, bookmarks,
downloads) store only the best advertised thumbnail URL - usually
maxresdefault.jpg, which doesn't exist for many older videos and 404s.
Live API results survive this because views fall back through the full
quality chain, but toVideo() rebuilt videos with that single dead URL,
leaving placeholder covers.

Reconstruct the quality fallback chain from the stored YouTube-style
URL at read time (Thumbnail.fallbackChain), and rewrite playlist list
covers to the always-available hqdefault variant
(Thumbnail.reliableURL), since those views render a single URL without
fallback. Also expand the fabricated maxres URL in PipedAPI into a
full chain.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
aa6e7fbce9 Stop tracking resume progress for live streams
With "Partially Watched Videos" set to Ask, opening a live stream showed
the resume sheet with a playback timestamp. A live position is relative
to the live edge, not a fixed timeline, so it is meaningless as a
playhead.

Live views are still recorded in history, but carry no resume position:

- WatchEntry gains a persisted isLive flag plus recordLiveWatch(), which
  stores no position and clears state a drifting HLS duration may have
  left behind. progress is always 0 and toVideo() propagates the flag so
  history rows render the LIVE badge.
- from(video:) clamps duration, keeping Piped's -1 live sentinel out of
  storage.
- DataManager routes live saves through recordLiveWatch() and clears the
  flag on the non-live path (before updateProgress, so the auto-finish
  check is not suppressed by the hard-0 live progress), letting an entry
  heal once the stream becomes a VOD.
- PlayerService skips completion saves, always starts at the live edge,
  and drops any startTime passed for a live video.
- The resume prompt, the "Continue at" button label, thumbnail progress
  bars, "X remaining", Continue Watching and tvOS Top Shelf all exclude
  live entries.
- CloudKit syncs isLive with the rest of the watch state: the conflict
  resolver carries it with watchedSeconds/isFinished and the sync engine
  applies it when merging into an existing entry. It is read leniently,
  so the schema version stays at 2 and older clients keep parsing these
  records.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
8faf20c9e9 Add loading external subtitle files for media-source playback
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
6a0fe2fbbf Show and label WebDAV/SMB video streams in the quality selector
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
9e546e29ee Label local-file video quality from mpv track info instead of Unknown
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
67ed8c322b Add embedded audio/subtitle track selection for multi-track files
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
150ebf42a4 Fix #955: make subtitle appearance settings adjustable on tvOS
Text color, border color, background color, border size, and bottom
margin were rendered as static rows on tvOS (ColorPicker and Slider are
unavailable there), so the focus engine skipped over them and they could
never be changed.

Replace them with menu pickers following the existing font size pattern:
- Text/border/background color: 9 preset colors with a swatch in the
  row label; stored values snap to the nearest preset
- Background opacity: 25-100% steps (iOS parity, where ColorPicker
  supports opacity for the background)
- Border size: 0.0-5.0 in 0.5 steps
- Bottom margin: 0-50% in 5% steps

Verified on tvOS simulator: rows are focusable, selections persist and
are rendered by MPV during playback.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
cd01c08b5a Fix #960: scope subscription counts, import/export to active account
When an Invidious/Piped account is active, several views read the local
SwiftData subscription store instead of the account, so counts
contradicted each other (Home tile said 10 while the Channels list
showed the server's 2):

- Home Channels tile now uses the provider's count via new
  SubscriptionService.cachedSubscriptionCount (fetched once in the
  background when the server cache isn't populated yet)
- Export footer and export content use the active account's list;
  export runs async with a spinner and surfaces fetch errors as a toast
- CSV/OPML import routes through SubscriptionService.importSubscriptions,
  subscribing on the server for server accounts instead of silently
  writing to the invisible local store
- ChannelView subscribe-state is corrected via the provider after the
  optimistic local-store read; unused *Sync write helpers removed
- Server-account subscribe/unsubscribe/import now post
  subscriptionsDidChange so other views refresh
- New "Delete Local Subscription Data" section in Subscriptions
  settings (visible with a server account) clears the local store and
  queues CloudKit deletions so iCloud doesn't restore it
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
155a5a48a0 Fix #958: switch MPVKit to yattee fork with AV1 VideoToolbox session recovery
iOS invalidates VideoToolbox sessions when the app is backgrounded.
FFmpeg's generic reconfig path restarts the session, but for AV1 every
inter frame then errors until the next keyframe, so mpv counted 3
consecutive errors and permanently fell back to software decoding
(heat/battery drain). The previous app-side polling recovery worked but
each reinit caused a refresh seek (audio flush + time jump).

The fork release (yattee/MPVKit 1.0.1, mpv v0.41.0 / FFmpeg n8.1.2)
carries an FFmpeg patch that fixes this at the decoder layer: on
session-level failure it keeps outputting the last successfully decoded
frame instead of erroring, retries the session restart with a 30-frame
backoff while backgrounded, and resumes live decode at the next
keyframe. Audio never stops, no seek, hardware decoding recovers
transparently - matching H.264/HEVC behavior.

The app-side recovery machinery (runHwdecRecoveryCheck) is no longer
needed and has been removed.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
c93b7388e9 Update MPVKit to 1.0.0 2026-08-03 23:34:06 +02:00
Arkadiusz Fal
76a709a921 Keep macOS player fullscreen when queue advances to different-aspect video
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.
2026-08-03 23:34:06 +02:00
Arkadiusz Fal
40f043b769 Fix startup crash in sideloaded builds without iCloud entitlements
Sideloading tools re-sign the app with a team that cannot register the
iCloud.stream.yattee.app container, and CKContainer(identifier:) fatally
traps when the entitlement is missing. Detect availability by parsing
the embedded provisioning profile, skip creating any CloudKit objects
when the entitlement is absent, and show an explanation in iCloud
settings instead of the sync toggle.
2026-08-03 23:34:06 +02:00
github-actions[bot]
7c07189023 Update AltStore source for 2.0.0 (268) 2026-07-27 06:49:20 +00:00
github-actions[bot]
29dbeef08d Bump build number to 268 2026-07-27 06:48:55 +00:00
Arkadiusz Fal
5c94526961 Exclude internal groups from TestFlight distribution
ASC now rejects assigning builds to internal groups explicitly
(they get access to new builds automatically), which failed all
beta lanes at upload_to_testflight.
2026-07-27 08:31:04 +02:00
Arkadiusz Fal
296779c63a Update CHANGELOG 2026-07-27 08:04:07 +02:00
Arkadiusz Fal
c01b8f63e1 Guard PiP bridge geometry writes against non-finite rects
Two build 264 crashes on macOS 26.5.1 hit AppKit's non-finite-frame
trap (_NSViewValidateGeometry) on AVKit's PiP sample-buffer host view
during a routine layout pass. The NaN is produced inside AVKit from
geometry we feed it - either the manual PiP window resize around a
video switch or an infinite container rect copied into the sample
buffer layer frame.

Add a sanitizedGeometry helper that rejects NaN/infinite or degenerate
rects and apply it at every layer/window geometry write in the bridge
(updateLayerFrame variants, moveLayer, updateVideoAspectRatio,
resizePiPWindow, updateLayerFrameToMatchPiPWindow). Rejected writes are
logged with their call site so the actual source can be identified when
it reproduces, instead of crashing on the next CATransaction commit.
2026-07-23 23:12:23 +02:00
Arkadiusz Fal
41d58f47a3 Disable zoom navigation transitions when running on a Mac
Dismissing any SwiftUI sheet while a .navigationTransition(.zoom) is
registered traps inside UIKit's _UIZoomTransitionController on the Mac
presentation path (macOS 26.4.1, builds 259/261 crash reports). The
same code path is stable on iOS and iPadOS, so this is an Apple
framework bug on Mac that can only be avoided.

Gate both zoom-transition modifiers on a cached runtime check for
isMacCatalystApp / isiOSAppOnMac so the Mac build falls into the
existing no-op branch while iOS and iPadOS keep the zoom animation.
2026-07-23 23:09:47 +02:00
Arkadiusz Fal
ce7e9cbde7 Fix crash in CAOpenGLLayer shadow-copy init on macOS 27 beta
macOS 27 beta routes contentsScale changes (window moving between
screens) through Core Animation's implicit-animation path, which builds
a presentation copy of the layer via init(layer:). Our override re-ran
visual setters on that copy, and -[CAOpenGLLayer setColorspace:]
dereferences render state the shadow copy doesn't own, crashing with
EXC_BAD_ACCESS.

Drop the redundant setter block (super.init(layer:) already copies
presentation values) and disable implicit actions around the
contentsScale writes in MPVOGLView so the presentation-copy path is
never entered for scale updates.
2026-07-23 23:08:34 +02:00
Arkadiusz Fal
2ff248e262 Harden player view detach and time-update gate heal from #957
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.
2026-07-23 23:06:48 +02:00
Arkadiusz Fal
faf0964746 Detect fragile GL driver by Metal GPU family instead of device model 2026-07-23 23:02:21 +02:00
Arkadiusz Fal
6994b9353a Show missing-credentials indicator for remote server sources
After reinstalling the app and importing sources from iCloud, remote
server instances with Keychain-only credentials showed no indication
that login is needed, unlike WebDAV/SMB sources.

- Show the orange key icon for Yattee Server instances without stored
  basic auth credentials (auth is always required for this type)
- Persist usesBasicAuth/usesAccountLogin flags on Instance (synced via
  iCloud) so missing Invidious/Piped/PeerTube credentials are detectable
  too; maintained on credential save/delete, login/logout, and legacy
  migration
- Centralize the check in AppEnvironment.needsCredentials(for:) used by
  SourcesListView, MediaSourcesView, and SourceRow
- Backfill the flags at launch from credentials currently in the
  Keychain so existing setups publish them without a re-login
2026-07-23 22:57:24 +02:00
Arkadiusz Fal
c3e39c955d Fix transparent background of iCloud sync overlay on tvOS
tvOS full screen covers have a transparent background, so the
onboarding view below leaked through the sync progress overlay.
Add a black background, matching the legacy accounts import cover.
2026-07-23 22:57:24 +02:00
Arkadiusz Fal
8f29b15fee Distribute TestFlight builds to all beta groups automatically 2026-07-23 22:57:24 +02:00
Arkadiusz Fal
65b2f985ef Merge pull request #957 from rswilem/fix/apple-tv-hd-black-video-956
Fix black video, freezes, autoplay and clock on Apple TV HD (A8, 2016)
2026-07-23 22:57:05 +02:00
Ramon
0db7b73d25 Fix black video, freezes, autoplay and clock on Apple TV HD (A8)
Fixes four bugs behind yattee#956 on Apple TV HD (AppleTV5,3, A8,
tvOS 26.5), where video played audio only with a permanently black
picture, never autoplayed, the clock stayed at 0:00, and navigating
during/after playback could freeze the whole app.

1. A8 GL driver deadlock (device-specific). glTexImage2D uploading
   float32 data into a float16 texture hangs in libGLImage's
   glgProcessPixelsWithProcessor (a dispatch_group_wait that never
   returns), permanently wedging mpv_render_context_render. mpv 0.37
   (commit 703f1588) turned on dithering (dither-depth=auto) and
   LUT-based scalers by default; on the A8 (no GL_EXT_texture_norm16)
   both upload float weights via that fatal conversion, and per-frame
   CPU plane uploads (videotoolbox-copy / software decode) hit the same
   path. Yattee 1.x shipped mpv 0.36 with these off, which is why the
   device worked there. Worked around on AppleTV5,3 only: dither-depth=no,
   hwdec=videotoolbox (zero-copy via IOSurface), and bilinear scalers.
   Confirmed by two on-device lldb backtraces.

2. Swift cooperative-pool starvation (all platforms). MPVClient's event
   loop ran as Task.detached and blocked a cooperative-pool thread for
   the client's entire lifetime. With two clients alive (active +
   pre-warmed) on a 2-core A8, both pool threads were held and every
   await in the app starved: the load pipeline froze at its first
   Task.sleep, killing autoplay, Now Playing and progress saving. Moved
   the event loop to a dedicated Thread; EAGLContext creation now bridges
   through GCD instead of Task.detached.

3. Leaked time-update gate (all platforms). PlayerService dropped all
   time updates while loadingVideoID was set; when a load task died
   mid-flight the flag leaked and the clock stayed at 0:00 during
   playback. Heal the gate on the ready/playing transition and leak-proof
   play() with a defer; log the previously silent load cancellation.

4. Orphaned player view (all platforms). Detach the shared render view in
   MPVContainerView.deinit when no successor container exists, and skip
   framebuffer recreation while the view is windowless, to stop a 100% CPU
   SwiftUI trait-update loop when opening a settings detail during playback.
2026-07-22 14:30:00 +02:00
github-actions[bot]
afc03188a0 Update AltStore source for 2.0.0 (266) 2026-07-19 18:22:00 +00:00
github-actions[bot]
c95f7dcbb9 Bump build number to 266 2026-07-19 18:21:31 +00:00
Arkadiusz Fal
c38f82062f Enable macOS TestFlight build by default in release workflow 2026-07-19 19:42:18 +02:00
Arkadiusz Fal
6e36499f58 Update CHANGELOG 2026-07-19 19:41:00 +02:00
Arkadiusz Fal
f275499c7b Fix macOS legacy import sheet missing grouped form style 2026-07-19 19:35:26 +02:00
Arkadiusz Fal
283c2cb9cb Fix tvOS legacy import rows acting as a single Remove button
On tvOS a Form row is one focusable unit, so the legacy account/source
cards - header, credential fields, and both buttons stacked in a single
row - focused as a whole and every click fired the first button (Remove),
making credentials and Import unreachable.

- Split the tvOS rows into individually focusable rows (info header,
  fields, Import, Remove), keeping the card layout on iOS/macOS
- Wrap the tvOS entry points in TVSidebarDetailContainer and drop the
  tvOS navigationTitle that rendered as a ghost behind the form
- Present the review screen from the first-launch alert as a
  fullScreenCover on tvOS: sheets render as a small transparent card
  that cannot fit the sidebar layout (Menu button dismisses the cover)
- Dim disabled TVSettingsButtonStyle buttons
- Widen the row icon frame for tvOS glyph sizes and skip the caption
  when it would repeat the host shown as the title
2026-07-19 19:23:36 +02:00
Arkadiusz Fal
9c97036684 Make Enable Logging the master switch over all verbose logging
The verbose toggles (MPV, remote control) kept working with the master
"Enable Logging" toggle off: the settings UI hides them, but their
stored values - verboseMPVLogging is even iCloud-synced - were still
honored at runtime, producing console output and enabling the periodic
MPV stats collection while logging appeared disabled.

Enable Logging now takes precedence everywhere:

- LoggingService gates its DEBUG-build OSLog console output on the
  master toggle (it previously ran before the isEnabled guard, so the
  Xcode console was never silent)
- MPVLogging requires loggingEnabled && verboseMPVLogging, which also
  gates the 10s playback stats task via MPVLogging.verboseEnabled
- rcDebug in LocalNetworkService and RemoteControlCoordinator requires
  the master toggle alongside verboseRemoteControlLogging
2026-07-19 01:00:01 +02:00
Arkadiusz Fal
6aa5fd8ee7 Fix periodic tvOS playback stutter from render stalls on mpvQueue
Build 264 stutters for a split second every ~10 seconds. The periodic
playback-stats task fetches ~30 debug properties in one ~190ms block on
mpvQueue, and MPVClient.render() wrapped mpv_render_context_render in
mpvQueue.sync - so the render thread queued behind the fetch and stalled
~190ms, at exactly the stats interval. Build 261 predates the stats
task, which is why it was unaffected.

libmpv's render API is documented thread-safe relative to the client
API, so render-context calls no longer go through mpvQueue:

- New renderContextLock protects renderContext lifecycle vs use;
  render(), renderWithDepth(), renderSoftware(), reportSwap(),
  shouldRenderUpdateFrame() and hasRenderContext use it
- Render-update callbacks dispatch on a dedicated renderEventQueue so
  frame-ready notifications can't queue behind property/command work
- destroyRenderContext takes mpvQueue then the lock, still excluding
  in-flight renders during teardown
- The stats task skips its property fetch on iOS/tvOS unless verbose
  MPV logging is on (macOS keeps it for the render watchdog)
- performRender slow-frame warnings now break down render vs present
  time and report frame-delivery gaps for future triage
2026-07-19 00:53:06 +02:00
Arkadiusz Fal
c3bb01e862 Remove old CLAUDE.md 2026-07-18 19:01:22 +02:00
Arkadiusz Fal
94e3c25b37 Stop attaching the TestFlight pkg to GitHub releases
The macOS pkg is App Store-signed (Sparkle stripped, not notarized) and
only exists for the TestFlight upload — installing it directly produces
an app that will not launch. Users should get the dmg or zip.
2026-07-18 18:54:34 +02:00
Arkadiusz Fal
658b1e827e Update AltStore source for 2.0.0 (264) 2026-07-18 18:40:09 +02:00
Arkadiusz Fal
02707ddd6a Fix AltStore source push rejected by branch protection
The update_altstore job pushed with the default GITHUB_TOKEN, which
cannot bypass the changes-through-PR ruleset on main. Check out with
REPO_TOKEN like the release and appcast jobs, and forward secrets to
the reusable workflow with secrets: inherit.
2026-07-18 18:40:09 +02:00
github-actions[bot]
447f593950 Bump build number to 264 2026-07-18 16:35:47 +00:00
Arkadiusz Fal
90a0c6fd1a Fix doubled fastlane/ prefix in notarize lane shell paths
fastlane's sh runs from the fastlane/ directory, so the stapler/ditto/
hdiutil commands resolved fastlane/builds/... to fastlane/fastlane/
builds/... and failed after successful notarization.
2026-07-18 18:10:48 +02:00
Arkadiusz Fal
f3f45fdfdc Fix mac notarized build archiving wrong platform
build_mac_app in the build_and_notarize lane was missing the sdk and
destination overrides needed for the multiplatform scheme, so gym
archived for tvOS and failed on provisioning profiles. Mirror the
working mac beta lane.
2026-07-18 18:00:57 +02:00
Arkadiusz Fal
a673968f1e Fix build error with confirmationDialog on CI Xcode
Replace the item-based confirmationDialog overload, which is unavailable
in the Xcode 26.5 SDK used on CI, with the isPresented/presenting
variant.
2026-07-18 17:54:49 +02:00
Arkadiusz Fal
0a91182089 Merge branch 'rewrite/v2' 2026-07-18 17:48:53 +02:00
Arkadiusz Fal
a27cad5121 Update changelog for build 263 2026-07-18 17:42:32 +02:00
Arkadiusz Fal
a6bce94bb2 Add logo to README 2026-07-18 17:38:43 +02:00
Arkadiusz Fal
4ec9b936a6 Fix background playback setting behavior across platforms
Hide the toggle on macOS, where apps are never suspended and playback
always continues regardless of the setting. Pause playback on iOS when
backgrounding with the setting disabled — the audio background mode
previously kept mpv playing, making the off state a no-op.

Move the continue-playing policy into PlayerService and make the
backend's background render pause unconditional (PiP-gated only):
it is a GPU optimization that should not depend on the setting.
Also ungate the player sheet visibility handlers, which manage
foreground mini player rendering unrelated to background playback.
2026-07-18 16:28:19 +02:00
Arkadiusz Fal
bc7b00dfb3 Restore last player window frame when auto-resize is off
With auto-resize disabled the separate player window opened at a
computed 16:9 default and was never corrected, so non-16:9 videos sat
letterboxed until a manual resize. Persist the window frame via
NSWindow frame autosave and restore it on open when auto-resize is
off, so the window reopens exactly as the user last left it.
2026-07-18 16:21:12 +02:00
Arkadiusz Fal
e905932cfa Use toolbar search placement on iPad channel view
The navigationBarDrawer search field on iPad added a resting scroll
position above the banner on iOS 27, letting the view scroll past the
banner top with the mirrored banner filling the gap. iPhone keeps the
drawer placement since its header reserves space for the search bar.
2026-07-18 12:54:47 +02:00
Arkadiusz Fal
74442f2764 Force soft top scroll edge effect for banner and video info views
iOS 27 changes the default scroll edge effect style to hard, which
draws a sharp cutoff line over the channel banner and video info
gradient extending under the toolbar.
2026-07-18 10:40:31 +02:00
Arkadiusz Fal
3a8f91f88c Reset player aspect ratio to 16:9 for audio-only playback
Audio-only streams never report a video size, so the aspect ratio of
the last played video stuck to the player area indefinitely once audio
mode was enabled. Reset it in play() when the selected stream is
audio-only, and let the macOS player window fall back to 16:9 instead
of keeping the previous lock.
2026-07-17 18:43:10 +02:00
Arkadiusz Fal
c2554cee04 Apply audio mode to downloaded videos
Downloads bypass stream selection and reach play() as ready-made local
file streams, so audio mode never affected them. Apply the mode at that
choke point instead: swap a local video stream for the separately
downloaded audio track, or play a muxed file with the video track
disabled (vid set per-load in MPVClient, never toggled live). The
transform is bidirectional so stored audio-only queue/history entries
restore full video when the mode is off, and setAudioMode now reloads
downloaded content at the current position in both directions.
2026-07-17 09:27:09 +02:00
Arkadiusz Fal
6514cb426e Keep macOS channel search bar pinned right during search
The content type picker in the center .principal toolbar slot is what
constrains the .searchable field to the trailing edge. Dropping the
picker when a search was submitted let the toolbar reflow and the search
field re-center. Keep the picker in place and disable it while searching
instead.
2026-07-16 21:35:47 +02:00
Arkadiusz Fal
e7886d9707 Raise player settings sheet detent so audio mode toggle is visible
Rest the quality/settings sheet at a 0.6 fraction instead of .medium on
iOS so the general section (playback speed / audio mode / lock) at the
bottom of the scroll view is reachable without dragging up to .large.
2026-07-16 20:12:41 +02:00
Arkadiusz Fal
c7ad4fad2e Make CloudKit apply path scope-aware and re-upload local-newer merges
CloudKit record names carry a source scope suffix so the same channel
or video ID can exist under different sources, but the apply path
matched local entities by bare ID: incoming records could merge into an
entity from a different scope, and a remote deletion of one scope
deleted every local entity with that ID and cascaded scoped deletions
for all other scopes back to CloudKit.

- Match local entities on the full mapper-derived record name when
  merging incoming records, materializing records for upload, and
  applying remote deletions.
- Remote deletions now remove only the matching entity and no longer
  queue CloudKit deletions (no echo, no cross-scope cascade).
- Insert dedupe for CloudKit-applied records is scope-aware, so a
  same-ID record from a different source inserts instead of being
  silently dropped. Remotely added bookmarks also update the
  fast-lookup cache immediately.
- When conflict resolution during a fetch keeps newer local data, the
  merge result is queued for upload; previously the server (and other
  devices) kept the stale version unless the entity was edited again.
  Strict timestamp comparisons prevent re-upload ping-pong between
  devices.
2026-07-15 20:15:03 +02:00
Arkadiusz Fal
262dab8f9e Skip local-only settings keys when enabling iCloud settings sync
refreshFromiCloud already skipped isLocalOnly keys, but the enable-time
paths did not: replaceWithiCloudData overwrote device-specific settings
with another device's values, and syncToiCloud uploaded local-only keys
to the ubiquitous store. Apply the same skip in both.
2026-07-15 06:33:02 +02:00
Arkadiusz Fal
10ca000387 Fix conflict resolver field names for recent channels and playlists
The recent-channel resolver copied avatarURLString/providerName and the
recent-playlist resolver copied authorID/providerName - fields that do
not exist in the records the mapper writes - while missing the real
ones (thumbnailURLString, subscriberCount, isVerified, videoCount). As
a result, when the local side won a conflict, those fields silently
kept the older server values.

Also drop the notificationsEnabled preservation in the subscription
resolver: the subscription mapper never writes that field since
notification preferences moved to their own record type.
2026-07-14 23:50:04 +02:00
Arkadiusz Fal
959641b642 Sync watch progress on natural video completion and backgrounding
Naturally finished videos only saved their 100% progress locally
(play() deliberately skips the sync-on-switch when videoEndedNaturally
is set), so the watched state never reached other devices in the
autoplay flow. Save completion through updateWatchProgress, which
queues the iCloud sync.

Also queue the current playback position when the app enters
background, so backgrounding mid-video hands the position off to other
devices instead of only syncing on explicit stop or video switch.
2026-07-14 23:08:39 +02:00
Arkadiusz Fal
9467ac4dba Migrate CloudKit sync to state-driven CKSyncEngine pattern
Register pending changes with the engine state instead of keeping
app-managed record arrays, and materialize records from local data at
send time in nextRecordZoneChangeBatch. This fixes several data-loss
paths:

- Pending changes now persist inside the engine state serialization,
  so saves queued during the debounce window (or while offline) survive
  app termination. Legacy record-name keys are migrated once.
- Transient CloudKit errors (zoneBusy, serviceUnavailable, rate limits,
  network failures, limitExceeded, batchRequestFailed) re-register the
  change for engine-scheduled retry instead of silently dropping it.
- Conflicts are resolved against the exact record that was sent; the
  resolved record (carrying the server change tag) is cached and takes
  precedence at the next send, and any newer local edit evicts it.
- Queue methods are now synchronous, removing a task-reordering race
  where a fast delete/re-add could end up deleting the re-added entity.
- Records are built fresh at send time, so queued changes no longer
  upload stale field snapshots, and batch size limits are handled by
  the framework.
2026-07-13 23:25:00 +02:00
Arkadiusz Fal
06087220ef Add audio-only music mode
Persistent per-platform mode that plays only the audio track to save
bandwidth and speed up loads. When enabled, stream selection picks the
best audio-only stream (preferred language, codec, bitrate) and never
fetches the video URL; toggling mid-video reloads at the current
position using the quality-switch path (live vid=no toggling is avoided
due to A/V desync).

- Toggle row in the player quality/settings sheet on iOS, macOS and
  tvOS, next to lock controls
- New audioMode control button type (red when active) for player
  controls and mini player layouts
- Picking a video quality explicitly turns the mode off; audio track
  picks keep it on and now work while audio-only is playing
- Stream URL refresh preserves audio-only playback instead of
  resurrecting video
- Queue preloads and history entries resolved before a toggle are
  discarded so selection re-runs with the current mode
- tvOS player shows the video thumbnail instead of a black screen
  during audio-only playback
- PiP button hidden for audio-only streams
2026-07-13 19:06:01 +02:00
Arkadiusz Fal
66f7602d08 Exit fullscreen before dismissing macOS player window
Ordering out a window in native fullscreen skips the exit transition and
strands its black fullscreen space on screen. Route all dismissal paths
(stop/collapse, PiP hide, window close) through exitFullScreenIfNeeded,
which leaves fullscreen first and defers hiding until the transition
completes, with a timeout fallback if AppKit refuses the exit.
2026-07-12 21:01:15 +02:00
Arkadiusz Fal
ab5163e29f Add toolbar scrim on channel view for pre-macOS 26 2026-07-12 19:09:28 +02:00
Arkadiusz Fal
aa81fdac7d Move channel tab picker to toolbar on macOS
Replace the centered avatar/name toolbar title with the
About/Videos/Shorts/Streams/Playlists segmented picker. The channel
avatar and name move to the leading edge next to the view options
button, in their own glass capsule (sharedBackgroundVisibility(.hidden)
+ glassEffect on macOS 26). Add spacing between the channel banner and
the content below now that the inline picker is gone.
2026-07-11 22:26:48 +02:00
Arkadiusz Fal
cc50c88d75 Fix Clear All Recents button stretching full width on macOS 2026-07-11 20:25:10 +02:00
Arkadiusz Fal
8b50c2ee14 Default Channels grid to 5 columns on macOS and tvOS 2026-07-10 21:07:31 +02:00
Arkadiusz Fal
31bbbd317f Fix truncated section footers in Remote Control sheet on macOS 2026-07-10 19:26:20 +02:00
Arkadiusz Fal
e6aa95b656 Handle live videos in mini player bar
Live streams have no meaningful duration, so the mini player's bottom
progress line collapsed to nothing (or showed a misleading partial fill
on DVR streams). Hide the line for live playback and show a red dot +
LIVE indicator next to the author name instead, matching the expanded
player controls. Applies to both the macOS capsule and iOS overlay
layouts.
2026-07-09 23:52:43 +02:00
Arkadiusz Fal
c37fdec0dc Add mac-release script for notarized Developer ID builds 2026-07-09 22:47:26 +02:00
Arkadiusz Fal
ffcb73fe33 Strengthen top bar scrim gradient on macOS player controls 2026-07-08 23:43:02 +02:00
Arkadiusz Fal
9918a68d73 Fix tvOS ao value shown in MPV options settings
The read-only defaults list grouped tvOS with iOS as ao=audiounit, but
MPVClient actually sets ao=avfoundation,audiounit on tvOS (avfoundation
first because audiounit can't open 32-channel HDMI Atmos routes).
Display-only fix; no playback behavior change.
2026-07-08 07:59:11 +02:00
Arkadiusz Fal
afa73e6bcb Prefer avfoundation AO on macOS 27 to avoid mpv hotplug listener crash
On macOS 27 beta, mpv's coreaudio AO fails to initialize (channel layout
set rejected with paramErr -50). The existing ao=coreaudio,avfoundation
fallback restored audio, but exposed an mpv bug: init() registers a HAL
hotplug listener (AudioObjectAddPropertyListener on the system object,
with the raw ao pointer as user data) before the step that fails, and
the coreaudio_error path never unregisters it. mpv then frees the ao
and falls back to avfoundation, leaving a dangling listener per
playback start. The next audio device change (AirPods connect,
sleep/wake device republish) invokes the listener with the freed
pointer and crashes with a use-after-free SIGSEGV on the
HALC_ProxyNotification Call Listener Queue (observed in build 262;
faulting address was reused "texture_" shader string memory).

Gate the AO order by OS version: on macOS 27+ use
avfoundation,coreaudio so the failing coreaudio init never runs; older
macOS keeps the native coreaudio first with avfoundation as fallback.
Update the read-only defaults mirror in MPV options settings to match.
2026-07-07 23:51:34 +02:00
Arkadiusz Fal
9e2b0fa095 Extend opaque background to settings and other pushed pages on macOS
The NavigationStack-level background wasn't enough: macOS draws its own
translucent material behind pushed navigation pages, and Form/List
containers draw a translucent scroll background on top of anything
placed behind them, so several pages kept the wallpaper-tinted look.

Bake the opaque background into SettingsFormContainer (covers all pages
built on it, pushed or not) and add opaqueSettingsFormBackground(),
which hides the container's scroll background before applying the
opaque one, to every macOS-reachable Form/List settings page: player
controls and its sub-editors, sidebar settings, edit source, legacy
data import, subscription/playlist import, customize home and its
shortcut style page, and the log viewer. Contributors, translators and
the remote control device page get the plain opaque background.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-07 22:16:09 +02:00
Arkadiusz Fal
8d4f472348 Unify macOS content view backgrounds with opaque window background
Views without an explicit background sat on the translucent window
material, which picks up a wallpaper tint (bluish in dark mode) and
clashed with views drawing windowBackgroundColor, making the video
info header gradient fade visibly mismatch. Add opaqueWindowBackground()
and apply it centrally in NavigationDestinationHandlerModifier to both
stack roots and pushed destinations, plus VideoInfoView for
presentations outside navigation stacks.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-06 09:36:49 +02:00
Arkadiusz Fal
48378d7ca8 Add download row context menu for pause, resume and cancel
Download row actions were wired only as swipe actions, and
SwipeActionModifier has #if !os(iOS) overloads that return the view
unmodified, so macOS had no way to pause, resume or cancel a download.
The video context menu could not cover it either, since the .downloads
context deliberately hides the built-in cancel item.

Give active download rows their own context menu with pause when queued
or downloading, resume when paused, retry when failed, and a destructive
cancel. Right-click on macOS, long-press on iOS.

Completed rows had a related bug: the delete item called an onDelete
callback that DownloadsView never passed, so it rendered but did nothing
on macOS. Drop the callback and delete via the download manager directly,
which is what already made the same menu work in HomeView.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-06 08:19:56 +02:00
Arkadiusz Fal
6fed1b0ca7 Enforce theme at the window level instead of preferredColorScheme
SwiftUI's .preferredColorScheme on the root view could not revert from
dark back to light/system while a sheet was presented, and on macOS it
never applied to secondary window scenes such as Settings. The override
is now applied directly to UIKit/AppKit instead.

- SettingsManager.applyTheme sets overrideUserInterfaceStyle on every
  window of every connected scene (iOS) or NSApp.appearance (macOS);
  the theme setter calls it, so any future code path that changes the
  theme outside the setter must do the same
- AppTheme gains userInterfaceStyle / appearance mappings, with .system
  resolving to .unspecified / nil so the OS setting takes over again
- Applied on app appear for the initial launch state, and after both
  iCloud sync paths, since theme can arrive as a synced change and is
  no longer driven by SwiftUI state

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-05 21:06:43 +02:00
Arkadiusz Fal
b632f11b2f Add separate light/dark mode accent colors
Adds an option in Appearance settings to use different accent colors
in light and dark mode, with a toggle to keep a single shared color
(the default, preserving existing behavior).

- New synced settings keys: accentColorDark, customAccentColorDark,
  useSeparateDarkAccentColor; dark values fall back to the light
  selection until explicitly set, so no migration is needed
- resolvedAccentColor returns a dynamic platform color (UIColor trait
  provider / NSColor appearance provider) when the toggle is on, so
  the root tint and all direct readers adapt to light/dark
  automatically without consumer changes
- Accent Color section shows two stacked preset+custom grids with
  Light/Dark headers when enabled, extracted into AccentColorGrid;
  CustomAccentColorButton now takes bindings so the macOS color panel
  edits whichever target was last opened
- New DarkAccentColorTests covering sync contract, default-off state,
  fallbacks, and toggle-off resolution

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-05 21:06:26 +02:00
Arkadiusz Fal
b47888fe04 Add custom accent color with system color picker
Add a Custom swatch to the appearance settings accent color grid,
backed by a hex value stored in settings (synced via iCloud like the
rest). iOS uses the native ColorPicker wheel; macOS uses a circular
swatch that opens NSColorPanel, since the SwiftUI color well looks out
of place among the circles. All accent consumers now read the resolved
color through SettingsManager.resolvedAccentColor.

The indigo preset is retired from the grid but kept in the enum, so
users who selected it keep their color until they pick another one.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-04 23:57:59 +02:00
Arkadiusz Fal
f5b86effd3 Fix layout jump when subscriptions sidebar appears after load on macOS
Load local-account subscriptions synchronously on appear so the channels
sidebar is present in the first layout pass, and reserve the sidebar
column with a spinner while async loads are in flight (gated by a
persisted flag so users without enough channels never see a phantom
column).
2026-07-04 17:04:09 +02:00
Arkadiusz Fal
ecb6a9dd7a Replace macOS inline player sheet with full-window overlay
When "Play in a separate window" is disabled, the expanded player was a
native sheet attached to the main window. AppKit refuses fullscreen with
an attached sheet, so pressing F swapped the sheet for an in-window
overlay before fullscreening — a visible two-step transition.

Present the inline player as the full-window overlay from the start, so
fullscreen is just the window's native toggle. This removes the whole
sheet-swap machinery (toggleSheetFullScreen with its detach timing,
SheetWindowResizer, sheet sizing helpers).

- beginInlineOverlay/endInlineOverlay hide/restore the main window
  toolbar and mirror the window's fullscreen state into the renamed
  isMacInlinePlayerFullScreen flag via NSWindow notifications, so exits
  through Esc/green button/Mission Control stay in sync
- Collapsing exits fullscreen only if it was entered for the video
- Esc in the windowed overlay collapses to the mini bar via a new
  onCollapse callback (replacing the sheet's default Esc-dismiss)
- ContentView registers its hosting window (MainContentWindowReader) so
  the overlay targets the right window even when the setting is toggled
  while the Settings window has focus
2026-07-03 21:57:55 +02:00
Arkadiusz Fal
944be5a722 Fix duplicate log actions in macOS settings toolbar
The Filter/Export/Clear buttons appeared twice on macOS: inline next
to the search bar and again inside the ellipsis toolbar menu. Show
them once as plain toolbar buttons on macOS; keep the menu on iOS/tvOS.
2026-07-03 19:18:25 +02:00
Arkadiusz Fal
fdd6075a49 Fix unclickable error/replay overlays in macOS 15 player window
On macOS 15 Sequoia the error overlay buttons (Retry / Play Next /
error details) and the ended-state replay buttons did nothing when
clicked, while working fine on macOS 26. Layered event logging showed
the click reached SwiftUI but always landed on MacOSPlayerControlsView,
which covers the whole video surface above those overlays: on Sequoia a
view with .onContinuousHover participates in click hit-testing across
its full frame, so even with its inner layers hit-disabled the controls
view formed an invisible wall. Drop the whole controls view out of
hit-testing while the ended/failed overlays own the surface.

Also keep the traffic-light buttons visible on the ended/failed
overlays: they used to fade out with the controls and nothing could
bring them back, leaving the error screen with no way to close the
window.
2026-07-02 18:34:43 +02:00
Arkadiusz Fal
30b2f67752 Auto-hide macOS player controls on mouse idle and hide pointer in fullscreen
The hide timer was gated on the pointer leaving the player view, which
never happens in fullscreen, so controls shown when pressing F stayed up
forever. Switch to idle-based hiding: mouse movement shows controls and
resets the 3s timer; controls fade after inactivity even while the
pointer rests over the video, unless it's on one of the control bars.
Entering fullscreen rebaselines hover tracking so the resize-driven
hover re-emission doesn't count as movement.

Also hide the mouse pointer (setHiddenUntilMouseMoves) when controls
hide in fullscreen, restoring it on exit or any mouse move.
2026-07-02 07:30:42 +02:00
Arkadiusz Fal
a3ba5c22e3 Update README 2026-07-01 22:29:42 +02:00
Arkadiusz Fal
b642813716 Fall back to avfoundation AO when coreaudio fails on macOS
macOS 27 beta's AUHAL rejects mpv's channel-layout property with paramErr
(-50), so the coreaudio AO fails to initialize and playback is silent.
Change the macOS ao option from "coreaudio" to "coreaudio,avfoundation"
so mpv self-heals: older macOS keeps using coreaudio unchanged, while the
beta drops to avfoundation (AVSampleBufferAudioRenderer) instead of playing
no sound.
2026-07-01 06:40:16 +02:00
Arkadiusz Fal
a4b0c60a3e Run AXe against an Xcode that ships SimulatorKit
AXe dlopens the private SimulatorKit.framework from
$DEVELOPER_DIR/Library/PrivateFrameworks. Xcode 27 relocated that
framework to Contents/SharedFrameworks, so under a selected Xcode 27
every axe command fails with "Failed to load essential private
frameworks" and all UI assertions fail uniformly.

Detect when the active Xcode lacks SimulatorKit at the path AXe expects
and inject DEVELOPER_DIR pointing at the first installed Xcode that has
it, for every axe subprocess. No global xcode-select switch required.

Claude-Session: https://claude.ai/code/session_01Aqq5bSGrYfjQNvkTEzWcva
2026-06-30 09:35:32 +02:00
Arkadiusz Fal
eb551e7641 Update app-launch-home UI baseline for redesigned Home shortcut cards 2026-06-30 06:37:24 +02:00
Arkadiusz Fal
403b83badc Show queue mode label and split bottom bar on macOS
On macOS the queue sheet's mode selector now shows both icon and text
and shares a single bottom toolbar bar with the Close button, pinned to
opposite edges.
2026-06-29 23:42:30 +02:00
Arkadiusz Fal
daf5fe1ad1 Fix full-width Speed menu on macOS Sequoia
The borderlessButton menu stretches to fill the available width on
macOS 15, so pin it to its intrinsic size with .fixedSize().
2026-06-29 21:04:41 +02:00
Arkadiusz Fal
f730542903 Right-align toast dismiss button on macOS 2026-06-28 09:57:25 +02:00
Arkadiusz Fal
b230d56379 Fix oversized ellipsis menu buttons on macOS Sequoia
The default SwiftUI Menu style on macOS 15 renders as a bordered
pull-down button that stretches to fill available width, while
macOS 26 hugs the label. Apply .menuStyle(.borderlessButton) and
.fixedSize() to the video context menu and playlist header menu.
2026-06-28 08:01:46 +02:00
Arkadiusz Fal
b72fd2d3f0 Fade traffic lights with controls in macOS player window
In the separate player window the traffic-light buttons stayed visible
while the controls overlay was hidden. Animate the standard window
buttons' alpha in sync with controls visibility, QuickTime-style.

Applies only to the dedicated player window (inline sheet and overlay
fullscreen presentations keep their parent window's chrome). Buttons
are handed back at full alpha when entering native fullscreen, when the
controls unmount (PiP, debug overlay), and when the view re-parents to
another window.
2026-06-27 21:30:52 +02:00
Arkadiusz Fal
46735b07aa Show macOS player controls on hover after auto-hide
When the pointer left the player window during playback, the auto-hide
timer set the manual showControls override to false. The override takes
precedence over hover state, so re-entering the window kept controls
hidden until a click toggled them back.

Clear a stale hide override on mouse activity so hovering reveals the
controls again, except while the details panel is open, which keeps
controls hidden on purpose.
2026-06-27 17:58:00 +02:00
Arkadiusz Fal
4ccbe153a7 Show macOS player controls immediately while video is loading
The controls overlay was gated on the player backend existing, but the
backend is created only after the video details fetch completes, so the
loading phase showed just the thumbnail and spinner with no controls.

Drop the backend gate (MPV is the only backend type) and resolve the
PiP backend dynamically in the toggle callback. Also make ended/failed
states hide controls directly in shouldShowControls instead of relying
on catching the state transition, and reset the manual visibility
override when a new load starts so controls reappear for the next video.
2026-06-26 23:30:30 +02:00
Arkadiusz Fal
04daf94057 Use native save panel for log export on macOS
Replace the log-content-with-copy sheet with an NSSavePanel that writes
the exported logs to a .txt file, matching the subscriptions and preset
export flows. ShareSheet presentation is now iOS-only; tvOS export is
unchanged.
2026-06-26 22:55:22 +02:00
Arkadiusz Fal
9bcb8e35a6 Add native fullscreen for macOS sheet-mode player via in-window overlay
The fullscreen button did nothing in sheet mode: AppKit ignores
toggleFullScreen on a window attached as a sheet, and the old fallback
targeted the key window — the sheet itself. Two other strategies were
ruled out first: fullscreening the sheet's parent is silently refused
while any sheet is attached, and resizing the sheet's backing window to
screen size NaN-asserts inside AppKit's sheet positioning even without
animation.

Working approach: swap the sheet for a plain overlay in the main window
and native-fullscreen the now sheet-free main window.

- isMacPlayerFullScreenOverlay on NavigationCoordinator dismisses the
  sheet (presentation binding excludes it; the setter ignores the
  dismissal write-back) and mounts ExpandedPlayerSheet as a black-backed
  overlay in ContentView
- ExpandedPlayerWindowManager fullscreens the parent after the sheet
  detaches, verifies AppKit accepted it (rolls back if refused), hides
  the window toolbar (NSWindow keeps it as a strip above the overlay
  otherwise) and restores it on exit
- a didExitFullScreen observer restores the sheet on any exit path
  (button, Esc, green button, Window menu); closing the player while
  fullscreen exits the main window's fullscreen too
- scheduleSharedViewAdoptionRetry on entry re-homes the shared render
  view once the dismissing sheet's window loses visibility — its steal
  is declined mid-dismissal and nothing else retried, leaving the
  overlay black until the next video load
2026-06-25 21:55:47 +02:00
Arkadiusz Fal
59cc7a8249 Enforce minimum size for macOS player window
The separate macOS player window (and the inline sheet, which locks to the
same window via applyAspectRatioConstraint) could be dragged arbitrarily
small. Two AppKit behaviors combined to defeat the intended minimum:

- Setting contentAspectRatio makes AppKit's aspect-ratio resize handler take
  over live drags and stop enforcing minSize/contentMinSize.
- NSHostingController resets minSize/contentMinSize to zero at runtime, even
  with sizingOptions = [].

Enforce the floor in windowWillResize(_:to:) from constants instead, growing
any under-sized proposal back up to a 360pt minimum height (aspect-scaled
width, with a floor for narrow videos) while preserving the proposed ratio.
2026-06-25 20:12:49 +02:00
Arkadiusz Fal
bef29a79a8 Keep Sparkle resetUpdateCycle on the main actor
SPUUpdater is a main-thread-only API; calling resetUpdateCycle() from a
detached utility task violated Sparkle's threading contract and raised
a Swift 6 concurrency error (non-Sendable SPUUpdater captured in a
detached task). A plain main-actor Task keeps the deferral that avoids
running the feed-cache work synchronously inside the didSet.
2026-06-24 18:52:32 +02:00
Arkadiusz Fal
80ec622e03 Fix release workflow gaps blocking first macOS beta
- Appcast publish: fetch gh-pages into a local branch before creating
  the worktree. actions/checkout's narrow refspec meant the branch was
  checked out detached, so `git push origin gh-pages` failed with
  "src refspec gh-pages does not match any" on a fresh runner.
- Gate the AltStore source update on an iOS build being part of the
  release; a mac/tvOS-only release has no IPA and would publish a
  broken entry.
- Pass the release tag to update-altstore explicitly (gh's "latest
  release" excludes prereleases), parse the build number correctly for
  beta tags (2.0.0-beta.263 -> 263, not beta.263), use the real
  Yattee-<version>-iOS.ipa asset name, and fail instead of writing a
  size-0 entry with a dead URL when the IPA asset is missing.
2026-06-24 07:25:29 +02:00
Arkadiusz Fal
da6c949f51 Resume CloudKit sync from persisted state instead of full re-fetch
Previously CKSyncEngine was created with nil state serialization on
every launch, replaying the entire zone change history (hundreds of
merge/delete log lines and re-downloads each start). Now the saved
state is loaded so launches only fetch changes since the last session.

To make incremental sync safe:
- Clear persisted state once when the record schema version increases,
  so records previously skipped as unsupported get re-delivered after
  an app update.
- Handle remote zone deletion in fetchedDatabaseChanges by recreating
  the zone and re-uploading local data, instead of leaving sync dead.

Account change and manual Refresh Sync still clear the state and force
a full fetch as before.
2026-06-23 23:54:40 +02:00
Arkadiusz Fal
7808d4c730 Fix black video in sheet-mode player and mini capsule hand-off delays
Sheet mode has no container lifecycle hook on either transition, so the
shared render view was stuck until the render watchdog (~10s of black):

- Presenting the sheet: the capsule parks the view while the sheet's
  window exists but is not yet visible - viewDidMoveToWindow has already
  fired and findTransferTarget rejects non-visible windows, so nothing
  picked the parked view up.
- Dismissing the sheet: the dismissed sheet's hierarchy stays alive
  holding the view inside a now-invisible window, with no unmount or
  park at all.

Add scheduleSharedViewAdoptionRetry() to MPVContainerNSView: for ~1s it
re-checks every 50ms whether the shared view needs re-homing (parked,
or owned by a container whose window lost visibility) and runs the same
recovery the watchdog uses. Deduplicated so overlapping triggers don't
stack loops. Triggered after parking with no transfer target and from
hide()'s no-window branch, which every sheet-mode collapse funnels
through.

The retry ticks pass quiet: true to recoverSharedPlayerViewIfNeeded so
teardown after closing a video (when no container legitimately exists)
no longer spams warnings, and the loop stops once the shared view is
gone.
2026-06-23 21:24:13 +02:00
Arkadiusz Fal
945804c109 Hand off render view to mini capsule immediately on player collapse
The capsule's claim on the shared render view is declined during the
player window's fade-out (the window is still visible, so the steal
guard refuses), and nothing retried after orderOut - the capsule stayed
black until the render watchdog recovered the view ~10 seconds later.

Call recoverSharedPlayerViewIfNeeded() in hide()'s cleanup right after
ordering the window out, so the view moves to the capsule as soon as
the collapse finishes. The PiP path is untouched - there the view must
stay in the hidden window.
2026-06-22 20:19:25 +02:00
Arkadiusz Fal
bf00c48e6c Fix macOS player window black video when mini capsule steals render view
Closing the player window via its close button left isPlayerCollapsing
stuck true (windowShouldClose has no animation completion to clear it),
so the mini capsule preview kept mounting its render container and
grabbed the shared render view on the next expand - the video rendered
into the 44x26 thumbnail while the player window stayed black.

- Clear isPlayerCollapsing on the next runloop in windowShouldClose
- Give containers in the tracked player window priority to claim the
  shared view (even from a visible owner), and refuse steals from the
  visible player window by outside containers
- Prefer the largest container in the player window during recovery so
  thumbnail-sized surfaces are never picked
- Add a watchdog misplaced-view check in MPVBackend that re-attaches
  the render view when it lives outside the visible player window
- Include container bounds and tracked-window flag in attach/decline
  logs
2026-06-22 19:55:32 +02:00
Arkadiusz Fal
b82241564d Fix macOS permanent black video after player collapse/expand cycles
In separate-window mode, hide() keeps the ordered-out window's SwiftUI
hierarchy alive (nilling contentViewController crashes AVKit), and its
updateNSView unconditionally re-attached the shared MPVOGLView -
stealing it into a window CoreAnimation never composites. The layer's
silent skip-render fallback then consumed every frame flag, so video
stayed black (climbing vo drops) until app restart.

- Decline stealing the shared view from a visible container into a
  non-visible one; owner-initiated transfers bypass the guard
- Only transfer to containers in visible windows or the window still
  tracked by ExpandedPlayerWindowManager; park the view otherwise and
  reclaim it when a container gains a window
- Log skip-render frame consumption (rate-limited) and track render
  health counters (draws/skips/dropped draws)
- Add a watchdog to the playback stats task that detects a stalled
  video output and re-attaches the view to a visible container
- Don't mount player layouts during zero-size layout passes
2026-06-21 21:55:37 +02:00
Arkadiusz Fal
1cd566ae29 Allow dragging the macOS player controls bar to reposition it
The glass controls capsule can now be dragged anywhere over the video.
Position is stored as a fraction of the container size so it survives
window resizes and aspect-ratio driven sheet resizes, persists across
sessions via SettingsManager, and snaps back magnetically when dropped
near the default bottom-center spot. Travel is clamped so the capsule
stays inside the player and never overlaps the top button row. A
mouseDownCanMoveWindow=false backing view keeps the window's
movable-by-background behavior from swallowing the drag.
2026-06-21 17:39:48 +02:00
Arkadiusz Fal
76ae2717b2 Show queue count badge on macOS queue button
Add a queue-count badge to the macOS player controls queue button
(MacOSControlsSectionRenderer) and the shared mini player queue button
(MiniPlayerView), matching the iOS pill button behavior.
2026-06-20 23:24:48 +02:00
Arkadiusz Fal
71eb2aacc7 Position macOS seek preview just above the progress bar
The storyboard seek preview was anchored to the top of the control-bar
pill and floated well above it. Anchor the preview and chapter capsule up
from the pill bottom (via geometry height) so they overlap the button row
and sit just above the progress bar, matching iOS proximity.
2026-06-20 22:49:22 +02:00
Arkadiusz Fal
e37ac12565 Fix macOS distribution entitlements and Sparkle beta defaults
- Add macOS-specific entitlements via CODE_SIGN_ENTITLEMENTS[sdk=macosx*]:
  the iOS aps-environment key is silently dropped from macOS exports,
  leaving distribution builds without a push entitlement (no CloudKit
  sync pushes). Release now uses Yattee-macOS.entitlements with
  com.apple.developer.aps-environment = production.
- Release-DeveloperID uses Yattee-macOS-DeveloperID.entitlements, adding
  the Sparkle mach-lookup temporary exceptions (-spks/-spki) required
  for the updater to install in a sandboxed app.
- Default Sparkle updater to the beta channel while only beta releases
  exist, so testers receive updates without toggling Advanced settings.
- Set NSHumanReadableCopyright for the app target.
2026-06-19 21:59:55 +02:00
Arkadiusz Fal
ca67d480ca Make seek durations configurable in Playback menu and add secondary seek
Playback menu seek items now follow the durations from the active player
controls preset instead of hardcoded 10s/30s labels. AppEnvironment caches
the active preset's center settings (refreshed on preset change
notifications) so menu commands can read them synchronously.

Add secondary seek durations (default 30s) to CenterSectionSettings,
configurable on macOS in Seek Durations settings, used by Shift+arrows in
the player and Cmd+Shift+arrows in the Playback menu.

Also let Cmd-based key equivalents pass through the player keyboard
monitor so menu shortcuts (e.g. Cmd+Option+arrow previous/next video)
work while the player window has focus, and unify macOS built-in preset
primary seek at 10s to match iOS/tvOS (built-in presets version 8).
2026-06-19 08:50:39 +02:00
Arkadiusz Fal
5bc774dd83 Fix missing storyboards when advancing to next queued video
Queue items carry a pre-resolved stream, so play() takes the
provided-stream shortcut and skips the full API fetch that populates
storyboards. The follow-up loadOnlineStreams() call fetched the response
containing them but dropped storyboards on the floor.

Keep the storyboards in loadOnlineStreams() and apply them when none are
set (preserving local storyboards for downloaded playback), and bail out
if the current video changed during the fetch.
2026-06-18 23:49:15 +02:00
Arkadiusz Fal
e42e48fcee Fix Sources home shortcut counting disabled instances 2026-06-18 19:38:27 +02:00
Arkadiusz Fal
ea1aa6fe7e Always restore from PiP to expanded player window on macOS
The PiP restore handler only expanded the player when mini player
video was disabled, which is the iOS behavior where playback can
continue in the mini player. On macOS the mini player is not a
restore target, so always reopen the player window; iOS keeps the
existing logic.
2026-06-17 19:05:03 +02:00
Arkadiusz Fal
5556be0504 Fix macOS player window staying visible when starting PiP after a stop
MPVBackend.stop() clears the PiP bridge callbacks on macOS to prevent
crashes during window close, but the backend is reused across videos
and setupPiPIfNeeded is guarded by isPiPSetUp, so the callbacks were
never wired again. The next PiP session then started without notifying
PlayerService (no onPiPStatusChanged), leaving the player window
visible alongside the PiP window, both playing.

Extract the callback wiring into wirePiPBridgeCallbacks() and re-run it
in startPiP() and in setupPiPIfNeeded when already set up.
2026-06-17 09:25:50 +02:00
Arkadiusz Fal
32b1dac249 Fix macOS PiP window not resizing when switching videos
Switching videos during active PiP left the window at the old video's
size with white space around the new content, for two reasons:

- MPV reports the new video's width and height as separate property
  events while the old values are kept across switches, so a transient
  mixed size (new width with old height) could reach the PiP capture
  path and AVKit sized the window from that frame. Coalesce the PiP
  capture-size/aspect update with a short debounce so only settled
  dimensions are used.

- On macOS nothing told AVKit about the new aspect: unlike iOS, the
  bridge never flushed the sample buffer renderer on an aspect change
  during active PiP, and AVKit doesn't resize the macOS PiP window on
  its own. Flush the renderer, reset the format description, and
  resize the PiP window to the new aspect ratio explicitly.
2026-06-16 23:59:57 +02:00
Arkadiusz Fal
b21a4920be Fix macOS opening player window instead of switching video in PiP
The PiP-active guard that skips expanding the player when opening a new
video was compiled iOS-only, so on macOS a browser deep link always
re-showed the hidden player window instead of playing the new video in
the existing PiP window. Extend the guard in both openVideo overloads
and the Handoff path to macOS.
2026-06-16 20:42:39 +02:00
Arkadiusz Fal
4effe5d7a5 Fix low-FPS iOS PiP by reporting swaps while PiP is active
Since 1d9c5e3b performRender feeds swap timing to mpv via
mpv_render_context_report_swap, but only after presentRenderbuffer -
which is skipped while PiP is active. Once swap reports stop mid-stream,
display-vdrop's vsync estimation goes stale and mpv drops nearly every
frame (vo drop counter climbing ~19/s on a 24fps stream), turning PiP
into a slideshow.

The frame is genuinely displayed during PiP via the
AVSampleBufferDisplayLayer, so report the swap on that path too.
2026-06-15 09:26:43 +02:00
Arkadiusz Fal
1157eef201 Fix macOS native styling for search toolbar pickers and view options
The content type segmented picker rendered as a Liquid-Glass pill instead
of a native macOS segmented control. Wrapping a `.segmented` Picker in
`.opacity`/`.disabled`/`.accessibilityHidden`/`.fixedSize` (used to keep
toolbar items mounted-but-invisible for stable layout) inserts a hosting
layer that makes the toolbar fall back to the pill rendering.

- SearchView: filters button and content type picker are now always
  visible on macOS, rendered bare so `.segmented` keeps native styling.
- SearchView: don't reset the content type to .video on submit on macOS,
  so a pre-selected type (e.g. Playlists) is preserved (iOS unchanged).
- InstanceBrowseView: same pill fix, rendering the tab / content type
  pickers bare via conditional `if` instead of opacity-masking.
- ViewOptionsSheet / MediaBrowserViewOptionsSheet: use `.formStyle(.grouped)`
  for native macOS form styling in the options popover.
2026-06-15 08:07:40 +02:00
Arkadiusz Fal
77720ccea1 Pin view options button to leading toolbar edge on macOS
Place the view options control on the leading (`.navigation`) edge on
macOS so it is consistently the first/leftmost toolbar button across all
views, instead of sitting on the right next to the search field in
search views and on the left in non-search views.

Each affected view gains a computed `viewOptionsPlacement` (`.navigation`
on macOS, `.primaryAction` elsewhere) so iOS/iPadOS/tvOS keep the
button trailing. Search-style views (Search, InstanceBrowse) declare the
view options item first so it renders left of the search-filters button;
their flexible spacer is kept to right-pin the search field. Channel's
fixed spacer between view options and the channel menu is now iOS-only.
2026-06-14 16:03:10 +02:00
Arkadiusz Fal
ad14c0129c Fix recent search tap showing suggestions instead of results on macOS Sequoia
Tapping a recent search (or suggestion, or opening a query deep link) mutates
the searchable text, which fires the onChange handler that clears results and
re-fetches suggestions. That raced against the search Task started by the same
tap: on Sequoia the onChange settled last, cancelling the in-flight search and
leaving suggestions visible instead of results.

Suppress the onChange-driven suggestion fetch for the single programmatic text
mutation via a one-shot flag, so the execute paths go straight to results
regardless of onChange-vs-Task ordering. Manual typing is unaffected.
2026-06-13 13:59:27 +02:00
Arkadiusz Fal
1369c93142 Show channel search bar and notification icon during loading
The .searchable modifier lived inside iOSChannelContent, which body only
renders once the channel network fetch completes, so the search field
appeared only after videos loaded. Hoist the search modifier chain into a
channelSearchable wrapper around the load-state Group in body so it shows
immediately in every state (iOS nav-bar drawer and macOS toolbar).

channelMenuIcon gated the notification lookup behind the network-loaded
channel object, forcing the icon to person.fill while loading and swapping
to bell.fill afterward. Use channel?.id.channelID ?? channelID (the same
fallback as refreshSubscription) so the bell state is correct from the
loading phase, since notificationsEnabled(for:) only needs the ID string.
2026-06-13 12:15:54 +02:00
Arkadiusz Fal
4cda0bbc7a Move instance browse pickers into macOS toolbar
The Popular/Trending tab picker now lives in the toolbar (principal
placement) instead of inline above the content. Once a search is
submitted it swaps to the search content type picker, with the search
filters button alongside it as a popover, matching the global Search
view. Items stay mounted invisible on macOS 26 so the toolbar layout
stays stable while typing.
2026-06-12 21:04:30 +02:00
Arkadiusz Fal
7ecd408f89 Pin search bar to right of macOS toolbar in list views
Match the global Search view by pinning the searchable field and
trailing buttons to the right edge on macOS 26 via a flexible
ToolbarSpacer at .primaryAction. Applied to History, Bookmarks,
Manage Channels, Downloads, Instance Browse, and PeerTube Explore.

In History, the clear-history item moves from .automatic to
.primaryAction so it groups with the other trailing controls.
2026-06-12 09:54:24 +02:00
Arkadiusz Fal
d7df883d6f Remove media sources as Home sections (shortcuts-only)
A media-source "section" only rendered as a full-width "Browse {name}"
link, identical for all source types and redundant with the shortcut
card. Remove it from the Available Sections add-list and clean up any
already-added media-source sections on load.

Keep HomeSectionItem.mediaSource as a decode-only case so existing saved
homeSectionOrder data (decoded all-or-nothing) still parses; strip those
entries via removeAllHomeMediaSourceSections(). Media-source shortcuts
are unchanged.
2026-06-11 20:16:00 +02:00
Arkadiusz Fal
8d556abd0b Split home shortcut card style into Layout, Color, and Palette
Decouple the two concerns that were entangled in a single card-style setting:

- Layout (Compact / Regular) now only controls positioning of the
  icon / title / subtitle / counter.
- Color (Soft / Vibrant) is a new independent setting: Soft = tinted
  background with palette-colored icon and neutral text; Vibrant =
  solid palette fill with white icon/text.
- Palette (Accent first, then Classic/Sunset/Meadow/Berry/Grape/Custom)
  now applies to both layouts and both colors, and is always shown.

Adds section headers (Layout / Color / Palette) to the style page and
fixes the Compact preview to show the count subtitle line. Default is
Regular + Soft + Accent.
2026-06-11 19:03:52 +02:00
Arkadiusz Fal
30fc8dc4c8 Halve spacing in home shortcuts edit preview grid 2026-06-10 21:43:38 +02:00
Arkadiusz Fal
05ecd55155 Halve spacing between home shortcut cards 2026-06-10 07:19:45 +02:00
Arkadiusz Fal
d7ec654f84 Add Hide option to home shortcut context menu 2026-06-09 08:46:51 +02:00
Arkadiusz Fal
8e48097a5a Prevent starting PiP from mini player bar while player window is open
Tapping the mini bar video thumbnail (with tap action set to PiP) or its
PiP button while the expanded player window was open started PiP playing
alongside the window. Bring the player window to the front instead when
it is open or opening.
2026-06-09 07:28:09 +02:00
Arkadiusz Fal
0d05daae9d Fix black screen after restoring PiP when player window was closed on macOS
The expanded player window hidden for PiP is reused on restore, so its
MPVContainerNSView kept a stale weak reference to the shared render view
that the mini player preview had taken during PiP. On restore the mini
preview released the view as an orphan and the reused container skipped
re-attaching it because its guard only compared view identity.

- Skip re-attach in setPlayerView only when the view is still actually
  our subview, matching the iOS container's stolen-view handling
- Transfer the shared render view to another living container on unmount
  instead of orphaning it, since SwiftUI is not guaranteed to re-run
  updateNSView on a hidden window's content after restore
- Force a repaint when restoring the window hidden for PiP; forced draws
  requested while ordered out are dropped and the layer is pull-based
2026-06-08 22:55:31 +02:00
Arkadiusz Fal
0fa4df161b Fix fullscreen not working when player window is pinned on macOS
A pinned window carries .fullScreenAuxiliary, which AppKit refuses to
make a primary fullscreen window, so toggleFullScreen was a silent
no-op. Drop the floating config before entering fullscreen and restore
it in windowDidExitFullScreen, keeping the pin setting intact.
2026-06-08 22:33:01 +02:00
Arkadiusz Fal
a0a6634d1c Add context menu with delete option to player controls editor buttons
Buttons in the section, mini player, and pill editors can now be
removed via long-press/right-click context menu instead of only
swipe-to-delete. This also gives macOS a delete affordance in the
section editor, which previously had none.
2026-06-07 08:10:36 +02:00
Arkadiusz Fal
3e7d50415b Fix playlist form layout on macOS 2026-06-07 07:58:09 +02:00
Arkadiusz Fal
8ab6d339f6 Fix queue and add-to-playlist sheets not opening on macOS
The mini player buttons set NavigationCoordinator flags, but only the
iOS tab views hosted the corresponding sheets. Host QueueManagementSheet
and PlaylistSelectorSheet on the macOS root view, bound directly to the
coordinator flags.

Also wire the add-to-playlist button in the full macOS player controls:
add the missing .addToPlaylist case to MacOSControlsSectionRenderer and
thread onShowPlaylistSelector from the expanded sheet layouts through
MacOSPlayerControlsView to the existing playlist sheet host.
2026-06-06 23:34:05 +02:00
Arkadiusz Fal
271aff86b5 Present view options and search filters as popovers on macOS
Plain-Form sheets rendered as columns-style content floating inside
oversized fixed frames on macOS. Anchor these panels to their toolbar
buttons as popovers instead, sized to content; iOS/tvOS keep sheets.

- Search and PeerTube explore filters apply live (Cancel never
  reverted anything anyway) and dismiss on outside click
- Subscriptions/Manage Channels "Subscriptions Data" opens as a sheet
  from the popover's onDisappear via a pending flag, since presenting
  while the popover dismisses gets swallowed
- Media browser view options drop navigation chrome on macOS
2026-06-06 22:59:36 +02:00
Arkadiusz Fal
dbe494f18a Move search filters to the toolbar on macOS
Relocate the funnel filter button and the content-type picker
(All/Videos/Playlists/Channels) from the inline results strip into the
window toolbar on macOS. The inline strip remains iOS-only.

Like on iOS, the controls appear only after a search is submitted
(gated on hasSearched). To keep the toolbar stable:

- Items stay in the toolbar permanently but are invisible and inert
  before a search, so the search field keeps a constant size; their
  liquid-glass backgrounds are suppressed with
  sharedBackgroundVisibility(.hidden) (macOS 26, with a conditional
  fallback on macOS 15).
- A flexible ToolbarSpacer pins the view-options button and search
  field to the trailing edge so they no longer shift when items
  appear.

The filters type onChange handler moved from the strip to the shared
iOS/macOS branch so both the strip and the toolbar picker reuse it.
2026-06-05 22:23:17 +02:00
Arkadiusz Fal
d667b56967 Fix silent audio on tvOS with Atmos Continuous Audio Output (#928)
With Atmos "Continuous Audio Output" enabled, some HDMI routes report
32 output channels, which mpv's audiounit AO cannot open - playback
stays silent while other (AVPlayer-based) apps work fine.

Update MPVKit to 0.41.0-n8.1.2, which builds mpv's avfoundation AO
(AVSampleBufferAudioRenderer, previously macOS-only) for iOS/tvOS
(mpvkit/MPVKit#73), and prefer it on tvOS via ao=avfoundation,audiounit
so mpv still falls back to audiounit if it fails to initialize. iOS and
macOS audio outputs are unchanged.

The MPVKit pin must be exactVersion: SwiftPM sorts the pre-release
identifier below 0.41.0, so a range starting at 0.41.0 never resolves
to it. Note this tag also bumps FFmpeg from 8.0 to n8.1.1.
2026-06-05 07:04:04 +02:00
Arkadiusz Fal
76e5220c95 Fix no video on iOS/tvOS devices after GL two-thread hardening
1d9c5e3b removed the main-thread EAGLContext bind from setupAsync so
the context is never left current on two threads. But libmpv's
mpv_render_context_create requires a current GL context on the calling
thread (it probes glGetString(GL_VERSION)), so render context creation
failed with MPV_ERROR_UNSUPPORTED (-18) and every video played with no
--vo ("No render context set") on real iOS/tvOS devices.

Bind the context around the createRenderContext call and unbind right
after. The bind is scoped to setup, before the display link starts and
before any frame is rendered, so the never-left-current invariant that
protects the A10X fence fix (#947/#949) still holds.

Verified on Apple TV 4K (3rd gen): createRenderContext succeeds and
video frames render.

Claude-Session: https://claude.ai/code/session_01UEXP5f6F4S1zLdY8fdVuLJ
2026-06-04 21:24:43 +02:00
Arkadiusz Fal
e94ff861c7 Fix stale queue after closing macOS player window with Cmd+W
Closing the separate player window via Cmd+W (or the traffic-light
button) stopped the player but left the queue populated, so the next
video tap showed the Play Now/Play Next/Add to Queue prompt. Clear the
queue in windowShouldClose to match the close button behavior.
2026-06-04 18:27:29 +02:00
Arkadiusz Fal
31151df169 Fix macOS player window blink when playing a new video
The separate player window's show() restore branch reset alphaValue to 0
and faded back in even when the window was already on screen, so every
Play Now while playing made the window flash. Only run the fade when the
window was actually hidden (PiP restore); otherwise just bring it forward.
2026-06-03 22:27:38 +02:00
Arkadiusz Fal
5c8c55f41c Fix macOS black video after player close/reopen
The macOS CAOpenGLLayer is pull-based: after the shared render view is
re-parented into a reopened player window, a single forced repaint is
issued. draw() consumed the forceDraw flag before validating the
viewport, so when that repaint landed while the layer was still 0x0
(the expanded window builds its content a runloop late), the request
was swallowed. With mpv paused during load, nothing repainted again,
leaving the video permanently black with working audio.

- Validate viewport/framebuffer in draw() before consuming flags;
  on failure keep the forced draw armed and retry (bounded, 5x50ms)
- Repaint from setFrameSize on the first non-zero layout while a
  forced draw is still pending
- Drop the stale fbo=1 fallback; render only into the validated
  live framebuffer binding
- clearToBlack: take the CGL lock and skip glClear when no
  framebuffer is bound - clearing FBO 0 on this core-profile context
  latched GL_INVALID_FRAMEBUFFER_OPERATION, which libmpv reported on
  every load
- Add diagnostics for dropped draws and the first-frame render
2026-06-03 22:00:28 +02:00
Arkadiusz Fal
8acf0f6d3f Allow save panels by granting user-selected file read/write entitlement 2026-06-02 20:51:38 +02:00
Arkadiusz Fal
96fef3663f Add glass background theme setting for macOS control bar
Rename the macOS "Control Bar Buttons" settings entry to "Control Bar"
and add an Auto/Light/Dark glass background picker on that page. The
choice is stored per-preset as GlobalLayoutSettings.controlBarTheme and
forces the bar's glass color scheme via glassBackground(colorScheme:).
2026-06-02 19:38:46 +02:00
Arkadiusz Fal
0d56dd02b7 Fix Home card style selection not persisting
The Home settings screen copies settings into local @State on appear and
saves on disappear. Pushing the card style page fired onDisappear (saving
pre-edit values), and popping back re-ran loadSettings, clobbering the
style just edited through the bindings — so the selection reverted.

Load settings only once per presentation, and persist style, palette, and
custom colors immediately from the style page via an onSave callback. The
owning view sits covered in the navigation stack where its own onChange
never fires, and swipe-dismissing the settings sheet from the style page
skips its lifecycle entirely, so saving must happen from the visible child.
2026-06-01 21:22:45 +02:00
Arkadiusz Fal
eea13a6290 Stop macOS player shortcuts from swallowing typing in other windows
The player's keyDown monitor only checked that some window was key, so
plain-key shortcuts (m, space, f, arrows) fired while typing into the
Settings window's text fields. Guard on the window actually hosting the
player controls and pass events through while a text field is editing.
2026-06-01 20:32:10 +02:00
Arkadiusz Fal
05bbd449f8 Drive macOS player control bars from presets
Render the macOS control bar button row and top bar from the active
preset's sections via a new MacOSControlsSectionRenderer, replacing the
hardcoded QuickTime-style rows in MacOSControlBar. Add a keepOnTop
button type and macOS-specific button availability lists, and adapt the
player controls settings editors (preset editor, section editor, button
configuration) for macOS.

Also apply the preset font style to the control bar time labels, and
update the built-in macOS Default preset order (queue before transport,
fullscreen at the end), bumping builtInPresetsVersion to 7.
2026-05-31 16:55:39 +02:00
Arkadiusz Fal
fa6ad2235c Fix tvOS GL render pipeline defects behind A10X IOFence crash
Apple TV 4K 1st gen on tvOS 26 crashes to the home screen with a
"blocked by IOFence" gpuEvent at playback start (#949) and stutters
with a starved demuxer cache (#947). Defensive fixes in the shared
iOS/tvOS OpenGL path:

- Drain in-flight GPU work (glFinish) before destroying the
  renderbuffer, so a pending presentRenderbuffer fence can't hold the
  CAEAGLLayer IOSurface being torn down or reallocated
- Never leave the EAGLContext current on the main thread: framebuffer
  create/destroy and the background glFinish now unbind on exit, and
  setup no longer binds the context on main at all
- Disable retained backing on tvOS; it exists only for iOS PiP frame
  capture and adds per-frame IOSurface fence pressure
- Report swaps to mpv after each present so display-vdrop (the tvOS
  default video-sync) gets swap-timing feedback, matching macOS

Add verbose-gated diagnostics for remote triage: playback stats every
10s (cache fill/rate, dropped frames, hwdec, avsync) and a rate-limited
slow-frame warning in performRender.

Addresses #949 and #947
2026-05-31 11:27:09 +02:00
Arkadiusz Fal
5950dbb22a Honor Lock Controls on macOS player
The macOS controls stack (MacOSPlayerControlsView + MacOSControlBar)
never read playerState.isControlsLocked, so locking via the settings
sheet had no effect: buttons, the scrubber, the volume slider, and the
AppKit keyboard shortcuts all kept working.

When locked, dim and disable transport, volume, the progress bar,
queue, the more menu, PiP, the pin/floating toggle, and the title-tap
details button; ignore playback keyboard shortcuts (space/arrows/mute)
while keeping F/Esc for fullscreen. Settings and Close stay enabled so
the user can reopen Settings to unlock.
2026-05-30 22:22:18 +02:00
Arkadiusz Fal
2f2e436fe2 Split macOS player mode into window toggle + floating control
Replace the three-way macOS Player Mode picker (Separate Window /
Floating Window / Inline) with a single 'Play in a separate window'
toggle in Playback settings, and move the always-on-top choice to a
pin button in the player's top bar (window mode only).

Floating was really a transient window property (NSWindow.level), not
a peer of the window/inline structural choice — so it belongs on a live
control, not in Settings. The pin state persists across sessions.

- Replace MacPlayerMode enum with macPlayerSeparateWindow /
  macPlayerFloating bool settings
- Branch runtime presentation and window level off the two bools
- Add pin.fill/pin toggle to MacOSPlayerControlsView top bar
- Update localization; drop obsolete playerMode strings and enum tests
2026-05-30 12:42:18 +02:00
Arkadiusz Fal
64ca1c10d9 Snap macOS player window to final size on open
The expanded player window opened at a seeded 16:9 size and was then
resized with an animated setFrame once the real aspect ratio / video
change landed. Because the video and controls are laid out from the
window's live size, they visibly grew/repositioned as the window
animated.

Force the first resize after each open to snap (no animation) so the
player appears at its final fixed layout, and seed the initial window
size and aspect constraint from the real video aspect ratio when it is
already known (e.g. expanding from the mini bar) to avoid even a jump.
Later resizes (switching to a different-aspect video while the window
stays open) still animate.
2026-05-29 18:36:39 +02:00
Arkadiusz Fal
32f526e05b Add macOS player top bar with title, avatar, and close
Show a top row in the macOS player controls (like tvOS) with the channel
avatar, video title, author name, and a close button whenever controls are
visible.

- Offset the top row below the traffic lights in the floating window by
  measuring the window buttons against the top bar (no effect in fullscreen,
  the inline sheet, or the side panel where there is no overlap).
- Tapping the avatar/title/author toggles the floating details panel, mirroring
  the iOS title/author control.
- Hide the controls immediately when the details panel opens instead of waiting
  for the auto-hide timer.
2026-05-29 08:17:14 +02:00
Arkadiusz Fal
486024834d Add position-based colorful Home shortcut palettes
Colorful shortcut cards now color by grid position instead of shortcut
identity, so colors stay in the same slot when shortcuts are reordered.

Adds a Palette option (Classic default, plus Sunset, Meadow, Berry,
Grape) and a Custom palette with a swatch-list editor (ColorPicker + hex
fields) and a switchable comma-separated text field. Color is resolved
per position and wraps by palette length, wired via a new
homeShortcutColorfulColor environment value and a Color(hex:) extension.
2026-05-28 23:26:05 +02:00
Arkadiusz Fal
bcd4206c55 Move Home shortcut style picker to its own page with live preview
Replace the inline Plain/Accent/Colorful picker in Home customization with
a navigation row that opens a dedicated page. The new page keeps the style
selector and renders a non-interactive preview of every shortcut type so
the chosen style is visible before committing.

- Add an optional styleOverride to HomeShortcutCardView so the preview can
  reflect the not-yet-saved selection.
- On macOS, navigate via navigationDestination(isPresented:) instead of a
  List-embedded NavigationLink, which otherwise stays stuck in its selected
  state after popping back and can't be reopened.
2026-05-28 20:34:38 +02:00
Arkadiusz Fal
ef213307dc Lock macOS inline player sheet resize to the video aspect ratio
In floating/window mode, interactive resize is aspect-locked via the
window's contentAspectRatio, so no black bars appear. The inline sheet
had no such lock, allowing free resize with pan bars on the sides.

Make applyAspectRatioConstraint static so the sheet path can reuse the
exact same lock the standalone window uses, and apply it to the sheet's
backing NSWindow from SheetWindowResizer whenever the video ratio is
known. Like the standalone window, the resize lock always follows the
real video ratio (independent of the playerSheetAutoResize size setting).
2026-05-27 23:21:07 +02:00
Arkadiusz Fal
6bc58f10b2 Open macOS inline player sheet at correct size when video is loaded
The inline-sheet content declared only a minWidth/minHeight floor, so
.presentationSizing(.fitted) opened the sheet at 640x360 and
SheetWindowResizer corrected it a runloop tick later — a visible
small-then-resize flash when re-opening the sheet while a video plays.

Feed the already-computed aspect-derived size as the frame's ideal size
so .fitted opens the sheet at the correct size immediately when the video
dimensions are known; the resizer's first pass then no-ops on its guard.
The floor (no max) is kept so content still tracks later animated resizes.
2026-05-27 23:03:40 +02:00
Arkadiusz Fal
07fd5e2744 Add "Edit Shortcuts" context menu to Home shortcuts
Long-press (or right-click) any Home shortcut to open the Home settings
sheet for reordering and toggling shortcuts.
2026-05-26 07:57:55 +02:00
Arkadiusz Fal
7a1af02418 Add card style option for Home shortcut cards
Adds a Style picker (Plain / Accent / Colorful) to the Customize Home
sheet, shown when the shortcuts layout is Cards. Defaults to Plain.

- Plain: current look (light accent tint + accent border)
- Accent: solid accent fill, white icon/text
- Colorful: solid fill with a fixed color per shortcut type

The filled styles use a Reminders-style layout (icon top-leading, count
top-trailing, title anchored to the bottom) with a subtle gradient sheen
over the fill color. Cards without a meaningful count (Open Link, Remote,
Subscriptions, instance content, media sources) hide the number; Remote
shows its status dot in the top-right slot instead.

Persisted via SettingsKey.homeShortcutCardStyle, mirroring the existing
homeShortcutLayout accessor.
2026-05-26 06:43:34 +02:00
Arkadiusz Fal
5f49f2d022 Fix macOS black video after re-parenting the shared player view
The player video surface is a single shared MPVOGLView that is re-parented
between the mini-bar container (main window) and the expanded-player sheet
(a separate sheet window). When presenting the sheet, the new container
attaches the shared view before the old mini-bar container tears down, so
the old container's viewWillMove(toSuperview: nil) was ripping the shared
view out of its new home via removeFromSuperview() — leaving it with no
window and a black surface. Symmetric, so it also blanked the mini-bar
preview after collapsing.

Only detach the shared view if it still belongs to the container
(superview === self). Also force a repaint on window reattach so a paused
frame redraws, and extend the onAppear resumeRendering to macOS.
2026-05-25 19:46:35 +02:00
Arkadiusz Fal
fe4e3c531a Use rounded font for macOS player time labels
Switch the current-time and duration labels in the macOS transport
control bar from a monospaced design to a rounded design with
.monospacedDigit(), keeping stable digit widths while matching the
softer Liquid Glass look.
2026-05-25 18:02:34 +02:00
Arkadiusz Fal
54488aea0f Keep macOS player transport buttons visible but disabled when unavailable
The play-next and Picture-in-Picture buttons were folded into the view
condition (hasNext / isPiPPossible), so they popped in and out of the
control bar and shifted the surrounding layout. Always render them (when
their callback is wired) and gate availability with .disabled() instead,
mirroring the existing play-previous button.
2026-05-24 21:05:29 +02:00
Arkadiusz Fal
9568149c21 Use native text Close button for sheet toolbars on macOS
Sheet dismiss buttons rendered as an iOS-style circular glass "X" on
macOS 26, which looks foreign on the desktop. Add a shared
sheetCloseToolbarItem helper that renders a native text "Close" button
on macOS while keeping the compact icon-only xmark on iOS/tvOS, and
replace the ~14 duplicated inline close buttons across sheets with it.
2026-05-24 12:34:53 +02:00
Arkadiusz Fal
c245a0e2a0 Resize macOS player sheet to match video aspect ratio
In sheet (inline) mode the expanded player kept a fixed size and never
reacted to the video's aspect ratio, unlike window/floating mode. Drive
the sheet's backing NSWindow size from the video aspect ratio, reusing
the same sizing math as the standalone window.

- Extract shared sizing into ExpandedPlayerWindowManager.fittedPlayerSize
  and add fittedSheetSize for the sheet path.
- Add SheetWindowResizer (NSViewRepresentable) + .sheetWindowSize modifier
  that resizes the hosting sheet window, since .presentationSizing(.fitted)
  only fits once at presentation.
- Let the sheet content fill the window and paint the window background
  black so animated resizes stay gap-free with no white bars.
2026-05-23 13:17:28 +02:00
Arkadiusz Fal
86f9ef56b3 Dismiss expanded player window when closing video from the mini bar
The capsule close button (and context-menu close) only stopped playback and
cleared the queue, leaving the separate macOS player window open on a black
frame. Route both through a shared closeVideo() that also sets
isPlayerExpanded = false, mirroring PlaybackCommands.closeVideo, so the
player window is dismissed.
2026-05-23 11:39:46 +02:00
Arkadiusz Fal
84c17382ce Redesign macOS now-playing bar as centered Liquid Glass capsule
- Replace the full-width material overlay bar with a compact, centered
  capsule (Apple Music style) via a macOS-specific macOSCapsuleLayout
- Extend glassBackground() to use real Liquid Glass on macOS 26 with a
  material fallback on macOS 15-25 (mirrors the PlayerOverlayButton pattern)
- Remove the duplicate close button (close now comes only from the
  configurable button set)
- Keep the capsule visible in the main window while the separate player
  window is open (window mode), still hidden in sheet mode
2026-05-22 23:43:51 +02:00
Arkadiusz Fal
a480b73e7c Open macOS player window immediately with loading feedback
On macOS the expanded player window built ExpandedPlayerSheet directly in
a fresh NSHostingController on every open. AppKit doesn't composite the
window until that heavy view finishes its first layout pass, so the click
felt like it waited for data to load before anything appeared.

Host a lightweight two-phase root (black + spinner) that composites
immediately, then defer building ExpandedPlayerSheet by one runloop so the
window shows at once and fades in with visible loading — matching iOS.

Because assigning contentViewController resizes the window to the hosting
controller's fitting size (tiny for the placeholder), force the content
size back to the seeded 16:9 initialSize so the window opens full-sized
during the loading phase too.
2026-05-22 23:13:08 +02:00
Arkadiusz Fal
93f9a382b0 Refine macOS player controls: add queue, more, previous; drop seek/fullscreen buttons
Add Queue (list.bullet), More (VideoContextMenuView ellipsis), and Play
Previous buttons to the macOS control bar. Play Previous is shown always and
disabled when the queue has no previous item. Remove the skip-back/forward 10s
buttons and the fullscreen button from the bar (arrow-key seek and F-key
fullscreen shortcuts are retained).

Queue reuses the existing cross-platform queue sheet; More reuses the shared
VideoContextMenuView (already !os(tvOS)), styled borderless to match the flat
bar buttons.
2026-05-21 21:36:09 +02:00
Arkadiusz Fal
64171a9cfb Add explicit sizing to sheets on macOS
Give player, download, source, and login sheets minimum frame dimensions on macOS so they open at a usable size instead of collapsing.
2026-05-21 18:19:19 +02:00
Arkadiusz Fal
7cb068c800 Fix Add to Playlist sheet layout on macOS
The sheet collapsed to an empty strip because a List inside a sheet
has no intrinsic height on macOS. Add an explicit minimum frame like
other sheets, gate presentationDetents to iOS, and replace the
prominent xmark toolbar button with a native Close button on macOS.
2026-05-20 09:15:27 +02:00
Arkadiusz Fal
6dfa63c263 Remove Enable DASH setting; make DASH a last-resort format
Drop the dashEnabled toggle and its plumbing. Stream auto-selection now
keeps DASH as a candidate but ranks it strictly below progressive and
HLS formats, so DASH is only chosen when it is the only format available
(including live-stream fallback after HLS). The manual quality selector
hides DASH entirely on iOS/tvOS/macOS. Reconciles BackendSwitcher, which
previously treated HLS/DASH equally, to use the same DASH-last ranking.
2026-05-20 07:14:31 +02:00
Arkadiusz Fal
88fafc5ada Remove Test Connection from source editing
The Test Connection buttons only probed API/server reachability, which
was misleading: a green result said nothing about whether videos would
actually play. Remove both the remote-server and WebDAV bandwidth test
buttons and all code exclusive to them (testBandwidth, BandwidthTestResult,
and orphaned localization keys). Add-time connectivity validation for
SMB/WebDAV sources is retained.
2026-05-19 20:40:28 +02:00
Arkadiusz Fal
858011c507 Generalize first-launch import prompt to cover sources
The prompt fires for any legacy data now, so word it for accounts and
sources rather than accounts only.
2026-05-19 20:25:33 +02:00
Arkadiusz Fal
86fbdd23f1 Allow importing account-less legacy instances as sources
Legacy v1 instances without an account had no import path after the
switch to explicit account review. Surface them in a separate "Sources"
section of the legacy import view where they can be added with a single
tap (no sign-in) or removed, mirroring the accounts flow. Instances tied
to an account stay in the Accounts section and are not duplicated.

Detection now gates the entry points and first-launch prompt on any
legacy data (accounts or sources), not accounts alone.
2026-05-18 23:54:42 +02:00
Arkadiusz Fal
de9b06072d Show only the alert on legacy account import success
Successful import fired both a toast and a confirmation alert for the
same event. Drop the toast and keep the alert.
2026-05-18 20:22:35 +02:00
Arkadiusz Fal
c4bcfd1211 Remove dead legacy-import pipeline and unused LegacyAccount.password
The manual account-import flow made the old silent instance-import
pipeline unreachable. Drop the orphaned parsing, reachability checks,
batch import, result/error types, and the old import row view, plus the
httpClient dependency they were the only consumer of. Also remove the
LegacyAccount.password field, which was parsed but never read.
2026-05-17 19:29:21 +02:00
Arkadiusz Fal
bde80bfef1 Add manual legacy account import 2026-05-17 13:08:32 +02:00
Arkadiusz Fal
69cf616465 Bump build number to 262 2026-05-16 23:26:30 +02:00
Arkadiusz Fal
b171a6d8c0 Update Sparkle 2026-05-16 20:47:13 +02:00
Arkadiusz Fal
37d11b50a8 Render Add Source buttons inline on macOS where toolbar items aren't shown
Pre-macOS 26 doesn't reliably surface a detail-pane NavigationStack's
toolbar confirmation items in the window title bar, leaving the
"Add Source" actions invisible. Gate the toolbar placement behind
macOS 26+ and fall back to an inline form/header button on older
macOS across the add-source views and the sources list.
2026-05-15 07:55:30 +02:00
Arkadiusz Fal
bc81b03821 Fall back to software OpenGL renderer instead of crashing on GPU-less Macs
MPVOpenGLLayer called fatalError() when CGLChoosePixelFormat failed for
both the 10-bit and 8-bit (kCGLPFAAccelerated) formats, killing the app at
launch on machines without a usable GPU such as virtual machines.

Add a non-accelerated software pixel format as a last resort and make the
whole OpenGL init path failable: createPixelFormat/createContext return nil
(with an error log) instead of aborting, MPVOpenGLLayer.init becomes init?,
and MPVOGLView holds an optional layer, throwing openGLSetupFailed from
setup(with:) when OpenGL is unavailable. That error is already handled by
MPVBackend.setupMPVAsync, so the app now launches cleanly and only logs that
video playback is unavailable.
2026-05-15 07:03:24 +02:00
Arkadiusz Fal
c49ae6c6a6 Capture player weakly in outer Task to fix mismatched ownership warning 2026-05-14 19:38:13 +02:00
Arkadiusz Fal
c168fbae02 Add tvOS A/V sync diagnostics on its own settings page
Surface audio-delay (±10/±100 ms) and video-sync-mode controls behind an
"A/V Sync" navigation row in Advanced settings rather than inline, keeping the
Advanced page uncluttered. Audio delay applies live to the running MPV
instance; sync mode takes effect on next playback.
2026-05-14 09:43:25 +02:00
Arkadiusz Fal
579df26284 Persist SwiftData within a background task on suspension (0xdead10cc)
On backgrounding, the app flushed pending CloudKit changes in an unguarded Task
while SwiftData autosave could leave an open SQLite transaction. iOS terminates
apps that hold the database lock across suspension (0xdead10cc), which was the
largest crash group on build 261. On iOS, commit pending changes synchronously
to release the lock, and run the async flush inside a beginBackgroundTask
assertion that ends when the flush completes.

Fixes 0xdead10cc SIGKILLs seen in TestFlight build 261 (17 reports).
2026-05-13 22:05:04 +02:00
Arkadiusz Fal
ba33823048 Run CloudKit record mapping on the main actor to fix SwiftData race
CloudKitRecordMapper was a standalone actor, so 'await recordMapper.toCKRecord'
from the @MainActor sync engine hopped off the main actor and read live
SwiftData @Model properties (e.g. LocalPlaylistItem.authorName) from the wrong
executor. SwiftData models are bound to the main ModelContext and are not
thread-safe, so this raced the backing store and crashed with EXC_BAD_ACCESS.

Make the mapper @MainActor (it must touch main-isolated models regardless) and
drop the now-redundant awaits. Fixes the crash for all synced model types.

Fixes an EXC_BAD_ACCESS seen in TestFlight build 261.
2026-05-13 20:56:03 +02:00
Arkadiusz Fal
926c6ebc97 Set popover anchor for share sheets to fix iPad crash
UIActivityViewController is presented as a popover on iPad and requires an
anchor; without a sourceView/barButtonItem UIKit aborts in
-[UIPopoverPresentationController presentationTransitionWillBegin]. Set
sourceView/sourceRect on both the direct presentation in the swipe-actions
modifier and the ShareSheet representable.

Fixes a SIGABRT seen in TestFlight build 261.
2026-05-12 23:54:57 +02:00
Arkadiusz Fal
2ed0dc72be Fix CheckedContinuation double-resume in waitForConnectionReady
The 'resumed' guard flag was set inside a dispatched MainActor Task rather than
synchronously in the connection state handler. Two states arriving in quick
succession (e.g. .ready then .failed) could both pass the guard before either
Task ran, resuming the continuation twice and trapping. Claim the continuation
synchronously on the serial queue before dispatching the side effects.

Fixes a SIGTRAP seen in TestFlight build 261.
2026-05-12 22:32:13 +02:00
Arkadiusz Fal
ec5ac944c4 Fix download completion crash from stale array index across await
completeMultiFileDownload captured an index via firstIndex, then awaited a
detached file-size calculation. Another concurrently-completing download could
mutate activeDownloads during that suspension, leaving the index stale and
crashing in Array.remove(at:) with an out-of-bounds index. Remove the download
by identity instead.

Fixes a SIGTRAP seen in TestFlight build 261 (5 reports).
2026-05-11 22:50:16 +02:00
Arkadiusz Fal
f74659e903 Fall back to lower-quality thumbnails when higher-res variants 404
YouTube's CDN advertises maxres/sddefault thumbnails for every video but
only generates them for sufficiently high-res uploads, so older/low-res
videos return 404 for those qualities. The view picked the best quality
and showed a blank placeholder on failure with no fallback.

VideoThumbnailView now accepts an ordered fallback chain and advances to
the next candidate via NukeUI's onCompletion when a load fails, so a valid
thumbnail is always shown. DeArrowVideoThumbnail feeds it DeArrow branding
first, then the video's own thumbnails best-quality-first.
2026-05-11 18:56:06 +02:00
Arkadiusz Fal
a734bb47f7 Fix downloads never finishing due to duplicate AppEnvironment
SwiftUI evaluates a @State default-value autoclosure more than once,
keeping only the first instance but still running the side effects of
the discarded ones. Each AppEnvironment() built its own DownloadManager,
and each DownloadManager registered a background URLSession under the
same identifier. The download task started on the surviving instance's
session, but iOS delivered the completion delegate callback to the other
(leaked) instance, whose activeDownloads was empty - so the finished
file was dropped and the progress spinner spun forever at 0 KB.

- Make AppEnvironment a process-wide singleton (static let shared) and
  reference it from the App's @State, guaranteeing exactly one instance
  (and one background session, one DataManager/CloudKit stack).
- Make DownloadManager.setDownloadSettings idempotent: only create the
  background session when none exists, so a re-entrant call never
  invalidateAndCancels the live session and kills in-flight downloads.
  Cellular changes already route through refreshCellularAccessSetting().
2026-05-10 23:46:02 +02:00
Arkadiusz Fal
92d0b3215b Resume MPV rendering when expanded player view re-appears
On iPad, expanding the player after collapsing left the MPV display link
paused, showing only a static frame. MiniPlayerView pauses rendering when
its video preview hides during expand, and PlayerService.playerSheetDidAppear
is gated behind background playback so it can't reliably resume.

Call resumeRendering() from MPVVideoView.onAppear so the expanded view
always restarts the display link.
2026-05-10 17:23:56 +02:00
github-actions[bot]
c71332f69f Bump build number to 261 2026-05-10 14:41:18 +00:00
Arkadiusz Fal
806be7d808 Bump build number to 260 2026-05-10 16:04:37 +02:00
Arkadiusz Fal
e4936873ca Update changelog 2026-05-10 15:50:24 +02:00
Arkadiusz Fal
a6b95e9dad Dismiss tvOS player panels when playback fails
Settings, queue, and details panels stayed open over the failure overlay
and error details sheet, obscuring them. Close any open right-side panel
on entry to the failed state.
2026-05-10 15:31:39 +02:00
Arkadiusz Fal
c52f035729 Increase tvOS queue row spacing for focus halo breathing room 2026-05-10 15:28:12 +02:00
Arkadiusz Fal
aa5e78a244 Silence Sendable warnings in TVRemoteHoldSeekOverlay
Annotate makeCoordinator/makeUIView/updateUIView as @MainActor so reading
the @MainActor-typed onTick closure stays within MainActor isolation and
no longer triggers a Sendable conversion warning.
2026-05-10 15:28:12 +02:00
Arkadiusz Fal
dac81e1ee8 Convert tvOS settings and queue overlays to half-screen panels
The Settings (quality/audio/subtitles) and Queue panels now slide in
from the right and occupy the right half of the screen, matching the
info/comments details panel introduced in 92cc8b79f. Video stays
visible on the left so the user retains visual context while browsing.

Both panels supply their own ultraThinMaterial backdrop and use a
custom title bar (replacing NavigationStack's auto-title on tvOS) so
the title styling and symmetric padding match across panels and
across pushed destination screens. The Menu button now pops the
quality panel's pushed Video/Audio/Subtitles detail screens before
dismissing the panel itself.

Removes the background Button from the focus tree while either panel
is open so D-pad left/right inside a row no longer escapes focus
into the player and triggers a seek. Initial focus is steered into
the first row programmatically since tvOS doesn't auto-focus inline
overlays the way it does for fullScreenCover.

Doubles the queue thumbnail size on tvOS (160x90) for readability at
the half-screen panel width.
2026-05-10 15:28:12 +02:00
Arkadiusz Fal
6e5714dd86 Fix tvOS MPV startup playback stability 2026-05-10 15:28:12 +02:00
Arkadiusz Fal
82d2830208 Refresh track list when advancing to next queued video
Player settings on tvOS showed the previous video's tracks after queue
advance because availableStreams was never cleared on a video change and
playQueuedVideo only seeded a single pre-resolved stream. Reset the list
on a new video, then fetch the full streams in the background for global
videos, and repoint state.currentStream/currentAudioStream to the
refreshed entries so the picker checkmarks land on the playing tracks.
2026-05-10 15:28:11 +02:00
Arkadiusz Fal
6a343311ea Add tvOS display frame rate and dynamic range matching
Lets the Apple TV switch its HDMI output to match the playing video's
frame rate and dynamic range via AVDisplayManager.preferredDisplayCriteria,
driven from MPV's container-fps and video-params/gamma. Two opt-in toggles
(default off) live under Playback → Display on tvOS; both are no-ops on
other platforms. Anchor an AVKit class symbol so the linker keeps AVKit
linked — Swift only autolinks AVFoundation here, and without AVKit the
UIWindow.avDisplayManager category isn't loaded at runtime.
2026-05-10 15:28:11 +02:00
Arkadiusz Fal
100e762d4b Suppress stale player error after switching videos mid-retry
If the MPV backend was retrying a failed load and the user switched to
another video before retries exhausted, the eventual error was published
to the player UI even though that video was no longer active. Guard the
catch block with a loadingVideoID check so stale errors are dropped.
2026-05-10 15:28:11 +02:00
Arkadiusz Fal
4935fbdb83 Remove tvOS close button from MPV debug stats overlay
Menu remote already dismisses the overlay via TVPlayerView's exit
command handler, so the in-overlay close button is redundant.
2026-05-10 15:28:11 +02:00
Arkadiusz Fal
c778ca5d06 Fix flaky integration tests and UI test runner robustness
- Skip Invidious integration tests gracefully on .noConnection so a
  transient instance outage no longer fails CI
- Point integration tests at i01.v.yattee.stream (the previous test
  instance was decommissioned)
- Force UTF-8 on AXe CLI output in the UI test wrapper; ASCII-tagged
  bytes were crashing JSON.parse in describe_ui
- Add iOS 26.4 visual baselines for app-launch-home and settings-main
2026-05-10 15:28:11 +02:00
Arkadiusz Fal
d0297a5e89 Fix tvOS MPV Options focus and Add/Edit sheet layout
Wrap pushed view in TVSidebarDetailContainer, list custom options
first so focus engages on a row, mark default options focusable so the
list scrolls. Replace toolbar-based Add/Edit sheets with padded VStacks
and inline confirm buttons. Cache customMPVOptions in @Observable
backing storage so writes refresh the list immediately.
2026-05-09 18:11:59 +02:00
Arkadiusz Fal
06ae5ac053 Round iOS player seek bar and show scrubber only while dragging
Clip the progress bar with a Capsule for neater edges, and reveal the
scrubber handle only during a drag with a spring zoom animation.
2026-05-09 16:54:45 +02:00
Arkadiusz Fal
d49591eaf4 Skip local-folder watches from iCloud sync 2026-05-09 15:53:54 +02:00
Arkadiusz Fal
c64f13a0e6 Show cached channel header on tvOS while channel loads
Render subscriber count, Subscribe button, and (for subscribed
channels) description in the tvOS loading state instead of just
avatar + name + spinner. Seed the in-memory author cache when
navigating to a channel from a video so the first-time channel
view has a name and avatar to display immediately.
2026-05-09 15:00:53 +02:00
Arkadiusz Fal
1f0f3a8cf0 Resume and seek when reopening currently-loaded video
When the same video was already loaded (typically paused), opening it
again via the URL scheme, a deep link, or a remote-control loadVideo
command did nothing — the player just stayed paused. Now the same-video
early-return path resumes playback if paused and seeks to the supplied
startTime, so timestamps from URLs and remotes are honoured even when
the video is already loaded.

URLRouter gains a parseTimestamp helper that reads t/time/start query
params in plain-seconds and YouTube-style (1h2m3s) forms, and the deep
link handler now forwards that timestamp through to openVideo.
2026-05-09 15:00:08 +02:00
Arkadiusz Fal
aabf5313fa Expose Background Playback toggle on tvOS, default off
Surfaces the existing iOS/macOS Background Playback setting in the tvOS
Playback settings, defaulting to off so audio stops when the user leaves
the app via the TV button. Pauses playback on .background/.inactive when
the toggle is off, regardless of audio route — the user's setting wins
over AirPlay/HomePod handoff. Also auto-shows the tvOS player controls
when returning to foreground so the paused state is immediately visible
and actionable.
2026-05-09 14:50:38 +02:00
Arkadiusz Fal
8a3f76bb1d Constrain tvOS details panel to right half of screen
Full-screen glass overlay was excessive on 4K displays and fully hid
the video. Wrap the panel in a GeometryReader sized to half the parent
width, slide it in from the trailing edge, and tighten internal
horizontal/top padding now that the content lives in a narrower column.
2026-05-09 14:35:00 +02:00
Arkadiusz Fal
42621b8193 Suppress tvOS Now Playing while AirPlay/HomePod route is active
On tvOS, registering MPRemoteCommandCenter handlers makes the system
classify the app as a long-form video media app. When audio is routed
to AirPlay 2 endpoints (HomePods), the system then enforces a ~2s
look-ahead buffer in the AVAudioSession → AirPlay 2 pipe for
multi-speaker sync. The result is a 2-3 second audio drain on pause
and refill on resume.

The buffer lives downstream of mpv, so no mpv command (ao-reload,
seek-flush, audio-add) can flush it; AVAudioSession setCategory
overrides (mode/policy) and setActive(false)/setActive(true) cycles
are also ignored once the app is media-classified.

Workaround: detect the active audio route via routeChangeNotification
on tvOS. While AirPlay/HomePod is the output, suppress
MPNowPlayingInfoCenter publication and disable every MPRemoteCommand
so tvOS un-classifies us. When the route returns to local outputs,
republish the cached Now Playing info and reconfigure remote commands.
A latch defers all media integration until the audio session has been
activated at least once, so no commands are registered before the
route can be evaluated.

Trade-off: while playing to HomePods, the Control Center widget and
external Siri Remote play/pause are not available — but pause/resume
is responsive.
2026-05-09 14:18:17 +02:00
Arkadiusz Fal
9287f5906d Make watched checkmark prominent on tvOS thumbnails
Bump glyph size, force white-on-black palette, and add a drop shadow so
the indicator stays readable on unfocused thumbnails from couch distance.
2026-05-09 11:35:46 +02:00
Arkadiusz Fal
9e13bffa8c Live-seek tvOS scrubber and auto-commit on idle
Throttle SELECT-based scrubbing to seek the underlying frame ~every
150ms instead of waiting 1s after pan-end, so the visible frame keeps
up with the scrub handle. Hide the redundant storyboard panel during
live scrub (the frame itself is now the preview) but keep the chapter
capsule visible. Storyboard panel still shown for D-pad arrow-seek
where the frame doesn't move until commit.

Auto-commit scrub mode after 3s of inactivity, matching
AVPlayerViewController behavior — playback resumes via the existing
scrub-pause wiring instead of staying paused indefinitely.
2026-05-09 11:24:46 +02:00
Arkadiusz Fal
80838db9cc Pause tvOS playback on seek bar scrub mode entry 2026-05-09 11:11:39 +02:00
Arkadiusz Fal
6173f63221 Prevent tvOS focus shadow from clipping between Home sections 2026-05-09 11:05:30 +02:00
Arkadiusz Fal
b6c3f0e71b Keep tvOS player controls visible on pause via on-screen button
Route the on-screen play/pause button through handlePlayPause() so it
follows the same visibility and auto-hide timer logic as the Siri Remote
hardware button: timer stops when paused (controls stay pinned) and
restarts on resume.
2026-05-09 11:00:04 +02:00
Arkadiusz Fal
7c1549ed35 Show watch progress bar on thumbnails in playlist, channel, and search views
These views rendered video thumbnails without passing watchProgress, so the
progress bar was silently missing. Apply the existing pattern from
SubscriptionsView: maintain a watchEntriesMap and forward watchProgress(for:)
to VideoRowView/VideoCardView at each call site.
2026-05-08 20:58:13 +02:00
Arkadiusz Fal
5ab9e3d5bf Surface mpv error details on stream load failure
Subscribe to mpv log messages and capture END_FILE error code/string so
load failures bubble up specific causes (HTTP 404/403, DNS failure,
demuxer errors) instead of a generic 10s timeout.
2026-05-08 20:43:27 +02:00
Arkadiusz Fal
f80ba26277 Enforce minimum 2 grid columns on tvOS 2026-05-08 20:04:29 +02:00
Arkadiusz Fal
5b9cd8c521 Dismiss tvOS sidebar detail pages when sidebar selection changes
tvOS's sidebarAdaptable TabView leaves the previously-pushed detail view
visible after the user picks another sidebar item, until they manually
press Menu. Broadcast a notification on tab change so any pushed
TVSidebarDetailContainer dismisses itself, and reset each tab's
NavigationPath. Also drop a redundant inner NavigationStack in the tvOS
SettingsView so subpages register on the tab's outer stack.
2026-05-08 19:35:36 +02:00
Arkadiusz Fal
10bd7d09af Use black icons on focused tvOS player control buttons for legibility 2026-05-08 19:03:35 +02:00
Arkadiusz Fal
765d322ee1 Use default foreground color for tvOS home section titles 2026-05-08 18:54:14 +02:00
Arkadiusz Fal
c8e716be94 Match tvOS play button background to prev/next transport buttons 2026-05-08 18:52:23 +02:00
Arkadiusz Fal
9b85ae2b13 Lock macOS player window resize to video aspect ratio 2026-05-08 18:32:43 +02:00
Arkadiusz Fal
b7b7c5ac62 Fix local folder playback after app container UUID changes
After iOS reinstall/restore the app container UUID rotates, which left both
the persisted source.url and the security-scoped bookmark pointing at a
no-longer-current path. Files derived a stale absolute path that got appended
onto the resolved bookmark, producing doubled URLs that MPV could not load.

- Resolve the base URL by picking whichever of the bookmark or source.url
  actually exists on disk.
- Compute MediaFile relative paths against the resolved root so they survive
  later container changes.
- Hold the security-scoped resource access for the source's lifetime via a
  shared resolver, so MPV can open files long after the directory enumeration
  that resolved the bookmark has returned.
- Normalize legacy absolute paths embedded in old recents/history video IDs
  so they re-resolve under the current container.
2026-05-08 18:23:16 +02:00
Arkadiusz Fal
5d88ed9743 Skip Sparkle strip in Debug to keep incremental builds working
Removing the embedded framework after every build broke Xcode's
incremental copier on the next build (it would try to copy individual
files into a destination directory tree that no longer existed). The
strip is only meaningful for App Store-bound Release builds, so skip
it in Debug — Sparkle in a local debug bundle is harmless.
2026-05-08 07:42:26 +02:00
Arkadiusz Fal
df0f144ced Make Strip Sparkle build phase resilient to rm -rf races
The post-embed strip script was intermittently failing with "Directory
not empty" when rm -rf raced against codesign/Spotlight/Xcode touching
the freshly-embedded framework. Atomically rename the framework out of
the embed path first, then rm -rf with a short retry loop, so a clean
build is no longer needed to recover.
2026-05-08 07:40:32 +02:00
Arkadiusz Fal
b163864628 Add interactive swipe-to-dismiss for iOS toasts
Toast cards now follow the finger upward and dismiss on either a
sufficient drag or a fast flick (via predicted-end translation). The
auto-dismiss timer pauses while the user is dragging and re-arms if
they release without dismissing.
2026-05-08 03:04:32 +02:00
Arkadiusz Fal
4f763373c1 Fix tvOS pickers 2026-05-08 02:18:25 +02:00
Arkadiusz Fal
c2758b0d0c Use light glass background for tvOS player control buttons 2026-05-07 18:36:47 +02:00
Arkadiusz Fal
c8bb13e229 Show playback failure overlay on tvOS
Previously a failed video left the user staring at a black screen / thumbnail
with no indication anything went wrong — playbackState went to .failed but
TVPlayerView never read it. Add a focusable glass overlay (Details / Retry /
Play Next or Close) gated on isFailed and a parallel one for retry-exhausted
state, with the regular controls and background tap-target disabled while
either is visible so focus stays inside the overlay. Hide the Copy/Share
toolbar items in ErrorDetailsSheet on tvOS where they aren't useful.
2026-05-07 18:29:48 +02:00
Arkadiusz Fal
158d518e3a Trim comments and hoist settings read in stream filtering
Drop comments restating what the code shows; hoist allowSoftwareDecodedFormats
out of the recommendedVideoStreams filter closure so the bridge property is
read once per render instead of once per stream.
2026-05-07 18:03:34 +02:00
Arkadiusz Fal
16477641ab Add Allow Software-Decoded Formats playback setting
Lets the auto stream selector pick formats whose codec isn't hardware
decoded on the current device. Defaults off; when on, 4K VP9/AV1 can be
auto-selected on Apple TV models without those decoders. Software-decoded
streams also move into the Recommended section so the selection stays
visible without enabling advanced stream details.
2026-05-07 18:00:14 +02:00
Arkadiusz Fal
823faee012 Add Show Sidebar toggle to tvOS Subscriptions view
Mirrors the existing iOS/macOS option using the shared
subscriptionsShowSidebar AppStorage key. When the sidebar is hidden
the View Options button moves above the feed so it remains reachable.
2026-05-07 06:57:06 +02:00
Arkadiusz Fal
6673d478c2 Hide feed channel filter strip on tvOS
The horizontal channel chip strip looks awkward on tvOS and the focus
interaction is clunky. Drop it from the tvOS branch of InstanceBrowseView;
other platforms keep it.
2026-05-07 06:32:14 +02:00
Arkadiusz Fal
51108738aa Add press-and-hold continuous seek on tvOS d-pad
The Siri Remote's left/right d-pad only delivered a single discrete
seek per click — holding the button did nothing. A window-level custom
UIGestureRecognizer now tracks the actual press duration and drives a
repeating seek tick (10s → 20s → 30s acceleration) until release,
routing through the existing accumulating-seek paths so the scrubber
preview, debounced commit, and on-screen feedback all keep working.
2026-05-07 06:18:40 +02:00
Arkadiusz Fal
cc109043b3 Unstick more tvOS focus dead-ends in channel views
Settings → Notifications → Manage Channels: wrap the tvOS NavigationLink
destination in TVSidebarDetailContainer(showsDismissButton: true) so the
no-subscriptions, error, and loading states all have a focusable Done.

Channels sidebar tab: lift the tvOS search field + View Options button
out of the loaded-channels branch and render it above every state. The
empty state previously had zero focusable elements, leaving the right
pane blank when swiping in from the sidebar.
2026-05-06 23:01:02 +02:00
Arkadiusz Fal
39beb45cff Make tvOS detail dismiss button opt-in and unstick more views
TVSidebarDetailContainer now exposes a showsDismissButton flag instead of
always attaching a Done toolbar item. The button is only enabled where a
view can end up with no focusable element on its own — Device
Capabilities (informational rows) and the Import Playlists/Subscriptions
flows.

Wrap Contributors, Translators, Acknowledgements, and Device Capabilities
destinations in TVSidebarDetailContainer for the consistent sidebar look,
and make the Translators/Acknowledgements rows focusable on tvOS by
wrapping them in Buttons so the Menu remote button can pop the stack.
2026-05-06 22:41:46 +02:00
Arkadiusz Fal
5c7429abf3 Fix tvOS soft-lock in import views when no rows are focusable
When all playlists/subscriptions were imported, every row collapsed to a
non-focusable checkmark and the Add All toolbar item disappeared, leaving
the view with no focusable element. The Menu button then closed the app
instead of popping the navigation stack.

Wrap the import destinations in TVSidebarDetailContainer for visual
consistency and add a Done toolbar item (cancellationAction) that is
always present on tvOS, reachable from any list row via swipe-up.
2026-05-06 22:17:08 +02:00
Arkadiusz Fal
38242edf0c Present instance login as full-screen cover on tvOS
The .sheet rendering on tvOS produced a tiny floating modal where the
"Sign In" title wrapped onto two lines and form fields overflowed. Use
.fullScreenCover on tvOS and wrap the login form in
TVSidebarDetailContainer so the title/icon sit in the standard 400pt
left sidebar. iOS and macOS keep the existing sheet presentation.
2026-05-06 21:44:55 +02:00
Arkadiusz Fal
411fcba037 Surface clearer error when adding a Piped frontend URL
Pointing AddRemoteServer at a Piped Vue SPA (e.g. the frontend host
rather than the API host) used to fail with a generic
"could not detect instance type" — every JSON probe got the same
index.html back. On the failure path, fetch `/` once more and look
for `<title>Piped</title>`; if matched, return a new
`pipedFrontendDetected` error that tells the user to enter the
Piped API URL instead.
2026-05-06 21:14:50 +02:00
Arkadiusz Fal
e3f4d764cc Add UI smoke test for Piped authenticated endpoints
Adds a Piped instance, logs in, and exercises the two settings flows that
hit the regressed endpoints directly — Import Subscriptions (/subscriptions)
and Import Playlists (/user/playlists). Asserts that "session is a required
parameter" never appears in the AX tree, catching the recent header-vs-query
auth regression end to end.

Promotes three tree-walking helpers (id_in_tree?, id_with_prefix_in_tree?,
label_in_tree?) onto UITest::Axe so the spec can fetch the AX tree once per
poll iteration and run all checks against it locally — roughly 6× fewer
`axe` subprocess spawns than calling element_exists? / text_visible? per
check, and a primitive other specs can reuse.
2026-05-06 20:17:28 +02:00
Arkadiusz Fal
6f8aa9a1b3 Block HTTP Basic Auth proxy for Piped sources
Piped's session token reuses the Authorization header, so a fronting basic
auth proxy can't coexist with logged-in Piped use — the two would clobber
each other's credentials on every authenticated request.

Add a supportsHTTPBasicAuthProxy capability on Instance/InstanceType (false
for Piped, true for everything else) and route it through:

- AddRemoteServerView refuses Piped if detection only succeeded behind basic
  auth, surfacing a localized "not supported" error instead of a silently
  broken instance, and hides the optional credentials section for Piped.
- EditSourceView hides the basic auth fields for Piped instances and clears
  any legacy stored credentials on save, in case a Piped source was added
  with credentials before this change.
2026-05-06 20:17:18 +02:00
Arkadiusz Fal
11841d7b41 Send Piped session token in Authorization header again
Commit aed78c13f moved the session token from the Authorization header to a
?authToken= query parameter on /subscriptions, /subscribe, /unsubscribe,
/user/playlists, and /playlists/{id}. Piped's backend only accepts the
?authToken= form on /feed; every other authenticated route reads it from
Authorization (the Java handler names the variable "session", which is why
the rejected requests returned "session is a required parameter"). Restore
the header form for those five routes and leave feed alone.
2026-05-06 20:17:09 +02:00
Arkadiusz Fal
fac297e4d6 Cache and prewarm Invidious proxy auto-detection
The proxy auto-detect path (when proxiesVideos is off) HEADed a
googlevideo URL with a 5 s timeout on every video. The verdict is a
property of the network, not the video, so the cost was paid for no
reason on videos 2..N. On a network where the CDN is blocked the full
5 s timeout was added to playback startup every single time.

Two changes:

1) ProxyDetectionCache (actor, per-instance, 10 min TTL). First miss
   pays the HEAD once and caches the verdict; subsequent videos hit
   the cache synchronously. Concurrent callers share one in-flight
   probe. The last-seen sample CDN URL is retained so future probes
   don't need a fresh URL from the current video.

2) PlayerService kicks off InvidiousAPI.prewarmProxyDetection() in
   parallel with the videoWith... API call. By the time streams come
   back, the verdict is usually already cached and proxyStreamsIfNeeded
   is a sync lookup. Cheap when there's nothing to prewarm.

Cache invalidation:
- on InstancesManager.update (URL change, proxy toggle flip)
- on InstancesManager.remove
- TTL covers the network-change case for now (no NWPathMonitor yet)
2026-05-04 08:04:55 +02:00
Arkadiusz Fal
93240b4314 Wire Yattee Server playback through /proxy/relay when proxiesVideos is on
The Sources -> Edit Source -> Proxy toggle now renders for Yattee
Server entries (supportsVideoProxying gains .yatteeServer). When the
toggle is on, playback fetches go through
videoWithProxyStreamsAndCaptionsAndStoryboards with mode .relay, so
the server returns signed /proxy/relay URLs (byte-relay, supports
HTTP Range, no on-disk caching). Downloads keep going through mode
.download so the server-side /proxy/fast/ flow continues to cache
files for repeat use.

InvidiousAPI.proxyStreamsIfNeeded early-returns for .yatteeServer
since proxying is now done at fetch time via ?proxy=true rather than
client-side host rewriting.
2026-05-04 08:01:51 +02:00
Arkadiusz Fal
73e3d8164b Keep macOS play pause control visible 2026-04-24 01:14:13 +02:00
Arkadiusz Fal
8d85749354 Show playlist title in macOS toolbar 2026-04-24 00:28:31 +02:00
Arkadiusz Fal
3b9144cd28 Fix macOS sidebar dynamic item switching 2026-04-23 23:19:39 +02:00
Arkadiusz Fal
85223894ff Improve macOS channel toolbar header 2026-04-23 23:10:37 +02:00
Arkadiusz Fal
6df80c0e79 Use user-selected accent color in Home, Subscriptions, and Downloads
Color.accentColor and .foregroundStyle(.tint) resolve to the asset
catalog accent on macOS, so Home shortcut cards, section header
links, the Subscriptions "All channels" header, and the Downloads
per-channel group headers stayed blue when the user picked a
different accent. Read the color from SettingsManager and apply it
directly, matching the pattern already used for the Play button.
2026-04-23 22:55:48 +02:00
Arkadiusz Fal
fd0eab7784 Prefetch fresh video thumbnail before swapping it into info view 2026-04-23 18:37:19 +02:00
Arkadiusz Fal
6eb215f59c fixup! Restore Settings as sidebar item on macOS 2026-04-23 18:26:21 +02:00
Arkadiusz Fal
664eeadba2 Stabilize Nuke cache key across rotating thumbnail URL tokens 2026-04-23 18:22:03 +02:00
Arkadiusz Fal
20b88a811e Use user-selected accent color for Play button tint on macOS 2026-04-23 18:07:58 +02:00
Arkadiusz Fal
a32582e171 Replace macOS video nav arrows with left/right keyboard shortcuts 2026-04-23 18:06:47 +02:00
Arkadiusz Fal
cda983651e Restore Settings as sidebar item on macOS
Remove the gear toolbar button that opened Settings as a sheet in the
NavigationSplitView sidebar column, and drop the macOS guard hiding
.settings from SidebarMainItem so it can be added to the sidebar and
rendered in the detail column like other items. The dedicated Settings
window (Cmd+,) is unchanged.
2026-04-23 18:00:42 +02:00
Arkadiusz Fal
c0815353d7 Create CNAME 2026-04-23 15:45:49 +02:00
Arkadiusz Fal
b23dfde602 Use macOS-native styling and larger text in video info view 2026-04-23 07:58:01 +02:00
Arkadiusz Fal
e0ad43ca0b Move Integrations into main settings section above Advanced
Drop the standalone iOS section for Integrations and inline its row into
the main list right above Advanced Settings. Swap the tvOS sidebar order
so Integrations appears before Advanced as well. macOS was already
correctly ordered via SettingsSection enum declaration.
2026-04-23 07:39:03 +02:00
Arkadiusz Fal
f804cc1521 Rename YouTube Enhancements settings to Integrations
Also swap the icon to puzzlepiece.extension, which better conveys that
this section houses third-party service hookups (SponsorBlock, Return
YouTube Dislike, DeArrow, short-link resolution) rather than being
YouTube-specific.

Hide the Resolve Short Links toggle on tvOS — there's no way to tap
inline description links or reach a system browser there — and tighten
the openInSystemBrowser platform guards so the iOS-only UIApplication
path isn't compiled on tvOS.
2026-04-23 07:34:31 +02:00
Arkadiusz Fal
5a839da1bd Resolve URL shorteners and prompt for ambiguous description links
Tapping bit.ly/tinyurl/t.co/etc. in a description or comment previously
opened Safari even when the destination was a playable YouTube URL.
Added an opt-in "Resolve Short Links" toggle under YouTube Enhancements
(off by default) that follows the redirect on tap: if the target is a
YouTube/PeerTube/direct-media URL, open it in-app; otherwise prompt the
user before falling back to yt-dlp extraction or the browser.

Also added a confirmation dialog for non-shortener links that only
matched the loose .externalVideo yt-dlp fallback, so arbitrary web
pages in descriptions no longer silently kick off extraction.

Prompts live on NavigationCoordinator and are dual-hosted by YatteeApp
and ExpandedPlayerSheet so they remain visible whether or not the
expanded player is covering the main view.
2026-04-23 07:29:57 +02:00
Arkadiusz Fal
d38b781858 Sparkle is macOS only 2026-04-23 06:19:24 +02:00
Arkadiusz Fal
29900b758d Point Sparkle feed at dl.yattee.stream custom domain
Bake https://dl.yattee.stream/appcast.xml into SUFeedURL and align the
appcast template and Sparkle setup notes so the gh-pages branch (served
under the custom Cloudflare-CNAMEd domain) is the single place to look
up the feed. Domain is reserved as a generic distribution surface for
Sparkle today and AltStore / other channels later.
2026-04-23 05:50:52 +02:00
Arkadiusz Fal
b5bab10694 Strip Sparkle from App Store (Release) macOS build
Add -Wl,-dead_strip_dylibs to the Release config so the linker drops the
unused Sparkle LC_LOAD_DYLIB, and a Run Script phase that removes the
auto-embedded Sparkle.framework from the app bundle for every config
except Release-DeveloperID. Disable user script sandboxing on the Yattee
target so the rm is permitted. Release-DeveloperID keeps Sparkle linked
and embedded for the notarized Developer ID channel.
2026-04-23 05:32:42 +02:00
Arkadiusz Fal
a2a4691957 Integrate Sparkle auto-updates for macOS Developer ID builds
New Release-DeveloperID configuration gates Sparkle behind a SPARKLE
compile flag so the App Store Release build stays Sparkle-free. Adds
SPUStandardUpdaterController wrapper, Check for Updates menu command,
Advanced Settings section with beta channel toggle, and a Ruby script
plus GitHub Actions job that signs each release and publishes the
appcast to gh-pages for consumption by Sparkle and Homebrew cask.
2026-04-23 04:51:00 +02:00
Arkadiusz Fal
29c67d3276 Show app icon and version at bottom of macOS settings sidebar 2026-04-22 23:09:17 +02:00
Arkadiusz Fal
6e91069ff3 Move Add Source button to toolbar in macOS Sources settings 2026-04-22 22:06:03 +02:00
Arkadiusz Fal
397fc46629 Open Settings in a dedicated resizable macOS window 2026-04-22 21:59:34 +02:00
Arkadiusz Fal
4d45f6870e Render clickable links and timestamps in comment text
Comments now use DescriptionText.attributed so URLs become tappable
and route through the same in-app pipeline as description links, and
timestamp strings seek the player.
2026-04-22 20:53:21 +02:00
Arkadiusz Fal
b54c32edad Route YouTube links tapped in descriptions through in-app playback
Description links to YouTube videos, channels, playlists, and external
video URLs now open in Yattee instead of Safari. When a video is
already playing, tapping a video link surfaces the existing
QueueActionSheet (Play Now / Play Next / Add to Queue) — the sheet is
hosted both at the app root and inside ExpandedPlayerSheet so it
appears above whichever layer is on screen.
2026-04-22 19:00:43 +02:00
Arkadiusz Fal
3afd0bdf78 Add inline Add Source button to Sources settings on macOS 2026-04-21 06:27:31 +02:00
Arkadiusz Fal
111c3d7360 Convert Download settings to macOS-native helpers 2026-04-21 04:15:35 +02:00
Arkadiusz Fal
f873aad9b9 Apply inset list style to Home settings on macOS 2026-04-21 03:40:57 +02:00
Arkadiusz Fal
bdd9f7f489 Tighten Sidebar settings for macOS
Drop the redundant inner NavigationStack on non-tvOS (the outer detail
pane already provides one) and apply .listStyle(.inset) on macOS. Kept
as List to preserve drag-to-reorder for the main navigation section.
2026-04-21 03:37:29 +02:00
Arkadiusz Fal
b275dbd7c0 Polish Log Viewer for macOS
Convert detail and filter sheets to shared helpers, add inline Filter /
Export / Clear buttons next to the search bar (toolbar items weren't
surfacing in the settings detail pane), inline the Reset Filters button
at the bottom of the filter sheet, use a 'Close' text button, and trim
the macOS Share Sheet to just the scrollable log with a Copy button.
2026-04-21 03:31:11 +02:00
Arkadiusz Fal
22b9cb7135 Convert Legacy Data Import to macOS-native helpers 2026-04-21 02:47:08 +02:00
Arkadiusz Fal
7ff889c132 Convert Contributors, Translators, Acknowledgements to macOS-native helpers 2026-04-21 02:41:15 +02:00
Arkadiusz Fal
07a1e0f81d Convert Manage Channel Notifications list to macOS-native helpers 2026-04-21 02:19:33 +02:00
Arkadiusz Fal
79459b8f2e Rework MPV Options for macOS-native look
Convert the main view to shared SettingsFormContainer/Section helpers,
drop the DisclosureGroup in favor of a single always-visible Default
Options section with monospaced trailing values, and redesign the
Add/Edit MPV option sheets on macOS as native dialogs with a Grid
layout, clearer footers, and keyboard-shortcut actions.
2026-04-21 01:12:42 +02:00
Arkadiusz Fal
a5f8bdacfb Convert About and Device Capabilities to macOS-native helpers 2026-04-21 00:55:53 +02:00
Arkadiusz Fal
48963a9e2e Convert Advanced and Developer settings to macOS-native helpers
Extend SettingsFormSection to accept a @ViewBuilder footer for sections
with dynamic multi-line content (last background refresh, orphaned
files status). Move trailing button accessories (size, progress) out of
button labels so buttons size to their content on macOS.
2026-04-21 00:48:30 +02:00
Arkadiusz Fal
72778870e1 Convert iCloud settings to macOS-native helpers
Add FixedIconWidthLabelStyle so sync category labels align when icons
have varying glyph widths.
2026-04-21 00:36:10 +02:00
Arkadiusz Fal
f173dd1c39 Convert YouTube Enhancements settings to macOS-native helpers
Includes SponsorBlock, Return YouTube Dislike, and DeArrow sub-screens.
Extend SettingsNavigationRow with an optional trailing content slot so
rows can show enabled/disabled status next to the chevron.
2026-04-21 00:31:04 +02:00
Arkadiusz Fal
60be0f8b53 Convert Privacy settings to macOS-native helpers 2026-04-20 23:43:19 +02:00
Arkadiusz Fal
c27fb3be34 Convert Notification settings to macOS-native helpers 2026-04-20 23:36:57 +02:00
Arkadiusz Fal
9912327448 Convert Layout & Navigation settings to macOS-native helpers
Add SettingsNavigationRow helper that renders destination-pushing rows
as plain list rows with a trailing chevron on macOS, and drop the top
divider in headerless sections so they don't render a stray rule.
2026-04-20 23:34:43 +02:00
Arkadiusz Fal
bb8fb28998 Convert Appearance settings to macOS-native helpers 2026-04-20 23:21:13 +02:00
Arkadiusz Fal
14b874022b Make Playback and Subtitles settings feel native on macOS
Add shared SettingsFormContainer/SettingsFormSection helpers that mirror
the Sources screen styling (uppercase subheadline headers, divider-
bracketed cards, ScrollView + LazyVStack) on macOS while keeping the
standard Form/Section layout on iOS and tvOS.

Convert PlaybackSettingsView and SubtitlesSettingsView to the new
helpers, wrap the macOS Settings detail pane in a NavigationStack so
NavigationLink pushes (Subtitles Appearance) render in the detail
column, fold the macOS-only Player Mode + Auto-resize player controls
into the Behavior section, and drop the unused queue footer.
2026-04-20 23:01:46 +02:00
Arkadiusz Fal
fef9a07aa9 Set minimum window size on macOS 2026-04-20 22:24:58 +02:00
Arkadiusz Fal
d9e4736547 Fix customize Home button on macOS 2026-04-20 22:22:32 +02:00
Arkadiusz Fal
49cdfb74af Remove Close button from Settings toolbar on macOS 2026-04-20 22:20:23 +02:00
Arkadiusz Fal
7d95a11286 Enlarge video card and row fonts on macOS 2026-04-20 21:34:14 +02:00
Arkadiusz Fal
d2b6a158db Enable app icon selection on macOS 2026-04-20 21:21:18 +02:00
Arkadiusz Fal
b0f9bb2229 Restrict swipe actions to iOS 2026-04-20 21:13:08 +02:00
Arkadiusz Fal
bb9ec2fc2a Make media browser file list feel native on macOS
Drop the iOS-grouped rounded card in MediaBrowserView's section
container for top/bottom dividers on macOS, force .buttonStyle(.plain)
on directory NavigationLinks to avoid default link tinting, and
shrink MediaFileRow's icon frame on macOS. iOS and tvOS unchanged.
2026-04-20 21:12:53 +02:00
Arkadiusz Fal
d8f10e984a Make sources list feel native on macOS
Drop the iOS-grouped rounded card, per-row chevron, and oversized
metrics on macOS. Use tighter padding, smaller icon/title fonts,
uppercase section headers, and top/bottom dividers so the list reads
like a native grouped Mac list. Force .buttonStyle(.plain) on row
buttons/NavigationLinks and add .contentShape(Rectangle()) so the
full row is hit-testable without picking up macOS's default link
styling. iOS and tvOS unchanged.
2026-04-20 21:07:05 +02:00
Arkadiusz Fal
267f770274 Add standard Settings menu item with Cmd+, on macOS 2026-04-20 20:55:07 +02:00
Arkadiusz Fal
508069cecf Make source add/edit forms feel native on macOS
Use grouped Form style with LabeledContent rows and move primary
actions into the sheet toolbar for SMB, WebDAV, Local Folder, Remote
Server and the Edit sheet. iOS and tvOS branches unchanged.
2026-04-20 20:51:24 +02:00
Arkadiusz Fal
e0e1e8cbd7 Add resizable subscriptions sidebar on iPad 2026-04-20 18:16:23 +02:00
Arkadiusz Fal
8ff5eccca9 Persist subscriptions sidebar width on macOS 2026-04-20 07:56:56 +02:00
Arkadiusz Fal
1ae73789a4 Smooth player details panel drag on iOS 2026-04-20 01:18:06 +02:00
Arkadiusz Fal
ad075319ee Tweak Subscriptions view options sheet layout 2026-04-19 18:26:51 +02:00
Arkadiusz Fal
31b244880b Add Show Sidebar toggle to Subscriptions view options
Adds a Show Sidebar toggle (iPad regular and macOS) that controls
channels sidebar visibility. When the sidebar is shown, the channel
strip picker is disabled and the redundant channel header link above
the feed is hidden. Layout picker now uses inline menu style for
consistency with other options.
2026-04-19 18:03:53 +02:00
Arkadiusz Fal
88a7c713fa Update localizable 2026-04-19 17:48:55 +02:00
Arkadiusz Fal
a3ad20fdf0 Disable settings in sidebar on macOS 2026-04-19 17:48:49 +02:00
Arkadiusz Fal
fe78261866 Add channels sidebar to Subscriptions on iPad regular width
On iPad in regular horizontal size class, the Subscriptions view now
shows a channels column next to the feed (mirroring macOS/tvOS) instead
of the floating channel strip. iPhone and compact-width iPad keep the
existing strip.

- Renames MacSubscriptionsSidebarRow to SubscriptionsSidebarRow and
  shares it across macOS and iOS.
- Uses a custom ScrollView-based sidebar on iPad to avoid iOS 26's
  sidebar background extension bleeding into the selection highlight.
- Forces inline toolbar title on iPad regular so scrolling either
  column behaves consistently.
2026-04-19 17:48:17 +02:00
Arkadiusz Fal
5e205e4a4c Use resizable sidebar layout for Subscriptions on macOS
Replaces the iOS-style floating channel strip with a dedicated
channels column next to the feed, using HSplitView for a native
draggable divider. Mirrors the tvOS two-column structure.
2026-04-19 15:04:21 +02:00
Arkadiusz Fal
68890b1f8a Use native macOS layout for OpenLinkSheet 2026-04-19 14:32:48 +02:00
Arkadiusz Fal
cee2793399 Use NavigationSplitView on macOS with persistent sidebar Settings button
Replaces the macOS TabView(.sidebarAdaptable) root with NavigationSplitView
so the Settings gear can live in the sidebar column's toolbar (next to the
sidebar toggle) instead of only appearing in HomeView's detail toolbar.
2026-04-19 14:01:06 +02:00
Arkadiusz Fal
cedefb5c97 Fix fastlane mac beta build for multiplatform scheme
Without sdk:"macosx", gym treats the multiplatform scheme as iOS and
fails with "IPA invalid". Without an explicit destination xcodebuild
picked tvOS. The output_name also needed the .app suffix removed since
gym appends .pkg for macOS app-store exports.
2026-04-19 13:33:15 +02:00
Arkadiusz Fal
181cf2f73a Fix collapsed sheets on macOS across the app
Add macOS-only minimum frame sizing to sheets that wrap a
NavigationStack/Form without intrinsic size, so they render properly
instead of collapsing to just the toolbar. Affects Customize Home,
subscription/channel view options, playlist create/edit, search
filters, media browser options, instance picker, log filters, preset
editor, and legacy data import result.
2026-04-19 11:33:16 +02:00
Arkadiusz Fal
80942dba69 Add iCloud section to macOS settings sidebar 2026-04-19 11:31:02 +02:00
Arkadiusz Fal
52a2a26f2f Fix collapsed AddSource/EditSource sheets on macOS
Add macOS-only minimum frame to NavigationStack so the sheet has
intrinsic size. Matches the pattern already used in
PeerTubeInstancesExploreView.
2026-04-19 11:24:32 +02:00
Arkadiusz Fal
e231be9c90 Update changelog 2026-04-18 21:11:44 +02:00
github-actions[bot]
e3ee528d66 Bump build number to 259 2026-04-18 19:06:30 +00:00
Arkadiusz Fal
796b646cb2 Remove YatteeShareExtension from scheme top-level build entries
Scheme-level build entries bypass the pbxproj target-dependency
platformFilter, so tvOS archives tried to build the iOS-only
ShareExtension and failed with "No profiles for *.ShareExtension".
The extension is still built on iOS via the Yattee target's
platform-filtered dependency and embed phase, matching how
YatteeTopShelf is wired for tvOS.
2026-04-18 20:54:34 +02:00
Arkadiusz Fal
7c78715c32 Remove residual Kannada translation from Shared/ directory 2026-04-18 20:40:57 +02:00
Arkadiusz Fal
b10dd431d1 Enable tvOS TestFlight build by default in release workflow 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
3a45a3e28e Update weblate credits 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
d0b4d0e64e Move tvOS sources actions to top bar with first-row focus
Sidebar buttons in TVSidebarDetailContainer were hard to focus from
the content list. Move the Add Source (and sort/group menu for Media
Sources) to a top HStack wrapped in focusSection(), matching the
pattern used in MediaBrowserView. Default focus lands on the first
source row via @FocusState + FirstRowFocusModifier.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
f60a6e3eec Fix tvOS focus trap on empty Home after fresh install
Render a focusable empty state on tvOS Home when no sections have content,
with an "Open Sources" button that switches the sidebar selection. Without
a focusable view the tvOS focus engine had no target, leaving the sidebar
unreachable after the initial iCloud alert was dismissed.

Also wire the selectedSidebarItem onChange handler into the tvOS TabView,
which was missing and prevented programmatic sidebar selection.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
823b8ae686 Pad comment focus area and remove dividers on tvOS 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
d1010507d9 Remove blue tint from tvOS comment replies indicators 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
8f00fe012f Add tvOS setting to close video with Menu button
When enabled, the Siri remote Menu button stops playback and clears the
queue instead of only collapsing the player, and the explicit top-bar
close (X) button is hidden.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
13ade8aad3 Add Continue Watching toggle to tab bar settings
Exposes ContinueWatchingView as an opt-in compact tab bar item,
hidden by default. Uses a short "Continue" label since the full
"Continue Watching" string does not fit as a tab bar title.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
096df34f64 Redesign tvOS player controls with centered transport cluster
- Move Close to a top-right circular icon; bottom row reorganizes into
  left (Settings/Info/Comments), center transport (Previous/PlayPause/Next),
  and right (Queue) clusters with equal side frames so transport stays
  geometrically centered.
- Introduce a circular icon-only `TVTransportButtonStyle` (primary variant
  for Play/Pause) mirroring the new Close button look.
- Always render Previous/Next so Play/Pause position is fixed; dim and
  disable when unavailable.
- Share `isTransportDisabled` on `PlayerState` and reuse it on iOS and
  tvOS; apply it (plus symbol replace transition) to the tvOS Play/Pause
  button.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
c0184712a9 Add Continue Watching toggle to sidebar settings
Exposes ContinueWatchingView as an opt-in main sidebar item,
hidden by default.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
2761fcbcfb Replace onboarding flow with silent v1 import and iCloud alert
Delete the multi-page onboarding sheet. On first launch the app now
silently imports any v1 instances from UserDefaults (splitting embedded
basic-auth credentials out of the URL and into the Keychain) and then,
if the device is signed in to iCloud, shows a single alert offering to
enable sync. Accepting shows a blocking progress overlay until the
initial upload completes.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
55f27e7f54 Add view options and refresh actions to tvOS media browser 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
bece7b35c7 Add clear history menu to tvOS History view 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
ee0666f5f7 Fix tvOS target configuration in Xcode project
Add tvos platformFilter to YatteeTopShelf.appex embed and target
dependency so it's only built for tvOS, and exclude Info-tvOS.plist
from Copy Bundle Resources via synchronized-folder membership
exceptions.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
c03cd0bd19 Store higher-quality YouTube thumbnail URLs in recent playlists
Rewrites /vi/ID/{default|mq|hq|sd}default.jpg to maxresdefault.jpg when
saving/updating a recent playlist, so the card shows a sharper image
than the low-res search-result thumbnail.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
663e96c859 Polish Search view layout on tvOS
- Disable scroll clipping so focused source/channel/playlist cards show full halo
- Remove rounded clip on source picker row that cut the Menu focus effect
- Replace tappable recents header Button with a plain label on tvOS
- Add vertical spacing between recent search items
- Widen recent channel and playlist cards and reserve space for two-line titles
- Increase horizontal spacing between cards so focus halos don't collide
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
5cbcceba9a Polish AddSourceView layout on tvOS
Add a TVSourceRowLabelStyle for consistent icon/text spacing, switch
the Scan Network button to TVFormRowButtonStyle so it matches the
NavigationLink rows, and drop the duplicate navigationTitle in
AddWebDAV/AddSMB views since the title is already shown in the
TVSidebarDetailContainer sidebar.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
0fe7194d68 Fix tvOS App Store upload rejection for icon assets
Use a tvOS-specific Info.plist to exclude iOS CFBundleAlternateIcons
keys that caused ITMS-90471 rejections, and drop empty image slots
from the tvOS brand asset catalog.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
6acfff6451 Update fastlane readme 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
fb14ed8ae9 Wire YatteeTopShelf into tvOS release lane
Fetch a match profile for the new stream.yattee.app.TopShelf bundle,
switch the extension target to manual signing, and map its profile in
build_app export_options so the tvOS archive signs both the main app
and the Top Shelf extension.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
9f86ff0667 Add tvOS Top Shelf extension
Surfaces Continue Watching, Recent Feed, and Recent Bookmarks in the
Apple TV Home top shelf when Yattee is focused. Tapping a tile opens
the video via the existing yattee://video/{id} deep link.

- New YatteeTopShelf app extension target (tvOS only). LD_ENTRY_POINT is
  overridden to _NSExtensionMain; the tv-app-extension product type
  defaults to _TVExtensionMain which is for the pre-tvOS-13 legacy API
  and crashes modern TVTopShelfContentProvider subclasses at launch.
- Main app writes per-section JSON snapshots (capped at 10 items each)
  to a shared App Group UserDefaults suite after bookmark, watch-history,
  and feed-cache changes, plus an initial write on launch.
- Enabled-sections list is mirrored to the same App Group so the
  extension can respect the user's selection without touching SwiftData.
- Settings → Top Shelf (tvOS only) lets the user toggle sections.
- Deep link playback shows a loading toast while video details are
  fetched, and an error toast if no source is configured.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
90c88728c4 Adapt resume action sheet layout for tvOS
Use a NavigationStack/List layout with a centered thumbnail, stacked
title, and card-styled buttons so the focus ring renders with proper
clearance on tvOS.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
e609e48449 Add bottom padding under tvOS search/action top bars
Separates the inline search field and action button from the video
listing below on History, Bookmarks, Instance browse, and Manage
channels screens.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
6eec42241d Disable scroll clipping for tvOS video listing grids
Allows focus-scaled cards to render past scroll view bounds on History,
Bookmarks, Search, and Instance browse screens.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
a0015086a2 Use menu picker style for tvOS view options sheets 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
2efa0708c8 Refresh expired thumbnail URLs for downloads and video info
Proxied thumbnail URLs from Invidious/Piped/Yattee server expire over
time. Two paths were left holding stale URLs: the Video Info carousel
kept the original list copy even after fresh details arrived, and
downloaded videos rendered from the remote URL snapshot taken at
download time while the local thumbnail on disk was ignored.

Evict stale URLs from the Nuke cache when fresh video details load,
pass the fresh details through to the videoCard thumbnail, and resolve
downloads' thumbnails from the local file when localThumbnailPath is
set.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
abd432fd0e Show video title placeholder while thumbnails load 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
6090454707 Use segmented picker style for tvOS grid columns count 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
42fe76836c Match tvOS media browser file row style with folders 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
39580d713b Make unplayable tvOS media files focusable with unsupported alert 2026-04-18 20:38:02 +02:00
Arkadiusz Fal
4c8a3ee5ba Push video info navigation from media source browser on tvOS
handlePendingNavigation guarded the .mediaSource append with
#if os(iOS) || os(macOS), so navigating to video info from a media
source on tvOS was silently dropped. The NavigationStack binding is
already unconditional, so the append is safe on all platforms.
2026-04-18 20:38:02 +02:00
Arkadiusz Fal
53144293c8 Tune tvOS media browser row layout 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
f2a5069cd2 Make tvOS Home section headers non-focusable
Render section headers as plain Text on tvOS so the focus engine
skips them and moves directly between video cards across sections.
iOS and macOS keep the tappable Button with chevron unchanged.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
060cff1449 Align tvOS History and Bookmarks header top padding 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
a66b2191d1 Keep focus on pressed tvOS channel tab button 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
281f5e0f13 Hide tvOS search type/filters until query entered
Match iOS behavior by gating the type switcher and filters menus on an
active query, and drop the .caption font so they render with the same
default button font as View Options.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
025dc73e59 Add channel video search on tvOS
Surface the existing in-channel search feature on tvOS as a final tab
after Playlists. Selecting it reveals a search field and results area
below the tab row, reusing the same API and result views as iOS/macOS.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
b479d63295 Respect video tap action settings in media browser
Playable files in the media source browser now honor tvOSVideoTapAction
on tvOS and thumbnailTapAction/textAreaTapAction on iOS/macOS, matching
other video lists. When openInfo navigates to VideoInfoView, playback
routes through QueueManager.playFromMediaBrowser so stream and caption
resolution keep working for Samba/WebDAV files.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
3126f5bc3e Show storyboard preview during tvOS scrubber arrow-seek
Arrow-seek on the focused scrubber previously moved the handle but
showed no storyboard/chapter context. Reuse the existing SELECT-scrub
overlay for arrow-seek too, with a ~2s lingering fade after the last
press so the preview doesn't vanish the instant the seek commits.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
d903eb6920 Polish PlaylistsListView for tvOS
Hide navigation title on tvOS, move the new-playlist action into an
inline focus section above the list, and make rows focusable via
NavigationLink so focus can move down from the button and default
focus lands on the first playlist.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
546ecf632e Fix tvOS view options sheet rendering in History and other views 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
a660591e8d Add Playlists entry to sidebar main navigation
Adds a toggleable "Playlists" item that opens PlaylistsListView,
mirroring the Channels → ManageChannelsView pattern.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
f8ca23308d Show grid columns option in view options on tvOS
Expose the grid columns picker in SubscriptionsView and ManageChannelsView
inline sheets, and track viewWidth in ChannelView's tvOS GeometryReaders so
the shared ViewOptionsSheet can compute a meaningful column range.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
cea8fcfe64 Fix tvOS bookmark details disappearing when reopening video info 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
5ef40e24bf Remove channel card background on tvOS and fix grid focus clipping 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
7a55f8ac3a Polish tvOS Manage Channels view options sheet layout 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
f302682a03 Give tvOS toast cards more room between icon and text 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
a3275f4cd7 Fix tvOS sidebar media source showing empty screen 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
afc4125bee Style tvOS playlist delete button with red text on bordered background 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
87965d654d Polish tvOS playlist sheets for focus and narrow layout 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
e583aa3fd7 Rework tvOS PeerTube browse and discovery sheets for sidebar layout
Drop nested NavigationStack, .searchable, and close button from the
PeerTube explore view on tvOS; use an inline search field plus Filters
button so focus separates cleanly and the sidebar title is no longer
overlapped. Keep the header visible across empty/error states so the
query can be cleared. Hide the filters and scan-network sheet toolbars
on tvOS, apply filter changes immediately, and add padding plus
scrollClipDisabled so focused rows aren't clipped.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
6d3bea7678 Rework tvOS sources list with sidebar and full-screen add flow
Give TVSidebarDetailContainer an optional bottom action slot and use it to
show the Add Source button beside the sources list on tvOS. Switch the
Settings > Sources list from a focus-capturing List to the same
ScrollView+LazyVStack layout MediaSourcesView already uses, drop
.buttonStyle(.card) so row icons no longer clip, and bump the row
icon-to-title spacing to 24pt. Replace the sheet-based Add/Edit flow in
MediaSourcesView with navigationDestinations wrapped in the sidebar
container, and decorate each Add Source form (WebDAV, SMB, remote server,
PeerTube browse) with its own sidebar icon and title.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
10a27a8105 Rework tvOS device control view with sidebar and focusable controls 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
43039513c1 Upsize AppIconPreview so tvOS settings icon renders sharp
The shared AppIconPreview asset maxed out at 180px, causing the icon in
the tvOS Settings sidebar (rendered at 200pt) to appear pixelated.
Regenerate all three scales from the 1024px master: 400/600/900px.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
77d982f422 Use glass background for tvOS player settings sheet
Match the info/comments and queue panels by replacing the black dim and
inner rounded card with a full-screen ultraThinMaterial backdrop and a
transparent list background on tvOS.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
71dd956f18 Show left-column icon and title for Home and Sidebar settings on tvOS 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
e2f3107833 Use menu-style pickers in tvOS settings
Introduce PlatformMenuPicker that wraps short-option pickers in
LabeledContent + .pickerStyle(.menu) on tvOS so they render as a
compact dropdown instead of pushing a full-screen option list. On
iOS/macOS it falls through to a plain Picker, leaving rendering
unchanged.

Applied across Playback, Subtitles, Sidebar, Privacy, and Advanced
settings. Long language lists in PlaybackSettingsView are left as
push-style.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
df232ad69a Rework tvOS Subscriptions view as two-column layout
Replace the tvOS Subscriptions header (All Channels link + View Options
button) with a left-column channels sidebar that filters the feed in
place, and move view options into a button at the top of the sidebar.
Drops the channel strip size picker from the tvOS options sheet since
the strip does not apply there, and mirrors the ContinueWatchingView
focus pattern so initial focus and post-filter focus land on the first
video row.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
f52ece330e Resize TV controls 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
033c93e542 Make Subscriptions view focusable on tvOS
Move the channel-link button and View Options into a top safe-area inset
on tvOS so they are reachable with the remote, mirroring the Continue
Watching pattern. Wrap chrome and content in focus sections with a
default-focus namespace so initial focus lands on the first video. Hide
the duplicate in-content section header on tvOS, and add
scrollClipDisabled to VideoListContainer so focus scaling on rows is
not clipped at the scroll edges.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
70a5375b7e Focus first video by default in Continue Watching on tvOS
Use @FocusState with programmatic assignment on appear instead of
prefersDefaultFocus, which is broken when the target sits inside a
ScrollView/LazyVGrid on tvOS. A 0.15s delay gives the grid time to
materialize cells before the focus write.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
65724ae201 Extract TVSidebarDetailContainer to its own file
Now used by multiple tvOS tabs beyond Settings, so move it out of
SettingsView.swift into Views/Components/ where reusable view primitives
live.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
68ab994798 Drop redundant navigation titles in tvOS Open URL and Remote Control
The sidebar decoration added by TVSidebarDetailContainer already shows
the screen title, so the navigation title would duplicate it on tvOS.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
a6cfccf5ed Make Continue Watching view focusable on tvOS
Replace the toolbar-based controls with an inline header row on tvOS so
the View Options and clear buttons are reachable with the remote. Drop
the navigation title, add an inline title, and disable ScrollView
clipping so the focus scale effect isn't clipped.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
b8390577cc Rename TVSettingsContainer to TVSidebarDetailContainer
The container is now used beyond settings (Open URL and Remote Control
tabs), so the name is broadened to reflect its general role as a
tvOS sidebar-decorated detail container.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
29e8d64c35 Use settings container layout for tvOS Open URL and Remote Control
Wrap OpenLinkView and RemoteControlContentView in TVSettingsContainer on
tvOS so they get the same left sidebar with large SF Symbol icon and
title as settings detail screens, for visual consistency.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
82a8ac2afa Fix storyboard downloads with yattee-server direct YouTube URLs
yattee-server returns direct YouTube CDN URLs in the storyboard `url`
and `templateUrl` fields instead of an Invidious-style VTT proxy path.
Two resulting issues:

- `Storyboard.directSheetURL` was replacing the whole `M$M` token with
  just the index, producing `.../0.jpg` (404) instead of `.../M0.jpg`.
  Replace `M$M` with `M\(index)` to preserve the literal `M` prefix;
  matching the full token also avoids clobbering `$M` sequences that
  may appear in `sigh=rs$...` query params.
- The download code fetched `proxyUrl` as if it were a WebVTT file;
  with yattee-server that downloads a JPEG that fails UTF-8 parsing.
  Skip the VTT round-trip when `proxyUrl` obviously points at an image.

Also align the on-disk filename with the local-playback template
(`sb_M$M.jpg` → `sb_M{N}.jpg`) so offline seek-bar previews resolve,
and add [Storyboard] debug logs at each decision point so future
failures can be diagnosed without guessing.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
58f1b8c1ad Use two-column layout for tvOS playlist detail view 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
a6d1c840f9 Hide non-working external links in tvOS settings
tvOS cannot open URLs in a browser, so the Community section
(GitHub/Discord) is omitted and Acknowledgements dependencies
render as plain text rather than tappable buttons.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
f2748bead6 Add tvOS app icon and top shelf images for Yattee2 brand
Generated from the existing 1024x1024 iOS icon — gradient extended
horizontally to fill the 5:3 tvOS canvas without stretching the symbol.
Top shelf images are pure blue gradients sampled from the icon.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
f49dfd6246 Hide description header in tvOS video info right column
The right column is already dedicated to video info so the "Description"
label is redundant. Add an opt-out `showsHeader` parameter to
`TVScrollableDescription` (default true) and pass false from
`VideoInfoView`; the player overlay and channel view keep the header.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
9b55ee7127 Add tvOS setting for video click behavior 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
fb2db35fe8 Use two-column layout for tvOS channel view 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
0aac9168cb Use two-column layout for tvOS video info view
Reworks VideoInfoView on tvOS into a persistent 30% left sidebar
(thumbnail, title, channel, Play / Add to Playlist / Bookmark) with a
scrollable right pane for description, stats, comments, related, and
watch history. Reuses the player's TVScrollableDescription (refactored
to self-manage focus) so the description supports click-to-lock
scrolling, and the outer ScrollView is disabled while locked. Comments
full-screen on tvOS, with commenter avatars no longer tappable and
accent-colored link text replaced with the default foreground.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
6a45ed7d0f Show video title and channel in tvOS sidebar Now Playing 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
d4f8cade90 Let tvOS chapter capsule grow with its title 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
851c7e2ebf Fade out tvOS player controls on auto-hide 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
944f849929 Revert "Fade out tvOS player controls on auto-hide"
This reverts commit a65fbc44ff721a7cb73afd04c41bd44d58bcb034.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
eb697b7bbc Add List/Grid layout option for Home sections
Introduces a "Display sections as" picker in Home settings with List and
Grid modes. Grid renders each section as a horizontal shelf of video
cards, defaulting to Grid on tvOS and List on iOS/macOS. Per-platform
defaults are preserved via a platform-specific settings key.

On tvOS the shelf is a focus section so swiping up/down between rows of
different lengths works without getting stuck at the end of a row.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
758f4a678d Fade out tvOS player controls on auto-hide 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
c52796db75 Remove seek time overlay from tvOS storyboard preview 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
d422bf13e5 Add Open URL and Remote Control as sidebar items
After disabling home shortcuts on tvOS, Open URL and Remote Control had
no entry point. Add them as configurable sidebar main items. Remote
Control defaults to visible on tvOS; Open URL defaults to hidden on all
platforms.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
e141a168f0 Use video context menu ControlGroup only on iOS 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
43f62d997f Match tvOS seek preview to iOS glass design 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
7067413b9b Push channel onto nav stack from tvOS details panel
The "View Channel" button in the tvOS video details panel only dismissed
the player; the channel was never pushed. Delegate to
NavigationCoordinator.navigateToChannel(for:collapsePlayer:) so
UnifiedTabView's pendingNavigation observer appends the destination,
matching iOS behavior and handling extracted/media-source cases.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
c3de87a12e Add channel avatar to tvOS player controls
Matches the iOS controls by showing the channel avatar next to the
video title and channel name, reusing ChannelAvatarView and the
Yattee Server fallback.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
4837fc6548 Make tvOS details panel use full screen 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
f2c2a86d47 Reorder tvOS player controls and add Previous button
Close button is now the rightmost action (after Queue), matching the
user-requested layout. A Previous button sits before Next and appears
whenever a queue is present, disabled until history accumulates.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
29782035f7 Open queue sheet from tvOS player controls
Turn the tvOS bottom-row queue count indicator into a focusable button
that opens QueueManagementSheet in a fullScreenCover with an
ultraThinMaterial backdrop, matching the Settings sheet pattern. Hide
the sheet's close toolbar button on tvOS (Menu button dismisses) and
replace the unusable Menu-based queue mode picker with an icon-only
tap-to-cycle button.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
9aeb329b64 Remove tvOS scrub hint labels from progress bar 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
24a728e692 Cancel tvOS scrub with Menu button instead of seeking
Pressing Menu while scrubbing now discards the pending scrub and leaves
playback time unchanged, instead of committing the seek via the
focus-loss path.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
bfc646a73f Scale tvOS scrub step by swipe rate
tvOS rasterizes Siri Remote touchpad swipes into discrete onMoveCommand
events at ~300-400ms, so a fast swipe and a single tap delivered the
same fixed step. Track gap between events: rapid same-direction events
(under 500ms) build a streak that multiplies the step via a power curve,
while deliberate taps still land on the base step.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
4c29ca9455 Seek with remote arrows when tvOS scrubber is focused
Pressing left/right on the focused progress bar now triggers the same
accumulating 10s seek as the hidden-controls flow, but updates the
visible scrubber in place with no overlay. Both tvOS arrow-seek paths
accumulate a signed net offset so a reverse press subtracts from the
pending amount instead of restarting from the current playback time.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
9260d48f4c Make tvOS sidebar main navigation toggles focusable
The tvOS Main Navigation list relied on .onMove with native Toggle
rows inside editMode, which leaves only the drag handle focusable on
tvOS. Replace it with a TVSidebarMainItemRow modeled on the home
customization screen: explicit up/down chevrons on the left and a
tap-to-toggle checkmark button as the row body. Required items render
disabled with a dimmed checkmark.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
2dcfe52bfb Fix broken modifier chain around playback speed menu style 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
c7d1f1c20b Seek with remote arrows when tvOS player controls are hidden
Left/right on the Siri Remote now seek instead of revealing controls,
reusing the iOS tap-seek accumulation handler and feedback overlay so
rapid presses compound into a single "+30s" / "-20s" jump. Up/down
still show controls.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
f5ddcd0fa5 Keep tvOS seek bar stationary and float preview above 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
0d5a733b0b Remove redundant center transport controls on tvOS
Siri Remote already handles play/pause and seeking natively, so the
on-screen skip/play/pause cluster was duplicate UI. Initial and
restored focus now targets the progress bar.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
c7942ef555 Rework tvOS player controls and settings sheet
Replace the tvOS bottom action bar with Settings / Info / Comments /
Next / Close. Settings reuses QualitySelectorView (video, audio,
subtitles, speed); Comments opens TVDetailsPanel directly on the
comments tab; Close stops playback and dismisses.

Debug button is hidden by default and can be re-enabled via a new
tvOS-only Advanced Settings > Developer toggle.

Present the settings sheet as a fullScreenCover with a centered
material card, fix the "Normal" hyphenation, and restyle row selection
throughout the quality selector on tvOS: per-row rounded backgrounds
with focus tint + stroke, vertical spacing instead of dividers, and a
focusable speed-rate menu.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
4f9285686a Avoid collapsed sidebar pill overlapping search on tvOS
Use the tab root's top safe-area inset instead of a fixed 20pt, so the
search/options header in History and Bookmarks clears the floating "Home"
pill drawn by the sidebarAdaptable TabView.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
d111f93462 Add vertical space between Home section header and rows on tvOS 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
39a04ba7a4 Hide Home shortcuts on tvOS 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
babaca74f2 Keep playback alive when dismissing tvOS player with Menu button
Calling stop() on Menu-button dismiss cleared currentVideo and tore down
the backend, so audio did not continue and the "Now Playing" sidebar
tab never appeared. Match the iOS/macOS dismissal path instead and just
collapse the fullScreenCover.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
2c49a5e65a Update localizable 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
9e95a91284 Sync sidebar/tab bar/home layout per-platform via iCloud
Extends `SettingsKey.isPlatformSpecific` to cover home, tab bar, and
sidebar layout, plus player details panel and video swipe actions, so
iOS devices sync these with other iOS devices (and tvOS with tvOS,
macOS with macOS) instead of overwriting each other via the shared
iCloud key. Adds a one-shot migration that copies legacy unprefixed
values into the new platform-prefixed slots locally and in iCloud,
preserving protected-key timestamps.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
f4605e7390 Force plain list style on tvOS and hide setting from appearance
Inset/grouped list style doesn't work well with tvOS focus effects.
Always return .plain on tvOS and hide the list style picker from
appearance settings.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
8253b1a247 Hide dividers between recent search items on tvOS 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
454f10b3ab Show selected content type in tvOS Type filter button
Display the current selection (All/Videos/Playlists/Channels) instead
of the static "Type" label so users can see the active filter at a
glance.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
d8722e6150 Remove overlay pattern from tvOS search results to fix focus clipping
The backgroundStyle.ignoresSafeArea().overlay(ScrollView) pattern
clips the tvOS focus effect at the overlay boundary. On tvOS, render
the ScrollView directly so focus highlights can extend naturally.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
885c478857 Increase top spacing for tvOS focus effect on first content row
Add padding above the content area so the tvOS focus highlight on the
first row isn't clipped by the search header above it.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
c3a2f7a965 Add bottom padding to tvOS search header for focus effect clearance 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
5417374275 Consolidate tvOS search header: search, type, filters, view options
Reorganize the tvOS search UI into a single header row with search
field, type filter menu, combined filters menu (sort/date/duration
with reset), and view options button. Removes the separate filter
strip between search and results on tvOS.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
debfdef26f Increase grid spacing on tvOS for focus effect clearance
The tvOS focus effect scales up the focused card, causing it to overlap
adjacent cards' text. Increase grid spacing from 32 to 48 points.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
84db5d0c42 Use List instead of Form for View Options on tvOS
Form inside a sheet causes clipped rows and invisible text on focused
items due to white-on-white rendering. Use a plain List on tvOS which
handles the focus styling correctly.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
310869fad8 Add horizontal padding to View Options sheet on tvOS
Prevents form rows from being cut off at the left and right edges
of the narrow tvOS sheet.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
58c3bdc0b6 Wrap View Options sheet in NavigationStack on tvOS for picker labels
The .pickerStyle(.menu) hid labels. Instead, wrap the Form in a
NavigationStack on tvOS so the default navigation picker style works
and shows both labels and values.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
338127c692 Fix disabled pickers in View Options sheet on tvOS
Use .pickerStyle(.menu) for Row Size, Columns, and Channel Strip
pickers on tvOS so they work inside a sheet without NavigationStack.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
893878c8a3 Update localizable 2026-04-18 20:38:01 +02:00
Arkadiusz Fal
d62ba1e143 Hide list row dividers and card clipping on tvOS
Dividers inside rows conflict with the tvOS focus highlight effect.
Remove dividers and the inset card background/clipShape on tvOS so
the focus effect renders cleanly without visual artifacts.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
3c7581de1a Replace search content type segmented picker with Menu on tvOS
Unifies the filter strip on tvOS so all filters (sort, date, duration,
content type) use the same inline Menu style instead of mixing menus
with a segmented picker.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
831773a609 Replace search filters sheet with inline menus on tvOS
The filters sheet is too small and awkward on tvOS. Replace the filter
button with inline Menu pickers for Sort By, Upload Date, and Duration
directly in the filter strip. Applied to both SearchView and
InstanceBrowseView.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
bcb0864fca Fix tvOS search view: replace searchable with inline TextField, fix clipped focus
Use inline TextField with focusSection instead of .searchable() on tvOS
to prevent keyboard/navigation title overlap. Remove clipShape on recent
search items so tvOS focus effect is not cut off.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
bbeb38ecf0 Hide link action, clipboard, and handoff settings on tvOS
These features are not available on Apple TV: clipboard monitoring,
default link action (no tap/share), and Handoff continuity.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
5ae1fc3f29 Fix tvOS instance browse view overlapping search and navigation UI
Use inline TextField with focusSection instead of .searchable() and
.navigationTitle() on tvOS, matching the pattern in HistoryListView.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
4b245ec176 Improve tvOS settings layout: use navigation instead of sheets, fix focus clipping
- Replace sheets with navigationDestination for Add/Edit Source on tvOS
  (tvOS sheets have fixed size that doesn't fit the content)
- Fix focused cell clipping by replacing TVSettingsContainer's frame-based
  layout with safeAreaInset, matching the main settings view pattern
- Use standard List with .listStyle(.grouped) for Sources on tvOS
- Add sidebar icons and titles to TVSettingsContainer for all settings
  subviews, utilizing the left column space
- Remove redundant large navigation titles on tvOS (shown in sidebar)
- Move Edit Source Save button from toolbar into form above Delete button
  for better tvOS focus navigation
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
b9a6d76ab3 Fix original audio detection so dubbed tracks don't play by default
parseAudioInfo() returned isOriginal=false for all streams when the
audioTrack object was present in the API response, preventing xtags
parsing from correctly identifying original tracks. This caused the
player to fall through to codec/bitrate sorting, often picking a
locale dub (e.g. Polish) instead of the original English audio.

Now determines isOriginal from both the audioTrack displayName
("original" keyword) and URL xtags (acont=original) for robustness.
Also adds isDefault to InvidiousAudioTrack for future use.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
7c28e86d96 Add UI smoke test for search and playback on basic-auth Invidious
Exercises the read path end-to-end against an Invidious instance fronted
by an HTTP Basic Auth reverse proxy: adds the instance via the new
basic-auth-aware add helper, navigates to Search, runs a query, taps the
first result, waits for the player to expand and start playback, then
closes the player. Confirms that ContentService's per-instance HTTPClient
(with the basic-auth Authorization header baked in via setDefaultHeaders)
is wired correctly through search, video metadata fetch, and stream
loading.

Skips cleanly when INVIDIOUS_BASIC_AUTH_USERNAME / _PASSWORD are not set.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
56cd60a8ba Add UI smoke tests for Invidious behind HTTP Basic Auth
Three end-to-end specs that exercise the new basic-auth flows against a
real Invidious instance fronted by an nginx reverse proxy:

  1. add flow: types the URL, hits Detect, fills the basic-auth fields
     when the basicAuthRequired UI state appears, taps Retry Detection,
     and confirms the instance lands in the Sources list.

  2. state assertion: types a URL, taps Detect, and verifies the form
     transitions into the basicAuthRequired state (Retry Detection button
     present, no detected type) when no credentials were supplied.

  3. proxied login: ensures the instance exists, then drives the standard
     Invidious login flow with the proxied account credentials. Confirms
     the SID Cookie auth coexists with the per-client Authorization
     header on the basic-auth-aware HTTPClient.

Test infrastructure additions:

- spec/ui/support/config.rb: env-driven accessors for the basic-auth URL
  and proxied-account credentials. No secrets committed.

- spec/ui/support/instance_setup.rb: helpers
  add_invidious_with_basic_auth, remove_and_add_invidious_with_basic_auth,
  find_basic_auth_text_fields (mirroring find_auth_text_fields), and
  fill_field for tapping a discovered field by frame and typing into it.

All three specs skip cleanly when the relevant env vars are not set.
2026-04-18 20:38:01 +02:00
Arkadiusz Fal
eefd49f743 Fix three basic-auth regressions surfaced by end-to-end testing
- InstanceDetector: a single 401 from one probe was over-eagerly concluded
  as "credentials invalid" / "credentials required". On instances behind a
  reverse proxy where one probe path (e.g. Yattee Server's /info) hits a
  same-origin redirect, iOS URLSession strips the Authorization header on
  the redirect and the request 401s even with valid credentials. Track 401s
  across all probes and only conclude basicAuthRequired/basicAuthInvalid
  when no probe matched and at least one returned 401.

- InstanceLoginView: the Invidious/Piped login flow constructed an API
  client backed by the shared appEnvironment.httpClient, which has no
  per-instance basic-auth headers. For instances behind a reverse proxy,
  the login POST 401d before reaching the upstream login endpoint. Build a
  per-instance HTTPClient with the basic-auth Authorization header baked in
  via setDefaultHeaders, mirroring ContentService.httpClientWithBasicAuth.

- InvidiousAPI.login: the login function constructs its own URLSession (to
  capture Set-Cookie via a redirect-blocking delegate), so it never
  inherits headers from the injected httpClient. Add an optional
  extraHeaders parameter and have InstanceLoginView pass the basic-auth
  header through when present. PipedAPI.login uses httpClient.fetch and
  inherits defaultHeaders correctly, so no change is needed there.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
3dd4073db7 Allow HTTP Basic Auth credentials for any remote-server instance type
EditSourceView now exposes the basic-auth username/password fields for every
instance type (Invidious, Piped, PeerTube, Yattee Server), keeping the
existing required-credentials UI for Yattee Server and adding an optional
section for the others. Credentials are loaded and persisted via
BasicAuthCredentialsManager regardless of type, and clearing both fields
deletes stored credentials for non-Yattee types.

AddRemoteServerView gains a new basicAuthRequired UI state: when instance
detection hits a 401 (the entire instance is behind a reverse proxy), the
view reveals username/password fields and a Retry Detection button. The
retry calls the detector with the credentials injected as an Authorization
header; on success the form transitions into the normal detected state with
the credentials pre-populated. A repeat 401 shows an inline "invalid
credentials" message instead of restarting the flow. For non-Yattee types,
any credentials entered during the flow are persisted alongside the new
instance.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
222b53d520 Surface 401 from instance detection so the user can supply credentials
When an instance sits behind a reverse proxy that requires HTTP Basic Auth,
every detection probe (/info, /api/v1/config, /api/v1/stats, /healthcheck,
/config) returns 401 before reaching the real backend, so the type cannot be
identified. Re-throw APIError.unauthorized from each probe instead of
swallowing it, and have detectWithResult convert the first 401 it sees into
DetectionError.basicAuthRequired. Add a basicAuthHeader parameter so the
caller can retry detection after the user provides credentials; if a retry
also returns 401, surface basicAuthInvalid instead.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
63f1cb1f25 Inject basic auth via per-instance HTTPClient default headers
Replace the YatteeServerAPI setAuthHeader/buildHeaders pattern (which was
race-prone on the shared actor across multiple instances) with a generic
mechanism: HTTPClient now supports a defaultHeaders dictionary applied to
every request, and ContentService builds a per-instance HTTPClient with the
basic-auth Authorization header baked in whenever credentials are configured.

The same code path now works uniformly for Invidious, Piped, PeerTube, and
Yattee Server, so any instance sitting behind a reverse proxy that requires
HTTP Basic Auth can be authenticated regardless of backend type. Cached
default API actors are still reused when no basic-auth header is needed.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
aed78c13fb Send Piped auth token via query parameter instead of header
Piped accepts the session token via either an Authorization header or an
authToken query parameter (the /feed endpoint already uses the latter form).
Switch all token-bearing Piped endpoints to the query-parameter form so the
Authorization header is free for HTTP Basic Auth from a fronting reverse
proxy. Affects subscriptions, subscribe, unsubscribe, userPlaylists, and
userPlaylist (including its nextpage pagination loop).
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
8cd3aca96c Generalize Yattee Server credentials manager to BasicAuthCredentialsManager
Renames YatteeServerCredentialsManager → BasicAuthCredentialsManager so the
same Keychain-backed username/password storage can be reused for any instance
type that sits behind a reverse proxy requiring HTTP Basic Auth. Adds a
one-time migration that moves existing items from the legacy
'com.yattee.yatteeserver' Keychain service to 'com.yattee.basicauth',
preserving the iCloud-sync attribute. No behavior change for end users.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
240cf23693 Fix uneven shortcut card heights on tvOS home screen
Always reserve space for the subtitle line so cards with and without
subtitles have consistent heights in the grid.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
0071e1b117 Skip notifications for upcoming/premiere videos
Videos that haven't premiered yet were triggering repeated notifications
on every background refresh cycle. Filter them out by checking isUpcoming
flag and rejecting videos with future publish dates.

Also decode isUpcoming/premiereTimestamp from Yattee Server feed responses
instead of hardcoding false/nil.
2026-04-18 20:38:00 +02:00
github-actions[bot]
00ba029a92 Bump build number to 256 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
88b095eb32 Fix GitHub release job: use REPO_TOKEN for checkout auth 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
89162741f7 Update changelog 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
9267504e26 Update dependencies 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
d6d15df105 Deduplicate time formatting and clean up unused code
Extract shared TimeInterval.formattedAsTimestamp replacing 8 identical
formatTime/formattedTime implementations across player views. Remove
unused currentTime parameter from GestureSeekPreviewView. Consolidate
duplicated geometry math in MacOSControlBar into seekPreviewPosition().
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
9b734f49ad Add separate glass capsule for chapter title above seek preview
Extract chapter title from inside the storyboard preview into a
standalone ChapterCapsuleView with its own glass capsule background.
The capsule follows the seek position horizontally but independently
clamps to screen edges using alignmentGuide, allowing it to be wider
than the storyboard thumbnail without going offscreen.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
4e8959d2df Fix missing leading padding on instance content section headers 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
f3061763da Show seek time preview when no storyboards available
Display a floating time pill above the seek bar during dragging
(iOS) and dragging/hovering (macOS) when video has no storyboard
thumbnails. Includes chapter name when available.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
a7e5ebb068 Fix 5 TestFlight crash types from builds 250-254
- Fix BGTaskScheduler assertion crash on Mac Catalyst by guarding all
  iOS background task APIs with isMacCatalystApp check
- Fix iPad popover crash in UIPopoverPresentationController by adding
  .presentationCompactAdaptation(.sheet) to all 27 confirmationDialogs
- Fix SwiftData assertion crash when accessing deleted Bookmark model
  properties during SwiftUI hit testing in BookmarkRowView
- Fix UICollectionView invalid item count crash on queue swipe-to-delete
  by using ID-based removal with withAnimation instead of stale index
- Fix Range crash in storyboard download when storyboardCount is zero
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
8e5947c558 Fix HTTP basic auth credentials being stripped from instance URLs
Preserve user:pass credentials in instance URLs so Invidious instances
behind nginx reverse proxies with HTTP basic auth work correctly (#926).
Add displayURL property to mask credentials in the UI.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
21da76a9ea Fix tests 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
4edb012181 Hide theme and accent color settings on tvOS
These settings don't work well on Apple TV, so exclude the
ThemeSection, AccentColorSection, and the .preferredColorScheme/.tint
modifiers from tvOS builds.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
f8da242968 Fix ContentUnavailableView centering on Apple TV
On tvOS, ContentUnavailableView inside a Group doesn't expand to fill
available space — it sizes to content and aligns top-leading. Add
.frame(maxWidth: .infinity, maxHeight: .infinity) to all instances
so they center correctly in their parent containers.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
924f62f5ef Update packages 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
59ccef950b Fix HomeView data staleness on new watch entries, tab switches, and settings dismissal
Post watchHistoryDidChange notification when a new watch entry is inserted
during local playback progress updates (but not on every progress tick).
Reload Home data when switching back to the Home tab and when the Customize
Home sheet is dismissed.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
acb6fb284a Fix Home view showing zero counts after returning from background
onAppear only fires once when the view first appears, not on foreground
return. Add scenePhase observer to reload data when the app becomes active.
2026-04-18 20:38:00 +02:00
github-actions[bot]
1e45333d1e Bump build number to 254 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
1c18d893af Update changelog 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
8c24b12b9a Migrate localization keys to dotted format
Remove 32 non-dotted keys (16 unused format specifiers, 16 word keys)
and replace with properly namespaced dotted keys following the existing
convention (common.*, player.*, search.*).
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
64193911c7 Show toolbar buttons and tab picker during channel loading
Display the view options button, channel menu, and content type tabs
immediately when the cached header is shown, instead of waiting for
the full channel data to load. The spinner now appears only in the
content area below the tabs.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
e956075f3c Fix CFNetwork SIGABRT crash when creating download tasks on invalidated session
The background URLSession could be in an invalid state when downloadTask(with:)
is called, because invalidateAndCancel() is asynchronous internally. This adds
an ObjC exception handler to catch NSExceptions from CFNetwork, nil guards on
the session, and safer session lifecycle management (nil after invalidation,
finishTasksAndInvalidate for cellular toggle).
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
54f175b294 Fix BGTaskScheduler crash by moving registration to App.init()
Apple requires BGTaskScheduler.register() to be called during the app
launch sequence before the run loop starts. Moving it from .onAppear
(too late) to init() prevents the crash on TestFlight builds.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
d1e63e85a4 Apply Invidious proxy rewriting to download streams
Downloads were using direct YouTube CDN URLs even when proxiesVideos
was enabled. Apply the same proxyStreamsIfNeeded used by the player
to the download code path in ContentService.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
7c2a205e74 Fix Piped relatedStreams decoding crash on missing fields
Malformed items in relatedStreams (e.g., missing title) no longer crash
the entire JSON decode. Reuses the existing PipedVideoItem (renamed from
PipedPlaylistItem) graceful-decoding wrapper for all relatedStreams arrays
in PipedStreamResponse, PipedChannelResponse, and PipedNextPageResponse.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
6298b38cba Add video proxy support with live toggle for Invidious/Piped instances
Adds a "Proxy videos" toggle in instance settings that routes video
streams through the instance instead of connecting directly to YouTube
CDN. Includes auto-detection of 403 blocks and live re-application of
proxy settings without requiring app restart or video reload.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
2e37873a12 Remove duplicate navigation titles on tvOS
The sidebarAdaptable TabView already shows tab names in the sidebar
pill, so the large .navigationTitle() was redundant on tvOS.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
013514adc3 Fix tvOS onboarding background transparency and button styling
Add opaque black background to onboarding on tvOS to prevent Home
screen content from leaking through the fullScreenCover. Replace
toolbar Skip button with plain overlay button to avoid blurred
material style, and add tvOS card button style with default focus
on Continue button.
2026-04-18 20:38:00 +02:00
github-actions[bot]
52bb32afdf Bump build number to 253 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
40212fb34c Skip build number commit when already up to date
The release job fails when the build number in the repo already matches
the computed value, causing git commit to exit with code 1 on an empty
changeset. Now we check for staged changes before committing.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
109d165dac Revert app icon name to Yattee2 for Icon Composer
The Yattee2.icon/ Icon Composer file is the correct source for app
icons. The previous change to AppIcon was incorrect — AppIcon.appiconset
is an empty legacy placeholder with no actual PNGs.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
c2ee65cacd Use macos-26 runner for Xcode 26 SDK support
iOS 26 APIs (matchedTransitionSource on ToolbarContent, etc.) require
the Xcode 26 SDK which is only available on macos-26 runners.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
c53b0b3386 Fix app icon name to match asset catalog
ASSETCATALOG_COMPILER_APPICON_NAME was set to "Yattee2" but the
asset catalog only has "AppIcon.appiconset".
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
bf1ed95281 Set code_sign_identity in update_code_signing_settings
Without explicit identity, xcodebuild defaults to "iOS Development"
which doesn't exist on CI. Set "Apple Distribution" for App Store
builds and "Developer ID Application" for notarized builds.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
03bf8d2654 Use fastlane update_code_signing_settings for manual signing
Replaced sed-based CODE_SIGN_STYLE override with fastlane's
update_code_signing_settings which also sets PROVISIONING_PROFILE_SPECIFIER.
This fixes the YatteeShareExtension build failure where it couldn't
find a provisioning profile under manual signing.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
aca9ab6a0b Skip GitHub release when any build job fails
Adding !failure() check so skipped builds (not selected) still allow
the release, but actual build failures block it.
2026-04-18 20:38:00 +02:00
github-actions[bot]
2aca95e3fa Bump build number to 252 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
0529c9105c Fix build number file path and remove update_fastlane in CI
- Write latest_build_number.txt to repo root using explicit path
  (fastlane runs from fastlane/ subdir, so relative path was wrong)
- Remove update_fastlane from before_all to avoid CI instability
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
161be24ad3 Fix release workflow by updating Ruby from 3.1 to 3.4
Ruby 3.1 is EOL and bundle install fails with exit code 5 on
latest macOS runners. Updated to match .ruby-version (3.4.8).
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
3b605020ed Update CHANGELOG 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
ee49671ea2 Fix release workflow to support non-main branches 2026-04-18 20:38:00 +02:00
Arkadiusz Fal
7dd2ee1582 Add git-cliff based changelog generator
Bash wrapper around git-cliff with cliff.toml config for regex-based
commit parsers handling skip patterns and category matching.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
bb1e7ddd68 Auto-increment build number in release workflow
Query App Store Connect for the latest TestFlight build number across
all platforms (iOS, tvOS, macOS) and auto-increment it, eliminating
the need for the separate bump-build workflow.
2026-04-18 20:38:00 +02:00
Arkadiusz Fal
2f1e699623 Update localizable 2026-04-18 20:37:35 +02:00
Arkadiusz Fal
e6834b6eff Add DEV badge on iCloud settings for debug builds
Shows an orange "DEV" capsule next to the iCloud row in Settings and a
development environment notice at the top of iCloud settings, helping
distinguish CloudKit dev environment from production during development.
2026-04-18 20:37:35 +02:00
Arkadiusz Fal
07003a36d7 Fix deleted playlists resurrecting from iCloud after app restart
Pending deletes were lost across app restarts because
recoverPersistedPendingChanges() never reconstructed CKRecord.ID
objects from persisted record names. Additionally, incoming iCloud
records for deleted playlists were blindly applied, and orphaned
playlist items in CloudKit would recreate placeholder playlists.

- Rebuild pendingDeletes array from UserDefaults on recovery
- Guard applyRemoteRecord against records pending local deletion
- Skip deferred items whose parent playlist is pending deletion
- Queue all playlist item deletions when deleting a playlist
- Clean up placeholder playlists for pending-delete playlists
2026-04-18 20:37:35 +02:00
Arkadiusz Fal
e38e4cca3a Fix feed channel filter avatars showing placeholders instead of images
The filter strip was passing the Invidious instance URL as serverURL to
AvatarURLBuilder, which built a Yattee Server-style /avatar/ path that
doesn't exist on Invidious. Now passes the actual Yattee Server URL
(matching SubscriptionsView pattern) and enriches channels from
CachedChannelData as a fallback when the API doesn't return thumbnails.
2026-04-18 20:37:35 +02:00
Arkadiusz Fal
904e4366fb Bump build number to 251 2026-04-18 20:37:35 +02:00
Arkadiusz Fal
fae390cff6 Fix build number 2026-04-18 20:37:35 +02:00
Arkadiusz Fal
1ac4e089fc Add Fastlane config and update release workflow for v2
Single unified "Yattee" scheme replaces per-platform schemes.
Release workflow now has toggleable platform inputs instead of
matrix strategy. Standalone mac notarized workflow removed in
favor of the build_mac_notarized toggle. Share extension bundle
ID updated from Open-in-Yattee to ShareExtension.
2026-04-18 20:37:35 +02:00
Arkadiusz Fal
7c33f7e9f3 Change default layout settings 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
7cea57c343 Update media browser view options sheet layout 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
d045a64b63 Persist media browser view options per source
Save sort order, sort direction, and show-only-playable filter to
UserDefaults keyed by source ID so preferences survive navigation.
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
7abd3a86fc Move close video button from toolbar into now playing card in RemoteControlView 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
5c82c37339 Add Enable All / Disable All menu to channel notifications settings 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
e4d275ad42 Show video thumbnail in mini player during PiP
When PiP is active, the MPV render view shows a black frame since
rendering goes to the PiP sample buffer layer. Overlay the video
thumbnail (preferring DeArrow) on top to cover the black area,
fading it in/out smoothly when PiP starts/stops.
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
2faae65e8b Add context menu and swipe actions to related videos in VideoInfoView 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
49a44da90e Fix Invidious login failing for passwords with special characters
Use URLComponents/URLQueryItem for standard form-URL encoding instead
of manual percent-encoding with CharacterSet.alphanumerics, which
included non-ASCII Unicode letters and had an unsafe raw-value fallback.
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
fa0536549a Fix subscriber count layout shift in VideoInfoView channel row 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
ef3cddefeb Persist author cache to disk for instant channel info across restarts
Back the in-memory authorCache with a JSON file in ~/Library/Caches/AuthorCache/.
Disk is lazy-loaded on first lookup and saved asynchronously on each cache update.
Capped at 500 entries to prevent unbounded growth.

- Cache author data from video detail API responses (PlayerService, VideoInfoView)
- Replace ChannelView's private CachedChannelHeader with shared CachedChannelData
- Enrich author with cached avatar/subscriber count in VideoChannelRow, TVDetailsPanel, VideoInfoView
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
e3606dbb3a Fix Feed tab flashing ContentUnavailableView on initial load
When a cancelled load task fell through to `isLoading = false`, it
created a 1-frame gap where the empty view rendered before the
replacement task set `isLoading` back to `true`. Return early on
cancellation so the surviving task controls loading state.
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
02de3d0bd5 Fix blurred background gradient not using DeArrow thumbnail 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
41c11d8839 Fix playlist rows in ChannelView not tappable in empty space 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
aaf53ef9d1 Fix lock screen always showing 10s seek regardless of system controls setting 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
f010650e5e Remove excessive logging 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
5f00f4934c Fix player dismiss gesture stuck after panel dismiss with comments expanded
Reset isCommentsExpanded and commentsFrame on the NavigationCoordinator
directly when the portrait panel is dismissed, since PortraitDetailsPanel
owns its own @State that doesn't sync back through .onChange during dismiss.
Also track comments overlay frame via GeometryReader so the dismiss gesture
can allow swipes outside the comments area instead of blanket-blocking.
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
ecaf553326 Fix incomplete playlist loading by paginating through all pages
Playlists only loaded the first page of videos. Add full pagination for
both Invidious and Piped playlist endpoints (public and authenticated).
Deduplicate Invidious results by playlist index to handle its overlapping
page windows. Also fix URL encoding in Invidious login to use strict
form-encoding charset.
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
0e0922dad0 Fix pull-to-refresh scroll offset not resetting in InstanceBrowseView
Move .refreshable from the outer GeometryReader onto the ScrollView
itself so SwiftUI can properly coordinate the scroll offset bounce-back.
The ScrollView was inside an .overlay() which doesn't participate in
the parent's layout system, breaking the offset reset.

Closes #917
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
7b43184a38 Fix URL scheme UI tests for YouTube deep links and content loading
Route HTTPS YouTube URLs through yattee://open?url= scheme since simctl
can't trigger Universal Links. Improve wait strategies: use player
expansion check for video tests, tree length threshold for channel/
playlist content loading. Add retry logic to cleanup_after_video.
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
307d6f7350 Add URL scheme UI tests for deep link navigation
Test yattee:// custom scheme URLs navigate to correct screens:
playlists, bookmarks, history, downloads, channels, subscriptions,
continue-watching, and search. Handles iOS system confirmation dialog
via coordinate taps since it's invisible to AXe. Settings deep link
is excluded (known app bug - doesn't render when pushed to nav stack).
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
3666154510 Fix UI tests for onboarding flow and AddRemoteServer redesign
- Skip onboarding in tests by setting UserDefaults before launch
- Update all addSource.* identifiers to addRemoteServer.* for new flow
- Switch from identifier-based to text-based element lookups (iOS 26 AXe limitation)
- Add Yattee Server credential support in instance setup
- Update baseline screenshots for Home tab and settings
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
37c75f25b0 Add AltStore source and separate update workflow from release pipeline
- Create standalone update-altstore.yml workflow (workflow_dispatch + workflow_call)
  that gets version from latest GitHub release tag instead of project.pbxproj
- Replace inline update_altstore job in release.yml with workflow_call reference
- Add altstore-source.json with app metadata and initial version entry
- Update README with revised features, TestFlight install link, and new logo assets
2026-04-18 20:37:25 +02:00
Arkadiusz Fal
f022b3dc30 Fix panscan zoom pushing controls off screen for portrait videos 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
11a8c79e21 Refactor views 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
425a2c590d Fix locales 2026-04-18 20:37:25 +02:00
Arkadiusz Fal
100df744d9 Yattee v2 rewrite 2026-04-18 20:37:24 +02:00
Arkadiusz Fal
d94a50f8c3 Retry SPM dependency resolve to work around binary-target race
xcodebuild's resolvePackageDependencies sometimes fails with
"already exists in file system" when multiple binary xcframework
targets from the same release URL try to extract concurrently.
The failing target varies across runs, confirming a race, not a
missing-file problem. Wipe the three SPM cache roots between
attempts and retry up to three times before giving up; once the
resolve succeeds, fastlane's own resolve step reuses the cache.
2026-04-18 19:41:42 +02:00
Arkadiusz Fal
b7edbe5683 Pin Xcode 26.0.1 for release and broaden SPM cache clear
Xcode 26.2 (the new default on macos-26) hits a SwiftPM race where
binary xcframework downloads fail with "already exists in file system".
Xcode 26.0.1 — the toolchain the last successful release ran against —
resolved the same deps cleanly. Also download the iOS platform if the
runner image only ships the platform bundled with the default Xcode,
and clear SPM cache more broadly rather than only the artifacts dir.
2026-04-18 19:33:48 +02:00
Arkadiusz Fal
42849c1aae Drop explicit Xcode 26.0.1 selection on release workflow
setup-xcode was switching to /Applications/Xcode_26.0.1.app, which on
the macos-26 runner lacks the iOS 26.0 platform SDK. The runner's
default Xcode has it bundled. Matches rewrite/v2.
2026-04-18 19:25:31 +02:00
Arkadiusz Fal
11f0dff4e2 Pass is_key_content_base64: true to app_store_connect_api_key
The DEVELOPER_KEY_CONTENT secret is stored base64-encoded, so fastlane
needs to be told to decode it before parsing. Matches the approach on
rewrite/v2. Removes the openssl-shell-out workaround from the previous
commits, which was solving the wrong problem.
2026-04-18 19:10:04 +02:00
Arkadiusz Fal
a26044cc04 Un-escape \n in DEVELOPER_KEY_CONTENT before openssl conversion
GitHub secrets store multi-line PEMs as a single line with literal "\n"
sequences. Fastlane's app_store_connect_api_key action un-escapes them
via gsub before use; the helper must do the same before writing the
temp file, otherwise openssl sees garbage.
2026-04-18 19:05:44 +02:00
Arkadiusz Fal
c978ec6b89 Work around invalid curve name on CI runners
The hosted macOS runner's OpenSSL rejects Apple's PKCS#8 .p8 key via
OpenSSL::PKey::EC.new with "invalid curve name". Shell out to system
openssl to convert the key to SEC1/traditional PEM before handing it
to fastlane's app_store_connect_api_key action.

Ref: fastlane/fastlane#20593
2026-04-18 19:03:27 +02:00
Arkadiusz Fal
4a69172bed Bump CI Ruby to 3.3
Fastlane 2.232 transitive deps (public_suffix 7, multi_json 1.20)
require Ruby >= 3.2, and Ruby 3.1 is EOL since March 2025.
2026-04-18 18:57:21 +02:00
Arkadiusz Fal
0db1c08b98 Bump fastlane to 2.232 to fix invalid curve name on CI
Works around OpenSSL::PKey::ECError when parsing App Store Connect
API .p8 keys on the updated GitHub-hosted macOS runner image.
2026-04-18 18:47:22 +02:00
Arkadiusz Fal
f9ecfcd3dd Fix README 2026-04-18 18:36:42 +02:00
Arkadiusz Fal
b9351b502c Update changelog 2026-04-18 18:33:24 +02:00
Arkadiusz Fal
f28fdcec96 Bump build number 2026-04-18 18:31:19 +02:00
Arkadiusz Fal
ba3da4fc03 Wire Finnish, Indonesian, Korean, Dutch, Swedish into Localizable.strings build
Commit 37c6f6abb added fi/id/ko/nl/sv to knownRegions but never registered
their .strings files, so Xcode never copied them into the app bundle and the
runtime fell back to English even when the scheme forced one of these
languages. Adds the missing PBXFileReference entries and includes them in
the Localizable.strings PBXVariantGroup.
2026-04-18 18:25:57 +02:00
Arkadiusz Fal
627ee48325 Update README 2026-04-18 17:59:44 +02:00
Arkadiusz Fal
f23b010241 Merge pull request #903 from weblate/weblate-yattee-localizable-strings
Translations update from Hosted Weblate
2026-04-18 12:28:59 +02:00
Sketch6580
9a5d377ae0 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/zh_Hans/
2026-04-02 01:09:50 +00:00
ButterflyOfFire
b789e320e0 Translated using Weblate (Kabyle)
Currently translated at 17.0% (96 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/kab/
2026-03-26 16:09:49 +00:00
Ghost of Sparta
6bdb187d18 Translated using Weblate (Hungarian)
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/hu/
2026-03-13 08:09:48 +00:00
Sketch6580
ca36254661 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (562 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/zh_Hans/
2026-02-07 20:54:12 +01:00
Aditya Bhat
3312e1df82 Translated using Weblate (Kannada)
Currently translated at 1.6% (9 of 562 strings)

Translation: Yattee/Localizable.strings
Translate-URL: https://hosted.weblate.org/projects/yattee/localizable-strings/kn/
2026-02-07 20:54:12 +01:00
Aditya Bhat
a484aaf889 Added translation using Weblate (Kannada) 2026-02-07 20:54:11 +01:00
375 changed files with 32229 additions and 8643 deletions

View File

@@ -9,15 +9,22 @@ on:
build_tvos:
description: 'Build tvOS (TestFlight)'
type: boolean
default: false
default: true
build_mac_beta:
description: 'Build macOS (TestFlight)'
type: boolean
default: false
default: true
build_mac_notarized:
description: 'Build macOS (notarized)'
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
type: boolean
default: false
default: true
release_channel:
description: 'Sparkle / Developer ID channel (also toggles GitHub prerelease flag)'
type: choice
options:
- beta
- stable
default: beta
create_release:
description: 'Create GitHub release'
type: boolean
@@ -42,7 +49,6 @@ env:
GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
CERTIFICATES_GIT_URL: ${{ secrets.CERTIFICATES_GIT_URL }}
TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }}
jobs:
determine_build_number:
@@ -174,15 +180,18 @@ jobs:
- uses: maierj/fastlane-action@v3.0.0
with:
lane: mac build_and_notarize
- run: |
echo "APP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
- name: ZIP build
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
- name: Resolve artifact paths
run: |
DIR="fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS"
echo "APP_PATH=$DIR/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
echo "DMG_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.dmg" >> $GITHUB_ENV
- uses: actions/upload-artifact@v4
with:
name: mac-notarized-build
path: ${{ env.ZIP_PATH }}
path: |
${{ env.ZIP_PATH }}
${{ env.DMG_PATH }}
if-no-files-found: error
release:
@@ -192,13 +201,16 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
tag: ${{ steps.compute_tag.outputs.tag }}
env:
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GIT_AUTHORIZATION }}
token: ${{ secrets.REPO_TOKEN }}
- name: Commit build number
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
@@ -212,14 +224,132 @@ jobs:
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Compute release tag
id: compute_tag
run: |
if [ "$RELEASE_CHANNEL" = "beta" ]; then
echo "tag=${VERSION_NUMBER}-beta.${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "tag=${VERSION_NUMBER}-${BUILD_NUMBER}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
- uses: ncipollo/release-action@v1
with:
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg
# No .pkg here on purpose: the mac TestFlight pkg is App Store-signed
# (no Sparkle, not notarized) and would not run if installed directly.
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.dmg
commit: ${{ github.ref_name }}
tag: ${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}
prerelease: true
tag: ${{ steps.compute_tag.outputs.tag }}
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
bodyFile: CHANGELOG.md
publish_appcast:
if: ${{ inputs.build_mac_notarized && inputs.create_release && !cancelled() && !failure() }}
needs: [determine_build_number, mac_notarized, release]
name: Publish Sparkle appcast
runs-on: macos-26
permissions:
contents: write
env:
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
REPO: ${{ github.repository }}
steps:
- name: Guard — secret configured
run: |
if [ -z "$SPARKLE_ED_PRIVATE_KEY" ]; then
echo "::error::SPARKLE_ED_PRIVATE_KEY secret is not set. Configure it with the base64-encoded private key exported via 'generate_keys -x'."
exit 1
fi
- uses: actions/checkout@v4
with:
token: ${{ secrets.REPO_TOKEN }}
- name: Download notarized mac artifact
uses: actions/download-artifact@v4
with:
name: mac-notarized-build
path: mac-artifacts
- name: Locate sign_update binary
id: find_sign_update
run: |
# Sparkle's `sign_update` ships as a package artifact. We need SPM to
# resolve the Sparkle package so the binary is present on disk.
xcodebuild -resolvePackageDependencies -project Yattee.xcodeproj -scheme Yattee >/dev/null
SIGN=$(find "$HOME/Library/Developer/Xcode/DerivedData" -name sign_update -type f 2>/dev/null | head -1)
if [ -z "$SIGN" ]; then
SIGN=$(find ~ -name sign_update -type f 2>/dev/null | head -1)
fi
if [ -z "$SIGN" ]; then
echo "::error::Could not locate sign_update binary"
exit 1
fi
echo "sign_update=$SIGN" >> "$GITHUB_OUTPUT"
- name: Checkout gh-pages (create if missing)
run: |
# Fetch into a local branch: actions/checkout configures a narrow
# refspec, so a plain `git fetch origin gh-pages` never creates
# origin/gh-pages, and a detached worktree has no local branch for
# `git push origin gh-pages` to resolve.
if git fetch origin +refs/heads/gh-pages:refs/heads/gh-pages; then
git worktree add gh-pages gh-pages
else
# First run — create orphan gh-pages with only appcast scaffolding.
git worktree add --detach gh-pages HEAD
cd gh-pages
git checkout --orphan gh-pages
git rm -rf . >/dev/null 2>&1 || true
cp ../scripts/sparkle/appcast_template.xml appcast.xml
cd ..
fi
- name: Write private key to a temp file
id: ed_key
run: |
KEY_FILE=$(mktemp)
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEY_FILE"
echo "path=$KEY_FILE" >> "$GITHUB_OUTPUT"
- name: Sign update and update appcast.xml
run: |
ZIP=$(find mac-artifacts -name '*.zip' | head -1)
if [ -z "$ZIP" ]; then
echo "::error::No .zip found in mac-artifacts"
exit 1
fi
./scripts/sparkle/update_appcast.rb \
--zip "$ZIP" \
--version "$VERSION_NUMBER" \
--build "$BUILD_NUMBER" \
--channel "$RELEASE_CHANNEL" \
--tag "$RELEASE_TAG" \
--sign-update-bin "${{ steps.find_sign_update.outputs.sign_update }}" \
--ed-key-file "${{ steps.ed_key.outputs.path }}" \
--appcast gh-pages/appcast.xml \
--repo "$REPO"
- name: Scrub private key
if: always()
run: rm -f "${{ steps.ed_key.outputs.path }}"
- name: Commit & push appcast.xml
run: |
cd gh-pages
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add appcast.xml
if git diff --cached --quiet; then
echo "No appcast changes to publish"
else
git commit -m "Publish Sparkle appcast: ${VERSION_NUMBER} (${BUILD_NUMBER}) [${RELEASE_CHANNEL}]"
git push origin gh-pages
fi
update_altstore:
# Only when an iOS IPA was actually built and released — a mac/tvOS-only
# release has no IPA and would corrupt the AltStore source.
if: ${{ inputs.build_ios && success() }}
needs: [release]
uses: ./.github/workflows/update-altstore.yml
secrets: inherit
with:
tag: ${{ needs.release.outputs.tag }}

View File

@@ -1,7 +1,16 @@
name: Update AltStore source
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish (defaults to the latest release)'
type: string
required: false
workflow_call:
inputs:
tag:
type: string
required: false
jobs:
update_altstore:
@@ -13,18 +22,32 @@ jobs:
- uses: actions/checkout@v4
with:
ref: main
- name: Get version info from latest release
# Default GITHUB_TOKEN cannot bypass the "changes through PR only"
# branch ruleset; REPO_TOKEN can (same as the release workflow).
token: ${{ secrets.REPO_TOKEN }}
- name: Get version info from release
run: |
TAG=$(gh release view --json tagName --jq '.tagName')
TAG="${{ inputs.tag }}"
if [ -z "$TAG" ]; then
TAG=$(gh release view --json tagName --jq '.tagName')
fi
# Tags are <version>-<build> (stable) or <version>-beta.<build> (beta),
# e.g. 2.0.0-263 or 2.0.0-beta.263.
echo "TAG=${TAG}" >> $GITHUB_ENV
echo "VERSION_NUMBER=${TAG%-*}" >> $GITHUB_ENV
echo "BUILD_NUMBER=${TAG##*-}" >> $GITHUB_ENV
echo "VERSION_NUMBER=${TAG%%-*}" >> $GITHUB_ENV
echo "BUILD_NUMBER=${TAG##*[-.]}" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get IPA size from release
run: |
SIZE=$(gh release view "${{ env.TAG }}" --json assets --jq '.assets[] | select(.name == "Yattee.ipa") | .size')
echo "IPA_SIZE=${SIZE:-0}" >> $GITHUB_ENV
IPA_NAME="Yattee-${{ env.VERSION_NUMBER }}-iOS.ipa"
SIZE=$(gh release view "${{ env.TAG }}" --json assets --jq ".assets[] | select(.name == \"$IPA_NAME\") | .size")
if [ -z "$SIZE" ]; then
echo "::error::Release ${{ env.TAG }} has no asset named $IPA_NAME — refusing to publish a broken AltStore entry"
exit 1
fi
echo "IPA_NAME=${IPA_NAME}" >> $GITHUB_ENV
echo "IPA_SIZE=${SIZE}" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update altstore-source.json
@@ -33,7 +56,7 @@ jobs:
jq --arg version "${{ env.VERSION_NUMBER }}" \
--arg build "${{ env.BUILD_NUMBER }}" \
--arg date "$DATE" \
--arg url "https://github.com/yattee/yattee/releases/download/${{ env.TAG }}/Yattee.ipa" \
--arg url "https://github.com/yattee/yattee/releases/download/${{ env.TAG }}/${{ env.IPA_NAME }}" \
--argjson size "${{ env.IPA_SIZE }}" \
'.apps[0].versions = [{
version: $version,

View File

@@ -13,6 +13,18 @@ This project targets the latest OS versions only - use newest APIs freely withou
**Test (single):** `xcodebuild test -scheme Yattee -destination 'platform=macOS' -only-testing:YatteeTests/TestSuiteName/testMethodName`
**Lint:** `periphery scan` (config: `.periphery.yml`)
## Build Configurations
Three configurations exist, mapped to distribution channels:
| Configuration | Sparkle (`#if SPARKLE`) | Used for |
|---|---|---|
| `Debug` | off | local development, tests |
| `Release` | off | App Store / TestFlight (`fastlane mac beta`) — must stay Sparkle-free, App Review rejects auto-update frameworks |
| `Release-DeveloperID` | **on** | Developer ID notarized build (`fastlane mac build_and_notarize`), distributed via GitHub Releases + Homebrew cask, receives Sparkle updates |
All Sparkle-dependent code must be wrapped in `#if SPARKLE ... #endif` so the `Release` variant links zero Sparkle symbols. When adding new Sparkle features, test both configs build clean on macOS.
## Code Style
**Language:** Swift 5.0+ with strict concurrency (Swift 6 mode enabled)

View File

@@ -1,37 +1,14 @@
## What's Changed
### New Features
* Persist media browser view options per source
* Add Enable All / Disable All menu to channel notifications settings
* Add context menu and swipe actions to related videos in Video Info View
* Persist author cache to disk for instant channel info across restarts
### Improvements
* Change default player layout settings
* Show video thumbnail in mini player during PiP
* Update media browser view options sheet layout
* Move close video button from toolbar into now playing card in Remote Control
### Bug Fixes
* Fix deleted playlists resurrecting from iCloud after app restart
* Fix feed channel filter avatars showing placeholders instead of images
* Fix Invidious login failing for passwords with special characters
* Fix subscriber count layout shift in Video Info View channel row
* Fix Feed tab flashing Content Unavailable View on initial load
* Fix blurred background gradient not using DeArrow thumbnail
* Fix playlist rows in Channel View not tappable in empty space
* Fix lock screen always showing 10s seek regardless of system controls setting
* Fix player dismiss gesture stuck after panel dismiss with comments expanded
* Fix incomplete playlist loading by paginating through all pages
* Fix pull-to-refresh scroll offset not resetting in Instance Browse View
* Fix URL scheme UI tests for YouTube deep links and content loading
* Fix UI tests for onboarding flow and AddRemoteServer redesign
* Fix panscan zoom pushing controls off screen for portrait videos
### Development
* Add Fastlane config and update release workflow for v2
* Add DEV badge on iCloud settings for debug builds
* Add git-cliff based changelog generator
* Add AltStore source and separate update workflow from release pipeline
* Add URL scheme UI tests for deep link navigation
* Refactor views
* Fix no streams reported from Piped (#974) - thanks @raycheung
* Fix description timestamp links seeking to wrong position for out-of-range values (#966) - thanks @YuriNachos
* Fix deep link timestamp parsing accepting infinity as seek position (#965) - thanks @YuriNachos
* Fix audio sample rate label dropping the kHz decimal (#964) - thanks @YuriNachos
* Fix playback rate display dropping meaningful digits (#963) - thanks @YuriNachos
* Fix www.duckduckgo.com misrouting to yt-dlp external video extraction (#962) - thanks @YuriNachos
### Other
* Chunk stateless feed requests to support more than 500 subscriptions (#967) - thanks @pehbehbeh

162
CLAUDE.md
View File

@@ -1,162 +0,0 @@
# Yattee Development Notes
## Testing Instances
- **Invidious**: `https://invidious.home.arekf.net/` - Use this instance for testing API calls
- **Yattee Server**: `https://main.s.yattee.stream` - Local self-hosted Yattee server for backend testing
## Related Projects
### Yattee Server
Location: `~/Developer/yattee-server`
A self-hosted API server powered by yt-dlp that provides an Invidious-compatible API for YouTube content. Used as an alternative backend when Invidious/Piped instances are blocked or unavailable.
**Key features:**
- Invidious-compatible API endpoints (`/api/v1/videos`, `/api/v1/channels`, `/api/v1/search`, etc.)
- Uses yt-dlp with deno for YouTube JS challenge solving
- Returns direct YouTube CDN stream URLs
- Optional backing Invidious instance for trending, popular, and search suggestions
**API endpoints:**
- `GET /api/v1/videos/{video_id}` - Video metadata and streams
- `GET /api/v1/channels/{channel_id}` - Channel info
- `GET /api/v1/channels/{channel_id}/videos` - Channel videos
- `GET /api/v1/search?q={query}` - Search
- `GET /api/v1/playlists/{playlist_id}` - Playlist info
**Limitations:**
- No comments support
- Stream URLs expire after a few hours
- Trending/popular/suggestions require backing Invidious instance
- scheme name to build is Yattee. use generic platform build instead of specific sim/device id
## UI Testing with AXe
The project uses a Ruby/RSpec-based UI testing framework with [AXe](https://github.com/cameroncooke/AXe) for simulator automation and visual regression testing.
### Running UI Tests
```bash
# Install dependencies (first time)
bundle install
# Run all UI tests
./bin/ui-test
# Skip build (faster iteration)
./bin/ui-test --skip-build
# Keep simulator running after tests
./bin/ui-test --keep-simulator
# Generate new baseline screenshots
./bin/ui-test --generate-baseline
# Run on a different device
./bin/ui-test --device "iPad Pro 13-inch (M5)"
```
### Creating Tests for New Features
When implementing a new feature, create a UI test to verify it works:
1. **Create a new spec file** in `spec/ui/smoke/`:
```ruby
# spec/ui/smoke/my_feature_spec.rb
require_relative '../spec_helper'
RSpec.describe 'My New Feature', :smoke do
before(:all) do
@udid = UITest::Simulator.boot(UITest::Config.device)
UITest::App.build(device: UITest::Config.device, skip: UITest::Config.skip_build?)
UITest::App.install(udid: @udid)
UITest::App.launch(udid: @udid)
sleep UITest::Config.app_launch_wait
@axe = UITest::Axe.new(@udid)
end
after(:all) do
UITest::App.terminate(udid: @udid, silent: true) if @udid
UITest::Simulator.shutdown(@udid) if @udid && !UITest::Config.keep_simulator?
end
it 'displays the new feature element' do
# Navigate to the feature if needed
@axe.tap_label('Settings')
sleep 1
# Check for expected elements
expect(@axe).to have_text('My New Feature')
end
it 'matches baseline screenshot', :visual do
screenshot = @axe.screenshot('my-feature-screen')
expect(screenshot).to match_baseline
end
end
```
2. **Available AXe actions:**
```ruby
@axe.tap_label('Button Text') # Tap by accessibility label
@axe.tap_id('accessibilityId') # Tap by accessibility identifier
@axe.tap_coordinates(x: 100, y: 200)
@axe.swipe(start_x: 200, start_y: 400, end_x: 200, end_y: 100)
@axe.gesture('scroll-down') # Presets: scroll-up, scroll-down, scroll-left, scroll-right
@axe.type('search text') # Type text
@axe.home_button # Press home
@axe.screenshot('name') # Take screenshot
```
3. **Available matchers:**
```ruby
expect(@axe).to have_element('AXUniqueId') # Check by accessibility identifier
expect(@axe).to have_text('Visible Text') # Check by accessibility label
expect(screenshot_path).to match_baseline # Visual comparison (2% threshold)
```
4. **Run with baseline generation:**
```bash
./bin/ui-test --generate-baseline --keep-simulator
```
5. **Inspect accessibility tree** to find element identifiers:
```bash
# Boot simulator and launch app first, then:
axe describe-ui --udid <SIMULATOR_UDID>
```
### Directory Structure
```
spec/
├── ui/
│ ├── spec_helper.rb # RSpec configuration
│ ├── support/
│ │ ├── config.rb # Test configuration
│ │ ├── simulator.rb # Simulator management
│ │ ├── app.rb # App build/install/launch
│ │ ├── axe.rb # AXe CLI wrapper
│ │ ├── axe_matchers.rb # Custom RSpec matchers
│ │ └── screenshot_comparison.rb
│ └── smoke/
│ └── app_launch_spec.rb # Example test
└── ui_snapshots/
├── baseline/ # Reference screenshots (by device/iOS version)
│ └── iPhone_17_Pro/
│ └── iOS_26_2/
│ └── app-launch-library.png
├── current/ # Current test run screenshots
├── diff/ # Visual diff images
└── false_positives.yml # Mark expected differences
```
### Tips
- Use `have_text` matcher for most checks - it's more reliable than `have_element` since iOS doesn't always expose accessibility identifiers
- Add `sleep 1` after navigation actions to let UI settle
- Use `--keep-simulator` during development to speed up iteration
- Check `spec/ui_snapshots/diff/` for visual diff images when tests fail
- Add entries to `false_positives.yml` for screenshots with expected dynamic content

1
CNAME Normal file
View File

@@ -0,0 +1 @@
dl.yattee.stream

View File

@@ -3,14 +3,14 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.8)
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
ast (2.4.3)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1213.0)
aws-sdk-core (3.242.0)
aws-partitions (1.1231.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -18,11 +18,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -30,7 +30,7 @@ GEM
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
bigdecimal (4.1.0)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -70,11 +70,11 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.232.1)
fastimage (2.4.1)
fastlane (2.232.2)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
@@ -124,10 +124,9 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.96.0)
google-apis-androidpublisher_v3 (0.98.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@@ -141,15 +140,15 @@ GEM
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.60.0)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@@ -171,7 +170,7 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.18.1)
json (2.19.3)
jwt (2.10.2)
base64
language_server-protocol (3.17.0.5)
@@ -189,12 +188,12 @@ GEM
os (1.1.4)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.1)
parser (3.3.11.1)
ast (~> 2.4.1)
racc
plist (3.7.2)
prism (1.9.0)
public_suffix (7.0.2)
public_suffix (7.0.5)
racc (1.8.1)
rainbow (3.1.1)
rake (13.3.1)
@@ -203,7 +202,7 @@ GEM
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
rspec (3.13.2)
@@ -215,13 +214,13 @@ GEM
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-retry (0.6.2)
rspec-core (> 3.3)
rspec-support (3.13.7)
rubocop (1.84.2)
rubocop (1.86.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -232,7 +231,7 @@ GEM
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
rubocop-ast (1.49.1)
parser (>= 3.3.7.2)
prism (~> 1.7)
rubocop-rspec (3.9.0)
@@ -250,7 +249,6 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)

111
README.md
View File

@@ -1,113 +1,26 @@
<div align="center">
<img src="Assets/yattee-logo.png" width="150" height="150" alt="Yattee logo">
<img src="assets/yattee-logo.png" width="128" height="128" alt="Yattee logo" />
<h1>Yattee</h1>
<p>Privacy-focused video player for iPhone, iPad, Mac, and Apple TV</p>
<p>Privacy oriented video player for iOS, tvOS and macOS<br /></p>
[![AGPL v3](https://shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html)
[![GitHub issues](https://img.shields.io/github/issues/yattee/yattee)](https://github.com/yattee/yattee/issues)
[![GitHub pull requests](https://img.shields.io/github/issues-pr/yattee/yattee)](https://github.com/yattee/yattee/pulls)
[![Discord](https://invidget.switchblade.xyz/pSnNKhZHEG)](https://yattee.stream/discord)
[![Discord](https://invidget.switchblade.xyz/pSnNKhZHEG)](https://yattee.stream/discord)
</div>
## Install
<a href="https://yattee.stream/beta2">
<img src="https://developer.apple.com/assets/elements/icons/testflight/testflight-64x64_2x.png" height="40" alt="TestFlight">
</a>
[Join the TestFlight beta](https://yattee.stream/beta2)
<!-- App Store link coming soon -->
<!-- TODO: new screenshot assets -->
> **Yattee 2 is in the works!** A new version of the app is being built with a refreshed experience.
> It pairs with the new [Yattee Server](https://github.com/yattee/yattee-server) — a self-hosted backend powered by yt-dlp that supports 1000+ sites.
> Join the [TestFlight beta](https://yattee.stream/beta2) to try early builds, and check the new documentation site at [docs.yattee.stream](https://docs.yattee.stream) for guides, roadmap and changelog.
## Features
**Playback**
- 4K video with custom MPV-based player (H.264, H.265, VP9, AV1)
- Picture in Picture, background audio, fullscreen
- Playback queue, history, resume from last position
- Chapter navigation, playback speed, subtitles and captions
- Gesture controls (seek, volume, brightness)
- Seek preview with storyboards
**Content Sources**
- YouTube via Invidious, Piped, or self-hosted Yattee Server
- PeerTube instances (federated video)
- Local files, SMB network shares, WebDAV servers
**Integrations**
- [SponsorBlock](https://sponsor.ajay.app/) (configurable skip categories)
- [DeArrow](https://dearrow.ajay.app/) (crowdsourced titles and thumbnails)
- [Return YouTube Dislike](https://returnyoutubedislike.com/)
**Privacy**
- No tracking, no ads, no account required
- All traffic goes through your chosen instances
**Library**
- Subscriptions with per-channel notifications
- Bookmarks with tags and notes, playlists, watch history
- Unified search across all configured sources
- Import/export subscriptions (JSON, CSV, OPML)
**Downloads & Sync**
- Offline video and audio downloads with quality selection
- iCloud sync for bookmarks, subscriptions, history, and settings across devices
- Handoff continuity between iPhone, iPad, Mac, and Apple TV
**Apple Ecosystem**
- iOS 18+ / macOS 15+ / tvOS 18+
- Native SwiftUI on every platform
- Customizable home layout, accent colors, player controls, and app icon
- Clipboard URL detection and deep linking (`yattee://`)
- Remote control between devices on your network
## Yattee Server
A self-hosted backend powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp) that gives Yattee superpowers.
- **Direct stream URLs** — gets fresh YouTube CDN URLs, bypassing Invidious/Piped blocks and rate limits
- **Play from 1000+ sites** — Vimeo, TikTok, Twitch, Dailymotion, Twitter/X, and anything else yt-dlp supports
- **Invidious-compatible API** — drop-in replacement, works alongside existing Invidious/Piped instances
- **Self-hosted & private** — run on your own hardware, no data leaves your network
- **Fast parallel streaming** — yt-dlp parallel downloading streams video while it downloads
- **Admin panel** — web UI for settings, credentials, and monitoring
- **Docker ready** — single container deployment
Check out the [yattee-server](https://github.com/yattee/yattee-server) repository to get started.
## Documentation
- [Installation](https://github.com/yattee/yattee/wiki/Installation-Instructions)
- [Building](https://github.com/yattee/yattee/wiki/Building-instructions)
- [Features](https://github.com/yattee/yattee/wiki/Features)
- [FAQ](https://github.com/yattee/yattee/wiki/FAQ)
- [Screenshots Gallery](https://github.com/yattee/yattee/wiki/Screenshots-Gallery)
- [Tips](https://github.com/yattee/yattee/wiki/Tips)
- [Integrations](https://github.com/yattee/yattee/wiki/Integrations)
- [Donations](https://github.com/yattee/yattee/wiki/Donations)
## Contributing
Browse the [issues](https://github.com/yattee/yattee/issues) list or open a new one to discuss your idea. Every contribution is welcome.
See [AGENTS.md](AGENTS.md) for developer setup and project architecture.
Join [Discord](https://yattee.stream/discord) or the [Matrix channel](https://matrix.to/#/#yattee:matrix.org) if you need advice or want to discuss the project.
## Translations
Help make Yattee accessible to everyone by contributing translations.
<a href="https://hosted.weblate.org/engage/yattee/">
<img src="https://hosted.weblate.org/widgets/yattee/-/localizable-strings/multi-auto.svg" alt="Translation status" />
</a>
Localization hosting provided by [Weblate](https://weblate.org/en/).
* Native user interface built with [SwiftUI](https://developer.apple.com/swiftui/) with customization settings
* Player queue and history
* Player component with custom controls, gestures and support for 4K playback
* Fullscreen, Picture in Picture and background audio playback
* [SponsorBlock](https://sponsor.ajay.app/), configurable categories to skip
## License
Yattee is shared under the [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.
Yattee and its components is shared on [AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html) license.

View File

@@ -7,12 +7,14 @@
objects = {
/* Begin PBXBuildFile section */
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; platformFilters = (macos, ); productRef = 370E71972F9A1A41000E04B2 /* Sparkle */; };
37767AB32F05766100D248FC /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB22F05766100D248FC /* Nuke */; };
37767AB52F05766100D248FC /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 37767AB42F05766100D248FC /* NukeUI */; };
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF2FD2EF21767002C1CD7 /* MPVKit-GPL */; };
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */ = {isa = PBXBuildFile; productRef = 378CF3002EF21783002C1CD7 /* MPVKit-GPL */; };
37BA19A62EE4DFEE001D7B0F /* Yattee2.icon in Resources */ = {isa = PBXBuildFile; fileRef = 37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */; };
37BA19B52EE4EB7F001D7B0F /* YatteeShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
37C0AAAA00000000AAAA000C /* YatteeTopShelf.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */; platformFilters = (tvos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -23,6 +25,13 @@
remoteGlobalIDString = 37BA19AA2EE4EB7F001D7B0F;
remoteInfo = YatteeShareExtension;
};
37C0AAAA00000000AAAA0008 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 372D1A1F2EDB163800F58F7A /* Project object */;
proxyType = 1;
remoteGlobalIDString = 37C0AAAA00000000AAAA0001;
remoteInfo = YatteeTopShelf;
};
37D0B2982EDB23BD00B9C4ED /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 372D1A1F2EDB163800F58F7A /* Project object */;
@@ -40,6 +49,7 @@
dstSubfolderSpec = 13;
files = (
37BA19B52EE4EB7F001D7B0F /* YatteeShareExtension.appex in Embed Foundation Extensions */,
37C0AAAA00000000AAAA000C /* YatteeTopShelf.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -50,6 +60,7 @@
372D1A272EDB163800F58F7A /* Yattee.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Yattee.app; sourceTree = BUILT_PRODUCTS_DIR; };
37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Yattee2.icon; sourceTree = "<group>"; };
37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = YatteeShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = YatteeTopShelf.appex; sourceTree = BUILT_PRODUCTS_DIR; };
37D0B2942EDB23BD00B9C4ED /* YatteeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = YatteeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -57,6 +68,7 @@
37A11F692EDC5DD700213864 /* Exceptions for "Yattee" folder in "Yattee" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
"Info-tvOS.plist",
Info.plist,
);
target = 372D1A262EDB163800F58F7A /* Yattee */;
@@ -68,6 +80,13 @@
);
target = 37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */;
};
37C0AAAA00000000AAAA000D /* Exceptions for "YatteeTopShelf" folder in "YatteeTopShelf" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 37C0AAAA00000000AAAA0001 /* YatteeTopShelf */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -87,6 +106,14 @@
path = YatteeShareExtension;
sourceTree = "<group>";
};
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
37C0AAAA00000000AAAA000D /* Exceptions for "YatteeTopShelf" folder in "YatteeTopShelf" target */,
);
path = YatteeTopShelf;
sourceTree = "<group>";
};
37D0B2952EDB23BD00B9C4ED /* YatteeTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = YatteeTests;
@@ -99,6 +126,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
370E71982F9A1A41000E04B2 /* Sparkle in Frameworks */,
37767AB32F05766100D248FC /* Nuke in Frameworks */,
378CF2FE2EF21767002C1CD7 /* MPVKit-GPL in Frameworks */,
378CF3012EF21783002C1CD7 /* MPVKit-GPL in Frameworks */,
@@ -113,6 +141,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
37C0AAAA00000000AAAA0005 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
37D0B2912EDB23BD00B9C4ED /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -129,6 +164,7 @@
372D1A292EDB163800F58F7A /* Yattee */,
37D0B2952EDB23BD00B9C4ED /* YatteeTests */,
37BA19AC2EE4EB7F001D7B0F /* YatteeShareExtension */,
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */,
372D1A282EDB163800F58F7A /* Products */,
37BA19A52EE4DFEE001D7B0F /* Yattee2.icon */,
);
@@ -140,6 +176,7 @@
372D1A272EDB163800F58F7A /* Yattee.app */,
37D0B2942EDB23BD00B9C4ED /* YatteeTests.xctest */,
37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */,
37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -155,11 +192,13 @@
372D1A242EDB163800F58F7A /* Frameworks */,
372D1A252EDB163800F58F7A /* Resources */,
37BA19BA2EE4EB7F001D7B0F /* Embed Foundation Extensions */,
37C0FEE100000000FEE100A1 /* Strip Sparkle (non-DeveloperID) */,
);
buildRules = (
);
dependencies = (
37BA19B42EE4EB7F001D7B0F /* PBXTargetDependency */,
37C0AAAA00000000AAAA0007 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
372D1A292EDB163800F58F7A /* Yattee */,
@@ -170,6 +209,7 @@
378CF3002EF21783002C1CD7 /* MPVKit-GPL */,
37767AB22F05766100D248FC /* Nuke */,
37767AB42F05766100D248FC /* NukeUI */,
370E71972F9A1A41000E04B2 /* Sparkle */,
);
productName = Yattee;
productReference = 372D1A272EDB163800F58F7A /* Yattee.app */;
@@ -197,6 +237,28 @@
productReference = 37BA19AB2EE4EB7F001D7B0F /* YatteeShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
37C0AAAA00000000AAAA0001 /* YatteeTopShelf */ = {
isa = PBXNativeTarget;
buildConfigurationList = 37C0AAAA00000000AAAA0009 /* Build configuration list for PBXNativeTarget "YatteeTopShelf" */;
buildPhases = (
37C0AAAA00000000AAAA0004 /* Sources */,
37C0AAAA00000000AAAA0005 /* Frameworks */,
37C0AAAA00000000AAAA0006 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
37C0AAAA00000000AAAA0003 /* YatteeTopShelf */,
);
name = YatteeTopShelf;
packageProductDependencies = (
);
productName = YatteeTopShelf;
productReference = 37C0AAAA00000000AAAA0002 /* YatteeTopShelf.appex */;
productType = "com.apple.product-type.tv-app-extension";
};
37D0B2932EDB23BD00B9C4ED /* YatteeTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 37D0B29A2EDB23BD00B9C4ED /* Build configuration list for PBXNativeTarget "YatteeTests" */;
@@ -236,6 +298,9 @@
37BA19AA2EE4EB7F001D7B0F = {
CreatedOnToolsVersion = 26.2;
};
37C0AAAA00000000AAAA0001 = {
CreatedOnToolsVersion = 26.2;
};
37D0B2932EDB23BD00B9C4ED = {
CreatedOnToolsVersion = 26.1.1;
TestTargetID = 372D1A262EDB163800F58F7A;
@@ -254,6 +319,7 @@
packageReferences = (
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */,
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */,
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 372D1A282EDB163800F58F7A /* Products */;
@@ -263,6 +329,7 @@
372D1A262EDB163800F58F7A /* Yattee */,
37D0B2932EDB23BD00B9C4ED /* YatteeTests */,
37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */,
37C0AAAA00000000AAAA0001 /* YatteeTopShelf */,
);
};
/* End PBXProject section */
@@ -283,6 +350,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
37C0AAAA00000000AAAA0006 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
37D0B2922EDB23BD00B9C4ED /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -292,6 +366,28 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
37C0FEE100000000FEE100A1 /* Strip Sparkle (non-DeveloperID) */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Strip Sparkle (non-DeveloperID)";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [ \"${CONFIGURATION}\" = \"Release-DeveloperID\" ] || [ \"${CONFIGURATION}\" = \"Debug\" ]; then\n echo \"Skipping Sparkle strip (config=${CONFIGURATION})\"\n exit 0\nfi\nFW=\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sparkle.framework\"\nif [ -d \"$FW\" ]; then\n echo \"Stripping Sparkle.framework (config=${CONFIGURATION})\"\n chflags -R nouchg \"$FW\" 2>/dev/null || true\n chmod -R u+w \"$FW\" 2>/dev/null || true\n TMP=\"${FW}.stripping.$$\"\n if mv \"$FW\" \"$TMP\" 2>/dev/null; then\n TARGET=\"$TMP\"\n else\n TARGET=\"$FW\"\n fi\n for i in 1 2 3 4 5; do\n rm -rf \"$TARGET\" && break\n sleep 0.2\n done\n if [ -e \"$TARGET\" ]; then\n echo \"warning: failed to fully remove $TARGET\" >&2\n exit 1\n fi\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
372D1A232EDB163800F58F7A /* Sources */ = {
isa = PBXSourcesBuildPhase;
@@ -307,6 +403,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
37C0AAAA00000000AAAA0004 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
37D0B2902EDB23BD00B9C4ED /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -323,6 +426,14 @@
target = 37BA19AA2EE4EB7F001D7B0F /* YatteeShareExtension */;
targetProxy = 37BA19B32EE4EB7F001D7B0F /* PBXContainerItemProxy */;
};
37C0AAAA00000000AAAA0007 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilters = (
tvos,
);
target = 37C0AAAA00000000AAAA0001 /* YatteeTopShelf */;
targetProxy = 37C0AAAA00000000AAAA0008 /* PBXContainerItemProxy */;
};
37D0B2992EDB23BD00B9C4ED /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 372D1A262EDB163800F58F7A /* Yattee */;
@@ -459,7 +570,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 253;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -476,7 +587,8 @@
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
ENABLE_USER_SELECTED_FILES = readonly;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readwrite;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
@@ -489,8 +601,11 @@
"$(BUILT_PRODUCTS_DIR)/Libsmbclient.framework/Headers/samba-4.0",
);
INFOPLIST_FILE = Yattee/Info.plist;
"INFOPLIST_FILE[sdk=appletvos*]" = "Yattee/Info-tvOS.plist";
"INFOPLIST_FILE[sdk=appletvsimulator*]" = "Yattee/Info-tvOS.plist";
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Arkadiusz Fal";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Yattee uses local network to discover and control playback on other Yattee devices.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -539,8 +654,9 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 253;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -557,7 +673,8 @@
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
ENABLE_USER_SELECTED_FILES = readonly;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readwrite;
GCC_PREPROCESSOR_DEFINITIONS = (
"GLES_SILENCE_DEPRECATION=1",
"GL_SILENCE_DEPRECATION=1",
@@ -568,8 +685,155 @@
"$(BUILT_PRODUCTS_DIR)/Libsmbclient.framework/Headers/samba-4.0",
);
INFOPLIST_FILE = Yattee/Info.plist;
"INFOPLIST_FILE[sdk=appletvos*]" = "Yattee/Info-tvOS.plist";
"INFOPLIST_FILE[sdk=appletvsimulator*]" = "Yattee/Info-tvOS.plist";
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Arkadiusz Fal";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Yattee uses local network to discover and control playback on other Yattee devices.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 2.0.0;
"OTHER_LDFLAGS[sdk=macosx*]" = (
"$(inherited)",
"-Wl,-dead_strip_dylibs",
);
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO;
RUNTIME_EXCEPTION_ALLOW_JIT = NO;
RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = YES;
RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO;
RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO;
RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/Yattee/Yattee-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,3";
TVOS_DEPLOYMENT_TARGET = 18.0;
XROS_DEPLOYMENT_TARGET = 26.1;
};
name = Release;
};
376D70452F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = "Release-DeveloperID";
};
376D70462F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Yattee2;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = NO;
ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
ENABLE_RESOURCE_ACCESS_LOCATION = NO;
ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO;
ENABLE_RESOURCE_ACCESS_PRINTING = NO;
ENABLE_RESOURCE_ACCESS_USB = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readwrite;
GCC_PREPROCESSOR_DEFINITIONS = (
"GLES_SILENCE_DEPRECATION=1",
"GL_SILENCE_DEPRECATION=1",
);
GENERATE_INFOPLIST_FILE = YES;
HEADER_SEARCH_PATHS = (
"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/Libsmbclient.framework/Headers/samba-4.0",
"$(BUILT_PRODUCTS_DIR)/Libsmbclient.framework/Headers/samba-4.0",
);
INFOPLIST_FILE = Yattee/Info.plist;
"INFOPLIST_FILE[sdk=appletvos*]" = "Yattee/Info-tvOS.plist";
"INFOPLIST_FILE[sdk=appletvsimulator*]" = "Yattee/Info-tvOS.plist";
INFOPLIST_KEY_CFBundleDisplayName = Yattee;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Arkadiusz Fal";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Yattee uses local network to discover and control playback on other Yattee devices.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
@@ -599,6 +863,7 @@
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "SPARKLE $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -609,14 +874,110 @@
TVOS_DEPLOYMENT_TARGET = 18.0;
XROS_DEPLOYMENT_TARGET = 26.1;
};
name = Release;
name = "Release-DeveloperID";
};
376D70472F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.YatteeTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx";
SUPPORTS_MACCATALYST = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,3";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Yattee.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Yattee";
TVOS_DEPLOYMENT_TARGET = 18.0;
XROS_DEPLOYMENT_TARGET = 26.1;
};
name = "Release-DeveloperID";
};
376D70482F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Share Extension";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = "Release-DeveloperID";
};
376D70492F996E0700255B09 /* Release-DeveloperID */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_ENTRY_POINT = _NSExtensionMain;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 18.0;
VALIDATE_PRODUCT = YES;
};
name = "Release-DeveloperID";
};
37BA19B72EE4EB7F001D7B0F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 253;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -647,7 +1008,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 253;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -674,12 +1035,81 @@
};
name = Release;
};
37C0AAAA00000000AAAA000A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_ENTRY_POINT = _NSExtensionMain;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 18.0;
};
name = Debug;
};
37C0AAAA00000000AAAA000B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 270;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Yattee Top Shelf";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_ENTRY_POINT = _NSExtensionMain;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.0.0;
PRODUCT_BUNDLE_IDENTIFIER = stream.yattee.app.TopShelf;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "appletvos appletvsimulator";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 3;
TVOS_DEPLOYMENT_TARGET = 18.0;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
37D0B29B2EDB23BD00B9C4ED /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 253;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -708,7 +1138,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 253;
CURRENT_PROJECT_VERSION = 270;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -740,6 +1170,7 @@
buildConfigurations = (
372D1A302EDB163900F58F7A /* Debug */,
372D1A312EDB163900F58F7A /* Release */,
376D70452F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -749,6 +1180,7 @@
buildConfigurations = (
372D1A332EDB163900F58F7A /* Debug */,
372D1A342EDB163900F58F7A /* Release */,
376D70462F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -758,6 +1190,17 @@
buildConfigurations = (
37BA19B72EE4EB7F001D7B0F /* Debug */,
37BA19B82EE4EB7F001D7B0F /* Release */,
376D70482F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
37C0AAAA00000000AAAA0009 /* Build configuration list for PBXNativeTarget "YatteeTopShelf" */ = {
isa = XCConfigurationList;
buildConfigurations = (
37C0AAAA00000000AAAA000A /* Debug */,
37C0AAAA00000000AAAA000B /* Release */,
376D70492F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -767,6 +1210,7 @@
buildConfigurations = (
37D0B29B2EDB23BD00B9C4ED /* Debug */,
37D0B29C2EDB23BD00B9C4ED /* Release */,
376D70472F996E0700255B09 /* Release-DeveloperID */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -774,6 +1218,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.9.1;
};
};
37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/Nuke";
@@ -784,15 +1236,20 @@
};
378CF2FF2EF21783002C1CD7 /* XCRemoteSwiftPackageReference "MPVKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/mpvkit/MPVKit.git";
repositoryURL = "https://github.com/yattee/MPVKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.41.0;
kind = exactVersion;
version = 1.0.1;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
370E71972F9A1A41000E04B2 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 370E71962F9A1A41000E04B2 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
37767AB22F05766100D248FC /* Nuke */ = {
isa = XCSwiftPackageProductDependency;
package = 37767AB12F05766100D248FC /* XCRemoteSwiftPackageReference "Nuke" */;

View File

@@ -1,13 +1,13 @@
{
"originHash" : "50421f80aba6d558399198148743d5d479edb3f6cc10024acd55828f8cf63959",
"originHash" : "3936a4172c132dff2ced455a1771e12f4759645440fa59d5b93f5a78b68d3ee4",
"pins" : [
{
"identity" : "mpvkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mpvkit/MPVKit.git",
"location" : "https://github.com/yattee/MPVKit.git",
"state" : {
"revision" : "613c0ccc3acf70e136aaff880a9b5fe8fdfaf5b8",
"version" : "0.41.0"
"revision" : "671d3484b00f438407564573a4e188631d81bc90",
"version" : "1.0.1"
}
},
{
@@ -15,8 +15,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke",
"state" : {
"revision" : "0ead44350d2737db384908569c012fe67c421e4d",
"version" : "12.8.0"
"revision" : "83e19143355b02e9261edb2323b3e1e93287ebb9",
"version" : "12.9.0"
}
},
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7",
"version" : "2.9.4"
}
}
],

View File

@@ -0,0 +1,9 @@
{
"id" : "8948cba2-509e-4ad8-bcb9-592de5bff7d1",
"targets" : [
{
"id" : "9FC1C0A4-CE29-437C-BB50-C93FE779B691",
"name" : "Yattee"
}
]
}

View File

@@ -21,20 +21,6 @@
ReferencedContainer = "container:Yattee.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "37BA19AA2EE4EB7F001D7B0F"
BuildableName = "YatteeShareExtension.appex"
BlueprintName = "YatteeShareExtension"
ReferencedContainer = "container:Yattee.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction

View File

@@ -78,6 +78,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
LoggingService.shared.logCloudKit("Requesting remote notification registration...")
NSApplication.shared.registerForRemoteNotifications()
if let rawValue = UserDefaults.standard.string(forKey: "appIcon"),
let icon = AppIcon(rawValue: rawValue) {
SettingsManager.applyMacAppIcon(icon)
}
}
func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 234 KiB

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tvOS-AppStore-1280x768.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,17 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.imagestacklayer"
},
{
"filename" : "Middle.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "tvOS-AppStore-1280x768.png",
"idiom" : "tv"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,7 @@
{
"images" : [],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "tvOS-Small@1x.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "tvOS-Small@2x.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,17 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.imagestacklayer"
},
{
"filename" : "Middle.imagestacklayer"
},
{
"filename" : "Back.imagestacklayer"
}
]
}

View File

@@ -0,0 +1,18 @@
{
"images" : [
{
"filename" : "tvOS-Small@1x.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"filename" : "tvOS-Small@2x.png",
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,7 @@
{
"images" : [],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,32 @@
{
"assets" : [
{
"filename" : "App Icon - App Store.imagestack",
"idiom" : "tv",
"role" : "primary-app-icon",
"size" : "1280x768"
},
{
"filename" : "App Icon.imagestack",
"idiom" : "tv",
"role" : "primary-app-icon",
"size" : "400x240"
},
{
"filename" : "Top Shelf Image Wide.imageset",
"idiom" : "tv",
"role" : "top-shelf-image-wide",
"size" : "2320x720"
},
{
"filename" : "Top Shelf Image.imageset",
"idiom" : "tv",
"role" : "top-shelf-image",
"size" : "1920x720"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,17 @@
{
"images" : [
{
"filename" : "TopShelf-Wide.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,17 @@
{
"images" : [
{
"filename" : "TopShelf.png",
"idiom" : "tv",
"scale" : "1x"
},
{
"idiom" : "tv",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -57,11 +57,34 @@ struct ContentView: View {
// Mini player overlay (macOS only)
#if os(macOS)
miniPlayerOverlay(appEnvironment: appEnvironment)
// Inline expanded player: presented as a full-window overlay
// (no native sheet a window with an attached sheet can't go
// fullscreen, so fullscreen is just the window's native toggle).
if isInlinePlayerOverlayActive(appEnvironment: appEnvironment) {
ZStack {
Color.black
ExpandedPlayerSheet()
}
.ignoresSafeArea()
.transition(.opacity)
}
#endif
}
#if os(macOS)
// Registers the hosting window so the inline overlay targets the real
// main window even when another window (e.g. Settings) has focus.
.background(MainContentWindowReader())
.animation(.easeInOut(duration: 0.2), value: isInlinePlayerOverlayActive(appEnvironment: appEnvironment))
.onChange(of: isInlinePlayerOverlayActive(appEnvironment: appEnvironment)) { _, active in
if active {
ExpandedPlayerWindowManager.shared.beginInlineOverlay()
} else {
ExpandedPlayerWindowManager.shared.endInlineOverlay()
}
}
.onChange(of: appEnvironment.navigationCoordinator.playerExpandTrigger) { _, _ in
if appEnvironment.settingsManager.macPlayerMode.usesWindow {
if appEnvironment.settingsManager.macPlayerSeparateWindow {
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
}
}
@@ -70,32 +93,37 @@ struct ContentView: View {
ExpandedPlayerWindowManager.shared.hide()
}
}
.onChange(of: appEnvironment.settingsManager.macPlayerMode) { oldMode, newMode in
.onChange(of: appEnvironment.settingsManager.macPlayerSeparateWindow) { _, separateWindow in
guard appEnvironment.navigationCoordinator.isPlayerExpanded else { return }
if oldMode.usesWindow && newMode.usesWindow {
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: newMode.isFloating)
} else if oldMode.usesWindow && !newMode.usesWindow {
if separateWindow {
// Inline overlay separate window: the overlay unmounts via the
// presentation condition; present the window directly.
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
} else {
// Separate window inline overlay: hide the window so the overlay
// (isPlayerExpanded && !separateWindow) takes over.
ExpandedPlayerWindowManager.shared.hide(animated: false)
} else if !oldMode.usesWindow && newMode.usesWindow {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
if appEnvironment.navigationCoordinator.isPlayerExpanded {
presentExpandedPlayerWindow(appEnvironment: appEnvironment)
}
}
}
}
.onChange(of: appEnvironment.settingsManager.macPlayerFloating) { _, floating in
guard appEnvironment.navigationCoordinator.isPlayerExpanded,
appEnvironment.settingsManager.macPlayerSeparateWindow else { return }
ExpandedPlayerWindowManager.shared.updateWindowLevel(floating: floating)
}
.sheet(isPresented: Binding(
get: {
appEnvironment.navigationCoordinator.isPlayerExpanded &&
!appEnvironment.settingsManager.macPlayerMode.usesWindow
},
set: { appEnvironment.navigationCoordinator.isPlayerExpanded = $0 }
get: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented },
set: { appEnvironment.navigationCoordinator.isMiniPlayerQueueSheetPresented = $0 }
)) {
ExpandedPlayerSheet()
.frame(minWidth: 640, minHeight: 480)
.presentationSizing(.fitted)
QueueManagementSheet()
}
.sheet(isPresented: Binding(
get: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented },
set: { appEnvironment.navigationCoordinator.isMiniPlayerPlaylistSheetPresented = $0 }
)) {
if let video = appEnvironment.playerService.state.currentVideo {
PlaylistSelectorSheet(video: video)
}
}
#elseif os(tvOS)
.fullScreenCover(isPresented: Binding(
@@ -111,6 +139,14 @@ struct ContentView: View {
private func presentExpandedPlayerWindow(appEnvironment: AppEnvironment) {
ExpandedPlayerWindowManager.shared.show(with: appEnvironment, animated: true)
}
/// Whether the inline (non-separate-window) expanded player overlay should
/// be presented. Shared by the overlay mount and the begin/end onChange so
/// the two can't drift apart.
private func isInlinePlayerOverlayActive(appEnvironment: AppEnvironment) -> Bool {
appEnvironment.navigationCoordinator.isPlayerExpanded &&
!appEnvironment.settingsManager.macPlayerSeparateWindow
}
#endif
#if os(macOS)
@@ -119,14 +155,17 @@ struct ContentView: View {
let playerState = appEnvironment.playerService.state
let hasActiveVideo = playerState.currentVideo != nil
let isExpanded = appEnvironment.navigationCoordinator.isPlayerExpanded
// The expanded player is a separate window in window mode, so keep the capsule
// visible alongside it. The inline overlay covers the window, so hide it there.
let usesWindow = appEnvironment.settingsManager.macPlayerSeparateWindow
if hasActiveVideo && !isExpanded {
if hasActiveVideo && (!isExpanded || usesWindow) {
VStack(spacing: 0) {
Spacer()
MiniPlayerView()
}
// Add padding for tab bar
.padding(.bottom, 49)
// Float the capsule above the bottom edge (macOS uses a sidebar, not a tab bar)
.padding(.bottom, 16)
// Use move-only transition (no opacity) to prevent thumbnail flash during collapse
.transition(.move(edge: .bottom))
.animation(.spring(response: 0.3), value: hasActiveVideo)

View File

@@ -47,7 +47,7 @@ final class AppEnvironment {
let handoffManager: HandoffManager
let invidiousCredentialsManager: InvidiousCredentialsManager
let pipedCredentialsManager: PipedCredentialsManager
let yatteeServerCredentialsManager: YatteeServerCredentialsManager
let basicAuthCredentialsManager: BasicAuthCredentialsManager
let homeInstanceCache: HomeInstanceCache
let invidiousAPI: InvidiousAPI
let pipedAPI: PipedAPI
@@ -56,6 +56,28 @@ final class AppEnvironment {
let legacyMigrationService: LegacyDataMigrationService
let sourcesSettings: SourcesSettings
/// Center-section settings of the active player controls preset, cached for
/// synchronous access. Menu bar commands read seek durations from here since
/// they cannot await the layout service actor.
private(set) var activeControlsCenterSettings: CenterSectionSettings = .default
@ObservationIgnored private var controlsSettingsObservers: [NSObjectProtocol] = []
// MARK: - Shared Instance
/// The single, process-wide app environment.
///
/// SwiftUI may evaluate a `@State` property's default-value autoclosure more
/// than once (it keeps only the first result but still runs the side effects
/// of the discarded instances). Constructing `AppEnvironment` more than once
/// would create multiple `DownloadManager`s and therefore multiple
/// background `URLSession`s registered under the same identifier causing
/// download-completion delegate callbacks to be delivered to an instance
/// whose `activeDownloads` is empty (the finished file is then dropped).
/// Referencing this `static let` from the App's `@State` guarantees exactly
/// one instance for the lifetime of the process.
static let shared = AppEnvironment()
// MARK: - Initialization
init(
@@ -82,12 +104,12 @@ final class AppEnvironment {
instances.setSettingsManager(settings)
self.instancesManager = instances
// Initialize Yattee Server Credentials Manager early (needed for ContentService)
let yatteeServerCreds = YatteeServerCredentialsManager()
yatteeServerCreds.settingsManager = settings
self.yatteeServerCredentialsManager = yatteeServerCreds
// Initialize Basic Auth Credentials Manager early (needed for ContentService)
let basicAuthCreds = BasicAuthCredentialsManager()
basicAuthCreds.settingsManager = settings
self.basicAuthCredentialsManager = basicAuthCreds
let contentSvc = ContentService(httpClient: client, yatteeServerCredentialsManager: yatteeServerCreds)
let contentSvc = ContentService(httpClient: client, basicAuthCredentialsManager: basicAuthCreds)
self.contentService = contentSvc
self.instanceDetector = InstanceDetector(httpClient: client)
self.navigationCoordinator = navigationCoordinator ?? NavigationCoordinator()
@@ -214,7 +236,7 @@ final class AppEnvironment {
// Wire up SMB client to check if SMB playback is active
// This prevents crashes from concurrent libsmbclient usage
let smbClientRef = self.smbClient
Task {
Task { [weak player] in
await smbClientRef.setPlaybackActiveCallback { [weak player] in
player?.state.isSMBPlaybackActive ?? false
}
@@ -277,7 +299,11 @@ final class AppEnvironment {
// Initialize Legacy Migration Service
self.legacyMigrationService = LegacyDataMigrationService(
instancesManager: instances,
httpClient: client
basicAuthCredentialsManager: basicAuthCreds,
invidiousCredentialsManager: invidiousCreds,
pipedCredentialsManager: pipedCreds,
invidiousAPI: invidiousAPI,
pipedAPI: pipedAPI
)
// Initialize Sources Settings
@@ -292,9 +318,25 @@ final class AppEnvironment {
// Wire up player controls layout service to player service (for preset-based settings)
player.setPlayerControlsLayoutService(layoutService)
// Cache active preset's center settings and keep them in sync
Task { await self.refreshActiveControlsSettings() }
for name: Notification.Name in [.playerControlsActivePresetDidChange, .playerControlsPresetsDidChange] {
controlsSettingsObservers.append(
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in
await self?.refreshActiveControlsSettings()
}
}
)
}
// Set up circular dependencies after all properties are initialized
bgRefreshManager.setAppEnvironment(self)
// Backfill credential flags for instances created before the flags
// existed, so existing setups publish them to iCloud without a re-login
backfillCredentialFlags()
// Log device capabilities on startup for debugging
HardwareCapabilities.shared.logCapabilities()
@@ -331,6 +373,12 @@ final class AppEnvironment {
// MARK: - Configuration
/// Refreshes the cached center-section settings from the active player controls preset.
func refreshActiveControlsSettings() async {
let layout = await playerControlsLayoutService.activeLayout()
activeControlsCenterSettings = layout.centerSettings
}
/// Updates the HTTP client's User-Agent configuration from current settings.
/// Call this after changing User-Agent related settings.
func updateUserAgent() {
@@ -374,12 +422,53 @@ final class AppEnvironment {
case .piped:
return pipedCredentialsManager
case .yatteeServer:
return yatteeServerCredentialsManager
return basicAuthCredentialsManager
default:
return nil
}
}
/// Backfills `usesBasicAuth`/`usesAccountLogin` on instances that were created
/// before these flags existed, based on credentials currently in the Keychain.
/// Only ever sets the flags a missing Keychain entry (e.g. right after a
/// reinstall) must not clear a previously recorded flag.
private func backfillCredentialFlags() {
for instance in instancesManager.instances {
if !instance.usesBasicAuth,
instance.supportsHTTPBasicAuthProxy,
basicAuthCredentialsManager.hasCredentials(for: instance) {
instancesManager.setUsesBasicAuth(true, for: instance)
}
if !instance.usesAccountLogin,
instance.supportsAuthentication,
let manager = credentialsManager(for: instance),
manager.isLoggedIn(for: instance) {
instancesManager.setUsesAccountLogin(true, for: instance)
}
}
}
/// Whether an instance is known to require credentials that are missing from
/// the Keychain (e.g. after reinstalling the app and importing sources from iCloud).
/// - Yattee Server always requires basic auth; other types require it when
/// `usesBasicAuth` was recorded.
/// - Invidious/Piped account logins are checked when `usesAccountLogin` was recorded.
func needsCredentials(for instance: Instance) -> Bool {
if instance.type == .yatteeServer || instance.usesBasicAuth,
!basicAuthCredentialsManager.hasCredentials(for: instance) {
return true
}
if instance.usesAccountLogin,
let manager = credentialsManager(for: instance),
!manager.isLoggedIn(for: instance) {
return true
}
return false
}
// MARK: - Preview/Testing Support
@MainActor

View File

@@ -0,0 +1,12 @@
import Foundation
enum AppGroup {
static let identifier = "group.stream.yattee.app.shared"
/// UserDefaults key holding an ordered [String] of enabled TopShelfSection raw values.
static let enabledSectionsKey = "topShelf.enabledSections"
static var defaults: UserDefaults {
UserDefaults(suiteName: identifier) ?? .standard
}
}

View File

@@ -20,3 +20,17 @@ struct FileCommands: Commands {
}
}
#endif
#if os(macOS)
/// App menu Settings item that opens the dedicated Settings window.
struct SettingsWindowMenuItem: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button(String(localized: "menu.app.settings")) {
openWindow(id: "settings")
}
.keyboardShortcut(",", modifiers: [.command])
}
}
#endif

View File

@@ -145,12 +145,17 @@ final class InstancesManager {
}
saveInstances()
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
}
func update(_ instance: Instance) {
if let index = instances.firstIndex(where: { $0.id == instance.id }) {
instances[index] = instance
saveInstances()
// Editing a source can change the proxy answer (URL change, toggle
// flip). Drop the cached auto-detect verdict so the next playback
// re-probes.
Task { await ProxyDetectionCache.shared.invalidate(instance: instance) }
}
}
@@ -169,6 +174,26 @@ final class InstancesManager {
}
}
/// Records whether an instance sits behind HTTP Basic Auth.
/// Persisted (and synced to iCloud) so missing Keychain credentials can be
/// detected after a reinstall.
func setUsesBasicAuth(_ value: Bool, for instance: Instance) {
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
instances[index].usesBasicAuth != value else { return }
instances[index].usesBasicAuth = value
saveInstances()
}
/// Records whether the user has logged into an account on an instance.
/// Persisted (and synced to iCloud) so missing Keychain credentials can be
/// detected after a reinstall.
func setUsesAccountLogin(_ value: Bool, for instance: Instance) {
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
instances[index].usesAccountLogin != value else { return }
instances[index].usesAccountLogin = value
saveInstances()
}
/// Sets the given instance as the primary (first) instance.
func setPrimary(_ instance: Instance) {
LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general)

View File

@@ -0,0 +1,25 @@
//
// Notifications.swift
// Yattee
//
// App-wide notification names.
//
import Foundation
extension Notification.Name {
static let showSettings = Notification.Name("showSettings")
static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet")
static let openDescriptionLink = Notification.Name("openDescriptionLink")
/// Posted when a URL shortener (bit.ly, etc.) has been resolved to an
/// ambiguous destination the app isn't certain it can play it, so the
/// user is prompted whether to try opening it in Yattee or in the browser.
/// `object` is the resolved `URL`.
static let promptResolvedShortLink = Notification.Name("promptResolvedShortLink")
/// Posted when a tapped link is not confidently a video (no YouTube /
/// PeerTube / direct-media match) but could potentially be extracted via
/// the Yattee server / yt-dlp. User is prompted whether to try extracting
/// or open it in the system browser instead.
/// `object` is the candidate `URL`.
static let promptAmbiguousExternalLink = Notification.Name("promptAmbiguousExternalLink")
}

View File

@@ -50,10 +50,10 @@ struct PlaybackCommands: Commands {
Divider()
// Seeking
seekBackward10Button
seekForward10Button
seekBackward30Button
seekForward30Button
seekBackwardButton
seekForwardButton
secondarySeekBackwardButton
secondarySeekForwardButton
Divider()
@@ -121,41 +121,59 @@ struct PlaybackCommands: Commands {
// MARK: - Seeking
private var seekBackward10Button: some View {
/// Seek durations follow the active player controls preset, matching the
/// in-player arrow key shortcuts.
private var seekBackwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.seekBackwardSeconds
}
private var seekForwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.seekForwardSeconds
}
private var seekBackwardButton: some View {
Button {
playerService.seekBackward(by: 10)
playerService.seekBackward(by: TimeInterval(seekBackwardSeconds))
} label: {
Text(String(localized: "menu.playback.seekBackward10"))
Text(String(localized: "menu.playback.seekBackward \(seekBackwardSeconds)"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var seekForward10Button: some View {
private var seekForwardButton: some View {
Button {
playerService.seekForward(by: 10)
playerService.seekForward(by: TimeInterval(seekForwardSeconds))
} label: {
Text(String(localized: "menu.playback.seekForward10"))
Text(String(localized: "menu.playback.seekForward \(seekForwardSeconds)"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command])
.disabled(!hasActiveVideo)
}
private var seekBackward30Button: some View {
private var secondarySeekBackwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.secondarySeekBackwardSeconds
}
private var secondarySeekForwardSeconds: Int {
appEnvironment.activeControlsCenterSettings.secondarySeekForwardSeconds
}
private var secondarySeekBackwardButton: some View {
Button {
playerService.seekBackward(by: 30)
playerService.seekBackward(by: TimeInterval(secondarySeekBackwardSeconds))
} label: {
Text(String(localized: "menu.playback.seekBackward30"))
Text(String(localized: "menu.playback.seekBackward \(secondarySeekBackwardSeconds)"))
}
.keyboardShortcut(.leftArrow, modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
}
private var seekForward30Button: some View {
private var secondarySeekForwardButton: some View {
Button {
playerService.seekForward(by: 30)
playerService.seekForward(by: TimeInterval(secondarySeekForwardSeconds))
} label: {
Text(String(localized: "menu.playback.seekForward30"))
Text(String(localized: "menu.playback.seekForward \(secondarySeekForwardSeconds)"))
}
.keyboardShortcut(.rightArrow, modifiers: [.command, .shift])
.disabled(!hasActiveVideo)
@@ -288,7 +306,7 @@ struct PlaybackCommands: Commands {
Text(String(localized: "menu.playback.pip"))
}
.keyboardShortcut("i", modifiers: [.command, .shift])
.disabled(!hasActiveVideo || !state.isPiPPossible)
.disabled(!hasActiveVideo || !state.isPiPPossible || state.currentStream?.isAudioOnly == true)
}
// MARK: - Close video button

View File

@@ -13,6 +13,10 @@ enum SettingsKey: String, CaseIterable {
// General
case theme
case accentColor
case customAccentColor
case accentColorDark
case customAccentColorDark
case useSeparateDarkAccentColor
case showWatchedCheckmark
// Playback
@@ -20,10 +24,12 @@ enum SettingsKey: String, CaseIterable {
case cellularQuality
case autoplay
case backgroundPlayback
case dashEnabled
case preferredAudioLanguage
case preferredSubtitlesLanguage
case resumeAction
case tvOSMenuButtonClosesVideo
case allowSoftwareDecodedFormats
case audioOnlyMode
// SponsorBlock
case sponsorBlockEnabled
@@ -40,8 +46,14 @@ enum SettingsKey: String, CaseIterable {
case deArrowAPIURL
case deArrowThumbnailAPIURL
// Short link resolution
case resolveShortLinksEnabled
// Platform-specific
case macPlayerMode
case macPlayerSeparateWindow
case macPlayerFloating
case macControlsBarOffsetX // Normalized X offset of macOS control bar from default position
case macControlsBarOffsetY // Normalized Y offset of macOS control bar from default position
case playerSheetAutoResize
case listStyle
@@ -60,9 +72,17 @@ enum SettingsKey: String, CaseIterable {
case homeShortcutOrder
case homeShortcutVisibility
case homeShortcutLayout
case homeShortcutCardStyle
case homeShortcutCardColor
case homeShortcutColorfulPalette
case homeShortcutCustomPaletteColors
case homeSectionOrder
case homeSectionVisibility
case homeSectionItemsLimit
case homeSectionLayout
// Top Shelf (tvOS)
case topShelfSections
// Tab Bar (compact size class)
case tabBarItemOrder
@@ -93,11 +113,16 @@ enum SettingsKey: String, CaseIterable {
// Advanced
case showAdvancedStreamDetails
case showPlayerAreaDebug
case showTVDebugButton
case verboseMPVLogging
case verboseRemoteControlLogging
case mpvBufferSeconds
case mpvUseEDLStreams
case zoomTransitionsEnabled
case tvMatchDisplayFrameRate
case tvMatchDisplayDynamicRange
case tvAudioDelayMs
case tvVideoSyncMode
// Details panel
case floatingDetailsPanelSide // Landscape only - which side the panel appears on
@@ -116,9 +141,31 @@ enum SettingsKey: String, CaseIterable {
case onboardingCompleted
/// Whether this key should have platform-specific prefixes.
/// Platform-specific keys are stored under a `iOS.` / `macOS.` / `tvOS.` prefix
/// in both UserDefaults and iCloud, so each platform family syncs independently.
var isPlatformSpecific: Bool {
switch self {
case .preferredQuality, .cellularQuality, .macPlayerMode, .listStyle:
case .preferredQuality, .cellularQuality, .allowSoftwareDecodedFormats, .audioOnlyMode,
.macPlayerSeparateWindow, .macPlayerFloating, .listStyle,
.macControlsBarOffsetX, .macControlsBarOffsetY,
// Home layout different UI paradigms per platform
.homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, .homeShortcutCardStyle,
.homeShortcutCardColor, .homeShortcutColorfulPalette, .homeShortcutCustomPaletteColors,
.homeSectionOrder, .homeSectionVisibility, .homeSectionItemsLimit, .homeSectionLayout,
// Top Shelf tvOS only
.topShelfSections,
// Tab bar (compact size class) layout
.tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab,
// Sidebar layout/selection
.sidebarMainItemOrder, .sidebarMainItemVisibility, .sidebarStartupTab,
.sidebarSourcesEnabled, .sidebarSourceSort, .sidebarSourcesLimitEnabled, .sidebarMaxSources,
.sidebarChannelsEnabled, .sidebarMaxChannels, .sidebarChannelSort, .sidebarChannelsLimitEnabled,
.sidebarPlaylistsEnabled, .sidebarMaxPlaylists, .sidebarPlaylistSort, .sidebarPlaylistsLimitEnabled,
// Player details panel iOS/iPadOS only, different on other platforms
.floatingDetailsPanelSide, .floatingDetailsPanelWidth,
.landscapeDetailsPanelVisible, .landscapeDetailsPanelPinned,
// Video swipe actions touch-gesture feature
.videoSwipeActionOrder, .videoSwipeActionVisibility:
return true
default:
return false

View File

@@ -39,6 +39,19 @@ extension SettingsManager {
}
}
/// Whether to show the Debug button in the tvOS player bottom controls.
/// Opens the MPV debug overlay. Default is false (hidden).
var showTVDebugButton: Bool {
get {
if let cached = _showTVDebugButton { return cached }
return bool(for: .showTVDebugButton, default: false)
}
set {
_showTVDebugButton = newValue
set(newValue, for: .showTVDebugButton)
}
}
/// Whether verbose MPV rendering logging is enabled.
/// When enabled, logs detailed OpenGL context, framebuffer, and display link state
/// to help diagnose rendering issues. Default is false (disabled).
@@ -128,6 +141,82 @@ extension SettingsManager {
}
}
/// Whether tvOS should request the Apple TV switch its HDMI output to match the
/// playing video's frame rate. Has no effect unless the user also enables
/// "Match Content Frame Rate" in tvOS Settings Video and Audio.
/// Default is true on tvOS (no-op on other platforms).
var tvMatchDisplayFrameRate: Bool {
get {
if let cached = _tvMatchDisplayFrameRate { return cached }
return bool(for: .tvMatchDisplayFrameRate, default: false)
}
set {
_tvMatchDisplayFrameRate = newValue
set(newValue, for: .tvMatchDisplayFrameRate)
}
}
/// Fixed audio-pipeline offset (in milliseconds) applied as MPV's `audio-delay`
/// on tvOS. Useful for compensating fixed HDMI/AVR output latency where MPV's
/// internal `avsync` reads 0 but audio is perceptibly ahead/behind video.
/// Positive values shift audio later; negative values shift it earlier.
/// Default is 0 (no offset). Setting is local-only and tvOS-only.
var tvAudioDelayMs: Double {
get {
if let cached = _tvAudioDelayMs { return cached }
let raw = localDefaults.object(forKey: "tvAudioDelayMs") as? Double
return raw ?? 0
}
set {
_tvAudioDelayMs = newValue
localDefaults.set(newValue, forKey: "tvAudioDelayMs")
}
}
/// Read tvOS audio-delay setting from a nonisolated context (e.g. MPVClient init).
/// Returns milliseconds; convert to seconds before passing to MPV.
nonisolated static func tvAudioDelayMsSync() -> Double {
guard UserDefaults.standard.object(forKey: "tvAudioDelayMs") != nil else { return 0 }
return UserDefaults.standard.double(forKey: "tvAudioDelayMs")
}
/// MPV `video-sync` mode override for tvOS (debug toggle). The shipped default
/// remains `display-vdrop`; this is exposed so we can A/B alternative modes on
/// real hardware when investigating A/V sync issues.
var tvVideoSyncMode: TVVideoSyncMode {
get {
if let cached = _tvVideoSyncMode { return cached }
guard let raw = localDefaults.string(forKey: "tvVideoSyncMode"),
let mode = TVVideoSyncMode(rawValue: raw) else { return .displayVdrop }
return mode
}
set {
_tvVideoSyncMode = newValue
localDefaults.set(newValue.rawValue, forKey: "tvVideoSyncMode")
}
}
nonisolated static func tvVideoSyncModeSync() -> TVVideoSyncMode {
guard let raw = UserDefaults.standard.string(forKey: "tvVideoSyncMode"),
let mode = TVVideoSyncMode(rawValue: raw) else { return .displayVdrop }
return mode
}
/// Whether tvOS should request the Apple TV switch its HDMI output to match the
/// playing video's dynamic range (SDR / HDR10 / HLG). Has no effect unless the
/// user also enables "Match Content Dynamic Range" in tvOS Settings.
/// Default is true on tvOS (no-op on other platforms).
var tvMatchDisplayDynamicRange: Bool {
get {
if let cached = _tvMatchDisplayDynamicRange { return cached }
return bool(for: .tvMatchDisplayDynamicRange, default: false)
}
set {
_tvMatchDisplayDynamicRange = newValue
set(newValue, for: .tvMatchDisplayDynamicRange)
}
}
/// Whether zoom navigation transitions are enabled (iOS only).
/// When enabled, navigating to video/channel/playlist details shows a zoom animation
/// from the source thumbnail. Disable if experiencing visual glitches with swipe-back gestures.
@@ -208,6 +297,9 @@ extension SettingsManager {
/// Default is plain. Synced per-platform via iCloud.
var listStyle: VideoListStyle {
get {
#if os(tvOS)
return .plain
#else
if let cached = _listStyle { return cached }
guard let rawValue = string(for: .listStyle),
let style = VideoListStyle(rawValue: rawValue) else {
@@ -215,6 +307,7 @@ extension SettingsManager {
}
_listStyle = style
return style
#endif
}
set {
_listStyle = newValue

View File

@@ -335,6 +335,11 @@ extension SettingsManager {
// Copy all local settings to iCloud
for key in SettingsKey.allCases {
// Skip local-only keys (device-specific settings that shouldn't sync)
if key.isLocalOnly {
continue
}
let pKey = platformKey(key)
if let value = localDefaults.object(forKey: pKey) {
ubiquitousStore.set(value, forKey: pKey)
@@ -362,6 +367,11 @@ extension SettingsManager {
// Copy all iCloud settings to local defaults
for key in SettingsKey.allCases {
// Skip local-only keys (device-specific settings that shouldn't sync)
if key.isLocalOnly {
continue
}
// Skip protected keys that should preserve local values
if keysToPreserve.contains(key) {
continue
@@ -389,6 +399,10 @@ extension SettingsManager {
clearCache()
updateLastSyncTime()
#if !os(tvOS)
Self.applyTheme(theme)
#endif
}
/// Refreshes settings from iCloud by copying iCloud values to local storage.
@@ -445,5 +459,11 @@ extension SettingsManager {
// Clear caches to force re-read from local storage
clearCache()
// Re-apply the theme in case it was among the synced changes
// it is enforced at the window level, not via SwiftUI state.
#if !os(tvOS)
Self.applyTheme(theme)
#endif
}
}

View File

@@ -87,4 +87,21 @@ extension SettingsManager {
set(newValue, for: .deArrowThumbnailAPIURL)
}
}
// MARK: - Short Link Resolution
/// When enabled, taps on known URL shorteners (bit.ly, tinyurl, t.co, ) in
/// descriptions and comments follow the redirect and, if the destination is a
/// supported YouTube/PeerTube URL, open it in-app. Off by default because it
/// performs a network request to the shortener host on tap.
var resolveShortLinksEnabled: Bool {
get {
if let cached = _resolveShortLinksEnabled { return cached }
return bool(for: .resolveShortLinksEnabled, default: false)
}
set {
_resolveShortLinksEnabled = newValue
set(newValue, for: .resolveShortLinksEnabled)
}
}
}

View File

@@ -6,8 +6,11 @@
//
import Foundation
#if os(iOS)
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
extension SettingsManager {
@@ -21,9 +24,28 @@ extension SettingsManager {
set {
_theme = newValue
set(newValue.rawValue, for: .theme)
Self.applyTheme(newValue)
}
}
/// Forces the theme onto the platform windows directly. SwiftUI's
/// `.preferredColorScheme` fails to revert from dark back to light/system
/// while a sheet is presented, and on macOS it never covered secondary
/// window scenes (Settings), so the override is applied at the
/// UIKit/AppKit level instead.
static func applyTheme(_ theme: AppTheme) {
#if os(iOS)
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
window.overrideUserInterfaceStyle = theme.userInterfaceStyle
}
}
#elseif os(macOS)
NSApp.appearance = theme.appearance
#endif
}
var accentColor: AccentColor {
get {
if let cached = _accentColor { return cached }
@@ -35,9 +57,92 @@ extension SettingsManager {
}
}
// MARK: - App Icon Settings (iOS only)
var customAccentColor: Color {
get {
let hex = _customAccentColor ?? string(for: .customAccentColor) ?? ""
return Color(hex: hex) ?? AccentColor.default.color
}
set {
let hex = newValue.toHexString()
_customAccentColor = hex
set(hex, for: .customAccentColor)
}
}
/// Whether the user picked a separate accent color for dark mode.
var useSeparateDarkAccentColor: Bool {
get {
if let cached = _useSeparateDarkAccentColor { return cached }
return bool(for: .useSeparateDarkAccentColor, default: false)
}
set {
_useSeparateDarkAccentColor = newValue
set(newValue, for: .useSeparateDarkAccentColor)
}
}
/// Accent color choice for dark mode. Falls back to the light selection until explicitly set.
var accentColorDark: AccentColor {
get {
if let cached = _accentColorDark { return cached }
if let raw = string(for: .accentColorDark), let value = AccentColor(rawValue: raw) {
return value
}
return accentColor
}
set {
_accentColorDark = newValue
set(newValue.rawValue, for: .accentColorDark)
}
}
/// Custom accent color for dark mode. Falls back to the light custom color until explicitly set.
var customAccentColorDark: Color {
get {
if let hex = _customAccentColorDark ?? string(for: .customAccentColorDark),
let color = Color(hex: hex) {
return color
}
return customAccentColor
}
set {
let hex = newValue.toHexString()
_customAccentColorDark = hex
set(hex, for: .customAccentColorDark)
}
}
/// Light (or shared) accent color, resolving `.custom` to the user-picked value.
private var resolvedLightAccentColor: Color {
accentColor == .custom ? customAccentColor : accentColor.color
}
/// Dark accent color, resolving `.custom` to the user-picked value.
private var resolvedDarkAccentColor: Color {
accentColorDark == .custom ? customAccentColorDark : accentColorDark.color
}
/// The effective accent color. When a separate dark color is enabled this is a
/// dynamic platform color that resolves per trait environment, so all consumers
/// (root tint and direct readers) adapt to light/dark automatically.
var resolvedAccentColor: Color {
let light = resolvedLightAccentColor
guard useSeparateDarkAccentColor else { return light }
let dark = resolvedDarkAccentColor
#if os(macOS)
return Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in
let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
return NSColor(isDark ? dark : light)
}))
#else
return Color(uiColor: UIColor { traits in
UIColor(traits.userInterfaceStyle == .dark ? dark : light)
})
#endif
}
// MARK: - App Icon Settings
#if os(iOS)
var appIcon: AppIcon {
get {
if let cached = _appIcon { return cached }
@@ -53,14 +158,53 @@ extension SettingsManager {
// Apply the icon change
Task { @MainActor in
#if os(iOS)
do {
try await UIApplication.shared.setAlternateIconName(newValue.alternateIconName)
} catch {
LoggingService.shared.error("Failed to set alternate icon: \(error)", category: .general)
}
#elseif os(macOS)
SettingsManager.applyMacAppIcon(newValue)
#endif
}
}
}
#if os(macOS)
static func applyMacAppIcon(_ icon: AppIcon) {
if icon == .default {
NSApp.applicationIconImage = nil
} else if let source = NSImage(named: icon.previewImageName) {
NSApp.applicationIconImage = makeMacIconImage(from: source)
}
}
/// Paints the source image onto a 1024×1024 canvas with the standard macOS
/// squircle mask and transparent padding so the Dock renders it like a
/// native app icon.
private static func makeMacIconImage(from source: NSImage) -> NSImage {
let canvasSize: CGFloat = 1024
// macOS icon grid: artwork occupies ~824×824 centered in a 1024 canvas,
// with a ~185pt corner radius on the masked rect.
let artworkSize: CGFloat = 824
let cornerRadius: CGFloat = 185
let inset = (canvasSize - artworkSize) / 2
let artworkRect = NSRect(x: inset, y: inset, width: artworkSize, height: artworkSize)
let image = NSImage(size: NSSize(width: canvasSize, height: canvasSize))
image.lockFocus()
let path = NSBezierPath(roundedRect: artworkRect, xRadius: cornerRadius, yRadius: cornerRadius)
path.addClip()
source.draw(in: artworkRect,
from: .zero,
operation: .sourceOver,
fraction: 1.0,
respectFlipped: true,
hints: [.interpolation: NSImageInterpolation.high.rawValue])
image.unlockFocus()
return image
}
#endif
/// Whether to show a checkmark badge on fully watched video thumbnails.
@@ -506,6 +650,26 @@ extension SettingsManager {
}
#endif
// MARK: - Video Tap Action (tvOS only)
#if os(tvOS)
/// Action to perform when clicking a video cell on tvOS. Default is openInfo.
var tvOSVideoTapAction: VideoTapAction {
get {
if let cached = _tvOSVideoTapAction { return cached }
guard let rawValue = localDefaults.string(forKey: "tvOSVideoTapAction"),
let action = VideoTapAction(rawValue: rawValue) else {
return .openInfo
}
return action
}
set {
_tvOSVideoTapAction = newValue
localDefaults.set(newValue.rawValue, forKey: "tvOSVideoTapAction")
}
}
#endif
// MARK: - Onboarding
/// Whether onboarding has been completed on this device.

View File

@@ -95,6 +95,86 @@ extension SettingsManager {
}
}
/// Layout for home shortcut cards (compact or regular). Default is regular.
var homeShortcutCardStyle: HomeShortcutCardStyle {
get {
if let cached = _homeShortcutCardStyle { return cached }
guard let rawValue = string(for: .homeShortcutCardStyle) else {
return .regular
}
return HomeShortcutCardStyle(rawValue: rawValue) ?? .regular
}
set {
_homeShortcutCardStyle = newValue
set(newValue.rawValue, for: .homeShortcutCardStyle)
}
}
/// Color emphasis for home shortcut cards (soft or vibrant), independent of
/// the layout style. Default is soft.
var homeShortcutCardColor: HomeShortcutCardColor {
get {
if let cached = _homeShortcutCardColor { return cached }
guard let rawValue = string(for: .homeShortcutCardColor) else {
return .soft
}
return HomeShortcutCardColor(rawValue: rawValue) ?? .soft
}
set {
_homeShortcutCardColor = newValue
set(newValue.rawValue, for: .homeShortcutCardColor)
}
}
/// Palette used by the "regular" card style. Colors are applied by grid
/// position. Default is Accent (the first palette option).
var homeShortcutColorfulPalette: HomeShortcutColorfulPalette {
get {
if let cached = _homeShortcutColorfulPalette { return cached }
guard let rawValue = string(for: .homeShortcutColorfulPalette) else {
return .accent
}
return HomeShortcutColorfulPalette(rawValue: rawValue) ?? .accent
}
set {
_homeShortcutColorfulPalette = newValue
set(newValue.rawValue, for: .homeShortcutColorfulPalette)
}
}
/// User-supplied hex colors for the custom "colorful" palette. Stored as a
/// single comma-separated string. Defaults to a starter set of colors.
var homeShortcutCustomPaletteColors: [String] {
get {
if let cached = _homeShortcutCustomPaletteColors { return cached }
guard let raw = string(for: .homeShortcutCustomPaletteColors) else {
return HomeShortcutColorfulPalette.customStarterColors
}
let colors = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
return colors
}
set {
_homeShortcutCustomPaletteColors = newValue
let joined = newValue.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.joined(separator: ",")
set(joined, for: .homeShortcutCustomPaletteColors)
}
}
/// Layout mode for home sections (list or grid). Default is list on iOS/macOS, grid on tvOS.
var homeSectionLayout: HomeSectionLayout {
get {
if let cached = _homeSectionLayout { return cached }
guard let rawValue = string(for: .homeSectionLayout) else {
return HomeSectionLayout.platformDefault
}
return HomeSectionLayout(rawValue: rawValue) ?? HomeSectionLayout.platformDefault
}
set {
_homeSectionLayout = newValue
set(newValue.rawValue, for: .homeSectionLayout)
}
}
// MARK: - Home Section Settings
/// Ordered list of home sections. Default order is bookmarks, history, downloads.
@@ -506,13 +586,6 @@ extension SettingsManager {
return existingCards.contains(card.id) ? [] : [card]
}
/// Returns all available section items for a media source that are NOT already added.
func availableSections(for source: MediaSource) -> [HomeSectionItem] {
let section = HomeSectionItem.mediaSource(sourceID: source.id)
let existingSections = Set(homeSectionOrder.map { $0.id })
return existingSections.contains(section.id) ? [] : [section]
}
/// Returns all available cards across all media sources, grouped by source.
func allAvailableMediaSourceShortcuts(sources: [MediaSource]) -> [(source: MediaSource, cards: [HomeShortcutItem])] {
sources.compactMap { source in
@@ -521,14 +594,6 @@ extension SettingsManager {
}
}
/// Returns all available sections across all media sources, grouped by source.
func allAvailableMediaSourceSections(sources: [MediaSource]) -> [(source: MediaSource, sections: [HomeSectionItem])] {
sources.compactMap { source in
let sections = availableSections(for: source)
return sections.isEmpty ? nil : (source, sections)
}
}
/// Removes all Home items for media sources that no longer exist.
func cleanupOrphanedHomeMediaSourceItems(validSourceIDs: Set<UUID>) {
var hadOrphans = false
@@ -602,6 +667,45 @@ extension SettingsManager {
}
}
/// Removes ALL media-source Home *sections* (regardless of whether the source
/// still exists). Media sources are shortcuts-only now a media-source
/// "section" was just a browse link and the feature was removed. Idempotent:
/// only writes when something changed, so it's safe to call on every load and
/// it also cleans up media-source sections that sync in from an older device.
/// Leaves media-source *shortcuts* (`homeShortcutOrder`/`homeShortcutVisibility`) untouched.
func removeAllHomeMediaSourceSections() {
var didChange = false
var sectionOrder = homeSectionOrder
let originalCount = sectionOrder.count
sectionOrder.removeAll { item in
if case .mediaSource = item { return true }
return false
}
if sectionOrder.count != originalCount {
LoggingService.shared.logCloudKit("removeAllHomeMediaSourceSections: removed \(originalCount - sectionOrder.count) media-source sections")
homeSectionOrder = sectionOrder
didChange = true
}
var sectionVis = homeSectionVisibility
let orphanedKeys = sectionVis.keys.filter { item in
if case .mediaSource = item { return true }
return false
}
if !orphanedKeys.isEmpty {
for key in orphanedKeys {
sectionVis.removeValue(forKey: key)
}
homeSectionVisibility = sectionVis
didChange = true
}
if didChange {
LoggingService.shared.logCloudKit("removeAllHomeMediaSourceSections: cleaned up media-source sections")
}
}
// MARK: - Tab Bar Settings (Compact Size Class)
/// Ordered list of tab bar items. Default order is subscriptions first, then others.

View File

@@ -16,6 +16,7 @@ extension SettingsManager {
/// NOT synced to iCloud - local-only storage.
var customMPVOptions: [String: String] {
get {
if let cached = _customMPVOptions { return cached }
guard let data = localDefaults.data(forKey: "customMPVOptions"),
let options = try? JSONDecoder().decode([String: String].self, from: data) else {
return [:]
@@ -23,6 +24,7 @@ extension SettingsManager {
return options
}
set {
_customMPVOptions = newValue
if let data = try? JSONEncoder().encode(newValue) {
localDefaults.set(data, forKey: "customMPVOptions")
}

View File

@@ -0,0 +1,74 @@
//
// SettingsManager+Migration.swift
// Yattee
//
// One-shot migrations that move legacy unprefixed values under
// platform-specific keys when `SettingsKey.isPlatformSpecific` flips to true.
//
import Foundation
extension SettingsManager {
private static let migrationFlagKey = "didMigratePlatformSpecificLayoutKeys_v1"
// Mirrors `SettingsManager+CloudSync.protectedVisibilityKeys`. Kept in sync manually
// because that collection is fileprivate; the set is small and rarely changes.
private static let protectedKeysForMigration: Set<SettingsKey> = [
.homeShortcutVisibility,
.homeSectionVisibility,
.homeShortcutOrder,
.homeSectionOrder
]
/// Copies any legacy unprefixed values for keys that became platform-specific into their
/// new `iOS.` / `macOS.` / `tvOS.` slots, both locally and (if iCloud sync is on) in iCloud.
/// Leaves the legacy unprefixed keys in place so older builds on other devices still work.
func migrateLayoutKeysToPlatformPrefixed() {
guard !localDefaults.bool(forKey: Self.migrationFlagKey) else { return }
let keysNeedingMigration = SettingsKey.allCases.filter { $0.isPlatformSpecific }
let pushToCloud = iCloudSyncEnabled && syncSettings
for key in keysNeedingMigration {
let pKey = platformKey(key)
let legacyKey = key.rawValue
// Skip if the prefixed form already exists or if the legacy key is the same as the prefixed
// key (e.g. on a platform where `platformKey` didn't rewrite it, though that shouldn't happen
// for isPlatformSpecific keys).
guard pKey != legacyKey,
localDefaults.object(forKey: pKey) == nil,
let legacyValue = localDefaults.object(forKey: legacyKey)
else { continue }
localDefaults.set(legacyValue, forKey: pKey)
if Self.protectedKeysForMigration.contains(key) {
let legacyTimestampKey = "\(legacyKey)_modifiedAt"
let newTimestampKey = modifiedAtKey(for: key)
let legacyTimestamp = localDefaults.double(forKey: legacyTimestampKey)
if legacyTimestamp > 0, localDefaults.double(forKey: newTimestampKey) == 0 {
localDefaults.set(legacyTimestamp, forKey: newTimestampKey)
}
}
if pushToCloud {
ubiquitousStore.set(legacyValue, forKey: pKey)
if Self.protectedKeysForMigration.contains(key) {
let newTimestampKey = modifiedAtKey(for: key)
let timestamp = localDefaults.double(forKey: newTimestampKey)
if timestamp > 0 {
ubiquitousStore.set(timestamp, forKey: newTimestampKey)
}
}
}
}
if pushToCloud {
ubiquitousStore.synchronize()
}
localDefaults.set(true, forKey: Self.migrationFlagKey)
LoggingService.shared.logCloudKit("Migrated legacy layout keys to platform-prefixed storage")
}
}

View File

@@ -40,7 +40,7 @@ extension SettingsManager {
var backgroundPlaybackEnabled: Bool {
get {
if let cached = _backgroundPlaybackEnabled { return cached }
return bool(for: .backgroundPlayback, default: true)
return bool(for: .backgroundPlayback, default: backgroundPlaybackDefault)
}
set {
_backgroundPlaybackEnabled = newValue
@@ -48,16 +48,49 @@ extension SettingsManager {
}
}
/// Whether DASH streams are enabled (MPV only).
/// Disabled by default as DASH can be unreliable with some Invidious instances.
var dashEnabled: Bool {
private var backgroundPlaybackDefault: Bool {
#if os(tvOS)
return false
#else
return true
#endif
}
/// tvOS only: when enabled, the Siri remote Menu button closes the video
/// (clears queue, stops playback) instead of only collapsing the player.
/// When enabled, the explicit top-bar close button is hidden.
var tvOSMenuButtonClosesVideo: Bool {
get {
if let cached = _dashEnabled { return cached }
return bool(for: .dashEnabled, default: false)
if let cached = _tvOSMenuButtonClosesVideo { return cached }
return bool(for: .tvOSMenuButtonClosesVideo, default: false)
}
set {
_dashEnabled = newValue
set(newValue, for: .dashEnabled)
_tvOSMenuButtonClosesVideo = newValue
set(newValue, for: .tvOSMenuButtonClosesVideo)
}
}
var allowSoftwareDecodedFormats: Bool {
get {
if let cached = _allowSoftwareDecodedFormats { return cached }
return bool(for: .allowSoftwareDecodedFormats, default: false)
}
set {
_allowSoftwareDecodedFormats = newValue
set(newValue, for: .allowSoftwareDecodedFormats)
}
}
/// Audio-only ("music") mode: when enabled, only the audio track is loaded
/// for every video until turned off. Persisted per platform.
var audioOnlyModeEnabled: Bool {
get {
if let cached = _audioOnlyModeEnabled { return cached }
return bool(for: .audioOnlyMode, default: false)
}
set {
_audioOnlyModeEnabled = newValue
set(newValue, for: .audioOnlyMode)
}
}

View File

@@ -68,14 +68,58 @@ extension SettingsManager {
#endif
#if os(macOS)
var macPlayerMode: MacPlayerMode {
/// Whether the expanded player opens in a separate window (vs. an inline sheet).
/// Default is `true`.
var macPlayerSeparateWindow: Bool {
get {
if let cached = _macPlayerMode { return cached }
return MacPlayerMode(rawValue: string(for: .macPlayerMode) ?? "") ?? .window
if let cached = _macPlayerSeparateWindow { return cached }
return bool(for: .macPlayerSeparateWindow, default: true)
}
set {
_macPlayerMode = newValue
set(newValue.rawValue, for: .macPlayerMode)
_macPlayerSeparateWindow = newValue
set(newValue, for: .macPlayerSeparateWindow)
}
}
/// Whether the separate player window floats above other windows (always on top).
/// Toggled live from the player's top-bar pin button and remembered across sessions.
/// Default is `false`.
var macPlayerFloating: Bool {
get {
if let cached = _macPlayerFloating { return cached }
return bool(for: .macPlayerFloating, default: false)
}
set {
_macPlayerFloating = newValue
set(newValue, for: .macPlayerFloating)
}
}
/// Normalized X offset of the macOS floating control bar from its default
/// bottom-center position, stored as a fraction of the player container width.
/// 0 = default docked position.
var macControlsBarOffsetX: Double {
get {
if let cached = _macControlsBarOffsetX { return cached }
return double(for: .macControlsBarOffsetX)
}
set {
_macControlsBarOffsetX = newValue
set(newValue, for: .macControlsBarOffsetX)
}
}
/// Normalized Y offset of the macOS floating control bar from its default
/// bottom-center position, stored as a fraction of the player container height.
/// 0 = default docked position.
var macControlsBarOffsetY: Double {
get {
if let cached = _macControlsBarOffsetY { return cached }
return double(for: .macControlsBarOffsetY)
}
set {
_macControlsBarOffsetY = newValue
set(newValue, for: .macControlsBarOffsetY)
}
}

View File

@@ -0,0 +1,43 @@
//
// SettingsManager+TopShelf.swift
// Yattee
//
// tvOS Top Shelf settings.
//
import Foundation
extension SettingsManager {
/// Ordered list of sections visible in the tvOS Top Shelf.
/// Inclusion means the section is shown; absence hides it.
var topShelfSections: [TopShelfSection] {
get {
if let cached = _topShelfSections { return cached }
guard let data = data(for: .topShelfSections),
let saved = try? JSONDecoder().decode([TopShelfSection].self, from: data) else {
return TopShelfSection.defaultOrder
}
return saved
}
set {
_topShelfSections = newValue
if let data = try? JSONEncoder().encode(newValue) {
set(data, for: .topShelfSections)
}
let tsKey = modifiedAtKey(for: .topShelfSections)
let now = Date().timeIntervalSince1970
localDefaults.set(now, forKey: tsKey)
if iCloudSyncEnabled && syncSettings && !isInitialSyncPending {
ubiquitousStore.set(now, forKey: tsKey)
}
mirrorEnabledSectionsToAppGroup(newValue)
}
}
/// Mirrors the enabled-sections list to the App Group UserDefaults suite
/// so the tvOS Top Shelf extension can read the user's selection.
func mirrorEnabledSectionsToAppGroup(_ sections: [TopShelfSection]) {
let rawValues = sections.map(\.rawValue)
AppGroup.defaults.set(rawValues, forKey: AppGroup.enabledSectionsKey)
}
}

View File

@@ -7,6 +7,11 @@
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
// MARK: - Theme & Appearance
@@ -22,6 +27,24 @@ enum AppTheme: String, CaseIterable, Codable {
case .dark: return .dark
}
}
#if canImport(UIKit)
var userInterfaceStyle: UIUserInterfaceStyle {
switch self {
case .system: return .unspecified
case .light: return .light
case .dark: return .dark
}
}
#elseif canImport(AppKit)
var appearance: NSAppearance? {
switch self {
case .system: return nil
case .light: return NSAppearance(named: .aqua)
case .dark: return NSAppearance(named: .darkAqua)
}
}
#endif
}
enum AccentColor: String, CaseIterable, Codable {
@@ -35,10 +58,20 @@ enum AccentColor: String, CaseIterable, Codable {
case blue
case purple
case indigo
case custom
/// Fixed swatches shown in the settings grid; `.custom` is rendered
/// separately as a color picker, and `.indigo` is retired from the grid
/// but still resolves for users who selected it before it was removed.
static var presets: [AccentColor] {
allCases.filter { $0 != .custom && $0 != .indigo }
}
/// The color for `.custom` lives in settings storage resolve through
/// `SettingsManager.resolvedAccentColor` instead of this property.
var color: Color {
switch self {
case .default: return .blue // System default accent color
case .default, .custom: return .blue // System default accent color
case .red: return .red
case .pink: return .pink
case .orange: return .orange
@@ -52,7 +85,6 @@ enum AccentColor: String, CaseIterable, Codable {
}
}
#if os(iOS)
enum AppIcon: String, CaseIterable, Codable {
case `default`
case classic
@@ -89,7 +121,6 @@ enum AppIcon: String, CaseIterable, Codable {
}
}
}
#endif
// MARK: - Video Quality
@@ -203,37 +234,6 @@ enum DownloadQuality: String, CaseIterable, Codable, Sendable {
}
}
// MARK: - macOS Player Mode
#if os(macOS)
enum MacPlayerMode: String, CaseIterable, Codable {
case window
case floatingWindow
case inline
var displayName: String {
switch self {
case .window: return String(localized: "settings.playback.macOS.playerMode.window")
case .floatingWindow: return String(localized: "settings.playback.macOS.playerMode.floatingWindow")
case .inline: return String(localized: "settings.playback.macOS.playerMode.inline")
}
}
/// Whether this mode uses a separate window (vs sheet/inline)
var usesWindow: Bool {
switch self {
case .window, .floatingWindow: return true
case .inline: return false
}
}
/// Whether the window should float above other windows
var isFloating: Bool {
self == .floatingWindow
}
}
#endif
// MARK: - Haptic Feedback
/// Intensity levels for haptic feedback.

View File

@@ -0,0 +1,30 @@
import Foundation
/// Sections that can appear in the tvOS Top Shelf.
/// Stored ordered in `SettingsKey.topShelfSections` inclusion = visible.
enum TopShelfSection: String, Codable, CaseIterable, Identifiable, Sendable {
case continueWatching
case recentFeed
case recentBookmarks
var id: String { rawValue }
var localizedTitle: String {
switch self {
case .continueWatching: return String(localized: "home.section.continueWatching")
case .recentFeed: return String(localized: "home.section.feed")
case .recentBookmarks: return String(localized: "home.section.bookmarks")
}
}
/// UserDefaults key (under the app-group suite) holding the JSON snapshot for this section.
var snapshotKey: String {
switch self {
case .continueWatching: return "topShelf.continueWatching"
case .recentFeed: return "topShelf.recentFeed"
case .recentBookmarks: return "topShelf.recentBookmarks"
}
}
static let defaultOrder: [TopShelfSection] = [.continueWatching, .recentFeed, .recentBookmarks]
}

View File

@@ -28,17 +28,23 @@ final class SettingsManager {
// Theme
var _theme: AppTheme?
var _accentColor: AccentColor?
var _customAccentColor: String?
var _accentColorDark: AccentColor?
var _customAccentColorDark: String?
var _useSeparateDarkAccentColor: Bool?
var _showWatchedCheckmark: Bool?
// Playback
var _preferredQuality: VideoQuality?
var _cellularQuality: VideoQuality?
var _backgroundPlaybackEnabled: Bool?
var _dashEnabled: Bool?
var _preferredAudioLanguage: String?
var _preferredSubtitlesLanguage: String?
var _playerVolume: Float?
var _resumeAction: ResumeAction?
var _tvOSMenuButtonClosesVideo: Bool?
var _allowSoftwareDecodedFormats: Bool?
var _audioOnlyModeEnabled: Bool?
// SponsorBlock
var _sponsorBlockEnabled: Bool?
@@ -53,6 +59,9 @@ final class SettingsManager {
var _deArrowAPIURL: String?
var _deArrowThumbnailAPIURL: String?
// Short link resolution
var _resolveShortLinksEnabled: Bool?
// User Agent
var _customUserAgent: String?
var _randomizeUserAgentPerRequest: Bool?
@@ -68,7 +77,10 @@ final class SettingsManager {
var _preferPortraitBrowsing: Bool?
#endif
#if os(macOS)
var _macPlayerMode: MacPlayerMode?
var _macPlayerSeparateWindow: Bool?
var _macPlayerFloating: Bool?
var _macControlsBarOffsetX: Double?
var _macControlsBarOffsetY: Double?
var _playerSheetAutoResize: Bool?
#endif
@@ -104,9 +116,20 @@ final class SettingsManager {
var _homeShortcutOrder: [HomeShortcutItem]?
var _homeShortcutVisibility: [HomeShortcutItem: Bool]?
var _homeShortcutLayout: HomeShortcutLayout?
var _homeShortcutCardStyle: HomeShortcutCardStyle?
var _homeShortcutCardColor: HomeShortcutCardColor?
var _homeShortcutColorfulPalette: HomeShortcutColorfulPalette?
var _homeShortcutCustomPaletteColors: [String]?
var _homeSectionOrder: [HomeSectionItem]?
var _homeSectionVisibility: [HomeSectionItem: Bool]?
var _homeSectionItemsLimit: Int?
var _homeSectionLayout: HomeSectionLayout?
// Top Shelf (tvOS)
var _topShelfSections: [TopShelfSection]?
// Custom MPV options (local-only)
var _customMPVOptions: [String: String]?
// Tab bar settings (compact size class only - iOS)
var _tabBarItemOrder: [TabBarItem]?
@@ -140,11 +163,16 @@ final class SettingsManager {
// Advanced settings
var _showAdvancedStreamDetails: Bool?
var _showPlayerAreaDebug: Bool?
var _showTVDebugButton: Bool?
var _verboseMPVLogging: Bool?
var _verboseRemoteControlLogging: Bool?
var _mpvBufferSeconds: Double?
var _mpvUseEDLStreams: Bool?
var _zoomTransitionsEnabled: Bool?
var _tvMatchDisplayFrameRate: Bool?
var _tvMatchDisplayDynamicRange: Bool?
var _tvAudioDelayMs: Double?
var _tvVideoSyncMode: TVVideoSyncMode?
// Details panel settings
var _floatingDetailsPanelSide: FloatingPanelSide?
@@ -188,13 +216,16 @@ final class SettingsManager {
var _textAreaTapAction: VideoTapAction?
#endif
// Video tap action (tvOS only)
#if os(tvOS)
var _tvOSVideoTapAction: VideoTapAction?
#endif
// Player Controls settings (controlsButtonSize moved to preset)
// Appearance settings
var _listStyle: VideoListStyle?
#if os(iOS)
var _appIcon: AppIcon?
#endif
// Video Swipe Actions
#if !os(tvOS)
@@ -252,6 +283,12 @@ final class SettingsManager {
}
}
// One-shot migration: move legacy unprefixed values for keys that became
// platform-specific into their new iOS./macOS./tvOS. slots. Must run before
// the initial iCloud refresh so it can seed the prefixed iCloud slot from the
// current local value before any remote data is read.
migrateLayoutKeysToPlatformPrefixed()
// Initial sync from iCloud to local storage (async to avoid blocking app launch)
// This ensures local defaults have the latest iCloud values before any reads.
// While sync is pending, suppress iCloud writes from set() to prevent stale
@@ -385,15 +422,21 @@ final class SettingsManager {
func clearCache() {
_theme = nil
_accentColor = nil
_customAccentColor = nil
_accentColorDark = nil
_customAccentColorDark = nil
_useSeparateDarkAccentColor = nil
_showWatchedCheckmark = nil
_preferredQuality = nil
_cellularQuality = nil
_backgroundPlaybackEnabled = nil
_dashEnabled = nil
_preferredAudioLanguage = nil
_preferredSubtitlesLanguage = nil
_playerVolume = nil
_resumeAction = nil
_tvOSMenuButtonClosesVideo = nil
_allowSoftwareDecodedFormats = nil
_audioOnlyModeEnabled = nil
_sponsorBlockEnabled = nil
_sponsorBlockCategories = nil
_sponsorBlockAPIURL = nil
@@ -403,6 +446,7 @@ final class SettingsManager {
_deArrowReplaceThumbnails = nil
_deArrowAPIURL = nil
_deArrowThumbnailAPIURL = nil
_resolveShortLinksEnabled = nil
_customUserAgent = nil
_randomizeUserAgentPerRequest = nil
_feedCacheValidityMinutes = nil
@@ -426,7 +470,10 @@ final class SettingsManager {
_syncSearchHistory = nil
_searchHistoryLimit = nil
#if os(macOS)
_macPlayerMode = nil
_macPlayerSeparateWindow = nil
_macPlayerFloating = nil
_macControlsBarOffsetX = nil
_macControlsBarOffsetY = nil
_playerSheetAutoResize = nil
#endif
// miniPlayerShowVideo and miniPlayerVideoTapAction moved to preset
@@ -436,9 +483,15 @@ final class SettingsManager {
_homeShortcutOrder = nil
_homeShortcutVisibility = nil
_homeShortcutLayout = nil
_homeShortcutCardStyle = nil
_homeShortcutCardColor = nil
_homeShortcutColorfulPalette = nil
_homeShortcutCustomPaletteColors = nil
_homeSectionOrder = nil
_homeSectionVisibility = nil
_homeSectionItemsLimit = nil
_homeSectionLayout = nil
_topShelfSections = nil
_tabBarItemOrder = nil
_tabBarItemVisibility = nil
_sidebarMainItemOrder = nil
@@ -459,11 +512,16 @@ final class SettingsManager {
_sidebarPlaylistsLimitEnabled = nil
_showAdvancedStreamDetails = nil
_showPlayerAreaDebug = nil
_showTVDebugButton = nil
_verboseMPVLogging = nil
_verboseRemoteControlLogging = nil
_mpvBufferSeconds = nil
_mpvUseEDLStreams = nil
_zoomTransitionsEnabled = nil
_tvMatchDisplayFrameRate = nil
_tvMatchDisplayDynamicRange = nil
_tvAudioDelayMs = nil
_tvVideoSyncMode = nil
_floatingDetailsPanelSide = nil
_floatingDetailsPanelWidth = nil
_landscapeDetailsPanelVisible = nil
@@ -490,6 +548,9 @@ final class SettingsManager {
_thumbnailTapAction = nil
_textAreaTapAction = nil
#endif
#if os(tvOS)
_tvOSVideoTapAction = nil
#endif
_listStyle = nil
#if os(iOS)
_appIcon = nil

View File

@@ -217,7 +217,7 @@ final class Bookmark {
publishedText: publishedText,
viewCount: viewCount,
likeCount: nil,
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, width: nil, height: nil)] } ?? [],
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
isLive: isLive,
isUpcoming: false,
scheduledStartTime: nil

View File

@@ -119,27 +119,57 @@ extension DataManager {
)
return try? modelContext.fetch(descriptor).first
}
/// Gets all bookmarks matching a video ID. The same ID can exist
/// under multiple source scopes; callers that care about a specific
/// source must pick the matching entity themselves.
func bookmarks(forVideoID videoID: String) -> [Bookmark] {
let descriptor = FetchDescriptor<Bookmark>(
predicate: #Predicate { $0.videoID == videoID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a single bookmark without queueing a CloudKit deletion.
/// Used by CloudKitSyncEngine when applying remote deletions.
func deleteBookmark(_ bookmark: Bookmark) {
let videoID = bookmark.videoID
modelContext.delete(bookmark)
save()
// Keep the fast-lookup cache accurate; another scope may still
// have a bookmark with this video ID
if self.bookmark(for: videoID) == nil {
cachedBookmarkedVideoIDs.remove(videoID)
}
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
}
/// Inserts a bookmark into the database.
/// Used by CloudKitSyncEngine for applying remote bookmarks.
func insertBookmark(_ bookmark: Bookmark) {
// Check for duplicates
// Check for duplicates within the same source scope - the same
// video ID can legitimately exist under different sources
let videoID = bookmark.videoID
let descriptor = FetchDescriptor<Bookmark>(
predicate: #Predicate { $0.videoID == videoID }
)
let scopeSuffix = bookmark.sourceScopeSuffix
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
modelContext.insert(bookmark)
save()
cachedBookmarkedVideoIDs.insert(videoID)
}
} catch {
// Insert anyway if we can't check
modelContext.insert(bookmark)
save()
cachedBookmarkedVideoIDs.insert(videoID)
}
TopShelfSnapshotWriter.writeBookmarks(dataManager: self)
}
/// Updates bookmark tags and note for a video.
@@ -179,3 +209,17 @@ extension DataManager {
}
}
}
// MARK: - Source Scope
private extension Bookmark {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: globalProvider,
instanceURLString: instanceURLString,
externalExtractor: externalExtractor
).recordNameSuffix
}
}

View File

@@ -115,6 +115,22 @@ extension DataManager {
}
}
/// Gets all notification settings matching a channel ID. The same ID can
/// exist under multiple source scopes.
func allChannelNotificationSettings(forChannelID channelID: String) -> [ChannelNotificationSettings] {
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
predicate: #Predicate { $0.channelID == channelID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a single notification settings record without queueing a
/// CloudKit deletion. Used by CloudKitSyncEngine when applying remote deletions.
func deleteChannelNotificationSettings(_ settings: ChannelNotificationSettings) {
modelContext.delete(settings)
save()
}
/// Deletes notification settings for a channel.
/// - Parameter channelID: The channel ID to delete settings for.
func deleteNotificationSettings(for channelID: String) {

View File

@@ -300,14 +300,34 @@ extension DataManager {
let descriptor = FetchDescriptor<RecentChannel>(
predicate: #Predicate { $0.channelID == channelID }
)
return try? modelContext.fetch(descriptor).first
}
/// Gets all recent channels matching a channel ID. The same ID can exist
/// under multiple source scopes.
func recentChannelEntries(forChannelID channelID: String) -> [RecentChannel] {
let descriptor = FetchDescriptor<RecentChannel>(
predicate: #Predicate { $0.channelID == channelID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a recent channel without queueing a CloudKit deletion.
/// Used by CloudKitSyncEngine when applying remote deletions.
func deleteRecentChannelEntry(_ channel: RecentChannel) {
modelContext.delete(channel)
save()
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
}
/// Inserts a recent channel (for CloudKit sync).
func insertRecentChannel(_ recentChannel: RecentChannel) {
// Check for duplicates by channelID
if recentChannelEntry(forChannelID: recentChannel.channelID) == nil {
// Check for duplicates within the same source scope - the same
// channel ID can legitimately exist under different sources
let scopeSuffix = recentChannel.sourceScopeSuffix
let existing = recentChannelEntries(forChannelID: recentChannel.channelID)
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
modelContext.insert(recentChannel)
save()
}
@@ -338,7 +358,7 @@ extension DataManager {
existing.title = playlist.title
existing.authorName = playlist.authorName
existing.videoCount = playlist.videoCount
existing.thumbnailURLString = playlist.thumbnailURL?.absoluteString
existing.thumbnailURLString = RecentPlaylist.reliableThumbnailURLString(playlist.thumbnailURL)
savedEntry = existing
} else {
// Create new entry
@@ -467,16 +487,62 @@ extension DataManager {
let descriptor = FetchDescriptor<RecentPlaylist>(
predicate: #Predicate { $0.playlistID == playlistID }
)
return try? modelContext.fetch(descriptor).first
}
/// Gets all recent playlists matching a playlist ID. The same ID can
/// exist under multiple source scopes.
func recentPlaylistEntries(forPlaylistID playlistID: String) -> [RecentPlaylist] {
let descriptor = FetchDescriptor<RecentPlaylist>(
predicate: #Predicate { $0.playlistID == playlistID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a recent playlist without queueing a CloudKit deletion.
/// Used by CloudKitSyncEngine when applying remote deletions.
func deleteRecentPlaylistEntry(_ playlist: RecentPlaylist) {
modelContext.delete(playlist)
save()
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
}
/// Inserts a recent playlist (for CloudKit sync).
func insertRecentPlaylist(_ recentPlaylist: RecentPlaylist) {
// Check for duplicates by playlistID
if recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) == nil {
// Check for duplicates within the same source scope - the same
// playlist ID can legitimately exist under different sources
let scopeSuffix = recentPlaylist.sourceScopeSuffix
let existing = recentPlaylistEntries(forPlaylistID: recentPlaylist.playlistID)
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
modelContext.insert(recentPlaylist)
save()
}
}
}
// MARK: - Source Scope
private extension RecentChannel {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: nil,
instanceURLString: instanceURLString,
externalExtractor: nil
).recordNameSuffix
}
}
private extension RecentPlaylist {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: nil,
instanceURLString: instanceURLString,
externalExtractor: nil
).recordNameSuffix
}
}

View File

@@ -200,6 +200,16 @@ extension DataManager {
}
}
/// Gets all subscriptions matching a channel ID. The same ID can exist
/// under multiple source scopes; callers that care about a specific
/// source must pick the matching entity themselves.
func subscriptions(forChannelID channelID: String) -> [Subscription] {
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Gets all subscriptions.
func subscriptions() -> [Subscription] {
let descriptor = FetchDescriptor<Subscription>(
@@ -219,15 +229,17 @@ extension DataManager {
/// Inserts a subscription into the database.
/// Used by SubscriptionService for caching server subscriptions locally.
func insertSubscription(_ subscription: Subscription) {
// Check for duplicates
// Check for duplicates within the same source scope - the same
// channel ID can legitimately exist under different sources
let channelID = subscription.channelID
let descriptor = FetchDescriptor<Subscription>(
predicate: #Predicate { $0.channelID == channelID }
)
let scopeSuffix = subscription.sourceScopeSuffix
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
modelContext.insert(subscription)
save()
}
@@ -279,6 +291,45 @@ extension DataManager {
}
}
/// Deletes all locally stored subscriptions, including their iCloud copies.
/// Server-account subscriptions (Invidious/Piped) are unaffected.
func deleteAllSubscriptions() {
let allSubscriptions = subscriptions()
guard !allSubscriptions.isEmpty else { return }
var deleteInfo: [(channelID: String, scope: SourceScope)] = []
for subscription in allSubscriptions {
let scope = SourceScope.from(
sourceRawValue: subscription.sourceRawValue,
globalProvider: subscription.providerName,
instanceURLString: subscription.instanceURLString,
externalExtractor: nil
)
deleteInfo.append((subscription.channelID, scope))
modelContext.delete(subscription)
}
save()
for info in deleteInfo {
cloudKitSync?.queueSubscriptionDelete(channelID: info.channelID, scope: info.scope)
}
SubscriptionFeedCache.shared.invalidate()
let change = SubscriptionChange(
addedSubscriptions: [],
removedChannelIDs: deleteInfo.map(\.channelID)
)
NotificationCenter.default.post(
name: .subscriptionsDidChange,
object: nil,
userInfo: [SubscriptionChange.userInfoKey: change]
)
LoggingService.shared.info("Deleted all \(deleteInfo.count) local subscriptions", category: .general)
}
/// Returns the total count of subscriptions.
var subscriptionCount: Int {
let descriptor = FetchDescriptor<Subscription>()
@@ -394,3 +445,17 @@ extension DataManager {
}
}
}
// MARK: - Source Scope
private extension Subscription {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: providerName,
instanceURLString: instanceURLString,
externalExtractor: nil
).recordNameSuffix
}
}

View File

@@ -13,8 +13,13 @@ extension DataManager {
/// Records or updates watch progress locally without triggering iCloud sync.
/// Use this for frequent updates during playback to avoid unnecessary sync overhead.
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
///
/// - Parameter isLive: Set by the player when the active stream is live but the
/// `Video` metadata does not say so (some sources hardcode `isLive: false`).
/// Live views are still recorded in history, but without a resume position.
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil, isLive: Bool = false) {
let videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
@@ -22,16 +27,30 @@ extension DataManager {
do {
let existing = try modelContext.fetch(descriptor)
if let existingEntry = existing.first {
existingEntry.updateProgress(seconds: seconds, duration: duration)
if isLiveContent {
existingEntry.recordLiveWatch()
} else {
// Clear the flag first - updateProgress's auto-finish check reads
// `progress`, which is hard-0 while isLive is still set
existingEntry.isLive = false
existingEntry.updateProgress(seconds: seconds, duration: duration)
}
save()
} else {
let newEntry = WatchEntry.from(video: video)
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
if !isLiveContent {
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry)
save()
// Notify HomeView when a new entry is inserted (not on every progress update)
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
}
save()
// Note: No CloudKit queueing - use updateWatchProgress() when sync is needed
} catch {
LoggingService.shared.logCloudKitError("Failed to update watch progress locally", error: error)
@@ -40,9 +59,10 @@ extension DataManager {
/// Records or updates watch progress for a video and queues for iCloud sync.
/// Use this when video closes or switches to sync the final progress.
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil, isLive: Bool = false) {
// Find existing entry or create new one
let videoID = video.id.videoID
let isLiveContent = video.isLive || isLive
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
@@ -51,13 +71,24 @@ extension DataManager {
let existing = try modelContext.fetch(descriptor)
let entry: WatchEntry
if let existingEntry = existing.first {
existingEntry.updateProgress(seconds: seconds, duration: duration)
if isLiveContent {
existingEntry.recordLiveWatch()
} else {
// Clear the flag first - updateProgress's auto-finish check reads
// `progress`, which is hard-0 while isLive is still set
existingEntry.isLive = false
existingEntry.updateProgress(seconds: seconds, duration: duration)
}
entry = existingEntry
} else {
let newEntry = WatchEntry.from(video: video)
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
if !isLiveContent {
newEntry.watchedSeconds = seconds
if let duration, duration > 0, newEntry.duration == 0 {
newEntry.duration = duration
}
} else {
newEntry.isLive = true
}
modelContext.insert(newEntry)
entry = newEntry
@@ -66,6 +97,7 @@ extension DataManager {
// Queue for CloudKit sync
cloudKitSync?.queueWatchEntrySave(entry)
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
} catch {
LoggingService.shared.logCloudKitError("Failed to update watch progress", error: error)
}
@@ -124,19 +156,39 @@ extension DataManager {
)
return try? modelContext.fetch(descriptor).first
}
/// Gets all watch entries matching a video ID. The same ID can exist
/// under multiple source scopes; callers that care about a specific
/// source must pick the matching entity themselves.
func watchEntries(forVideoID videoID: String) -> [WatchEntry] {
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Deletes a single watch entry without queueing a CloudKit deletion.
/// Used by CloudKitSyncEngine when applying remote deletions.
func deleteWatchEntry(_ entry: WatchEntry) {
modelContext.delete(entry)
save()
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
}
/// Inserts a watch entry into the database.
/// Used by CloudKitSyncEngine for applying remote watch history.
func insertWatchEntry(_ watchEntry: WatchEntry) {
// Check for duplicates
// Check for duplicates within the same source scope - the same
// video ID can legitimately exist under different sources
let videoID = watchEntry.videoID
let descriptor = FetchDescriptor<WatchEntry>(
predicate: #Predicate { $0.videoID == videoID }
)
let scopeSuffix = watchEntry.sourceScopeSuffix
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
if !existing.contains(where: { $0.sourceScopeSuffix == scopeSuffix }) {
modelContext.insert(watchEntry)
save()
}
@@ -145,6 +197,7 @@ extension DataManager {
modelContext.insert(watchEntry)
save()
}
TopShelfSnapshotWriter.writeContinueWatching(dataManager: self)
}
/// Clears all watch history.
@@ -371,3 +424,17 @@ extension DataManager {
}
}
}
// MARK: - Source Scope
private extension WatchEntry {
/// Record-name scope suffix used to distinguish same-ID entities across sources.
var sourceScopeSuffix: String {
SourceScope.from(
sourceRawValue: sourceRawValue,
globalProvider: globalProvider,
instanceURLString: instanceURLString,
externalExtractor: externalExtractor
).recordNameSuffix
}
}

View File

@@ -75,10 +75,16 @@ final class LocalPlaylist {
}
/// The first video's thumbnail URL for display.
///
/// Rewritten to the always-available `hqdefault` variant because the stored
/// URL is the best advertised quality (often `maxresdefault`), which 404s
/// for many older videos and would leave the cover blank.
var thumbnailURL: URL? {
(items ?? []).sorted { $0.sortOrder < $1.sortOrder }
.first?
.thumbnailURL
Thumbnail.reliableURL(
for: (items ?? []).sorted { $0.sortOrder < $1.sortOrder }
.first?
.thumbnailURL
)
}
/// Sorted items by order.
@@ -249,7 +255,7 @@ extension LocalPlaylistItem {
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
isLive: isLive,
isUpcoming: false,
scheduledStartTime: nil

View File

@@ -57,10 +57,18 @@ final class RecentPlaylist {
title: playlist.title,
authorName: playlist.authorName,
videoCount: playlist.videoCount,
thumbnailURLString: playlist.thumbnailURL?.absoluteString
thumbnailURLString: reliableThumbnailURLString(playlist.thumbnailURL)
)
}
/// Rewrites YouTube `/vi/ID/...` thumbnails to the always-available `hqdefault.jpg`
/// variant: recent playlist cards render a single URL without fallback, and
/// higher-quality variants (`maxresdefault`/`sddefault`) 404 for many older videos.
static func reliableThumbnailURLString(_ url: URL?) -> String? {
guard let url else { return nil }
return (Thumbnail.reliableURL(for: url) ?? url).absoluteString
}
private static func extractSourceInfo(from source: ContentSource) -> (String, String?) {
switch source {
case .global:

View File

@@ -51,6 +51,10 @@ final class WatchEntry {
/// Thumbnail URL string.
var thumbnailURLString: String?
/// Whether this entry was recorded for a live stream.
/// Live entries never carry a resume position - see `recordLiveWatch()`.
var isLive: Bool = false
// MARK: - Watch Progress
/// Last watched position in seconds.
@@ -86,7 +90,8 @@ final class WatchEntry {
duration: TimeInterval,
thumbnailURLString: String? = nil,
watchedSeconds: TimeInterval = 0,
isFinished: Bool = false
isFinished: Bool = false,
isLive: Bool = false
) {
self.videoID = videoID
self.sourceRawValue = sourceRawValue
@@ -102,6 +107,7 @@ final class WatchEntry {
self.thumbnailURLString = thumbnailURLString
self.watchedSeconds = watchedSeconds
self.isFinished = isFinished
self.isLive = isLive
self.createdAt = Date()
self.updatedAt = Date()
}
@@ -136,8 +142,10 @@ final class WatchEntry {
}
/// Watch progress as a percentage (0.0 to 1.0).
/// Always 0 for live streams - their playback position is relative to the
/// live edge, so it does not describe progress through a fixed timeline.
var progress: Double {
guard duration > 0 else { return 0 }
guard !isLive, duration > 0 else { return 0 }
return min(watchedSeconds / duration, 1.0)
}
@@ -188,6 +196,20 @@ final class WatchEntry {
}
}
/// Records a live-stream view without storing a resume position.
/// Live playback positions are relative to the live edge, so keeping them
/// would make the app offer to "continue" from a meaningless timestamp.
/// Also clears state a previous non-live save may have left behind, which
/// heals entries wrongly marked finished by a drifting HLS duration.
func recordLiveWatch() {
isLive = true
watchedSeconds = 0
duration = 0
isFinished = false
finishedAt = nil
updatedAt = Date()
}
/// Marks the video as finished.
func markAsFinished() {
isFinished = true
@@ -219,8 +241,8 @@ extension WatchEntry {
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
isLive: false,
thumbnails: Thumbnail.fallbackChain(for: thumbnailURL),
isLive: isLive,
isUpcoming: false,
scheduledStartTime: nil
)
@@ -260,8 +282,11 @@ extension WatchEntry {
title: video.title,
authorName: video.author.name,
authorID: video.author.id,
duration: video.duration,
thumbnailURLString: video.bestThumbnail?.url.absoluteString
// Live videos report no usable duration - Invidious sends 0 and Piped
// uses -1 as its live sentinel, which must never reach storage.
duration: video.isLive ? 0 : max(0, video.duration),
thumbnailURLString: video.bestThumbnail?.url.absoluteString,
isLive: video.isLive
)
}
}

View File

@@ -0,0 +1,17 @@
//
// Array+Chunked.swift
// Yattee
//
// Splitting arrays into fixed-size chunks.
//
import Foundation
extension Array {
/// Splits the array into chunks of the specified size.
func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map {
Array(self[$0..<Swift.min($0 + size, count)])
}
}
}

View File

@@ -0,0 +1,71 @@
//
// Color+Hex.swift
// Yattee
//
// Hex-string parsing/formatting for SwiftUI Color, plus the environment value
// used to drive position-based colorful shortcut cards.
//
import SwiftUI
extension Color {
/// Creates a color from a `#RRGGBB` / `RRGGBB` hex string (also accepts
/// `#RGB` shorthand and an optional `AA`/`AARRGGBB`/`RRGGBBAA` alpha).
/// Returns `nil` for anything it can't parse so bad input is simply skipped.
init?(hex: String) {
var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if cleaned.hasPrefix("#") {
cleaned.removeFirst()
}
guard !cleaned.isEmpty, cleaned.allSatisfy({ $0.isHexDigit }) else { return nil }
// Expand #RGB shorthand to #RRGGBB.
if cleaned.count == 3 {
cleaned = cleaned.map { "\($0)\($0)" }.joined()
}
guard let value = UInt64(cleaned, radix: 16) else { return nil }
let r, g, b, a: Double
switch cleaned.count {
case 6: // RRGGBB
r = Double((value & 0xFF0000) >> 16) / 255
g = Double((value & 0x00FF00) >> 8) / 255
b = Double(value & 0x0000FF) / 255
a = 1
case 8: // RRGGBBAA
r = Double((value & 0xFF00_0000) >> 24) / 255
g = Double((value & 0x00FF_0000) >> 16) / 255
b = Double((value & 0x0000_FF00) >> 8) / 255
a = Double(value & 0x0000_00FF) / 255
default:
return nil
}
self.init(red: r, green: g, blue: b, opacity: a)
}
/// Formats the resolved color as an uppercase `#RRGGBB` string.
func toHexString() -> String {
let resolved = resolve(in: EnvironmentValues())
let r = Int((max(0, min(1, resolved.red)) * 255).rounded())
let g = Int((max(0, min(1, resolved.green)) * 255).rounded())
let b = Int((max(0, min(1, resolved.blue)) * 255).rounded())
return String(format: "#%02X%02X%02X", r, g, b)
}
}
// MARK: - Environment: position-based colorful color
private struct HomeShortcutColorfulColorKey: EnvironmentKey {
static let defaultValue: Color? = nil
}
extension EnvironmentValues {
/// The colorful-style fill color resolved from the shortcut's grid position.
/// When set, it overrides a card's fixed `colorfulColor`.
var homeShortcutColorfulColor: Color? {
get { self[HomeShortcutColorfulColorKey.self] }
set { self[HomeShortcutColorfulColorKey.self] = newValue }
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
extension TimeInterval {
/// Formats as "M:SS" or "H:MM:SS" when hours > 0.
var formattedAsTimestamp: String {
let totalSeconds = Int(max(0, self))
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
}

View File

@@ -0,0 +1,50 @@
//
// ToolbarContent+Close.swift
// Yattee
//
// Shared sheet-dismiss toolbar item.
//
import SwiftUI
/// Standard sheet-dismiss toolbar item.
///
/// On macOS this renders a native text button (e.g. "Close"), which fits the desktop
/// convention. On iOS/tvOS it renders the compact icon-only xmark used by the rest of
/// the app's sheets.
///
/// Placement defaults to `.confirmationAction`; only the *label* changes per platform,
/// so it can be dropped into existing `.toolbar { }` blocks without changing layout.
@ToolbarContentBuilder
func sheetCloseToolbarItem(
placement: ToolbarItemPlacement = .confirmationAction,
titleKey: LocalizedStringResource = "common.close",
identifier: String? = nil,
action: @escaping () -> Void
) -> some ToolbarContent {
ToolbarItem(placement: placement) {
sheetCloseButton(titleKey: titleKey, identifier: identifier, action: action)
}
}
@ViewBuilder
private func sheetCloseButton(
titleKey: LocalizedStringResource,
identifier: String?,
action: @escaping () -> Void
) -> some View {
let button = Button(role: .cancel, action: action) {
#if os(macOS)
Text(String(localized: titleKey))
#else
Label(String(localized: titleKey), systemImage: "xmark")
.labelStyle(.iconOnly)
#endif
}
if let identifier {
button.accessibilityIdentifier(identifier)
} else {
button
}
}

View File

@@ -0,0 +1,24 @@
//
// View+SoftScrollEdgeEffect.swift
// Yattee
//
// iOS 27 changed the default scroll edge effect style from soft to hard,
// which draws a sharp dividing line under the toolbar. Views that extend
// a banner or gradient beneath the toolbar (channel header, video info)
// need the soft style so the artwork stays cleanly visible.
//
import SwiftUI
extension View {
/// Forces the soft (blur/fade) scroll edge effect on the top edge
/// for scroll views in this hierarchy. No-op before iOS 26/macOS 26.
@ViewBuilder
func softTopScrollEdgeEffect() -> some View {
if #available(iOS 26, macOS 26, tvOS 26, *) {
scrollEdgeEffectStyle(.soft, for: .top)
} else {
self
}
}
}

View File

@@ -9,6 +9,14 @@
import SwiftUI
#if os(iOS)
/// Whether the iOS binary is running on a Mac (Catalyst or "Designed for iPad").
/// Zoom transitions trap inside UIKit's _UIZoomTransitionController when a sheet
/// is dismissed on the Mac presentation path, so they must stay off there.
private let zoomTransitionsRunningOnMac: Bool =
ProcessInfo.processInfo.isMacCatalystApp || ProcessInfo.processInfo.isiOSAppOnMac
#endif
// MARK: - Environment Keys
/// Environment key to pass the navigation transition namespace through the view hierarchy.
@@ -49,7 +57,7 @@ struct ZoomTransitionSourceModifier<ID: Hashable>: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
if zoomTransitionsEnabled, let namespace {
if zoomTransitionsEnabled, !zoomTransitionsRunningOnMac, let namespace {
content
.matchedTransitionSource(id: id, in: namespace)
} else {
@@ -75,7 +83,7 @@ struct ZoomTransitionDestinationModifier<ID: Hashable>: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
if zoomTransitionsEnabled, let namespace {
if zoomTransitionsEnabled, !zoomTransitionsRunningOnMac, let namespace {
content
.navigationTransition(.zoom(sourceID: id, in: namespace))
} else {

View File

@@ -0,0 +1,17 @@
//
// ObjCExceptionHandler.h
// Yattee
//
// Catches ObjC NSExceptions that Swift cannot handle natively.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Executes a block and catches any NSException thrown.
/// Returns YES if the block executed without throwing, NO if an exception was caught.
/// If an exception is caught, it is returned via the outException parameter.
BOOL tryCatchObjCException(void (NS_NOESCAPE ^block)(void), NSException *_Nullable *_Nullable outException);
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,20 @@
//
// ObjCExceptionHandler.m
// Yattee
//
// Catches ObjC NSExceptions that Swift cannot handle natively.
//
#import "ObjCExceptionHandler.h"
BOOL tryCatchObjCException(void (NS_NOESCAPE ^block)(void), NSException *_Nullable *_Nullable outException) {
@try {
block();
return YES;
} @catch (NSException *exception) {
if (outException) {
*outException = exception;
}
return NO;
}
}

47
Yattee/Info-tvOS.plist Normal file
View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>stream.yattee.app.feedRefresh</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>stream.yattee.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>yattee</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_yattee._tcp</string>
<string>_webdav._tcp</string>
<string>_webdavs._tcp</string>
<string>_smb._tcp</string>
</array>
<key>NSUserActivityTypes</key>
<array>
<string>stream.yattee.app.activity</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>remote-notification</string>
<string>fetch</string>
</array>
</dict>
</plist>

View File

@@ -4,6 +4,15 @@
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<!-- Sparkle auto-updates (Developer ID / Homebrew cask build only; ignored on App Store + iOS + tvOS) -->
<key>SUFeedURL</key>
<string>https://dl.yattee.stream/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>BLtSfi3Epsl97XpMy734PhlbscxWwWpi6moT/S++A+4=</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUEnableInstallerLauncherService</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>stream.yattee.app.feedRefresh</string>

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ struct CachedChannelData: Codable {
let thumbnailURL: URL?
let bannerURL: URL?
let subscriberCount: Int?
let description: String?
/// In-memory cache of author data from video detail API responses.
@MainActor
@@ -32,21 +33,25 @@ struct CachedChannelData: Codable {
.appendingPathComponent("authors.json")
}
init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?) {
init(name: String, thumbnailURL: URL?, bannerURL: URL?, subscriberCount: Int?, description: String? = nil) {
self.name = name
self.thumbnailURL = thumbnailURL
self.bannerURL = bannerURL
self.subscriberCount = subscriberCount
self.description = description
}
@MainActor
static func cacheAuthor(_ author: Author) {
guard author.thumbnailURL != nil || author.subscriberCount != nil else { return }
guard !author.id.isEmpty, !author.name.isEmpty else { return }
loadFromDiskIfNeeded()
let existing = authorCache[author.id]
authorCache[author.id] = CachedChannelData(
name: author.name,
thumbnailURL: author.thumbnailURL,
bannerURL: nil,
subscriberCount: author.subscriberCount
thumbnailURL: author.thumbnailURL ?? existing?.thumbnailURL,
bannerURL: existing?.bannerURL,
subscriberCount: author.subscriberCount ?? existing?.subscriberCount,
description: existing?.description
)
// Evict oldest entries if over limit
@@ -101,6 +106,7 @@ struct CachedChannelData: Codable {
thumbnailURL = subscription.avatarURL
bannerURL = subscription.bannerURL
subscriberCount = subscription.subscriberCount
description = subscription.channelDescription
}
init(from recentChannel: RecentChannel) {
@@ -108,6 +114,7 @@ struct CachedChannelData: Codable {
thumbnailURL = recentChannel.thumbnailURLString.flatMap { URL(string: $0) }
bannerURL = nil // RecentChannel doesn't store banner
subscriberCount = recentChannel.subscriberCount
description = nil
}
/// Load cached data for a channel ID from Subscription or RecentChannel.

View File

@@ -18,6 +18,11 @@ struct Caption: Identifiable, Codable, Hashable, Sendable {
/// The URL to fetch the caption content
let url: URL
/// Original filename of a user-picked external subtitle file.
/// When set, it is used as the display name so two picked files with the
/// same language remain distinguishable from API captions and each other.
var pickedFileName: String? = nil
/// Whether this is an auto-generated caption
var isAutoGenerated: Bool {
label.contains("auto-generated")
@@ -37,6 +42,9 @@ struct Caption: Identifiable, Codable, Hashable, Sendable {
/// Formatted display name for the caption
var displayName: String {
if let pickedFileName {
return pickedFileName
}
// Try to get localized language name (AUTO badge shown separately in UI)
if let localizedName = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
return localizedName

View File

@@ -73,6 +73,19 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
/// Whether to allow invalid/self-signed SSL certificates.
var allowInvalidCertificates: Bool
/// Whether to route video streams through this instance instead of connecting directly to YouTube CDN.
var proxiesVideos: Bool
/// Whether this instance sits behind an HTTP Basic Auth reverse proxy.
/// Set when basic auth credentials are stored, so a missing Keychain entry
/// (e.g. after reinstalling and importing sources from iCloud) can be detected.
/// Yattee Server always requires basic auth regardless of this flag.
var usesBasicAuth: Bool
/// Whether the user has logged into an account on this instance (Invidious/Piped).
/// Set on login, so a missing Keychain credential can be detected after reinstall.
var usesAccountLogin: Bool
// MARK: - Initialization
init(
@@ -83,7 +96,10 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
isEnabled: Bool = true,
dateAdded: Date = Date(),
apiKey: String? = nil,
allowInvalidCertificates: Bool = false
allowInvalidCertificates: Bool = false,
proxiesVideos: Bool = false,
usesBasicAuth: Bool = false,
usesAccountLogin: Bool = false
) {
self.id = id
self.type = type
@@ -93,6 +109,24 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
self.dateAdded = dateAdded
self.apiKey = apiKey
self.allowInvalidCertificates = allowInvalidCertificates
self.proxiesVideos = proxiesVideos
self.usesBasicAuth = usesBasicAuth
self.usesAccountLogin = usesAccountLogin
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
type = try container.decode(InstanceType.self, forKey: .type)
url = try container.decode(URL.self, forKey: .url)
name = try container.decodeIfPresent(String.self, forKey: .name)
isEnabled = try container.decode(Bool.self, forKey: .isEnabled)
dateAdded = try container.decode(Date.self, forKey: .dateAdded)
apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey)
allowInvalidCertificates = try container.decode(Bool.self, forKey: .allowInvalidCertificates)
proxiesVideos = try container.decodeIfPresent(Bool.self, forKey: .proxiesVideos) ?? false
usesBasicAuth = try container.decodeIfPresent(Bool.self, forKey: .usesBasicAuth) ?? false
usesAccountLogin = try container.decodeIfPresent(Bool.self, forKey: .usesAccountLogin) ?? false
}
// MARK: - Computed Properties
@@ -101,6 +135,17 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
name ?? url.host ?? url.absoluteString
}
/// Returns the URL string with embedded credentials stripped for safe display in the UI.
var displayURL: String {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.user != nil else {
return url.absoluteString
}
components.user = nil
components.password = nil
return components.url?.absoluteString ?? url.absoluteString
}
var contentSource: ContentSource {
type.contentSource(for: url)
}
@@ -148,6 +193,27 @@ extension Instance {
var supportsPopular: Bool {
type == .invidious || type == .yatteeServer
}
/// Whether this instance supports proxying video streams through itself.
var supportsVideoProxying: Bool {
type == .invidious || type == .piped || type == .yatteeServer
}
/// Whether this instance can sit behind an HTTP Basic Auth reverse proxy.
/// Piped is excluded: its session token is sent in the same `Authorization`
/// header that the proxy would consume, so logged-in features can't coexist
/// with proxy credentials.
var supportsHTTPBasicAuthProxy: Bool {
type.supportsHTTPBasicAuthProxy
}
}
extension InstanceType {
/// See `Instance.supportsHTTPBasicAuthProxy`. Type-only variant for flows
/// (e.g., AddRemoteServer detection) that don't yet have an `Instance`.
var supportsHTTPBasicAuthProxy: Bool {
self != .piped
}
}
// MARK: - Instance Validation
@@ -245,10 +311,6 @@ extension Instance {
components.path = String(components.path.dropLast())
}
// Strip embedded credentials (security best practice)
components.user = nil
components.password = nil
return components.url
}
}

View File

@@ -0,0 +1,182 @@
//
// MPVTrack.swift
// Yattee
//
// Model representing one entry of mpv's `track-list` property.
//
import Foundation
/// A single track reported by mpv's `track-list` property (embedded or external).
struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
enum TrackType: String, Decodable, Sendable {
case video
case audio
case sub
}
/// mpv track id unique only within a track type.
let trackID: Int
let type: TrackType
let title: String?
let lang: String?
let isDefault: Bool
let isForced: Bool
/// True for tracks loaded via `sub-add`/`audio-add` (external files).
let isExternal: Bool
/// Whether mpv currently plays this track.
let isSelected: Bool
/// Cover-art pseudo video tracks.
let isAlbumArt: Bool
let codec: String?
let channelCount: Int?
let sampleRate: Int?
let width: Int?
let height: Int?
let fps: Double?
/// Identifiable across types mpv ids collide between audio/sub/video.
var id: String { "\(type.rawValue):\(trackID)" }
private enum CodingKeys: String, CodingKey {
case trackID = "id"
case type
case title
case lang
case isDefault = "default"
case isForced = "forced"
case isExternal = "external"
case isSelected = "selected"
case isAlbumArt = "albumart"
case codec
case channelCount = "demux-channel-count"
case sampleRate = "demux-samplerate"
case width = "demux-w"
case height = "demux-h"
case fps = "demux-fps"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
trackID = try container.decode(Int.self, forKey: .trackID)
type = try container.decode(TrackType.self, forKey: .type)
title = try container.decodeIfPresent(String.self, forKey: .title)
lang = try container.decodeIfPresent(String.self, forKey: .lang)
isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false
isForced = try container.decodeIfPresent(Bool.self, forKey: .isForced) ?? false
isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false
isSelected = try container.decodeIfPresent(Bool.self, forKey: .isSelected) ?? false
isAlbumArt = try container.decodeIfPresent(Bool.self, forKey: .isAlbumArt) ?? false
codec = try container.decodeIfPresent(String.self, forKey: .codec)
channelCount = try container.decodeIfPresent(Int.self, forKey: .channelCount)
sampleRate = try container.decodeIfPresent(Int.self, forKey: .sampleRate)
width = try container.decodeIfPresent(Int.self, forKey: .width)
height = try container.decodeIfPresent(Int.self, forKey: .height)
fps = try container.decodeIfPresent(Double.self, forKey: .fps)
}
init(
trackID: Int,
type: TrackType,
title: String? = nil,
lang: String? = nil,
isDefault: Bool = false,
isForced: Bool = false,
isExternal: Bool = false,
isSelected: Bool = false,
isAlbumArt: Bool = false,
codec: String? = nil,
channelCount: Int? = nil,
sampleRate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
fps: Double? = nil
) {
self.trackID = trackID
self.type = type
self.title = title
self.lang = lang
self.isDefault = isDefault
self.isForced = isForced
self.isExternal = isExternal
self.isSelected = isSelected
self.isAlbumArt = isAlbumArt
self.codec = codec
self.channelCount = channelCount
self.sampleRate = sampleRate
self.width = width
self.height = height
self.fps = fps
}
/// Base language code normalized to the 2-letter form when possible, so
/// Matroska 3-letter codes ("eng") match preference values ("en").
var baseLanguageCode: String? {
guard let lang, !lang.isEmpty, lang != "und" else { return nil }
var code = lang.lowercased()
if let hyphenIndex = code.firstIndex(of: "-") {
code = String(code[..<hyphenIndex])
}
guard code.count == 3 else { return code }
// ISO 639-2 -> 639-1 where a 2-letter code exists ("eng" -> "en").
return Locale.LanguageCode(code).identifier(.alpha2) ?? code
}
/// "Director's Commentary", "English", or a numbered fallback.
var displayName: String {
if let title, !title.isEmpty {
return title
}
if let baseLanguageCode,
let localized = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
return localized
}
return String(localized: "player.track.number \(trackID)")
}
/// Whether this track's language matches a user preference code like "en".
func matchesLanguage(_ preferredCode: String?) -> Bool {
guard let preferredCode, !preferredCode.isEmpty,
let baseLanguageCode else { return false }
var preferred = preferredCode.lowercased()
if let hyphenIndex = preferred.firstIndex(of: "-") {
preferred = String(preferred[..<hyphenIndex])
}
if preferred.count == 3 {
preferred = Locale.LanguageCode(preferred).identifier(.alpha2) ?? preferred
}
return preferred == baseLanguageCode
}
/// Format a sample rate in Hz as the conventional kHz label.
/// 48000 "48 kHz", 44100 "44.1 kHz", 88200 "88.2 kHz",
/// 22050 "22.05 kHz", 176400 "176.4 kHz".
/// Whole-kHz rates drop the decimal; fractional rates keep their
/// meaningful digits (12 decimals, trailing zero stripped).
static func formatSampleRate(_ hz: Int) -> String {
let kHz = Double(hz) / 1000.0
if kHz == kHz.rounded() {
return "\(Int(kHz)) kHz"
}
var label = String(format: "%.2f", kHz)
if label.hasSuffix("0") {
label.removeLast()
}
return "\(label) kHz"
}
/// Secondary detail line for advanced mode, e.g. "aac · 2ch · 44.1 kHz".
var detailText: String? {
var parts: [String] = []
if let codec, !codec.isEmpty {
parts.append(codec)
}
if let channelCount {
parts.append("\(channelCount)ch")
}
if let sampleRate {
parts.append(Self.formatSampleRate(sampleRate))
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
}

View File

@@ -67,7 +67,14 @@ struct MediaFile: Identifiable, Hashable, Sendable {
/// Full URL to this file.
var url: URL {
source.url.appendingPathComponent(path)
// For local folders, the persisted `source.url` may point at a stale app
// container path after iOS reinstall/restore. Prefer the freshly resolved
// bookmark URL stored in `LocalFolderURLResolver` when available.
if source.type == .localFolder,
let resolved = LocalFolderURLResolver.resolvedURL(for: source.id) {
return resolved.appendingPathComponent(path)
}
return source.url.appendingPathComponent(path)
}
/// File extension (lowercase).

View File

@@ -15,6 +15,8 @@ enum SidebarItem: Hashable, Identifiable {
case sources
case settings
case nowPlaying
case openURL
case remoteControl
// MARK: - Dynamic Channel Items
case channel(channelID: String, name: String, source: ContentSource)
@@ -34,6 +36,8 @@ enum SidebarItem: Hashable, Identifiable {
case downloads
case subscriptionsFeed
case manageChannels
case playlistsList
case continueWatching
// MARK: - Identifiable
@@ -49,6 +53,10 @@ enum SidebarItem: Hashable, Identifiable {
return "settings"
case .nowPlaying:
return "now-playing"
case .openURL:
return "open-url"
case .remoteControl:
return "remote-control"
case .channel(let channelID, _, let source):
return "channel-\(source.provider)-\(channelID)"
case .playlist(let id, _):
@@ -67,6 +75,10 @@ enum SidebarItem: Hashable, Identifiable {
return "subscriptions-feed"
case .manageChannels:
return "manage-channels"
case .playlistsList:
return "playlists-list"
case .continueWatching:
return "continue-watching"
}
}
@@ -84,6 +96,10 @@ enum SidebarItem: Hashable, Identifiable {
return String(localized: "tabs.settings")
case .nowPlaying:
return String(localized: "sidebar.nowPlaying")
case .openURL:
return String(localized: "sidebar.mainItem.openURL")
case .remoteControl:
return String(localized: "sidebar.mainItem.remoteControl")
case .channel(_, let name, _):
return name
case .playlist(_, let title):
@@ -102,6 +118,10 @@ enum SidebarItem: Hashable, Identifiable {
return String(localized: "home.subscriptions.title")
case .manageChannels:
return String(localized: "sidebar.manageChannels")
case .playlistsList:
return String(localized: "home.playlists.title")
case .continueWatching:
return String(localized: "home.continueWatching.title")
}
}
@@ -117,6 +137,10 @@ enum SidebarItem: Hashable, Identifiable {
return "gear"
case .nowPlaying:
return "play.circle.fill"
case .openURL:
return "link"
case .remoteControl:
return "antenna.radiowaves.left.and.right"
case .channel:
return "person.circle"
case .playlist:
@@ -135,6 +159,10 @@ enum SidebarItem: Hashable, Identifiable {
return "play.square.stack.fill"
case .manageChannels:
return "person.2"
case .playlistsList:
return "list.bullet.rectangle"
case .continueWatching:
return "play.circle"
}
}
@@ -144,7 +172,7 @@ enum SidebarItem: Hashable, Identifiable {
/// Returns nil for items that are root views (home, search) which don't push.
func navigationDestination() -> NavigationDestination? {
switch self {
case .home, .search, .sources, .settings, .nowPlaying:
case .home, .search, .sources, .settings, .nowPlaying, .openURL, .remoteControl:
// These are root tabs, not push destinations
return nil
case .channel(let channelID, _, let source):
@@ -166,6 +194,10 @@ enum SidebarItem: Hashable, Identifiable {
return .subscriptionsFeed
case .manageChannels:
return .manageChannels
case .playlistsList:
return .playlists
case .continueWatching:
return .continueWatching
}
}
@@ -174,7 +206,7 @@ enum SidebarItem: Hashable, Identifiable {
/// Whether this is a fixed navigation item (always visible).
var isFixedNavigation: Bool {
switch self {
case .home, .search, .sources, .settings, .nowPlaying:
case .home, .search, .sources, .settings, .nowPlaying, .openURL, .remoteControl:
return true
default:
return false

View File

@@ -16,18 +16,23 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case history
case downloads
case channels
case playlists
case sources
case settings
case openURL
case remoteControl
case continueWatching
var id: String { rawValue }
/// Default order for sidebar main items.
static var defaultOrder: [SidebarMainItem] {
[.search, .home, .subscriptions, .bookmarks, .history, .channels, .sources, .downloads, .settings]
[.search, .home, .subscriptions, .bookmarks, .history, .channels, .playlists, .sources, .openURL, .remoteControl, .downloads, .continueWatching, .settings]
}
/// Default visibility (all visible except subscriptions and channels).
static var defaultVisibility: [SidebarMainItem: Bool] {
#if os(tvOS)
[
.search: true,
.home: true,
@@ -36,9 +41,30 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
.history: false,
.downloads: true,
.channels: false,
.playlists: false,
.sources: true,
.settings: true
.settings: true,
.openURL: false,
.remoteControl: true,
.continueWatching: false
]
#else
[
.search: true,
.home: true,
.subscriptions: false,
.bookmarks: false,
.history: false,
.downloads: true,
.channels: false,
.playlists: false,
.sources: true,
.settings: true,
.openURL: false,
.remoteControl: false,
.continueWatching: false
]
#endif
}
/// SF Symbol icon name.
@@ -51,8 +77,12 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .history: "clock"
case .downloads: "arrow.down.circle"
case .channels: "person.2"
case .playlists: "list.bullet.rectangle"
case .sources: "server.rack"
case .settings: "gear"
case .openURL: "link"
case .remoteControl: "antenna.radiowaves.left.and.right"
case .continueWatching: "play.circle"
}
}
@@ -66,8 +96,12 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .history: String(localized: "sidebar.mainItem.history")
case .downloads: String(localized: "sidebar.mainItem.downloads")
case .channels: String(localized: "sidebar.mainItem.channels")
case .playlists: String(localized: "sidebar.mainItem.playlists")
case .sources: String(localized: "sidebar.mainItem.sources")
case .settings: String(localized: "sidebar.mainItem.settings")
case .openURL: String(localized: "sidebar.mainItem.openURL")
case .remoteControl: String(localized: "sidebar.mainItem.remoteControl")
case .continueWatching: String(localized: "sidebar.mainItem.continueWatching")
}
}
@@ -90,6 +124,12 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
#else
return true
#endif
case .settings:
#if os(macOS)
return false
#else
return true
#endif
default:
return true
}
@@ -108,8 +148,12 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .history: return TabBarItem.history.rawValue
case .downloads: return TabBarItem.downloads.rawValue
case .channels: return TabBarItem.channels.rawValue
case .playlists: return TabBarItem.playlists.rawValue
case .sources: return TabBarItem.sources.rawValue
case .settings: return TabBarItem.settings.rawValue
case .openURL: return "open-url"
case .remoteControl: return "remote-control"
case .continueWatching: return "continue-watching"
}
}
@@ -123,8 +167,12 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .history: return .history
case .downloads: return .downloads
case .channels: return .manageChannels
case .playlists: return .playlistsList
case .sources: return .sources
case .settings: return .settings
case .openURL: return .openURL
case .remoteControl: return .remoteControl
case .continueWatching: return .continueWatching
}
}
@@ -134,11 +182,12 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .subscriptions: self = .subscriptions
case .channels: self = .channels
case .bookmarks: self = .bookmarks
case .playlists: return nil // No direct mapping - playlists isn't a SidebarMainItem
case .playlists: self = .playlists
case .history: self = .history
case .downloads: self = .downloads
case .sources: self = .sources
case .settings: self = .settings
case .continueWatching: self = .continueWatching
}
}
}

View File

@@ -17,17 +17,18 @@ enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
case downloads
case sources
case settings
case continueWatching
var id: String { rawValue }
/// Default order for tab bar items.
static var defaultOrder: [TabBarItem] {
[.subscriptions, .channels, .bookmarks, .playlists, .history, .sources, .downloads, .settings]
[.subscriptions, .channels, .bookmarks, .playlists, .history, .continueWatching, .sources, .downloads, .settings]
}
/// Default visibility (only subscriptions visible by default).
static var defaultVisibility: [TabBarItem: Bool] {
[.subscriptions: false, .channels: false, .bookmarks: false, .playlists: false, .history: false, .downloads: true, .sources: true, .settings: false]
[.subscriptions: false, .channels: false, .bookmarks: false, .playlists: false, .history: false, .downloads: true, .sources: true, .settings: false, .continueWatching: false]
}
/// SF Symbol icon name.
@@ -41,6 +42,7 @@ enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .downloads: "arrow.down.circle"
case .sources: "server.rack"
case .settings: "gear"
case .continueWatching: "play.circle"
}
}
@@ -55,6 +57,7 @@ enum TabBarItem: String, CaseIterable, Codable, Identifiable, Sendable {
case .downloads: String(localized: "tabBar.item.downloads")
case .sources: String(localized: "tabBar.item.sources")
case .settings: String(localized: "tabBar.item.settings")
case .continueWatching: String(localized: "tabBar.item.continueWatching")
}
}
}

View File

@@ -10,30 +10,43 @@ import Foundation
/// Closure type for loading more videos via continuation
typealias LoadMoreVideosCallback = @Sendable () async throws -> ([Video], String?)
/// Extra payload required to play files from a media source (WebDAV/SMB/local folder)
/// via `QueueManager.playFromMediaBrowser`. Without this, playback falls back to
/// `openVideo` / `playFromList`, which do not set up on-demand stream and caption
/// resolution for media-browser files.
struct MediaBrowserPlaybackInfo: Equatable, Hashable {
let source: MediaSource
let allFilesInFolder: [MediaFile]
}
/// Context information for playing a video with queue support.
/// Used when navigating from list views (subscriptions, search, etc.) to video info pages.
struct VideoQueueContext {
/// The video being viewed
let video: Video
/// Queue source for continuation loading
let queueSource: QueueSource?
/// Display label for the queue source (e.g., "Subscriptions", "Search Results")
let sourceLabel: String?
/// All videos in the current list
let videoList: [Video]?
/// Index of the current video in the list
let videoIndex: Int?
/// Optional start time in seconds
let startTime: TimeInterval?
/// Callback to load more videos when reaching the end of the current list
/// Returns new videos and updated continuation token
let loadMoreVideos: LoadMoreVideosCallback?
/// When set, playback from VideoInfoView must route through
/// `QueueManager.playFromMediaBrowser` using this payload.
var mediaBrowserPlayback: MediaBrowserPlaybackInfo? = nil
/// Whether this context has valid queue information
var hasQueueInfo: Bool {
@@ -77,7 +90,8 @@ extension VideoQueueContext: Equatable {
lhs.sourceLabel == rhs.sourceLabel &&
lhs.videoList == rhs.videoList &&
lhs.videoIndex == rhs.videoIndex &&
lhs.startTime == rhs.startTime
lhs.startTime == rhs.startTime &&
lhs.mediaBrowserPlayback == rhs.mediaBrowserPlayback
}
}
@@ -89,5 +103,6 @@ extension VideoQueueContext: Hashable {
hasher.combine(videoList)
hasher.combine(videoIndex)
hasher.combine(startTime)
hasher.combine(mediaBrowserPlayback)
}
}

View File

@@ -11,7 +11,7 @@ extension LayoutPreset {
/// Bump this version whenever any built-in preset definition changes.
/// On launch, the app compares this against the last-applied version
/// and replaces stale built-in presets with fresh copies from code.
static let builtInPresetsVersion = 5
static let builtInPresetsVersion = 8
// MARK: - Built-in Preset IDs
@@ -25,6 +25,19 @@ extension LayoutPreset {
/// Default preset with balanced controls for general use.
static func defaultPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset {
LayoutPreset(
id: BuiltInID.defaultPreset,
name: String(localized: "controls.preset.default"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: deviceClass == .macOS ? macOSDefaultLayout() : standardDefaultLayout()
)
}
/// Default layout for iOS/tvOS (touch-oriented overlay controls).
private static func standardDefaultLayout() -> PlayerControlsLayout {
// Top buttons: titleAuthor (wideOnly), spacer, orientationLock, close
let topButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(
@@ -110,7 +123,7 @@ extension LayoutPreset {
// Mini player: show video, tap for PiP
let miniPlayerSettings = MiniPlayerSettings()
let layout = PlayerControlsLayout(
return PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
@@ -120,20 +133,84 @@ extension LayoutPreset {
playerPillSettings: playerPillSettings,
miniPlayerSettings: miniPlayerSettings
)
}
return LayoutPreset(
id: BuiltInID.defaultPreset,
name: String(localized: "controls.preset.default"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: layout
/// Default layout for macOS, mirroring the QuickTime-style control bar:
/// top bar with title/author, keep-on-top pin and close; capsule row with
/// volume slider, transport and trailing actions around flexible spacers.
private static func macOSDefaultLayout() -> PlayerControlsLayout {
let topButtons: [ControlButtonConfiguration] = [
.defaultConfiguration(for: .titleAuthor),
.flexibleSpacer(),
.defaultConfiguration(for: .keepOnTop),
.defaultConfiguration(for: .close)
]
let bottomButtons: [ControlButtonConfiguration] = [
ControlButtonConfiguration(
buttonType: .volume,
settings: .slider(SliderSettings(sliderBehavior: .alwaysVisible))
),
.flexibleSpacer(),
.defaultConfiguration(for: .queue),
.defaultConfiguration(for: .playPrevious),
.defaultConfiguration(for: .playPause),
.defaultConfiguration(for: .playNext),
.flexibleSpacer(),
.defaultConfiguration(for: .contextMenu),
.defaultConfiguration(for: .settings),
.defaultConfiguration(for: .pictureInPicture),
.defaultConfiguration(for: .fullscreen)
]
// On macOS center settings only drive seek amounts (keyboard arrows
// and default seek buttons).
let centerSettings = CenterSectionSettings(
showPlayPause: true,
showSeekBackward: true,
showSeekForward: true,
seekBackwardSeconds: 10,
seekForwardSeconds: 10,
leftSlider: .disabled,
rightSlider: .disabled
)
let globalSettings = GlobalLayoutSettings(
style: .plain,
buttonSize: .medium,
fontStyle: .system,
systemControlsMode: .seek,
systemControlsSeekDuration: .tenSeconds,
volumeMode: .mpv
)
return PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
globalSettings: globalSettings,
progressBarSettings: ProgressBarSettings(),
gesturesSettings: nil,
playerPillSettings: nil,
miniPlayerSettings: MiniPlayerSettings()
)
}
/// Minimal preset with stripped-down controls for distraction-free playback.
static func minimalPreset(for deviceClass: DeviceClass = .current) -> LayoutPreset {
LayoutPreset(
id: BuiltInID.minimalPreset,
name: String(localized: "controls.preset.minimal"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: deviceClass == .macOS ? macOSMinimalLayout() : standardMinimalLayout()
)
}
/// Minimal layout for iOS/tvOS.
private static func standardMinimalLayout() -> PlayerControlsLayout {
let topButtons: [ControlButtonConfiguration] = [
.flexibleSpacer(),
.defaultConfiguration(for: .close)
@@ -200,7 +277,7 @@ extension LayoutPreset {
sponsorBlockSettings: SponsorBlockSegmentSettings(showSegments: false)
)
let layout = PlayerControlsLayout(
return PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
@@ -210,15 +287,56 @@ extension LayoutPreset {
playerPillSettings: playerPillSettings,
miniPlayerSettings: miniPlayerSettings
)
}
return LayoutPreset(
id: BuiltInID.minimalPreset,
name: String(localized: "controls.preset.minimal"),
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
isBuiltIn: true,
deviceClass: deviceClass,
layout: layout
/// Minimal layout for macOS: bare play/pause and settings in the capsule,
/// close-only top bar, no chapters or SponsorBlock markers.
private static func macOSMinimalLayout() -> PlayerControlsLayout {
let topButtons: [ControlButtonConfiguration] = [
.flexibleSpacer(),
.defaultConfiguration(for: .close)
]
let bottomButtons: [ControlButtonConfiguration] = [
.flexibleSpacer(),
.defaultConfiguration(for: .playPause),
.flexibleSpacer(),
.defaultConfiguration(for: .settings)
]
let centerSettings = CenterSectionSettings(
showPlayPause: true,
showSeekBackward: true,
showSeekForward: true,
seekBackwardSeconds: 10,
seekForwardSeconds: 10,
leftSlider: .disabled,
rightSlider: .disabled
)
let globalSettings = GlobalLayoutSettings(
style: .plain,
buttonSize: .medium,
fontStyle: .system,
systemControlsMode: .seek,
systemControlsSeekDuration: .tenSeconds,
volumeMode: .mpv
)
let progressBarSettings = ProgressBarSettings(
showChapters: false,
sponsorBlockSettings: SponsorBlockSegmentSettings(showSegments: false)
)
return PlayerControlsLayout(
topSection: LayoutSection(buttons: topButtons),
centerSettings: centerSettings,
bottomSection: LayoutSection(buttons: bottomButtons),
globalSettings: globalSettings,
progressBarSettings: progressBarSettings,
gesturesSettings: nil,
playerPillSettings: nil,
miniPlayerSettings: MiniPlayerSettings()
)
}

View File

@@ -25,6 +25,12 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
/// Number of seconds for the seek forward button.
var seekForwardSeconds: Int
/// Number of seconds for the secondary seek backward shortcut ( + seek).
var secondarySeekBackwardSeconds: Int
/// Number of seconds for the secondary seek forward shortcut ( + seek).
var secondarySeekForwardSeconds: Int
/// Type of slider to show on the left edge of the player (iOS only).
var leftSlider: SideSliderType
@@ -40,6 +46,8 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
/// - showSeekForward: Show seek forward button. Defaults to true.
/// - seekBackwardSeconds: Seconds to seek backward. Defaults to 10.
/// - seekForwardSeconds: Seconds to seek forward. Defaults to 10.
/// - secondarySeekBackwardSeconds: Seconds for the secondary () backward seek. Defaults to 30.
/// - secondarySeekForwardSeconds: Seconds for the secondary () forward seek. Defaults to 30.
/// - leftSlider: Type of slider on left edge. Defaults to disabled.
/// - rightSlider: Type of slider on right edge. Defaults to disabled.
init(
@@ -48,6 +56,8 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
showSeekForward: Bool = true,
seekBackwardSeconds: Int = 10,
seekForwardSeconds: Int = 10,
secondarySeekBackwardSeconds: Int = 30,
secondarySeekForwardSeconds: Int = 30,
leftSlider: SideSliderType = .disabled,
rightSlider: SideSliderType = .disabled
) {
@@ -56,6 +66,8 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
self.showSeekForward = showSeekForward
self.seekBackwardSeconds = max(1, seekBackwardSeconds)
self.seekForwardSeconds = max(1, seekForwardSeconds)
self.secondarySeekBackwardSeconds = max(1, secondarySeekBackwardSeconds)
self.secondarySeekForwardSeconds = max(1, secondarySeekForwardSeconds)
self.leftSlider = leftSlider
self.rightSlider = rightSlider
}
@@ -68,6 +80,8 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
case showSeekForward
case seekBackwardSeconds
case seekForwardSeconds
case secondarySeekBackwardSeconds
case secondarySeekForwardSeconds
case leftSlider
case rightSlider
}
@@ -80,6 +94,8 @@ struct CenterSectionSettings: Codable, Hashable, Sendable {
seekBackwardSeconds = try container.decode(Int.self, forKey: .seekBackwardSeconds)
seekForwardSeconds = try container.decode(Int.self, forKey: .seekForwardSeconds)
// New properties with defaults for backward compatibility
secondarySeekBackwardSeconds = try container.decodeIfPresent(Int.self, forKey: .secondarySeekBackwardSeconds) ?? 30
secondarySeekForwardSeconds = try container.decodeIfPresent(Int.self, forKey: .secondarySeekForwardSeconds) ?? 30
leftSlider = try container.decodeIfPresent(SideSliderType.self, forKey: .leftSlider) ?? .disabled
rightSlider = try container.decodeIfPresent(SideSliderType.self, forKey: .rightSlider) ?? .disabled
}

View File

@@ -39,7 +39,9 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
case titleAuthor
case panscan
case autoPlayNext
case audioMode
case seek
case keepOnTop
// MARK: - Version Tracking
@@ -115,8 +117,12 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
return String(localized: "controls.button.panscan")
case .autoPlayNext:
return String(localized: "controls.button.autoPlayNext")
case .audioMode:
return String(localized: "controls.button.audioMode")
case .seek:
return String(localized: "controls.button.seek")
case .keepOnTop:
return String(localized: "controls.button.keepOnTop")
}
}
@@ -183,8 +189,12 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
return "arrow.left.and.right.square"
case .autoPlayNext:
return "play.square.stack.fill"
case .audioMode:
return "music.note"
case .seek:
return "goforward.10" // Default icon, actual icon is determined by settings
case .keepOnTop:
return "pin"
}
}
@@ -224,6 +234,31 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
/// Button types available for top/bottom sections.
static var availableForHorizontalSections: [ControlButtonType] {
#if os(macOS)
[
.spacer,
.timeDisplay,
.titleAuthor,
.playPrevious,
.playPause,
.playNext,
.seek,
.queue,
.close,
.keepOnTop,
.volume,
.pictureInPicture,
.fullscreen,
.controlsLock,
.settings,
.playbackSpeed,
.share,
.contextMenu,
.autoPlayNext,
.audioMode,
.mpvDebug
]
#else
[
.spacer,
.timeDisplay,
@@ -252,9 +287,11 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
.panelToggle,
.panscan,
.autoPlayNext,
.audioMode,
.airplay,
.mpvDebug
]
#endif
}
/// Button types for center section (play/pause, seek).
@@ -287,6 +324,25 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
/// Button types available for the mini player (curated subset for compact UI).
static var availableForMiniPlayer: [ControlButtonType] {
#if os(macOS)
[
// Transport
.playPause,
.playPrevious,
.playNext,
.seek,
// Queue & Actions
.queue,
.close,
// Player Actions
.share,
.addToPlaylist,
.pictureInPicture,
// Utility
.playbackSpeed,
.audioMode
]
#else
[
// Transport
.playPause,
@@ -302,7 +358,9 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable {
.airplay,
.pictureInPicture,
// Utility
.playbackSpeed
.playbackSpeed,
.audioMode
]
#endif
}
}

View File

@@ -411,6 +411,9 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
/// How volume is controlled during playback.
var volumeMode: VolumeMode
/// Forced appearance for the macOS control bar's glass background.
var controlBarTheme: ControlsTheme
/// Theme derived from style (for backwards compatibility).
var theme: ControlsTheme { style.theme }
@@ -433,6 +436,7 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
case systemControlsMode
case systemControlsSeekDuration
case volumeMode
case controlBarTheme
}
init(from decoder: Decoder) throws {
@@ -445,6 +449,7 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
systemControlsMode = try container.decodeIfPresent(SystemControlsMode.self, forKey: .systemControlsMode) ?? .seek
systemControlsSeekDuration = try container.decodeIfPresent(SystemControlsSeekDuration.self, forKey: .systemControlsSeekDuration) ?? .tenSeconds
volumeMode = try container.decodeIfPresent(VolumeMode.self, forKey: .volumeMode) ?? .mpv
controlBarTheme = try container.decodeIfPresent(ControlsTheme.self, forKey: .controlBarTheme) ?? .system
}
func encode(to encoder: Encoder) throws {
@@ -457,6 +462,7 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
try container.encode(systemControlsMode, forKey: .systemControlsMode)
try container.encode(systemControlsSeekDuration, forKey: .systemControlsSeekDuration)
try container.encode(volumeMode, forKey: .volumeMode)
try container.encode(controlBarTheme, forKey: .controlBarTheme)
}
// MARK: - Initialization
@@ -469,7 +475,8 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
controlsFadeOpacity: Double = 0.5,
systemControlsMode: SystemControlsMode = .seek,
systemControlsSeekDuration: SystemControlsSeekDuration = .tenSeconds,
volumeMode: VolumeMode = .mpv
volumeMode: VolumeMode = .mpv,
controlBarTheme: ControlsTheme = .system
) {
self.style = style
self.buttonSize = buttonSize
@@ -478,6 +485,7 @@ struct GlobalLayoutSettings: Codable, Hashable, Sendable {
self.systemControlsMode = systemControlsMode
self.systemControlsSeekDuration = systemControlsSeekDuration
self.volumeMode = volumeMode
self.controlBarTheme = controlBarTheme
}
// MARK: - Defaults

View File

@@ -99,7 +99,12 @@ struct Storyboard: Hashable, Sendable, Codable {
/// - Returns: Direct URL for the sprite sheet, or nil if invalid
func directSheetURL(for index: Int) -> URL? {
guard index >= 0, index < storyboardCount else { return nil }
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "\(index)")
// YouTube storyboard filenames are `M{N}.jpg` and the templateUrl encodes the
// slot as `M$M`. The leading `M` is literal, so replace `M$M` with `M{index}`
// (not bare `\(index)`) otherwise the file becomes `0.jpg` instead of `M0.jpg`
// and YouTube returns 404. Matching the full `M$M` token also avoids accidentally
// rewriting any `$M` that appears later in query params such as `sigh=rs$...`.
let urlString = templateUrl.replacingOccurrences(of: "M$M", with: "M\(index)")
return URL(string: urlString)
}

View File

@@ -189,6 +189,64 @@ struct StreamResolution: Codable, Hashable, Sendable, Comparable, CustomStringCo
}
}
// MARK: - Audio-Only Variant
extension Stream {
/// Whether the backend must disable the video track when loading this stream:
/// a muxed file being played as audio-only (see `audioOnlyVariant()`).
var requiresVideoTrackDisabled: Bool {
isAudioOnly && videoCodec != nil
}
/// Creates an audio-only copy of this stream keeping the same URL.
/// Used for muxed local files in audio mode: `videoCodec` stays set so
/// `requiresVideoTrackDisabled` tells the backend to skip the video track.
func audioOnlyVariant() -> Stream {
Stream(
url: url,
resolution: nil,
format: format,
videoCodec: videoCodec,
audioCodec: audioCodec,
bitrate: bitrate,
fileSize: fileSize,
isAudioOnly: true,
isLive: isLive,
mimeType: mimeType,
audioLanguage: audioLanguage,
audioTrackName: audioTrackName,
isOriginalAudio: isOriginalAudio,
httpHeaders: httpHeaders
)
}
}
// MARK: - URL Rewriting
extension Stream {
/// Creates a copy of this stream with a different URL.
/// Used for proxying streams through an instance.
func withURL(_ newURL: URL) -> Stream {
Stream(
url: newURL,
resolution: resolution,
format: format,
videoCodec: videoCodec,
audioCodec: audioCodec,
bitrate: bitrate,
fileSize: fileSize,
isAudioOnly: isAudioOnly,
isLive: isLive,
mimeType: mimeType,
audioLanguage: audioLanguage,
audioTrackName: audioTrackName,
isOriginalAudio: isOriginalAudio,
httpHeaders: httpHeaders,
fps: fps
)
}
}
// MARK: - Preview Data
extension Stream {

View File

@@ -0,0 +1,29 @@
//
// TVVideoSyncMode.swift
// Yattee
//
// MPV `video-sync` mode override exposed on tvOS for A/V sync debugging.
//
import SwiftUI
enum TVVideoSyncMode: String, CaseIterable, Codable {
/// Lock video to display refresh, drop/repeat frames to match. Audio plays at PTS.
/// Current shipping default.
case displayVdrop = "display-vdrop"
/// Lock video to display refresh; resample audio to match. Eliminates A/V drift
/// when the display mode doesn't match content fps, at the cost of tiny audio
/// pitch correction.
case displayResample = "display-resample"
/// libmpv default: video adjusts to audio. Most forgiving when display-mode
/// matching is uncertain.
case audio
var displayName: LocalizedStringKey {
switch self {
case .displayVdrop: "settings.playback.tvVideoSyncMode.displayVdrop"
case .displayResample: "settings.playback.tvVideoSyncMode.displayResample"
case .audio: "settings.playback.tvVideoSyncMode.audio"
}
}
}

View File

@@ -91,6 +91,25 @@ struct Video: Identifiable, Codable, Sendable {
thumbnails.sorted { $0.quality > $1.quality }.first
}
/// Thumbnail URLs ordered best-quality-first.
///
/// Used as a fallback chain: backends (and YouTube's CDN) often advertise
/// `maxres`/`sddefault` variants that don't actually exist for a given video
/// and 404. Consumers try these in order and drop to the next when one fails.
var thumbnailURLsByQuality: [URL] {
thumbnails.sorted { $0.quality > $1.quality }.map(\.url)
}
/// Best thumbnail URL rewritten to the always-available `hqdefault` variant.
///
/// For consumers that render or fetch a single URL with no way to fall back
/// through the quality chain (Now Playing artwork, Top Shelf, remote control,
/// navigation covers): the best advertised variant is often `maxresdefault`,
/// which 404s for many older videos.
var reliableThumbnailURL: URL? {
Thumbnail.reliableURL(for: bestThumbnail?.url)
}
var formattedDuration: String {
guard !isLive else { return "LIVE" }
guard duration > 0 else { return "" }
@@ -302,6 +321,55 @@ struct Thumbnail: Codable, Hashable, Sendable {
self.width = width
self.height = height
}
/// Expands a single stored YouTube-style thumbnail URL (`/vi/<id>/<variant>.jpg`)
/// into a best-first fallback chain. Persistence models (playlists, watch history,
/// bookmarks) store only the best advertised URL usually `maxresdefault.jpg`,
/// which doesn't exist for many older videos and 404s. Reconstructing the
/// lower-quality variants lets thumbnail views fall back the same way they do
/// for live API results. Non-matching URLs get a single-entry chain.
/// Rewrites a YouTube-style thumbnail URL (`/vi/<id>/<variant>.jpg`) to the
/// `hqdefault.jpg` variant, which exists for effectively every video (unlike
/// `maxresdefault`/`sddefault`, which 404 for many older uploads). Use where a
/// single URL is displayed without fallback, e.g. playlist covers. Non-matching
/// URLs are returned unchanged.
static func reliableURL(for url: URL?) -> URL? {
guard let url else { return nil }
let variants = ["maxresdefault.jpg", "sddefault.jpg", "hqdefault.jpg", "mqdefault.jpg", "default.jpg"]
let path = url.path
guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil,
let match = variants.first(where: { path.hasSuffix($0) }),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
components.path = String(path.dropLast(match.count)) + "hqdefault.jpg"
return components.url ?? url
}
static func fallbackChain(for url: URL?) -> [Thumbnail] {
guard let url else { return [] }
let variants: [(suffix: String, quality: Quality)] = [
("maxresdefault.jpg", .maxres),
("sddefault.jpg", .standard),
("hqdefault.jpg", .high),
("mqdefault.jpg", .medium),
("default.jpg", .default),
]
let path = url.path
guard path.range(of: #"/vi/[^/]+/"#, options: .regularExpression) != nil,
let current = variants.first(where: { path.hasSuffix($0.suffix) }),
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return [Thumbnail(url: url, quality: .medium)]
}
let basePath = String(path.dropLast(current.suffix.count))
return variants.compactMap { variant in
guard variant.quality <= current.quality else { return nil }
var variantComponents = components
variantComponents.path = basePath + variant.suffix
guard let variantURL = variantComponents.url else { return nil }
return Thumbnail(url: variantURL, quality: variant.quality)
}
}
}
// MARK: - Preview Support

View File

@@ -43,7 +43,7 @@ enum GridConstants {
/// Spacing between grid items.
static let spacing: CGFloat = {
#if os(tvOS)
32
48
#else
12
#endif
@@ -55,6 +55,15 @@ enum GridConstants {
/// Maximum allowed columns (to prevent excessive density).
static let maxAllowedColumns = 6
/// Minimum allowed columns. tvOS doesn't make sense with a single column.
static let minAllowedColumns: Int = {
#if os(tvOS)
2
#else
1
#endif
}()
/// Threshold for compact card styling (columns >= this use compact mode).
static let compactThreshold = 3
}
@@ -74,14 +83,14 @@ func maxGridColumns(
// Formula: availableWidth = (columns * minCardWidth) + ((columns - 1) * spacing)
// Solving for columns: columns = (availableWidth + spacing) / (minCardWidth + spacing)
let maxColumns = Int((availableWidth + spacing) / (minCardWidth + spacing))
return max(1, min(maxColumns, GridConstants.maxAllowedColumns))
return max(GridConstants.minAllowedColumns, min(maxColumns, GridConstants.maxAllowedColumns))
}
/// Creates grid columns for a LazyVGrid with the specified count.
/// - Parameter count: Number of columns
/// - Returns: Array of flexible GridItems with top alignment
func makeGridColumns(count: Int) -> [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: GridConstants.spacing, alignment: .top), count: max(1, count))
Array(repeating: GridItem(.flexible(), spacing: GridConstants.spacing, alignment: .top), count: max(GridConstants.minAllowedColumns, count))
}
// MARK: - Grid Layout Configuration
@@ -118,7 +127,7 @@ struct GridLayoutConfiguration {
/// Effective column count, clamped to valid range.
var effectiveColumns: Int {
min(max(1, gridColumns), max(1, maxColumns))
min(max(GridConstants.minAllowedColumns, gridColumns), max(GridConstants.minAllowedColumns, maxColumns))
}
/// Whether cards should use compact styling (3+ columns).

Some files were not shown because too many files have changed in this diff Show More