Compare commits

...

14 Commits

Author SHA1 Message Date
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
24 changed files with 365 additions and 77 deletions

View File

@@ -49,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:

View File

@@ -2,12 +2,16 @@
## What's Changed
### New Features
* Show missing-credentials indicator for remote server sources
### Bug Fixes
* 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
* Fix black video, freezes, autoplay and clock on Apple TV HD (A8) - thanks @rswilem
* Fix crash in CAOpenGLLayer shadow-copy init on macOS 27 beta
* Fix transparent background of iCloud sync overlay on tvOS
### Other
* Make Enable Logging the master switch over all verbose logging
* Fix some reported crashes

View File

@@ -570,7 +570,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
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 = 266;
CURRENT_PROJECT_VERSION = 268;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;

View File

@@ -333,6 +333,10 @@ final class AppEnvironment {
// 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()
@@ -424,6 +428,47 @@ final class AppEnvironment {
}
}
/// 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

@@ -174,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

@@ -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

@@ -76,6 +76,16 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
/// 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(
@@ -87,7 +97,9 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
dateAdded: Date = Date(),
apiKey: String? = nil,
allowInvalidCertificates: Bool = false,
proxiesVideos: Bool = false
proxiesVideos: Bool = false,
usesBasicAuth: Bool = false,
usesAccountLogin: Bool = false
) {
self.id = id
self.type = type
@@ -98,6 +110,8 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
self.apiKey = apiKey
self.allowInvalidCertificates = allowInvalidCertificates
self.proxiesVideos = proxiesVideos
self.usesBasicAuth = usesBasicAuth
self.usesAccountLogin = usesAccountLogin
}
init(from decoder: Decoder) throws {
@@ -111,6 +125,8 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
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

View File

@@ -221,13 +221,16 @@ final class LegacyDataMigrationService {
password: basicAuthCredentials.password,
for: instance
)
instancesManager.setUsesBasicAuth(true, for: instance)
}
invidiousCredentialsManager.setCredential(credential, for: instance)
instancesManager.setUsesAccountLogin(true, for: instance)
case .piped:
credential = try await pipedAPI.login(username: username, password: password, instance: instance)
addInstanceIfNeeded(instance)
pipedCredentialsManager.setCredential(credential, for: instance)
instancesManager.setUsesAccountLogin(true, for: instance)
default:
throw APIError.notSupported
@@ -264,6 +267,7 @@ final class LegacyDataMigrationService {
password: basicAuthCredentials.password,
for: instance
)
instancesManager.setUsesBasicAuth(true, for: instance)
}
removeLegacyInstance(item)

View File

@@ -7,6 +7,7 @@
import Foundation
import Libmpv
import Metal
#if os(macOS)
import OpenGL.GL
@@ -161,8 +162,9 @@ final class MPVClient: @unchecked Sendable {
weak var delegate: MPVClientDelegate?
/// Event loop task
private var eventLoopTask: Task<Void, Never>?
/// Whether the dedicated event-loop thread has been started (mpvQueue-guarded).
/// The loop itself exits via `isDestroyed` + `mpv_wakeup` + `eventLoopExitSemaphore`.
private var eventLoopRunning = false
/// Semaphore signaled when event loop exits
private let eventLoopExitSemaphore = DispatchSemaphore(value: 0)
@@ -187,6 +189,20 @@ final class MPVClient: @unchecked Sendable {
destroy()
}
// MARK: - Device Detection
#if os(tvOS)
/// A8-class (PowerVR Series6XT) and older GPUs: their GL driver deadlocks on
/// the float texture uploads mpv's renderer defaults to (issue #956).
/// Keyed on the Metal GPU family rather than the device model so every device
/// with this GPU generation is covered, e.g. Apple TV HD (AppleTV5,3, A8).
/// If an A10-class device ever shows the same hang, bump the check to .apple4.
static let hasFragileGLDriver: Bool = {
guard let device = MTLCreateSystemDefaultDevice() else { return true }
return !device.supportsFamily(.apple3) // .apple2 = A8/A8X, .apple1 = A7
}()
#endif
// MARK: - Logging Helpers
/// Log to LoggingService from any thread (async dispatch to MainActor).
@@ -244,9 +260,16 @@ final class MPVClient: @unchecked Sendable {
case "warn":
Task { @MainActor in LoggingService.shared.logMPVWarning(formatted) }
default:
// Surface info always; verbose only for renderer/decoder subsystems
// so the persisted log stays readable when verbose capture is on.
let p = prefix.lowercased()
let rendererRelevant = p.hasPrefix("vo") || p.contains("gpu")
|| p.contains("placebo") || p.hasPrefix("vd") || p.hasPrefix("ffmpeg")
if level == "info" || rendererRelevant {
Task { @MainActor in LoggingService.shared.logMPV(formatted) }
}
}
}
/// Snapshot the recent log buffer (most-recent-last). Thread-safe.
func recentLogLines(minimumSeverity: Int = 0) -> [MPVLogLine] {
@@ -303,10 +326,10 @@ final class MPVClient: @unchecked Sendable {
log("Initialized, setting up property observers...")
// Subscribe to mpv log messages so we can capture HTTP/demuxer/decoder errors
// and surface them when load fails. "warn" covers HTTP errors, demuxer/codec
// failures, and network issues without flooding on the happy path.
let logLevelResult = mpv_request_log_messages(mpv, "warn")
// Capture HTTP/demuxer/decoder errors to surface on load failure.
// Verbose logging (when enabled) also captures renderer init details.
let mpvLogLevel = MPVLogging.verboseEnabled ? "v" : "warn"
let logLevelResult = mpv_request_log_messages(mpv, mpvLogLevel)
if logLevelResult < 0 {
logWarning("Failed to subscribe to mpv log messages: \(String(cString: mpv_error_string(logLevelResult)))")
}
@@ -394,9 +417,8 @@ final class MPVClient: @unchecked Sendable {
mpv_wakeup(mpv)
}
let hasTask = eventLoopTask != nil
eventLoopTask?.cancel()
eventLoopTask = nil
let hasTask = eventLoopRunning
eventLoopRunning = false
return hasTask
}
@@ -449,6 +471,19 @@ final class MPVClient: @unchecked Sendable {
setOptionSync("target-prim", "bt.709")
setOptionSync("target-trc", "srgb")
#if os(tvOS)
// Avoid the float texture uploads that hang the A8 GL driver (issue #956):
// dithering and LUT scalers (mpv 0.37+ defaults) and CPU frame uploads all
// deadlock. Zero-copy VideoToolbox uploads via IOSurface instead.
if Self.hasFragileGLDriver {
setOptionSync("dither-depth", "no")
setOptionSync("hwdec", "videotoolbox")
setOptionSync("scale", "bilinear")
setOptionSync("dscale", "bilinear")
setOptionSync("cscale", "bilinear")
}
#endif
// Use display-vdrop: drops/repeats frames to match display timing
// This is lighter weight than display-resample (no interpolation overhead)
// and handles both hardware and software decode gracefully.
@@ -1523,9 +1558,18 @@ final class MPVClient: @unchecked Sendable {
// MARK: - Event Loop
private func startEventLoop() {
eventLoopTask = Task.detached(priority: .high) { [weak self] in
// Dedicated OS thread, not the Swift cooperative pool: runEventLoop blocks
// in mpv_wait_event for the client's whole lifetime, and as a Task it would
// permanently occupy a pool thread. On a 2-core device the two live clients
// (active + pre-warmed) would hold both pool threads and starve every await
// in the app (issue #956).
eventLoopRunning = true
let thread = Thread { [weak self] in
self?.runEventLoop()
}
thread.name = "stream.yattee.mpv.events"
thread.qualityOfService = .userInitiated
thread.start()
}
private func runEventLoop() {
@@ -1534,7 +1578,7 @@ final class MPVClient: @unchecked Sendable {
eventLoopExitSemaphore.signal()
}
while !Task.isCancelled && !isDestroyed {
while !isDestroyed {
guard let mpv else { break }
// Wait for events with a short timeout

View File

@@ -12,6 +12,7 @@ import AppKit
import CoreMedia
import CoreVideo
import Libmpv
import QuartzCore
// MARK: - MPVOGLView
@@ -168,8 +169,13 @@ final class MPVOGLView: NSView {
stopDisplayLink()
startDisplayLink()
// Update contents scale for new window
// Update contents scale for new window. Disable implicit actions so
// Core Animation never builds a presentation copy of the GL layer
// for this change (crashes on macOS 27 beta).
CATransaction.begin()
CATransaction.setDisableActions(true)
videoLayer?.contentsScale = window.backingScaleFactor
CATransaction.commit()
// Reattaching (e.g. macOS player sheet dismissed via ESC then re-expanded)
// detaches this shared view without stopping playback. macOS drawing is
@@ -182,9 +188,14 @@ final class MPVOGLView: NSView {
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
// Update contents scale when backing properties change
// Update contents scale when backing properties change. Disable implicit
// actions so Core Animation never builds a presentation copy of the GL
// layer for this change (crashes on macOS 27 beta).
if let scale = window?.backingScaleFactor {
CATransaction.begin()
CATransaction.setDisableActions(true)
videoLayer?.contentsScale = scale
CATransaction.commit()
}
// Update display refresh rate

View File

@@ -236,15 +236,12 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
self.bufferDepth = previousLayer.bufferDepth
self.isSetup = previousLayer.isSetup
// super.init(layer:) already copies presentation values; re-invoking
// setters like `colorspace` on the shadow copy dereferences render
// state the copy doesn't own and crashes (macOS 27 beta routes
// contentsScale changes through this path on screen changes).
super.init(layer: layer)
autoresizingMask = previousLayer.autoresizingMask
backgroundColor = previousLayer.backgroundColor
isOpaque = previousLayer.isOpaque
colorspace = previousLayer.colorspace
contentsFormat = previousLayer.contentsFormat
isAsynchronous = previousLayer.isAsynchronous
Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: created shadow copy", category: .mpv)
}

View File

@@ -249,6 +249,7 @@ final class MPVPiPBridge: NSObject {
let newHeight = currentBounds.width / aspectRatio
let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight)
if let newBounds = sanitizedGeometry(newBounds, from: "updateVideoAspectRatio") {
CATransaction.begin()
CATransaction.setDisableActions(true)
sampleBufferLayer.bounds = newBounds
@@ -256,6 +257,7 @@ final class MPVPiPBridge: NSObject {
LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), layer bounds: \(newBounds)", category: .mpv)
}
}
#else
// On iOS, don't modify bounds when PiP is inactive - this causes frame misalignment
// (negative Y offset) which breaks the system's PiP restore UI positioning.
@@ -371,9 +373,25 @@ final class MPVPiPBridge: NSObject {
}
#endif
/// Validate a rect before it reaches PiP geometry. AppKit traps the whole
/// app when AVKit's PiP host view derives a non-finite frame from our
/// layer/window geometry (build 264 crash), so reject NaN/infinite or
/// degenerate rects and log the site instead of storing poisoned values.
private func sanitizedGeometry(_ rect: CGRect, from site: String) -> CGRect? {
guard rect.origin.x.isFinite, rect.origin.y.isFinite,
rect.width.isFinite, rect.height.isFinite,
rect.width > 0, rect.height > 0
else {
LoggingService.shared.debug("MPVPiPBridge: rejected invalid geometry \(rect) from \(site)", category: .mpv)
return nil
}
return rect
}
/// Update the layer frame when container bounds change.
/// On macOS, the frame should be relative to the window's content view.
func updateLayerFrame(_ frame: CGRect) {
guard let frame = sanitizedGeometry(frame, from: "updateLayerFrame(_:)") else { return }
sampleBufferLayer.frame = frame
}
@@ -381,7 +399,8 @@ final class MPVPiPBridge: NSObject {
/// Update the layer frame based on container view's bounds.
/// Call this on macOS when the container view's size changes.
func updateLayerFrame(for containerView: NSView) {
sampleBufferLayer.frame = containerView.bounds
guard let frame = sanitizedGeometry(containerView.bounds, from: "updateLayerFrame(for:)") else { return }
sampleBufferLayer.frame = frame
}
#endif
@@ -390,7 +409,9 @@ final class MPVPiPBridge: NSObject {
/// as the layer must be in a visible window hierarchy.
func moveLayer(to containerView: PlatformView) {
sampleBufferLayer.removeFromSuperlayer()
sampleBufferLayer.frame = containerView.bounds
if let frame = sanitizedGeometry(containerView.bounds, from: "moveLayer(to:)") {
sampleBufferLayer.frame = frame
}
#if os(iOS)
containerView.layer.addSublayer(sampleBufferLayer)
#elseif os(macOS)
@@ -886,14 +907,19 @@ extension MPVPiPBridge {
}
let contentRect = pipWindow.contentRect(forFrameRect: pipWindow.frame)
guard contentRect.width > 0 else { return }
guard contentRect.width > 0, contentRect.width.isFinite else { return }
let newContentHeight = contentRect.width / aspectRatio
guard newContentHeight.isFinite, newContentHeight > 0 else {
LoggingService.shared.debug("MPVPiPBridge: resizePiPWindow - rejected invalid content height \(newContentHeight) for aspect \(aspectRatio)", category: .mpv)
return
}
let heightDelta = newContentHeight - contentRect.height
guard abs(heightDelta) >= 1 else { return }
var frame = pipWindow.frame
frame.size.height += heightDelta
frame.origin.y -= heightDelta // keep the top edge in place
guard let frame = sanitizedGeometry(frame, from: "resizePiPWindow") else { return }
pipWindow.setFrame(frame, display: true, animate: false)
LoggingService.shared.debug("MPVPiPBridge: Resized PiP window for aspect \(aspectRatio): \(frame)", category: .mpv)
@@ -1000,7 +1026,7 @@ extension MPVPiPBridge {
// Fix mispositioned internal AVKit views that cause the black bar
fixPiPLayerHostViewPosition(in: pipWindow)
let newFrame = CGRect(origin: .zero, size: windowSize)
guard let newFrame = sanitizedGeometry(CGRect(origin: .zero, size: windowSize), from: "updateLayerFrameToMatchPiPWindow") else { return }
if sampleBufferLayer.frame.size != newFrame.size {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match PiP window: \(sampleBufferLayer.frame) -> \(newFrame)", category: .mpv)
@@ -1012,7 +1038,7 @@ extension MPVPiPBridge {
} else {
// Fallback: try to match superlayer
guard let superlayer = sampleBufferLayer.superlayer else { return }
let superBounds = superlayer.bounds
guard let superBounds = sanitizedGeometry(superlayer.bounds, from: "updateLayerFrameToMatchPiPWindow(superlayer)") else { return }
if sampleBufferLayer.frame != superBounds {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match superlayer: \(sampleBufferLayer.frame) -> \(superBounds)", category: .mpv)
CATransaction.begin()

View File

@@ -448,6 +448,14 @@ final class MPVRenderView: UIView {
// Skip if framebuffer already matches
guard framebufferMismatch else { return }
// Don't recreate the framebuffer for an orphaned (windowless) view: binding
// the CAEAGLLayer off-main would collide with the main-thread CoreAnimation
// commit and pin a CPU (issue #956).
guard window != nil else {
MPVLogging.warn("layoutSubviews: skipped framebuffer recreation (no window)")
return
}
MPVLogging.logTransition("layoutSubviews - size mismatch",
fromSize: CGSize(width: CGFloat(renderWidth), height: CGFloat(renderHeight)),
toSize: CGSize(width: CGFloat(expectedFBWidth), height: CGFloat(expectedFBHeight)))
@@ -487,19 +495,23 @@ final class MPVRenderView: UIView {
MPVLogging.log("setupAsync: creating EAGLContext on background thread")
let glStartTime = Date()
let context = try await Task.detached(priority: .userInitiated) {
// This runs on background thread - doesn't block main thread!
// Bridge through GCD, not Task.detached: EAGLContext creation can block for
// seconds in the GL driver, and a detached task would tie up a Swift
// cooperative-pool thread the app needs elsewhere (issue #956).
let context: EAGLContext = try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
if let ctx = EAGLContext(api: .openGLES3) {
MPVLogging.log("setupAsync: created OpenGL ES 3.0 context")
return ctx
continuation.resume(returning: ctx)
} else if let ctx = EAGLContext(api: .openGLES2) {
MPVLogging.log("setupAsync: created OpenGL ES 2.0 context (ES3 unavailable)")
return ctx
continuation.resume(returning: ctx)
} else {
MPVLogging.warn("setupAsync: failed to create EAGLContext")
throw MPVRenderError.openGLSetupFailed
continuation.resume(throwing: MPVRenderError.openGLSetupFailed)
}
}
}
}.value
let glCreateTime = Date().timeIntervalSince(glStartTime)
MPVLogging.log("setupAsync: EAGLContext created",

View File

@@ -538,9 +538,9 @@ final class MPVBackend: PlayerBackend {
LoggingService.shared.logMPV("MPV stream loaded successfully")
} catch is CancellationError {
// Re-throw cancellation errors without retry
// Only reset isInitialLoading if we're still the active load operation
// A newer load may have already set isInitialLoading=true
// Re-throw cancellation errors without retry.
// Only reset isInitialLoading if we're still the active load operation.
LoggingService.shared.debug("MPV: loadWithRetry cancelled", category: .mpv)
if currentLoadingID == loadingID {
isInitialLoading = false
}

View File

@@ -250,6 +250,13 @@ final class PlayerService {
// Mark that we're loading this video - time updates will be ignored until loading completes
loadingVideoID = video.id
// Release the gate on any exit path (a newer play() claims it with its own
// id, so only clear our own). Prevents the time-update gate from leaking if
// the load flow exits early (issue #956).
defer {
if loadingVideoID == video.id { loadingVideoID = nil }
}
state.setPlaybackState(.loading)
state.isFirstFrameReady = false // Reset until first frame of new video is rendered
state.isBufferReady = false // Reset until buffer is ready for smooth playback
@@ -2844,6 +2851,18 @@ extension PlayerService: PlayerBackendDelegate {
func backend(_ backend: any PlayerBackend, didChangeState playbackState: PlaybackState) {
LoggingService.shared.debug("Backend state changed to: \(playbackState)", category: .player)
// Clear the time-update gate once the new video is loaded, in case the load
// flow wedged before doing so (issue #956). Only .ready: it comes from mpv's
// file-loaded event, which only the new load can emit. The previous video
// (still playing on the reused backend during the details/streams fetch) can
// emit .playing via buffering recovery or unpause healing on that would
// open the gate to stale time updates and break the stale-error check in
// play()'s catch, silently swallowing genuine load failures.
if playbackState == .ready, loadingVideoID != nil {
loadingVideoID = nil
}
state.setPlaybackState(playbackState)
delegate?.playerService(self, didChangeState: playbackState)
}

View File

@@ -40,6 +40,12 @@ struct MediaSourcesView: View {
appEnvironment?.settingsManager.listStyle ?? .inset
}
/// Whether a remote server instance requires credentials that are missing from
/// the Keychain (e.g. after reinstalling and importing sources from iCloud).
private func needsCredentials(_ instance: Instance) -> Bool {
appEnvironment?.needsCredentials(for: instance) ?? false
}
var body: some View {
Group {
#if os(tvOS)
@@ -517,6 +523,12 @@ struct MediaSourcesView: View {
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
.font(.caption)
.foregroundStyle(.secondary)
if needsCredentials(instance) {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
}
Spacer()

View File

@@ -86,6 +86,16 @@ private class MPVContainerView: UIView {
}
}
MPVLogging.warn("MPVContainerView deinit: no surviving container to transfer player view to!")
// Detach the shared view now instead of leaving it bound to this
// deallocating container: otherwise SwiftUI keeps reconciling a
// half-alive view and spins the main-thread trait update loop (100%
// CPU hang when opening a settings detail during playback, issue #956).
// Guard by superview: currentPlayerView is a stale weak ref when another
// (not yet windowed) container has stolen the view don't rip it out.
if playerView.superview === self {
playerView.removeFromSuperview()
}
}
}

View File

@@ -753,7 +753,8 @@ struct AddRemoteServerView: View {
type: type,
url: url,
name: name.isEmpty ? nil : name,
allowInvalidCertificates: allowInvalidCertificates
allowInvalidCertificates: allowInvalidCertificates,
usesBasicAuth: true
)
appEnvironment.basicAuthCredentialsManager.setCredentials(
@@ -783,14 +784,16 @@ struct AddRemoteServerView: View {
// For other instance types: optionally store HTTP Basic Auth credentials
// (used when the instance is fronted by a reverse proxy that requires basic auth).
let hasBasicAuth = !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty
let instance = Instance(
type: type,
url: url,
name: name.isEmpty ? nil : name,
allowInvalidCertificates: allowInvalidCertificates
allowInvalidCertificates: allowInvalidCertificates,
usesBasicAuth: hasBasicAuth
)
if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty {
if hasBasicAuth {
appEnvironment.basicAuthCredentialsManager.setCredentials(
username: basicAuthUsername,
password: basicAuthPassword,

View File

@@ -345,6 +345,7 @@ private struct EditRemoteServerContent: View {
.fullScreenCover(isPresented: $showLoginSheet) {
InstanceLoginView(instance: instance) { credential in
appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance)
appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance)
isLoggedIn = true
}
}
@@ -352,6 +353,7 @@ private struct EditRemoteServerContent: View {
.sheet(isPresented: $showLoginSheet) {
InstanceLoginView(instance: instance) { credential in
appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance)
appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance)
isLoggedIn = true
}
}
@@ -409,6 +411,7 @@ private struct EditRemoteServerContent: View {
private func logout() {
appEnvironment?.credentialsManager(for: instance)?.deleteCredential(for: instance)
appEnvironment?.instancesManager.setUsesAccountLogin(false, for: instance)
isLoggedIn = false
}
@@ -445,7 +448,9 @@ private struct EditRemoteServerContent: View {
}
private func performSave() {
var updated = instance
// Start from the manager's latest copy so flags updated while this sheet
// was open (e.g. usesAccountLogin set by the login sheet) are preserved.
var updated = appEnvironment?.instancesManager.instances.first { $0.id == instance.id } ?? instance
updated.name = name.isEmpty ? nil : name
updated.isEnabled = isEnabled
updated.allowInvalidCertificates = allowInvalidCertificates
@@ -458,14 +463,17 @@ private struct EditRemoteServerContent: View {
if hadStoredBasicAuth {
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
}
updated.usesBasicAuth = false
} else if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty {
appEnvironment?.basicAuthCredentialsManager.setCredentials(
username: basicAuthUsername,
password: basicAuthPassword,
for: instance
)
updated.usesBasicAuth = true
} else if hadStoredBasicAuth, instance.type != .yatteeServer {
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
updated.usesBasicAuth = false
}
appEnvironment?.instancesManager.update(updated)

View File

@@ -32,6 +32,13 @@ struct SourceRow: View {
return status == .authFailed || status == .authRequired
}
/// Whether a remote server instance requires credentials that are missing from
/// the Keychain (e.g. after reinstalling and importing sources from iCloud).
private var needsCredentials: Bool {
guard case .remoteServer(let instance) = source else { return false }
return appEnvironment?.needsCredentials(for: instance) ?? false
}
var body: some View {
#if os(tvOS)
Button(action: onEdit) {
@@ -92,7 +99,7 @@ struct SourceRow: View {
@ViewBuilder
private var statusView: some View {
if needsPassword {
if needsPassword || needsCredentials {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)

View File

@@ -403,9 +403,19 @@ struct SourcesListView: View {
.contentShape(Rectangle())
}
/// Whether a remote server instance requires credentials that are missing from
/// the Keychain (e.g. after reinstalling and importing sources from iCloud).
private func needsCredentials(_ instance: Instance) -> Bool {
appEnvironment?.needsCredentials(for: instance) ?? false
}
@ViewBuilder
private func instanceStatusView(for instance: Instance) -> some View {
if let status = instancesManager?.status(for: instance) {
if needsCredentials(instance) {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)
} else if let status = instancesManager?.status(for: instance) {
switch status {
case .authFailed:
Label(String(localized: "sources.status.authFailed"), systemImage: "exclamationmark.triangle.fill")

View File

@@ -182,6 +182,11 @@ struct YatteeApp: App {
.fullScreenCover(isPresented: $showingICloudProgress) {
ICloudSyncProgressView()
.appEnvironment(appEnvironment)
#if os(tvOS)
// tvOS full screen covers have a transparent background,
// letting the view below leak through.
.background(Color.black.ignoresSafeArea())
#endif
}
#endif
#if os(tvOS)

View File

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

View File

@@ -10,7 +10,6 @@ TEMP_KEYCHAIN_USER = ENV['TEMP_KEYCHAIN_USER']
TEMP_KEYCHAIN_PASSWORD = ENV['TEMP_KEYCHAIN_PASSWORD']
DEVELOPER_APP_IDENTIFIER = ENV['DEVELOPER_APP_IDENTIFIER']
GIT_AUTHORIZATION = ENV['GIT_AUTHORIZATION']
TESTFLIGHT_EXTERNAL_GROUPS = ENV['TESTFLIGHT_EXTERNAL_GROUPS']
XCODEPROJ = "#{APP_NAME}.xcodeproj"
SCHEME = APP_NAME
@@ -35,6 +34,20 @@ def ensure_temp_keychain(name, password)
create_temp_keychain(name, password)
end
# Beta groups are managed in App Store Connect; fetching them at upload time
# means new groups get builds without touching CI config.
def all_testflight_group_names(api_key)
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.from(hash: api_key)
app = Spaceship::ConnectAPI::App.find(DEVELOPER_APP_IDENTIFIER)
UI.user_error!("App #{DEVELOPER_APP_IDENTIFIER} not found on App Store Connect") if app.nil?
# Internal groups can't be assigned builds via the API (ASC rejects it);
# they get access to new builds automatically anyway.
groups = app.get_beta_groups.reject(&:is_internal_group).map(&:name)
UI.user_error!("No TestFlight beta groups found for #{DEVELOPER_APP_IDENTIFIER}") if groups.empty?
UI.message("Distributing build to TestFlight groups: #{groups.join(', ')}")
groups
end
add_extra_platforms(platforms: [:tvos])
before_all do
@@ -148,7 +161,9 @@ platform :ios do
upload_to_testflight(
api_key: api_key,
ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
changelog: changelog
changelog: changelog,
distribute_external: true,
groups: all_testflight_group_names(api_key)
)
end
end
@@ -220,7 +235,9 @@ platform :tvos do
upload_to_testflight(
api_key: api_key,
ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
changelog: changelog
changelog: changelog,
distribute_external: true,
groups: all_testflight_group_names(api_key)
)
end
end
@@ -284,7 +301,9 @@ platform :mac do
upload_to_testflight(
api_key: api_key,
pkg: lane_context[SharedValues::PKG_OUTPUT_PATH],
changelog: changelog
changelog: changelog,
distribute_external: true,
groups: all_testflight_group_names(api_key)
)
end