Commit Graph

41 Commits

Author SHA1 Message Date
Arkadiusz Fal
45e19e6536 Open more YouTube link types and playlists from links
Route YouTube search results links to search and nocookie embeds to the
video, match YouTube hosts exactly, and report YouTube pages without a
video as unsupported instead of sending them to yt-dlp. A watch link
inside a playlist shows both the video and the playlist when loaded, and
plays the video with the playlist queued around it. URL scheme play,
queue and bookmark actions accept playlist links.
2026-10-08 22:50:36 +02:00
Arkadiusz Fal
03be964c66 Load a single link from Open Link into its details screen
Open Link gets a Load action that resolves one link and shows its video
details page, playlist or channel screen instead of only playing or
downloading it. Play and Download stay as shortcuts for video links.
Timed links start at their timestamp from the details page, the Show
Details link action opens the details page, and a short extraction cache
avoids re-extracting the same link for details and playback.
2026-10-08 18:25:32 +02:00
Arkadiusz Fal
c579ee63a2 Support x-callback-url for URL scheme actions
yattee://x-callback-url/{verb}?… runs the same action as yattee://{verb}?…
and then opens x-success with the result's values appended, x-error with
errorCode/errorMessage, or x-cancel when a confirmation is declined (x-error
with errorCode=cancelled when no x-cancel is given). Unknown verbs report
unsupportedAction. Callbacks to file:, javascript: and data: URLs are ignored.

Adds yattee://player/state, which reports what is playing (title, videoID,
channel, playing, position, duration, rate) without changing anything.
2026-10-05 23:59:05 +02:00
Arkadiusz Fal
5ae07574cb Add URL scheme actions: play, queue, download, bookmark, subscribe, player controls
yattee://play|play-next|queue|download|bookmark|subscribe|unsubscribe?url={link}
and yattee://player/play|pause|toggle|next|previous|close|seek|rate run through
one LinkActionPerformer that reports a LinkActionResult, ready for
x-callback-url. An unencoded url= keeps its own query; only the action's own
parameters end it.

Actions that change saved state ask first through a confirmation sheet
presented by the top-most sheet host, unless the new 'Run Link Actions
Without Asking' setting is on. Download skips the question when the quality
picker will show anyway. Player commands leave sheets and the player alone.
2026-10-05 23:58:43 +02:00
Arkadiusz Fal
dea4f590ec Restore 1.x yattee:// links and harden URL scheme routing
- Route legacy yattee://watch?v=, yattee://youtube.com/..., yattee://https://...
  links like their web equivalents (only natively handled destinations)
- Unwrap yattee://open?url= inside route() so every caller keeps the inner
  URL's own query items
- Never route file:// URLs as direct media
- Support source/instance on yattee://playlist and PeerTube /w/p/ and
  /video-playlists/ URLs
- Leave open sheets alone and show a toast for links that go nowhere
2026-10-05 23:33:48 +02:00
Arkadiusz Fal
cf90eb4d4e Dismiss sheets when navigation arrives from outside the app
Tapping a new-videos notification while Home's Settings sheet was up
switched to the Subscriptions feed underneath the sheet, so the app
came back still showing Settings. The existing dismissSettings() hook
was never called.

NavigationCoordinator.dismissPresentations() bumps a generation that
the main window's root sheet host and screen sheets (Home's Settings,
Remote Devices, Customize Home; Subscriptions' view options and data)
observe via .dismissesOnExternalNavigation(_:). The notification,
deep links and Handoff call it; in-app navigation and layout swaps do
not. The expanded player keeps its own sheets.

The notification also collapses the expanded player so the feed it
opens is visible.
2026-10-04 23:50:39 +02:00
Arkadiusz Fal
1c7314f74a Present deep-link download options through the shared sheet host
The download deep link kept the video, streams and captions in separate
states next to a presented flag, and the sheet was declared only at the
app root, so it opened behind an expanded player. It is now a global
request for the download options AppSheet.
2026-10-04 22:56:05 +02:00
Arkadiusz Fal
3e561cc3b5 Present error details and Open Link through the shared sheet host
Error details were declared in the video info view and the expanded
player, and Open Link three times: at the app root for deep links and
for the File menu command, and on Home. All of them are now AppSheets;
the deep link and menu requests go through the global request, so a
deep link that arrives while the player is expanded no longer opens the
sheet behind it.

The four sign-in modals that were a full-screen cover on tvOS and a
sheet elsewhere, each written out twice, use a platformSheet helper.
2026-10-04 21:04:19 +02:00
Arkadiusz Fal
e3d3809f2f Present row sheets through the shared sheet host
Each video row carried its own sheets: tappableVideo added the queue
action and resume sheets, the context menu and swipe actions added
add-to-playlist and download options, so a row could declare up to six
sheets, owned by a row that a list may recycle.

Rows now present those through the nearest sheet host, as does the
video info view's resume prompt. The queue action sheet for a tapped
description link becomes a global request instead of being declared
at both the app root and the expanded player.

Sheets take the color scheme where their host sits, so the floating
details panel's dark override no longer reaches them and the
presentationColorScheme workaround is gone.
2026-10-04 09:07:05 +02:00
Arkadiusz Fal
79202667c0 Show mini player sheets already requested when the root appears
The root view mirrors the coordinator's mini-player queue and playlist
sheet flags into local state on change only, so a flag set before the
view first appeared was never shown. Read the initial value too.

Also remove the iOS Settings sheet driven by the .showSettings
notification, which nothing posts.
2026-10-03 23:22:08 +02:00
Arkadiusz Fal
6efd60a18e Play direct media links without Yattee Server by probing the URL first
Debrid and CDN download links (TorBox, Real-Debrid, presigned S3) hide the
file behind an opaque path, so the extension-based direct media check missed
them and every one was sent to Yattee Server for yt-dlp extraction, failing
outright when no server is configured.

Add DirectMediaProbe: a HEAD request classifies the response by Content-Type
and Content-Disposition, and when the type is generic or HEAD is refused, a
ranged GET sniffs the first bytes for container signatures (Matroska/WebM,
ISO BMFF, Ogg, FLAC, MP3, RIFF, FLV, ASF, MPEG-PS/TS, HLS and DASH
manifests). Anything playable goes straight to mpv with the server-reported
filename as the title; everything else falls back to extraction as before.

The probe runs ahead of the Yattee Server path in ExternalVideoView, both
OpenLinkSheet flows and the deep-link download path, and PlayerService
rebuilds direct_media streams itself so replays from history no longer
depend on a server either.
2026-09-21 20:03:06 +02:00
Arkadiusz Fal
5651f8d431 Add a toolbar layout for the video info actions with a configurable primary action
Layout & Navigation gains a Video Info section on iOS/macOS: the actions
(Play, Download, Share, Add to Playlist, Bookmark) can stay as inline
button rows or move to the navigation bar, where the chosen primary
action is pinned and the rest live in an ellipsis menu. The toolbar
layout with Download pinned is the new default, since the hero thumbnail
already plays the video on tap; a one-time TipKit popover on the
thumbnail points that out. tvOS keeps the existing button layout.
2026-09-20 23:52:20 +02:00
Arkadiusz Fal
de8ed91029 Use a scheme-adaptive background for tvOS full screen covers
tvOS full screen covers are transparent, so each one filled itself with
a hardcoded black — invisible black-on-black text under the light color
scheme (e.g. the View All Comments page). A shared
tvFullScreenCoverBackground() modifier now picks black or light gray
from the color scheme; applied to the comments cover, subscription
destination picker, instance login, edit source, iCloud progress, and
legacy accounts import covers.
2026-09-19 23:04:01 +02:00
Arkadiusz Fal
7bcc711c40 Mirror a watch into the account it was watched under 2026-09-06 11:06:15 +02:00
Arkadiusz Fal
ca73b68ad9 Let activation know the accounts before it repairs the selection
Foreground activation started identity resolution, the deletion repair and
the CloudKit fetch as three things racing each other. With a tombstone for
the selected account already on disk and a sibling login on the same server
whose status had not been read back yet, the repair ran against `checking`
— which it will not repair onto — and landed on local. It commits, so the
status refresh arriving a moment later changed nothing: the selection no
longer named an account for a second repair to act on.

The steps now run as one sequence in both platform paths, because each one
is what makes the next correct: the identity says whose accounts these are,
the status refresh says which of them this device can use, the repair reads
those statuses, and the fetch goes last so a deletion it delivers repairs
against the same refreshed picture.
2026-09-05 22:27:21 +02:00
Arkadiusz Fal
175b98097a Put the account switcher where the data is, and retire the old setting
Switching accounts was buried in Subscriptions settings behind a picker
and a confirmation dialog, and it wrote two stores that could disagree.
One shared switcher now sits in the Subscriptions and Playlists
toolbars, the macOS sidebar footer and the Settings root, and every path
goes through AppEnvironment.switchDataContext(to:) so a switch behaves
the same wherever it is made. Switching is one tap: it is reversible, so
there is nothing to confirm.

Rows come from DataContextValidator.availableContexts, name themselves
through displayName(for:), and carry a status suffix when the account is
not signed in — plus the instance host when two accounts share a name.
tvOS gets the same rows as a full-screen picker.

Subscriptions also explains itself when the selected account cannot
answer: a thin banner names the cause and says the data below is local.
This is the first consumer of the validator's unavailability, and
correctIfContextRemoved() now runs on foreground activation, where
SubscriptionAccountValidator used to auto-correct any selection it could
not immediately verify.

With the switcher reading the context directly, the legacy
subscriptionAccount setting has no readers left: the setting, its
validator, the mirror that kept it in step and the feed cache's
instance-keyed fallbacks all go. The cache invalidation the mirror also
carried stays, under its own name. AccountsManager still reads the raw
legacy key for the one-way context migration.
2026-09-03 23:59:32 +02:00
Arkadiusz Fal
defa6cf084 Make the account picker and feed route see account logins
Round three of review.

The subscription picker is fed by SubscriptionAccountValidator, which still
decided availability from the instance-keyed credential managers. A login
created after the revamp writes only an account-scoped key, so it never
appeared in the picker at all - which meant the account resolver added last
round could not even be reached. Availability now comes from AccountsManager
and account status.

SubscriptionFeedCache dispatched on the legacy setting and picked its
instance from it, so an account waiting on its credential produced
subscriptions from local data while the feed reported notAuthenticated, and
deleting the selected account could leave the route pointing at its server.
Both the route and the credential now come from the effective context, with
the legacy lookup kept only as a fallback for state that predates accounts.

Foreground activation re-resolved the identity scope but only recomputed
statuses when the scope actually moved. An iCloud Keychain credential
arriving after launch does not move it, so the account stayed
credentialMissing and reads stayed on the local fallback until relaunch.
Statuses are now recomputed on every activation in both scene-phase
branches.

Providers are bound to an account but never told it anything: a revoked SID
left the account signedIn, so the validator kept treating it as valid and
neither the banner nor the read fallback engaged. Server calls now record
their outcome. Conservatively - a raw 401 marks the credential rejected only
when no reverse-proxy basic auth is configured, since otherwise the 401 may
have come from the proxy. The two-stage probe that disambiguates the rest is
Phase 2; until then an ambiguous failure leaves the status alone rather than
wrongly telling the user to sign in again.
2026-08-25 22:40:47 +02:00
Arkadiusz Fal
c4053d5100 Close identity and adoption holes in credential scoping
Four issues from an external review of the previous commit.

The scope was resolved once at startup, so signing out or switching Apple
account while the app kept running left it pinned to the old identity -
reads and writes would then reach the previous account's retained
credentials, which is the exact leak scoping exists to prevent.
CredentialIdentityScope now observes NSUbiquityIdentityDidChange and
re-resolves.

Legacy deletion authority checked only the instance and the identity, not
the account. On an instance with several accounts, deleting any of them
satisfied the marker lookup and could retire the legacy item another
account had adopted, logging out devices still on the previous release.
The marker's accountID is now required to match.

A first-time resolution that failed (launched offline, CloudKit
unreachable) was never retried, disabling credentials for the rest of the
process. resolve() now retries with backoff, and resolveIfNeeded() runs on
foreground activation.

Adoption re-checked nothing at execution time, so a scoped credential
delivered by iCloud Keychain between rendering the offer and tapping it
would be overwritten with the older legacy secret. Adoption now re-verifies
eligibility and writes with a non-replacing add, leaving a duplicate
strictly alone.

Adds regression tests for the sibling-account deletion and the adoption
race.
2026-08-23 23:39: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
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
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
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
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
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
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
bde80bfef1 Add manual legacy account import 2026-05-17 13:08:32 +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
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
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
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
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
397fc46629 Open Settings in a dedicated resizable macOS window 2026-04-22 21:59:34 +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
fef9a07aa9 Set minimum window size on macOS 2026-04-20 22:24:58 +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
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
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
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
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
100df744d9 Yattee v2 rewrite 2026-04-18 20:37:24 +02:00