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 }} GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
CERTIFICATES_GIT_URL: ${{ secrets.CERTIFICATES_GIT_URL }} CERTIFICATES_GIT_URL: ${{ secrets.CERTIFICATES_GIT_URL }}
TESTFLIGHT_EXTERNAL_GROUPS: ${{ secrets.TESTFLIGHT_EXTERNAL_GROUPS }}
jobs: jobs:
determine_build_number: determine_build_number:

View File

@@ -2,12 +2,16 @@
## What's Changed ## What's Changed
### New Features
* Show missing-credentials indicator for remote server sources
### Bug Fixes ### Bug Fixes
* Fix macOS legacy import sheet missing grouped form style * Fix black video, freezes, autoplay and clock on Apple TV HD (A8) - thanks @rswilem
* Fix tvOS legacy import rows acting as a single Remove button * Fix crash in CAOpenGLLayer shadow-copy init on macOS 27 beta
* Fix periodic tvOS playback stutter from render stalls on mpvQueue * Fix transparent background of iCloud sync overlay on tvOS
### Other ### 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; AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements; CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
@@ -656,7 +656,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements; CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements"; "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
@@ -800,7 +800,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements; CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements"; "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
@@ -881,7 +881,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -910,7 +910,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist; INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -942,7 +942,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements; CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist; INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -977,7 +977,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist; INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1008,7 +1008,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist; INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1040,7 +1040,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements; CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist; INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1074,7 +1074,7 @@
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements; CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist; INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1109,7 +1109,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1138,7 +1138,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 266; CURRENT_PROJECT_VERSION = 268;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ; DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;

View File

@@ -333,6 +333,10 @@ final class AppEnvironment {
// Set up circular dependencies after all properties are initialized // Set up circular dependencies after all properties are initialized
bgRefreshManager.setAppEnvironment(self) 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 // Log device capabilities on startup for debugging
HardwareCapabilities.shared.logCapabilities() 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 // MARK: - Preview/Testing Support
@MainActor @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. /// Sets the given instance as the primary (first) instance.
func setPrimary(_ instance: Instance) { func setPrimary(_ instance: Instance) {
LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general) LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general)

View File

@@ -9,6 +9,14 @@
import SwiftUI 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 // MARK: - Environment Keys
/// Environment key to pass the navigation transition namespace through the view hierarchy. /// 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 { func body(content: Content) -> some View {
#if os(iOS) #if os(iOS)
if zoomTransitionsEnabled, let namespace { if zoomTransitionsEnabled, !zoomTransitionsRunningOnMac, let namespace {
content content
.matchedTransitionSource(id: id, in: namespace) .matchedTransitionSource(id: id, in: namespace)
} else { } else {
@@ -75,7 +83,7 @@ struct ZoomTransitionDestinationModifier<ID: Hashable>: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
#if os(iOS) #if os(iOS)
if zoomTransitionsEnabled, let namespace { if zoomTransitionsEnabled, !zoomTransitionsRunningOnMac, let namespace {
content content
.navigationTransition(.zoom(sourceID: id, in: namespace)) .navigationTransition(.zoom(sourceID: id, in: namespace))
} else { } 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. /// Whether to route video streams through this instance instead of connecting directly to YouTube CDN.
var proxiesVideos: Bool 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 // MARK: - Initialization
init( init(
@@ -87,7 +97,9 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
dateAdded: Date = Date(), dateAdded: Date = Date(),
apiKey: String? = nil, apiKey: String? = nil,
allowInvalidCertificates: Bool = false, allowInvalidCertificates: Bool = false,
proxiesVideos: Bool = false proxiesVideos: Bool = false,
usesBasicAuth: Bool = false,
usesAccountLogin: Bool = false
) { ) {
self.id = id self.id = id
self.type = type self.type = type
@@ -98,6 +110,8 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
self.apiKey = apiKey self.apiKey = apiKey
self.allowInvalidCertificates = allowInvalidCertificates self.allowInvalidCertificates = allowInvalidCertificates
self.proxiesVideos = proxiesVideos self.proxiesVideos = proxiesVideos
self.usesBasicAuth = usesBasicAuth
self.usesAccountLogin = usesAccountLogin
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -111,6 +125,8 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey) apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey)
allowInvalidCertificates = try container.decode(Bool.self, forKey: .allowInvalidCertificates) allowInvalidCertificates = try container.decode(Bool.self, forKey: .allowInvalidCertificates)
proxiesVideos = try container.decodeIfPresent(Bool.self, forKey: .proxiesVideos) ?? false 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 // MARK: - Computed Properties

View File

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

View File

@@ -7,6 +7,7 @@
import Foundation import Foundation
import Libmpv import Libmpv
import Metal
#if os(macOS) #if os(macOS)
import OpenGL.GL import OpenGL.GL
@@ -161,8 +162,9 @@ final class MPVClient: @unchecked Sendable {
weak var delegate: MPVClientDelegate? weak var delegate: MPVClientDelegate?
/// Event loop task /// Whether the dedicated event-loop thread has been started (mpvQueue-guarded).
private var eventLoopTask: Task<Void, Never>? /// The loop itself exits via `isDestroyed` + `mpv_wakeup` + `eventLoopExitSemaphore`.
private var eventLoopRunning = false
/// Semaphore signaled when event loop exits /// Semaphore signaled when event loop exits
private let eventLoopExitSemaphore = DispatchSemaphore(value: 0) private let eventLoopExitSemaphore = DispatchSemaphore(value: 0)
@@ -187,6 +189,20 @@ final class MPVClient: @unchecked Sendable {
destroy() 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 // MARK: - Logging Helpers
/// Log to LoggingService from any thread (async dispatch to MainActor). /// Log to LoggingService from any thread (async dispatch to MainActor).
@@ -244,9 +260,16 @@ final class MPVClient: @unchecked Sendable {
case "warn": case "warn":
Task { @MainActor in LoggingService.shared.logMPVWarning(formatted) } Task { @MainActor in LoggingService.shared.logMPVWarning(formatted) }
default: 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) } Task { @MainActor in LoggingService.shared.logMPV(formatted) }
} }
} }
}
/// Snapshot the recent log buffer (most-recent-last). Thread-safe. /// Snapshot the recent log buffer (most-recent-last). Thread-safe.
func recentLogLines(minimumSeverity: Int = 0) -> [MPVLogLine] { func recentLogLines(minimumSeverity: Int = 0) -> [MPVLogLine] {
@@ -303,10 +326,10 @@ final class MPVClient: @unchecked Sendable {
log("Initialized, setting up property observers...") log("Initialized, setting up property observers...")
// Subscribe to mpv log messages so we can capture HTTP/demuxer/decoder errors // Capture HTTP/demuxer/decoder errors to surface on load failure.
// and surface them when load fails. "warn" covers HTTP errors, demuxer/codec // Verbose logging (when enabled) also captures renderer init details.
// failures, and network issues without flooding on the happy path. let mpvLogLevel = MPVLogging.verboseEnabled ? "v" : "warn"
let logLevelResult = mpv_request_log_messages(mpv, "warn") let logLevelResult = mpv_request_log_messages(mpv, mpvLogLevel)
if logLevelResult < 0 { if logLevelResult < 0 {
logWarning("Failed to subscribe to mpv log messages: \(String(cString: mpv_error_string(logLevelResult)))") 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) mpv_wakeup(mpv)
} }
let hasTask = eventLoopTask != nil let hasTask = eventLoopRunning
eventLoopTask?.cancel() eventLoopRunning = false
eventLoopTask = nil
return hasTask return hasTask
} }
@@ -449,6 +471,19 @@ final class MPVClient: @unchecked Sendable {
setOptionSync("target-prim", "bt.709") setOptionSync("target-prim", "bt.709")
setOptionSync("target-trc", "srgb") 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 // Use display-vdrop: drops/repeats frames to match display timing
// This is lighter weight than display-resample (no interpolation overhead) // This is lighter weight than display-resample (no interpolation overhead)
// and handles both hardware and software decode gracefully. // and handles both hardware and software decode gracefully.
@@ -1523,9 +1558,18 @@ final class MPVClient: @unchecked Sendable {
// MARK: - Event Loop // MARK: - Event Loop
private func startEventLoop() { 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() self?.runEventLoop()
} }
thread.name = "stream.yattee.mpv.events"
thread.qualityOfService = .userInitiated
thread.start()
} }
private func runEventLoop() { private func runEventLoop() {
@@ -1534,7 +1578,7 @@ final class MPVClient: @unchecked Sendable {
eventLoopExitSemaphore.signal() eventLoopExitSemaphore.signal()
} }
while !Task.isCancelled && !isDestroyed { while !isDestroyed {
guard let mpv else { break } guard let mpv else { break }
// Wait for events with a short timeout // Wait for events with a short timeout

View File

@@ -12,6 +12,7 @@ import AppKit
import CoreMedia import CoreMedia
import CoreVideo import CoreVideo
import Libmpv import Libmpv
import QuartzCore
// MARK: - MPVOGLView // MARK: - MPVOGLView
@@ -168,8 +169,13 @@ final class MPVOGLView: NSView {
stopDisplayLink() stopDisplayLink()
startDisplayLink() 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 videoLayer?.contentsScale = window.backingScaleFactor
CATransaction.commit()
// Reattaching (e.g. macOS player sheet dismissed via ESC then re-expanded) // Reattaching (e.g. macOS player sheet dismissed via ESC then re-expanded)
// detaches this shared view without stopping playback. macOS drawing is // detaches this shared view without stopping playback. macOS drawing is
@@ -182,9 +188,14 @@ final class MPVOGLView: NSView {
override func viewDidChangeBackingProperties() { override func viewDidChangeBackingProperties() {
super.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 { if let scale = window?.backingScaleFactor {
CATransaction.begin()
CATransaction.setDisableActions(true)
videoLayer?.contentsScale = scale videoLayer?.contentsScale = scale
CATransaction.commit()
} }
// Update display refresh rate // Update display refresh rate

View File

@@ -236,15 +236,12 @@ final class MPVOpenGLLayer: CAOpenGLLayer {
self.bufferDepth = previousLayer.bufferDepth self.bufferDepth = previousLayer.bufferDepth
self.isSetup = previousLayer.isSetup 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) 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 Task { @MainActor in
LoggingService.shared.debug("MPVOpenGLLayer: created shadow copy", category: .mpv) 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 newHeight = currentBounds.width / aspectRatio
let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight) let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight)
if let newBounds = sanitizedGeometry(newBounds, from: "updateVideoAspectRatio") {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
sampleBufferLayer.bounds = newBounds 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) LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), layer bounds: \(newBounds)", category: .mpv)
} }
}
#else #else
// On iOS, don't modify bounds when PiP is inactive - this causes frame misalignment // 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. // (negative Y offset) which breaks the system's PiP restore UI positioning.
@@ -371,9 +373,25 @@ final class MPVPiPBridge: NSObject {
} }
#endif #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. /// Update the layer frame when container bounds change.
/// On macOS, the frame should be relative to the window's content view. /// On macOS, the frame should be relative to the window's content view.
func updateLayerFrame(_ frame: CGRect) { func updateLayerFrame(_ frame: CGRect) {
guard let frame = sanitizedGeometry(frame, from: "updateLayerFrame(_:)") else { return }
sampleBufferLayer.frame = frame sampleBufferLayer.frame = frame
} }
@@ -381,7 +399,8 @@ final class MPVPiPBridge: NSObject {
/// Update the layer frame based on container view's bounds. /// Update the layer frame based on container view's bounds.
/// Call this on macOS when the container view's size changes. /// Call this on macOS when the container view's size changes.
func updateLayerFrame(for containerView: NSView) { func updateLayerFrame(for containerView: NSView) {
sampleBufferLayer.frame = containerView.bounds guard let frame = sanitizedGeometry(containerView.bounds, from: "updateLayerFrame(for:)") else { return }
sampleBufferLayer.frame = frame
} }
#endif #endif
@@ -390,7 +409,9 @@ final class MPVPiPBridge: NSObject {
/// as the layer must be in a visible window hierarchy. /// as the layer must be in a visible window hierarchy.
func moveLayer(to containerView: PlatformView) { func moveLayer(to containerView: PlatformView) {
sampleBufferLayer.removeFromSuperlayer() sampleBufferLayer.removeFromSuperlayer()
sampleBufferLayer.frame = containerView.bounds if let frame = sanitizedGeometry(containerView.bounds, from: "moveLayer(to:)") {
sampleBufferLayer.frame = frame
}
#if os(iOS) #if os(iOS)
containerView.layer.addSublayer(sampleBufferLayer) containerView.layer.addSublayer(sampleBufferLayer)
#elseif os(macOS) #elseif os(macOS)
@@ -886,14 +907,19 @@ extension MPVPiPBridge {
} }
let contentRect = pipWindow.contentRect(forFrameRect: pipWindow.frame) 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 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 let heightDelta = newContentHeight - contentRect.height
guard abs(heightDelta) >= 1 else { return } guard abs(heightDelta) >= 1 else { return }
var frame = pipWindow.frame var frame = pipWindow.frame
frame.size.height += heightDelta frame.size.height += heightDelta
frame.origin.y -= heightDelta // keep the top edge in place 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) pipWindow.setFrame(frame, display: true, animate: false)
LoggingService.shared.debug("MPVPiPBridge: Resized PiP window for aspect \(aspectRatio): \(frame)", category: .mpv) 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 // Fix mispositioned internal AVKit views that cause the black bar
fixPiPLayerHostViewPosition(in: pipWindow) 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 { if sampleBufferLayer.frame.size != newFrame.size {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match PiP window: \(sampleBufferLayer.frame) -> \(newFrame)", category: .mpv) LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match PiP window: \(sampleBufferLayer.frame) -> \(newFrame)", category: .mpv)
@@ -1012,7 +1038,7 @@ extension MPVPiPBridge {
} else { } else {
// Fallback: try to match superlayer // Fallback: try to match superlayer
guard let superlayer = sampleBufferLayer.superlayer else { return } 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 { if sampleBufferLayer.frame != superBounds {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match superlayer: \(sampleBufferLayer.frame) -> \(superBounds)", category: .mpv) LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match superlayer: \(sampleBufferLayer.frame) -> \(superBounds)", category: .mpv)
CATransaction.begin() CATransaction.begin()

View File

@@ -448,6 +448,14 @@ final class MPVRenderView: UIView {
// Skip if framebuffer already matches // Skip if framebuffer already matches
guard framebufferMismatch else { return } 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", MPVLogging.logTransition("layoutSubviews - size mismatch",
fromSize: CGSize(width: CGFloat(renderWidth), height: CGFloat(renderHeight)), fromSize: CGSize(width: CGFloat(renderWidth), height: CGFloat(renderHeight)),
toSize: CGSize(width: CGFloat(expectedFBWidth), height: CGFloat(expectedFBHeight))) toSize: CGSize(width: CGFloat(expectedFBWidth), height: CGFloat(expectedFBHeight)))
@@ -487,19 +495,23 @@ final class MPVRenderView: UIView {
MPVLogging.log("setupAsync: creating EAGLContext on background thread") MPVLogging.log("setupAsync: creating EAGLContext on background thread")
let glStartTime = Date() let glStartTime = Date()
let context = try await Task.detached(priority: .userInitiated) { // Bridge through GCD, not Task.detached: EAGLContext creation can block for
// This runs on background thread - doesn't block main thread! // 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) { if let ctx = EAGLContext(api: .openGLES3) {
MPVLogging.log("setupAsync: created OpenGL ES 3.0 context") MPVLogging.log("setupAsync: created OpenGL ES 3.0 context")
return ctx continuation.resume(returning: ctx)
} else if let ctx = EAGLContext(api: .openGLES2) { } else if let ctx = EAGLContext(api: .openGLES2) {
MPVLogging.log("setupAsync: created OpenGL ES 2.0 context (ES3 unavailable)") MPVLogging.log("setupAsync: created OpenGL ES 2.0 context (ES3 unavailable)")
return ctx continuation.resume(returning: ctx)
} else { } else {
MPVLogging.warn("setupAsync: failed to create EAGLContext") MPVLogging.warn("setupAsync: failed to create EAGLContext")
throw MPVRenderError.openGLSetupFailed continuation.resume(throwing: MPVRenderError.openGLSetupFailed)
}
}
} }
}.value
let glCreateTime = Date().timeIntervalSince(glStartTime) let glCreateTime = Date().timeIntervalSince(glStartTime)
MPVLogging.log("setupAsync: EAGLContext created", MPVLogging.log("setupAsync: EAGLContext created",

View File

@@ -538,9 +538,9 @@ final class MPVBackend: PlayerBackend {
LoggingService.shared.logMPV("MPV stream loaded successfully") LoggingService.shared.logMPV("MPV stream loaded successfully")
} catch is CancellationError { } catch is CancellationError {
// Re-throw cancellation errors without retry // Re-throw cancellation errors without retry.
// Only reset isInitialLoading if we're still the active load operation // Only reset isInitialLoading if we're still the active load operation.
// A newer load may have already set isInitialLoading=true LoggingService.shared.debug("MPV: loadWithRetry cancelled", category: .mpv)
if currentLoadingID == loadingID { if currentLoadingID == loadingID {
isInitialLoading = false 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 // Mark that we're loading this video - time updates will be ignored until loading completes
loadingVideoID = video.id 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.setPlaybackState(.loading)
state.isFirstFrameReady = false // Reset until first frame of new video is rendered state.isFirstFrameReady = false // Reset until first frame of new video is rendered
state.isBufferReady = false // Reset until buffer is ready for smooth playback 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) { func backend(_ backend: any PlayerBackend, didChangeState playbackState: PlaybackState) {
LoggingService.shared.debug("Backend state changed to: \(playbackState)", category: .player) 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) state.setPlaybackState(playbackState)
delegate?.playerService(self, didChangeState: playbackState) delegate?.playerService(self, didChangeState: playbackState)
} }

View File

@@ -40,6 +40,12 @@ struct MediaSourcesView: View {
appEnvironment?.settingsManager.listStyle ?? .inset 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 { var body: some View {
Group { Group {
#if os(tvOS) #if os(tvOS)
@@ -517,6 +523,12 @@ struct MediaSourcesView: View {
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)") Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if needsCredentials(instance) {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
} }
Spacer() Spacer()

View File

@@ -86,6 +86,16 @@ private class MPVContainerView: UIView {
} }
} }
MPVLogging.warn("MPVContainerView deinit: no surviving container to transfer player view to!") 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, type: type,
url: url, url: url,
name: name.isEmpty ? nil : name, name: name.isEmpty ? nil : name,
allowInvalidCertificates: allowInvalidCertificates allowInvalidCertificates: allowInvalidCertificates,
usesBasicAuth: true
) )
appEnvironment.basicAuthCredentialsManager.setCredentials( appEnvironment.basicAuthCredentialsManager.setCredentials(
@@ -783,14 +784,16 @@ struct AddRemoteServerView: View {
// For other instance types: optionally store HTTP Basic Auth credentials // For other instance types: optionally store HTTP Basic Auth credentials
// (used when the instance is fronted by a reverse proxy that requires basic auth). // (used when the instance is fronted by a reverse proxy that requires basic auth).
let hasBasicAuth = !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty
let instance = Instance( let instance = Instance(
type: type, type: type,
url: url, url: url,
name: name.isEmpty ? nil : name, name: name.isEmpty ? nil : name,
allowInvalidCertificates: allowInvalidCertificates allowInvalidCertificates: allowInvalidCertificates,
usesBasicAuth: hasBasicAuth
) )
if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty { if hasBasicAuth {
appEnvironment.basicAuthCredentialsManager.setCredentials( appEnvironment.basicAuthCredentialsManager.setCredentials(
username: basicAuthUsername, username: basicAuthUsername,
password: basicAuthPassword, password: basicAuthPassword,

View File

@@ -345,6 +345,7 @@ private struct EditRemoteServerContent: View {
.fullScreenCover(isPresented: $showLoginSheet) { .fullScreenCover(isPresented: $showLoginSheet) {
InstanceLoginView(instance: instance) { credential in InstanceLoginView(instance: instance) { credential in
appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance) appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance)
appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance)
isLoggedIn = true isLoggedIn = true
} }
} }
@@ -352,6 +353,7 @@ private struct EditRemoteServerContent: View {
.sheet(isPresented: $showLoginSheet) { .sheet(isPresented: $showLoginSheet) {
InstanceLoginView(instance: instance) { credential in InstanceLoginView(instance: instance) { credential in
appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance) appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance)
appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance)
isLoggedIn = true isLoggedIn = true
} }
} }
@@ -409,6 +411,7 @@ private struct EditRemoteServerContent: View {
private func logout() { private func logout() {
appEnvironment?.credentialsManager(for: instance)?.deleteCredential(for: instance) appEnvironment?.credentialsManager(for: instance)?.deleteCredential(for: instance)
appEnvironment?.instancesManager.setUsesAccountLogin(false, for: instance)
isLoggedIn = false isLoggedIn = false
} }
@@ -445,7 +448,9 @@ private struct EditRemoteServerContent: View {
} }
private func performSave() { 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.name = name.isEmpty ? nil : name
updated.isEnabled = isEnabled updated.isEnabled = isEnabled
updated.allowInvalidCertificates = allowInvalidCertificates updated.allowInvalidCertificates = allowInvalidCertificates
@@ -458,14 +463,17 @@ private struct EditRemoteServerContent: View {
if hadStoredBasicAuth { if hadStoredBasicAuth {
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance) appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
} }
updated.usesBasicAuth = false
} else if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty { } else if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty {
appEnvironment?.basicAuthCredentialsManager.setCredentials( appEnvironment?.basicAuthCredentialsManager.setCredentials(
username: basicAuthUsername, username: basicAuthUsername,
password: basicAuthPassword, password: basicAuthPassword,
for: instance for: instance
) )
updated.usesBasicAuth = true
} else if hadStoredBasicAuth, instance.type != .yatteeServer { } else if hadStoredBasicAuth, instance.type != .yatteeServer {
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance) appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
updated.usesBasicAuth = false
} }
appEnvironment?.instancesManager.update(updated) appEnvironment?.instancesManager.update(updated)

View File

@@ -32,6 +32,13 @@ struct SourceRow: View {
return status == .authFailed || status == .authRequired 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 { var body: some View {
#if os(tvOS) #if os(tvOS)
Button(action: onEdit) { Button(action: onEdit) {
@@ -92,7 +99,7 @@ struct SourceRow: View {
@ViewBuilder @ViewBuilder
private var statusView: some View { private var statusView: some View {
if needsPassword { if needsPassword || needsCredentials {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill") Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2) .font(.caption2)
.foregroundStyle(.orange) .foregroundStyle(.orange)

View File

@@ -403,9 +403,19 @@ struct SourcesListView: View {
.contentShape(Rectangle()) .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 @ViewBuilder
private func instanceStatusView(for instance: Instance) -> some View { 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 { switch status {
case .authFailed: case .authFailed:
Label(String(localized: "sources.status.authFailed"), systemImage: "exclamationmark.triangle.fill") Label(String(localized: "sources.status.authFailed"), systemImage: "exclamationmark.triangle.fill")

View File

@@ -182,6 +182,11 @@ struct YatteeApp: App {
.fullScreenCover(isPresented: $showingICloudProgress) { .fullScreenCover(isPresented: $showingICloudProgress) {
ICloudSyncProgressView() ICloudSyncProgressView()
.appEnvironment(appEnvironment) .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 #endif
#if os(tvOS) #if os(tvOS)

View File

@@ -18,6 +18,15 @@
"category": "entertainment", "category": "entertainment",
"screenshots": [], "screenshots": [],
"versions": [ "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", "version": "2.0.0",
"buildVersion": "264", "buildVersion": "264",

View File

@@ -10,7 +10,6 @@ TEMP_KEYCHAIN_USER = ENV['TEMP_KEYCHAIN_USER']
TEMP_KEYCHAIN_PASSWORD = ENV['TEMP_KEYCHAIN_PASSWORD'] TEMP_KEYCHAIN_PASSWORD = ENV['TEMP_KEYCHAIN_PASSWORD']
DEVELOPER_APP_IDENTIFIER = ENV['DEVELOPER_APP_IDENTIFIER'] DEVELOPER_APP_IDENTIFIER = ENV['DEVELOPER_APP_IDENTIFIER']
GIT_AUTHORIZATION = ENV['GIT_AUTHORIZATION'] GIT_AUTHORIZATION = ENV['GIT_AUTHORIZATION']
TESTFLIGHT_EXTERNAL_GROUPS = ENV['TESTFLIGHT_EXTERNAL_GROUPS']
XCODEPROJ = "#{APP_NAME}.xcodeproj" XCODEPROJ = "#{APP_NAME}.xcodeproj"
SCHEME = APP_NAME SCHEME = APP_NAME
@@ -35,6 +34,20 @@ def ensure_temp_keychain(name, password)
create_temp_keychain(name, password) create_temp_keychain(name, password)
end 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]) add_extra_platforms(platforms: [:tvos])
before_all do before_all do
@@ -148,7 +161,9 @@ platform :ios do
upload_to_testflight( upload_to_testflight(
api_key: api_key, api_key: api_key,
ipa: lane_context[SharedValues::IPA_OUTPUT_PATH], ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
changelog: changelog changelog: changelog,
distribute_external: true,
groups: all_testflight_group_names(api_key)
) )
end end
end end
@@ -220,7 +235,9 @@ platform :tvos do
upload_to_testflight( upload_to_testflight(
api_key: api_key, api_key: api_key,
ipa: lane_context[SharedValues::IPA_OUTPUT_PATH], ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
changelog: changelog changelog: changelog,
distribute_external: true,
groups: all_testflight_group_names(api_key)
) )
end end
end end
@@ -284,7 +301,9 @@ platform :mac do
upload_to_testflight( upload_to_testflight(
api_key: api_key, api_key: api_key,
pkg: lane_context[SharedValues::PKG_OUTPUT_PATH], pkg: lane_context[SharedValues::PKG_OUTPUT_PATH],
changelog: changelog changelog: changelog,
distribute_external: true,
groups: all_testflight_group_names(api_key)
) )
end end