mirror of
https://github.com/yattee/yattee.git
synced 2026-08-09 00:31:29 +00:00
Compare commits
14 Commits
2.0.0-beta
...
2.0.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29dbeef08d | ||
|
|
5c94526961 | ||
|
|
296779c63a | ||
|
|
c01b8f63e1 | ||
|
|
41d58f47a3 | ||
|
|
ce7e9cbde7 | ||
|
|
2ff248e262 | ||
|
|
faf0964746 | ||
|
|
6994b9353a | ||
|
|
c3e39c955d | ||
|
|
8f29b15fee | ||
|
|
65b2f985ef | ||
|
|
0db7b73d25 | ||
|
|
afc03188a0 |
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -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:
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,7 +260,14 @@ final class MPVClient: @unchecked Sendable {
|
||||
case "warn":
|
||||
Task { @MainActor in LoggingService.shared.logMPVWarning(formatted) }
|
||||
default:
|
||||
Task { @MainActor in LoggingService.shared.logMPV(formatted) }
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -249,12 +249,14 @@ final class MPVPiPBridge: NSObject {
|
||||
let newHeight = currentBounds.width / aspectRatio
|
||||
let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight)
|
||||
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
sampleBufferLayer.bounds = newBounds
|
||||
CATransaction.commit()
|
||||
if let newBounds = sanitizedGeometry(newBounds, from: "updateVideoAspectRatio") {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
sampleBufferLayer.bounds = newBounds
|
||||
CATransaction.commit()
|
||||
|
||||
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
|
||||
// On iOS, don't modify bounds when PiP is inactive - this causes frame misalignment
|
||||
@@ -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()
|
||||
|
||||
@@ -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!
|
||||
if let ctx = EAGLContext(api: .openGLES3) {
|
||||
MPVLogging.log("setupAsync: created OpenGL ES 3.0 context")
|
||||
return ctx
|
||||
} else if let ctx = EAGLContext(api: .openGLES2) {
|
||||
MPVLogging.log("setupAsync: created OpenGL ES 2.0 context (ES3 unavailable)")
|
||||
return ctx
|
||||
} else {
|
||||
MPVLogging.warn("setupAsync: failed to create EAGLContext")
|
||||
throw MPVRenderError.openGLSetupFailed
|
||||
// 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")
|
||||
continuation.resume(returning: ctx)
|
||||
} else if let ctx = EAGLContext(api: .openGLES2) {
|
||||
MPVLogging.log("setupAsync: created OpenGL ES 2.0 context (ES3 unavailable)")
|
||||
continuation.resume(returning: ctx)
|
||||
} else {
|
||||
MPVLogging.warn("setupAsync: failed to create EAGLContext")
|
||||
continuation.resume(throwing: MPVRenderError.openGLSetupFailed)
|
||||
}
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
let glCreateTime = Date().timeIntervalSince(glStartTime)
|
||||
MPVLogging.log("setupAsync: EAGLContext created",
|
||||
@@ -558,7 +570,7 @@ final class MPVRenderView: UIView {
|
||||
} else {
|
||||
MPVLogging.log("setupAsync: deferring displayLink (framebuffer not ready)")
|
||||
}
|
||||
|
||||
|
||||
isSetup = true
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user