Compare commits

..

11 Commits

Author SHA1 Message Date
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
17 changed files with 353 additions and 341 deletions

View File

@@ -13,7 +13,7 @@ on:
build_mac_beta:
description: 'Build macOS (TestFlight)'
type: boolean
default: false
default: true
build_mac_notarized:
description: 'Build macOS (notarized Developer ID + Sparkle appcast)'
type: boolean
@@ -237,7 +237,9 @@ jobs:
fi
- uses: ncipollo/release-action@v1
with:
artifacts: artifacts/**/*.ipa,artifacts/**/*.zip,artifacts/**/*.pkg,artifacts/**/*.dmg
# 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: ${{ steps.compute_tag.outputs.tag }}
prerelease: ${{ steps.compute_tag.outputs.prerelease }}
@@ -349,5 +351,6 @@ jobs:
if: ${{ inputs.build_ios && success() }}
needs: [release]
uses: ./.github/workflows/update-altstore.yml
secrets: inherit
with:
tag: ${{ needs.release.outputs.tag }}

View File

@@ -22,6 +22,9 @@ jobs:
- uses: actions/checkout@v4
with:
ref: main
# 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="${{ inputs.tag }}"

View File

@@ -1,44 +1,13 @@
## What's Changed
**macOS support** — Yattee 2 arrives on the Mac with its first public beta. It brings the full Yattee 2 experience to the desktop with a native interface: a dedicated player window with fullscreen and stay on top option, Picture in Picture, customizable and movable player controls, keyboard shortcuts, and other features present in the iOS app.
### Bug Fixes
The macOS app is available via [TestFlight](https://yattee.stream/beta2), or as a notarized direct download from [GitHub Releases](https://github.com/yattee/yattee/releases) that keeps itself up to date with built-in automatic updates.
* Fix macOS legacy import sheet missing grouped form style
* Fix tvOS legacy import rows acting as a single Remove button
* Fix periodic tvOS playback stutter from render stalls on mpvQueue
### General
### Other
#### New Features
* Add audio-only music mode, available in video player settings and player controls button
* Add custom accent color setting with system color picker and separate light and dark mode colors
* Redesign Home shortcut cards with layout, color, and palette options and a live style preview
* Add Edit Shortcuts and Hide options to Home shortcut context menus
* Add pause, resume, and cancel context menu to download rows
* Add manual legacy account import and allow importing account-less legacy instances as sources
#### Improvements
* Rework iCloud sync engine for more reliable syncing; resume sync from saved state instead of re-fetching everything
* Sync watch progress when a video plays to the end and when the app goes to the background
* Fall back to lower-quality thumbnails when higher-resolution variants are unavailable
* Apply the selected theme at the window level so it takes effect everywhere
#### Bug Fixes
* Fix disabling Background Playback having no effect
* Fix downloads never finishing and a crash on download completion
* Fix missing storyboards when advancing to the next queued video
* Fix live videos shown in the mini player bar
* Fix crash when opening the share sheet on iPad
* Fix rare data loss when the app is suspended in the background
* Fix iCloud sync conflicts for recent channels, playlists, and watch progress
* Fix Sources home shortcut counting disabled instances
### iOS
* Use toolbar search placement on iPad channel view
### tvOS
* Add A/V sync diagnostics settings page
* Default Channels grid to 5 columns
* Fixes for some reported playback issues
* Make Enable Logging the master switch over all verbose logging

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

View File

@@ -570,7 +570,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -656,7 +656,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -800,7 +800,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -881,7 +881,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -910,7 +910,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -942,7 +942,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -977,7 +977,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1008,7 +1008,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1040,7 +1040,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1074,7 +1074,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1109,7 +1109,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -1138,7 +1138,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 264;
CURRENT_PROJECT_VERSION = 266;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;

View File

@@ -192,8 +192,12 @@ final class LoggingService: Sendable {
/// OSLog output is synchronous; in-app storage is dispatched to MainActor.
nonisolated func log(level: LogLevel, category: LogCategory, message: String, details: String? = nil) {
// Log to OSLog in DEBUG builds for Xcode console visibility
// OSLog/Logger is thread-safe, so this can be called from any thread
// OSLog/Logger is thread-safe, so this can be called from any thread.
// The master "Enable Logging" toggle gates this too - it takes
// precedence over everything, so logging off means a silent console.
// (Read UserDefaults directly: this runs off the MainActor.)
#if DEBUG
if UserDefaults.standard.bool(forKey: "loggingEnabled") {
let fullMessage = details.map { "\(message) - \($0)" } ?? message
switch level {
case .debug:
@@ -205,6 +209,7 @@ final class LoggingService: Sendable {
case .error:
osLogger.error("[\(category.rawValue)] \(fullMessage)")
}
}
#endif
// Dispatch in-app storage to MainActor asynchronously

View File

@@ -140,6 +140,18 @@ final class MPVClient: @unchecked Sendable {
private var mpv: OpaquePointer?
private var renderContext: OpaquePointer?
private let mpvQueue = DispatchQueue(label: "stream.yattee.mpv.client", qos: .userInteractive)
/// Protects `renderContext` lifecycle vs use. Render-context calls are
/// thread-safe relative to the client API per libmpv docs, so they must NOT
/// serialize through `mpvQueue`: any slow queue work (e.g. a batched debug
/// property fetch) would block `render()` and visibly stall playback
/// (~190ms hitch every 10s on tvOS, build 264 regression).
private let renderContextLock = NSLock()
/// Serial queue for render-update callback dispatch. Deliberately separate
/// from `mpvQueue` so frame-ready notifications are never delayed behind
/// property/command work.
private let renderEventQueue = DispatchQueue(label: "stream.yattee.mpv.render-events", qos: .userInteractive)
private var isDestroyed = false
#if os(macOS)
@@ -1780,7 +1792,9 @@ final class MPVClient: @unchecked Sendable {
return false
}
renderContextLock.lock()
renderContext = ctx
renderContextLock.unlock()
log("Render context created successfully")
MPVLogging.log("createRenderContext: success")
@@ -1800,10 +1814,16 @@ final class MPVClient: @unchecked Sendable {
#else
// On iOS/tvOS, check if this update includes an actual video frame.
// This is needed for PiP frame capture which relies on onVideoFrameReady.
client.mpvQueue.async {
guard let renderCtx = client.renderContext else { return }
// Dispatched on renderEventQueue (NOT mpvQueue) so frame notifications
// are never delayed behind slow property/command work.
client.renderEventQueue.async {
client.renderContextLock.lock()
var hasVideoFrame = false
if let renderCtx = client.renderContext, !client.isDestroyed {
let flags = mpv_render_context_update(renderCtx)
let hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
}
client.renderContextLock.unlock()
// Always notify for general redraw
client.onRenderUpdate?()
@@ -1829,7 +1849,9 @@ final class MPVClient: @unchecked Sendable {
/// - Note: This may trigger a priority inversion warning because MPV's internal
/// threads run at default QoS. This is unavoidable when using mpv_render_context_render.
func render(fbo: Int32, width: Int32, height: Int32) {
mpvQueue.sync {
renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let renderContext, !isDestroyed else {
// Log when render is skipped to help diagnose black screen issues
MPVLogging.warn("render: skipped",
@@ -1860,7 +1882,6 @@ final class MPVClient: @unchecked Sendable {
}
}
}
}
/// Create a render context for software rendering (CPU-based, for simulator).
/// - Returns: Whether creation succeeded
@@ -1897,7 +1918,9 @@ final class MPVClient: @unchecked Sendable {
return false
}
renderContextLock.lock()
renderContext = ctx
renderContextLock.unlock()
log("Software render context created successfully")
MPVLogging.log("createSoftwareRenderContext: success")
@@ -1910,10 +1933,14 @@ final class MPVClient: @unchecked Sendable {
let client = Unmanaged<MPVClient>.fromOpaque(clientPtr).takeUnretainedValue()
// Check if this update includes an actual video frame
client.mpvQueue.async {
guard let renderCtx = client.renderContext else { return }
client.renderEventQueue.async {
client.renderContextLock.lock()
var hasVideoFrame = false
if let renderCtx = client.renderContext, !client.isDestroyed {
let flags = mpv_render_context_update(renderCtx)
let hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
}
client.renderContextLock.unlock()
// Always notify for general redraw
client.onRenderUpdate?()
@@ -1939,7 +1966,9 @@ final class MPVClient: @unchecked Sendable {
/// - Returns: true if a frame was rendered, false otherwise
@discardableResult
func renderSoftware(buffer: UnsafeMutableRawPointer, width: Int32, height: Int32, stride: Int) -> Bool {
mpvQueue.sync {
renderContextLock.lock()
defer { renderContextLock.unlock() }
return {
guard let renderContext, !isDestroyed else {
return false
}
@@ -1992,15 +2021,21 @@ final class MPVClient: @unchecked Sendable {
}
return true
}
}()
}
/// Report that the next frame should be rendered.
func reportRenderUpdate() {
mpvQueue.async { [weak self] in
guard let self, let renderContext = self.renderContext, !self.isDestroyed else { return }
renderEventQueue.async { [weak self] in
guard let self else { return }
self.renderContextLock.lock()
var hasFrame = false
if let renderContext = self.renderContext, !self.isDestroyed {
let flags = mpv_render_context_update(renderContext)
if flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0 {
hasFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
}
self.renderContextLock.unlock()
if hasFrame {
self.onRenderUpdate?()
}
}
@@ -2008,7 +2043,11 @@ final class MPVClient: @unchecked Sendable {
/// Destroy the render context.
func destroyRenderContext() {
// mpvQueue serializes against create; renderContextLock excludes
// in-flight render/update calls while the context is freed.
mpvQueue.sync {
renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let ctx = renderContext else {
MPVLogging.log("destroyRenderContext: no context to destroy")
return
@@ -2022,7 +2061,9 @@ final class MPVClient: @unchecked Sendable {
/// Whether the render context is initialized.
var hasRenderContext: Bool {
mpvQueue.sync { renderContext != nil }
renderContextLock.lock()
defer { renderContextLock.unlock() }
return renderContext != nil
}
// MARK: - macOS OpenGL Context Management
@@ -2052,18 +2093,21 @@ final class MPVClient: @unchecked Sendable {
// MARK: - Frame Timing
/// Report that a frame was swapped/presented (for vsync timing).
/// Called synchronously from the render thread right after present - direct
/// call (no queue hop) keeps the swap timestamp accurate for mpv's vsync
/// estimation and avoids any queue-contention delay.
func reportSwap() {
mpvQueue.async { [weak self] in
guard let ctx = self?.renderContext else { return }
renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let ctx = renderContext, !isDestroyed else { return }
mpv_render_context_report_swap(ctx)
}
}
/// Check if MPV has a frame ready to render (non-blocking).
/// This is safe to call from any thread - mpv's render context API is thread-safe.
func shouldRenderUpdateFrame() -> Bool {
// Don't use mpvQueue.sync here - it can cause deadlocks when called from render queue
// mpv_render_context_update is documented as thread-safe
renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let ctx = renderContext, !isDestroyed else { return false }
let flags = mpv_render_context_update(ctx)
return flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
@@ -2071,7 +2115,9 @@ final class MPVClient: @unchecked Sendable {
/// Get the render context directly (for thread-safe mpv render operations).
var mpvRenderContext: OpaquePointer? {
renderContext
renderContextLock.lock()
defer { renderContextLock.unlock() }
return renderContext
}
// MARK: - Rendering with Depth
@@ -2083,7 +2129,9 @@ final class MPVClient: @unchecked Sendable {
/// - height: Render height in pixels
/// - depth: Color depth (8 or 16 for 10-bit)
func renderWithDepth(fbo: Int32, width: Int32, height: Int32, depth: Int32) {
mpvQueue.sync {
renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let renderContext, !isDestroyed else {
MPVLogging.warn("renderWithDepth: skipped",
details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)")
@@ -2118,4 +2166,3 @@ final class MPVClient: @unchecked Sendable {
}
}
}
}

View File

@@ -37,8 +37,11 @@ enum MPVLogging {
if now - _lastCheckTime > cacheDurationNanos {
_lastCheckTime = now
// Read from UserDefaults directly for thread safety
// (SettingsManager is @MainActor)
_cachedIsEnabled = UserDefaults.standard.bool(forKey: "verboseMPVLogging")
// (SettingsManager is @MainActor).
// The master "Enable Logging" switch takes precedence: with it off,
// verbose MPV logging is off no matter what the verbose flag says.
_cachedIsEnabled = UserDefaults.standard.bool(forKey: "loggingEnabled")
&& UserDefaults.standard.bool(forKey: "verboseMPVLogging")
}
return _cachedIsEnabled

View File

@@ -836,9 +836,23 @@ final class MPVRenderView: UIView {
private var slowFrameCount: UInt64 = 0
private var lastSlowFrameWarning: Date = .distantPast
/// Debug: start time of the previous performRender, to spot gaps where mpv
/// stopped delivering frames (core-side stall vs. GL-side slow frame).
private var lastRenderStartMediaTime: CFTimeInterval = 0
private var lastGapWarning: Date = .distantPast
private func performRender() {
defer { isRendering = false }
let frameStart = Date()
let tFrameStart = CACurrentMediaTime()
let gapSincePrevFrame = lastRenderStartMediaTime > 0 ? tFrameStart - lastRenderStartMediaTime : 0
lastRenderStartMediaTime = tFrameStart
if hasRenderedFirstFrame, gapSincePrevFrame > 0.25,
Date().timeIntervalSince(lastGapWarning) > 2 {
lastGapWarning = Date()
MPVLogging.warn("performRender: frame delivery gap",
details: "gap=\(Int(gapSincePrevFrame * 1000))ms since previous frame")
}
guard let eaglContext, let mpvClient, framebuffer != 0 else {
// Log when render is skipped due to missing resources (rare but important)
@@ -894,6 +908,8 @@ final class MPVRenderView: UIView {
mpvClient.render(fbo: GLint(framebuffer), width: renderWidth, height: renderHeight)
}
let tRenderEnd = CACurrentMediaTime()
// Present the renderbuffer (skip when PiP is active - main view is hidden anyway)
if !isPiPActive {
glBindRenderbuffer(GLenum(GL_RENDERBUFFER), colorRenderbuffer)
@@ -922,8 +938,10 @@ final class MPVRenderView: UIView {
slowFrameCount += 1
if Date().timeIntervalSince(lastSlowFrameWarning) > 5 {
lastSlowFrameWarning = Date()
let renderMs = Int((tRenderEnd - tFrameStart) * 1000)
let presentMs = Int((CACurrentMediaTime() - tRenderEnd) * 1000)
MPVLogging.warn("performRender: slow frame",
details: "duration=\(Int(frameDuration * 1000))ms slowFramesSinceLastWarning=\(slowFrameCount) size=\(renderWidth)x\(renderHeight)")
details: "duration=\(Int(frameDuration * 1000))ms render=\(renderMs)ms present+swap=\(presentMs)ms gapBefore=\(Int(gapSincePrevFrame * 1000))ms slowFramesSinceLastWarning=\(slowFrameCount) size=\(renderWidth)x\(renderHeight)")
slowFrameCount = 0
}
}

View File

@@ -2063,6 +2063,15 @@ extension MPVBackend: MPVClientDelegate {
try? await Task.sleep(for: .seconds(10))
guard let self, !Task.isCancelled, let client = self.mpvClient else { return }
#if !os(macOS)
// Only macOS needs the property fetch every tick (render
// watchdog below). Elsewhere the batched mpv_get_property
// calls contend with the playback core and caused a visible
// stutter every 10s on tvOS, so skip the fetch entirely
// unless verbose logging wants the stats line.
guard MPVLogging.verboseEnabled else { continue }
#endif
let props = await client.getDebugPropertiesAsync()
#if os(macOS)

View File

@@ -92,8 +92,10 @@ final class LocalNetworkService {
}
/// Log debug-level message. Only logs if verbose remote control logging is enabled.
/// The master "Enable Logging" toggle takes precedence over the verbose flag.
private func rcDebug(_ operation: String, _ message: String) {
guard UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return }
guard UserDefaults.standard.bool(forKey: "loggingEnabled"),
UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return }
let fullMessage = "[RemoteControl] \(operation) - \(message)"
LoggingService.shared.logRemoteControlDebug(fullMessage)
}

View File

@@ -116,8 +116,10 @@ final class RemoteControlCoordinator {
}
/// Log debug-level message. Only logs if verbose remote control logging is enabled.
/// The master "Enable Logging" toggle takes precedence over the verbose flag.
private func rcDebug(_ operation: String, _ message: String) {
guard UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return }
guard UserDefaults.standard.bool(forKey: "loggingEnabled"),
UserDefaults.standard.bool(forKey: "verboseRemoteControlLogging") else { return }
let fullMessage = "[RemoteControl] \(operation) - \(message)"
LoggingService.shared.logRemoteControlDebug(fullMessage)
}

View File

@@ -412,7 +412,12 @@ struct AdvancedSettingsView: View {
if appEnvironment?.legacyMigrationService.hasLegacyDataToImport() == true {
#if os(tvOS)
NavigationLink {
TVSidebarDetailContainer(
systemImage: "person.badge.key",
title: String(localized: "migration.accounts.title")
) {
LegacyDataImportView()
}
} label: {
Label(String(localized: "migration.accounts.title"), systemImage: "person.badge.key")
}

View File

@@ -42,7 +42,11 @@ struct LegacyAccountsImportView: View {
contentList
}
}
#if !os(tvOS)
// On tvOS the title comes from the TVSidebarDetailContainer sidebar;
// a navigationTitle would render as a giant ghost behind the form.
.navigationTitle(String(localized: "migration.accounts.title"))
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@@ -124,6 +128,9 @@ struct LegacyAccountsImportView: View {
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
#if os(macOS)
.formStyle(.grouped)
#endif
}
private var emptyState: some View {
@@ -255,13 +262,12 @@ private struct LegacyAccountImportRow: View {
!state.username.isEmpty && !state.password.isEmpty && !state.isImporting
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
private var infoHeader: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 28)
.frame(width: legacyRowIconWidth)
VStack(alignment: .leading, spacing: 3) {
Text(item.displayName)
@@ -279,6 +285,46 @@ private struct LegacyAccountImportRow: View {
Spacer()
}
}
var body: some View {
#if os(tvOS)
// On tvOS a Form row is a single focusable unit, so packing the whole card
// into one row makes the first button (Remove) swallow every click.
// Emit each interactive element as its own row instead.
infoHeader
.padding(.vertical, 8)
credentialsFields
if let errorMessage = state.errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.red)
}
Button(action: onImport) {
if state.isImporting {
HStack(spacing: 6) {
ProgressView()
Text(String(localized: "migration.importing"))
}
} else {
Text(String(localized: "migration.import"))
}
}
.buttonStyle(TVSettingsButtonStyle())
.disabled(!canImport)
Button(role: .destructive, action: onRemove) {
Text(String(localized: "common.remove"))
.foregroundStyle(.red)
}
.buttonStyle(TVSettingsButtonStyle())
.disabled(state.isImporting)
#else
VStack(alignment: .leading, spacing: 12) {
infoHeader
credentialsFields
@@ -314,6 +360,7 @@ private struct LegacyAccountImportRow: View {
}
}
.padding(.vertical, 8)
#endif
}
@ViewBuilder
@@ -351,26 +398,49 @@ private struct LegacyInstanceImportRow: View {
let onImport: () -> Void
let onRemove: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
private var infoHeader: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 28)
.frame(width: legacyRowIconWidth)
VStack(alignment: .leading, spacing: 3) {
Text(item.instanceDisplayName)
.font(.headline)
Text(item.url.host ?? item.url.absoluteString)
// instanceDisplayName falls back to the host for unnamed
// instances; skip the caption instead of repeating it.
if let host = item.url.host, host != item.instanceDisplayName {
Text(host)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
}
}
var body: some View {
#if os(tvOS)
infoHeader
.padding(.vertical, 8)
Button(action: onImport) {
Text(String(localized: "migration.import"))
}
.buttonStyle(TVSettingsButtonStyle())
Button(role: .destructive, action: onRemove) {
Text(String(localized: "common.remove"))
.foregroundStyle(.red)
}
.buttonStyle(TVSettingsButtonStyle())
#else
VStack(alignment: .leading, spacing: 12) {
infoHeader
HStack {
Button(role: .destructive, action: onRemove) {
@@ -388,9 +458,19 @@ private struct LegacyInstanceImportRow: View {
}
}
.padding(.vertical, 8)
#endif
}
}
/// tvOS renders .title2 glyphs far larger than the 28pt frame used on other platforms.
private var legacyRowIconWidth: CGFloat {
#if os(tvOS)
return 64
#else
return 28
#endif
}
private func legacyInstanceIcon(for type: InstanceType) -> String {
switch type {
case .invidious:

View File

@@ -88,9 +88,11 @@ struct TVSettingsTextField: View {
/// Button style for settings forms - subtle focus effect without glow
struct TVSettingsButtonStyle: ButtonStyle {
@Environment(\.isFocused) private var isFocused
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(isEnabled ? 1 : 0.4)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(

View File

@@ -184,6 +184,22 @@ struct YatteeApp: App {
.appEnvironment(appEnvironment)
}
#endif
#if os(tvOS)
// tvOS sheets render as a small centered card that cannot fit
// the sidebar-detail layout use a full screen cover instead.
.fullScreenCover(isPresented: $showingLegacyAccountsImport) {
NavigationStack {
TVSidebarDetailContainer(
systemImage: "person.badge.key",
title: String(localized: "migration.accounts.title")
) {
LegacyAccountsImportView()
}
.appEnvironment(appEnvironment)
}
.background(Color.black.ignoresSafeArea())
}
#else
.sheet(isPresented: $showingLegacyAccountsImport) {
NavigationStack {
LegacyAccountsImportView()
@@ -193,6 +209,7 @@ struct YatteeApp: App {
.frame(minWidth: 560, minHeight: 560)
#endif
}
#endif
#if os(iOS)
.sheet(isPresented: $showingSettings) {
SettingsView()

View File

@@ -18,6 +18,15 @@
"category": "entertainment",
"screenshots": [],
"versions": [
{
"version": "2.0.0",
"buildVersion": "264",
"date": "2026-07-18T16:39:59+00:00",
"localizedDescription": "",
"downloadURL": "https://github.com/yattee/yattee/releases/download/2.0.0-beta.264/Yattee-2.0.0-iOS.ipa",
"size": 34324249,
"minOSVersion": "18.0"
},
{
"version": "2.0.0",
"buildVersion": "250",