Compare commits

...

13 Commits

Author SHA1 Message Date
github-actions[bot]
10ea2798ee Update AltStore source for 2.0.0 (278) 2026-10-31 19:49:25 +00:00
github-actions[bot]
71b3b2cce5 Bump build number to 278 2026-10-31 19:49:04 +00:00
Arkadiusz Fal
dd1213c5a8 Ignore benign altool CDUploaderStateInfoResumeFilePath error in fastlane
Xcode 27.2's altool logs an ERROR line about the uploader resume file even
when the upload succeeds, which made upload_to_testflight fail before
setting the changelog and distributing to external groups.
2026-10-31 20:30:32 +01:00
Arkadiusz Fal
f0578e99cd Update CHANGELOG.md 2026-10-31 19:56:55 +01:00
Arkadiusz Fal
4487dd14cd Bump build number to 277 2026-10-31 19:53:15 +01:00
Arkadiusz Fal
42dac052bf Merge remote-tracking branch 'origin/main' 2026-10-31 19:50:57 +01:00
Arkadiusz Fal
8ee5d81a99 Open SwiftData and TipKit stores after didFinishLaunching on iOS
TestFlight crashpoint "_hasTableWithName: + 12" (0xdead10cc) kills the
app ~1s after launch while ModelContainer.init runs WAL recovery from
YatteeApp.init(), before UIApplicationMain. That init also runs during
prewarm launches that iOS can suspend, and the suspended process holds
the store's SQLite lock. In some reports TipKit was opening its own
store from Tips.configure at the same time.

On iOS, DataManager now opens its store lazily, and AppDelegate's
didFinishLaunching configures TipKit and opens the store inside an
OpenDataStore background task. macOS and tvOS keep opening at init.
In-memory stores (tests, previews) open immediately.

Instrumentation: launch logs record prewarm, launch options, whether
the background task was granted, and open duration and trigger. Debug
iOS builds assert before opening if anything touches the store ahead
of didFinishLaunching.

This is a mitigation, not a confirmed fix; watch the crashpoint in the
next build. Moving the store out of the app group is deliberately not
done.
2026-10-31 19:50:30 +01:00
Arkadiusz Fal
63d5e8bbe6 Log and alert when hardware decoding falls back to software mid-playback
With Enable Logging on, MPVBackend watches hwdec-current (property event
plus a single-property poll in the stats loop, since mpv does not always
emit an event for a silent fallback). When a file that was hardware
decoded drops to software, it logs a warning with codec/stream, playback
position, scene phase and time since the last change, thermal/low-power
state, PiP and the recent mpv log lines, and shows a toast asking to
export logs (deferred until the app is active again).

The background diagnostics heartbeat now includes the decoder state.
2026-10-31 16:26:18 +01:00
Arkadiusz Fal
27a6ddd61f Update MPVKit to 1.0.2
The fork release stops the avfoundation audio output from restarting
while paused, which left it spinning for samples in the background.
2026-10-31 14:08:25 +01:00
Arkadiusz Fal
913b61980d Reload the avfoundation audio output when it restarts while paused
The heat in the iOS 27.2 TestFlight report (~36% CPU for 85 minutes with
playback paused) started right after an audio route change while paused:
mpv's ao_avfoundation logged "restarting due to system notification"
and an unnamed thread went to ~36% immediately, still in the foreground.
Its restart handler calls stop() then start() regardless of pause state;
start() sets the synchronizer rate to 1 and requests media data, but
paused mpv supplies no samples, so the empty renderer re-requests in a
tight loop and the running renderer keeps iOS from suspending the app.

When that log line arrives and mpv is paused, issue ao-reload so the AO
is rebuilt paused. (The display link guard in the previous commit did not
reproduce the spin when tested on a device.)
2026-10-31 11:44:27 +01:00
Arkadiusz Fal
c44edd23e6 Keep the display link paused while the app is in the background
A TestFlight log (iOS 27.2, build 276) showed ~36% CPU on an unnamed
thread for 85 minutes with playback paused in the background, heating
the phone. A lock during a quick inactive/active bounce rebuilt the
player container after the app had entered the background, and its
resumeRendering() un-paused the display link there; heartbeats showed
displayLink=running@60 inBackground=true for the whole period. An
earlier identical rebuild in the foreground left background CPU at 0.4%.

resumeRendering() now ignores calls in the background (unless PiP is
rendering) and a display link created there starts paused;
appDidBecomeActive resumes it.
2026-10-31 11:11:39 +01:00
Arkadiusz Fal
29163b7989 Update GitHub Actions to Node 24 versions 2026-10-31 10:52:27 +01:00
github-actions[bot]
03599d33b4 Update AltStore source for 2.0.0 (276) 2026-10-28 21:58:11 +00:00
17 changed files with 455 additions and 114 deletions

View File

@@ -58,13 +58,13 @@ jobs:
build_number: ${{ steps.calc.outputs.build_number }}
version_number: ${{ steps.version.outputs.version_number }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
cache-version: 1
- uses: maierj/fastlane-action@v3.0.0
- uses: maierj/fastlane-action@v3.2.0
with:
lane: latest_build_number
- name: Calculate next build number
@@ -85,7 +85,7 @@ jobs:
name: Release iOS to TestFlight
runs-on: xcode-27
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Select Xcode 27.2 RC
# Pinned by full path to the RC; App Store Connect rejects beta-built uploads.
run: |
@@ -101,10 +101,10 @@ jobs:
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
- uses: maierj/fastlane-action@v3.2.0
with:
lane: ios beta
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: ios-beta-build
path: fastlane/builds/**/*.ipa
@@ -116,7 +116,7 @@ jobs:
name: Release tvOS to TestFlight
runs-on: xcode-27
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Select Xcode 27.2 RC
# Pinned by full path to the RC; App Store Connect rejects beta-built uploads.
run: |
@@ -132,10 +132,10 @@ jobs:
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
- uses: maierj/fastlane-action@v3.2.0
with:
lane: tvos beta
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: tvos-beta-build
path: fastlane/builds/**/*.ipa
@@ -147,7 +147,7 @@ jobs:
name: Release macOS to TestFlight
runs-on: xcode-27
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Select Xcode 27.2 RC
# Pinned by full path to the RC; App Store Connect rejects beta-built uploads.
run: |
@@ -163,10 +163,10 @@ jobs:
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ needs.determine_build_number.outputs.build_number }}/' Yattee.xcodeproj/project.pbxproj
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
- uses: maierj/fastlane-action@v3.2.0
with:
lane: mac beta
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: mac-beta-build
path: fastlane/builds/**/*.pkg
@@ -181,7 +181,7 @@ jobs:
BUILD_NUMBER: ${{ needs.determine_build_number.outputs.build_number }}
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Select Xcode 27.2 RC
# Pinned by full path to the RC; App Store Connect rejects beta-built uploads.
run: |
@@ -197,7 +197,7 @@ jobs:
sed -i '' 's/CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = ${{ env.BUILD_NUMBER }}/' Yattee.xcodeproj/project.pbxproj
- name: Clear SPM cache
run: rm -rf ~/Library/Caches/org.swift.swiftpm/artifacts
- uses: maierj/fastlane-action@v3.0.0
- uses: maierj/fastlane-action@v3.2.0
with:
lane: mac build_and_notarize
- name: Resolve artifact paths
@@ -206,7 +206,7 @@ jobs:
echo "APP_PATH=$DIR/Yattee.app" >> $GITHUB_ENV
echo "ZIP_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
echo "DMG_PATH=$DIR/Yattee-${{ env.VERSION_NUMBER }}-macOS.dmg" >> $GITHUB_ENV
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: mac-notarized-build
path: |
@@ -228,7 +228,7 @@ jobs:
VERSION_NUMBER: ${{ needs.determine_build_number.outputs.version_number }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
token: ${{ secrets.REPO_TOKEN }}
- name: Commit build number
@@ -241,7 +241,7 @@ jobs:
git commit -m "Bump build number to ${{ env.BUILD_NUMBER }}"
git push origin ${{ github.ref_name }}
}
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: artifacts
- name: Compute release tag
@@ -285,7 +285,7 @@ jobs:
echo "::error::SPARKLE_ED_PRIVATE_KEY secret is not set. Configure it with the base64-encoded private key exported via 'generate_keys -x'."
exit 1
fi
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
token: ${{ secrets.REPO_TOKEN }}
- name: Select Xcode 27.2 RC
@@ -294,7 +294,7 @@ jobs:
sudo xcode-select -s /Applications/Xcode_27.2_Release_Candidate.app
xcodebuild -version
- name: Download notarized mac artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: mac-notarized-build
path: mac-artifacts

View File

@@ -19,7 +19,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: main
# Default GITHUB_TOKEN cannot bypass the "changes through PR only"

View File

@@ -2,27 +2,8 @@
## What's Changed
### New Features
* Add performance logging and background diagnostics
### Improvements
* Update AltStore source for 2.0.0 (275)
### Bug Fixes
* Fix player brightness controls on iPhone Duo
### Other
* Don't animate the video info carousel into place when it first opens
* Stop decoding video during background playback on iOS
* Scroll mini player marquee with Core Animation on iOS and tvOS
* Cut foreground CPU while playing and paused
* Read the Duo status column's occlusion regions from a full-bleed reader
* Cut foreground CPU during audio-only playback
* Stop re-rendering hidden player UI during background playback
* Use production CloudKit environment for Xcode builds
* Raise Open Link URL limit from 20 to 200
* Rework GitHub issue templates to ask for logs and reproduction details
### Bug fixes
* Log and alert when hardware decoding falls back to software mid-playback
* Reload the avfoundation audio output when it restarts while paused
* Keep the display link paused while the app is in the background
* Open SwiftData and TipKit stores after didFinishLaunching on iOS

View File

@@ -637,7 +637,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -722,7 +722,7 @@
AUTOMATION_APPLE_EVENTS = NO;
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -808,7 +808,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -953,7 +953,7 @@
CODE_SIGN_ENTITLEMENTS = Yattee/Yattee.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Yattee/Yattee-macOS-DeveloperID.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
ENABLE_APP_SANDBOX = YES;
@@ -1034,7 +1034,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -1063,7 +1063,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1095,7 +1095,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1130,7 +1130,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1161,7 +1161,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1192,7 +1192,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeShareExtension/YatteeShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeShareExtension/Info.plist;
@@ -1224,7 +1224,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1258,7 +1258,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1292,7 +1292,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = YatteeTopShelf/YatteeTopShelf.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = YatteeTopShelf/Info.plist;
@@ -1327,7 +1327,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -1356,7 +1356,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -1385,7 +1385,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 276;
CURRENT_PROJECT_VERSION = 278;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 78Z5H3M6RJ;
GENERATE_INFOPLIST_FILE = YES;
@@ -1491,7 +1491,7 @@
repositoryURL = "https://github.com/yattee/MPVKit.git";
requirement = {
kind = exactVersion;
version = 1.0.1;
version = 1.0.2;
};
};
/* End XCRemoteSwiftPackageReference section */

View File

@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/yattee/MPVKit.git",
"state" : {
"revision" : "671d3484b00f438407564573a4e188631d81bc90",
"version" : "1.0.1"
"revision" : "29051134058937871b37ad42f520f9eb6129966a",
"version" : "1.0.2"
}
},
{

View File

@@ -16,6 +16,39 @@ class AppDelegate: NSObject, UIApplicationDelegate {
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Open the SwiftData stores (TipKit's and the app's) here rather than in
// `YatteeApp.init()`, which also runs during prewarm launches that iOS
// suspends before `UIApplicationMain` (0xdead10cc if an SQLite lock is
// held). The assertion keeps a background launch running until the
// opens, including any WAL recovery, have finished and released locks.
DataManager.didFinishLaunching = true
var storeTask: UIBackgroundTaskIdentifier = .invalid
storeTask = application.beginBackgroundTask(withName: "OpenDataStore") {
LoggingService.shared.warning("Background task OpenDataStore expired", category: .diagnostics)
if storeTask != .invalid {
application.endBackgroundTask(storeTask)
storeTask = .invalid
}
}
LoggingService.shared.info(
"didFinishLaunching",
category: .diagnostics,
details: "applicationState: \(application.applicationState.rawValue), "
+ "prewarm: \(ProcessInfo.processInfo.environment["ActivePrewarm"] == "1"), "
+ "launchOptions: \(launchOptions?.keys.map(\.rawValue).sorted() ?? []), "
// `.invalid` means iOS granted no background time. The stores
// are opened anyway — deferring would only move the same
// unprotected open to the first caller (e.g. a silent-push
// sync) — but the log makes that launch identifiable.
+ "assertion: \(storeTask != .invalid)"
)
YatteeApp.configureTips()
AppEnvironment.shared.dataManager.openStore(trigger: "didFinishLaunching")
if storeTask != .invalid {
application.endBackgroundTask(storeTask)
storeTask = .invalid
}
// Required for CKSyncEngine to receive silent push notifications for remote changes.
// The remote-notification background mode is already in Info.plist.
LoggingService.shared.logCloudKit("Requesting remote notification registration...")

View File

@@ -14,9 +14,28 @@ import SwiftData
final class DataManager {
// MARK: - Properties
let modelContainer: ModelContainer
let modelContext: ModelContext
/// The SwiftData container, opened on first access.
///
/// The on-disk store lives in the app-group container. Opening it takes an
/// SQLite lock (and runs WAL recovery after an unclean exit), and iOS kills
/// an app suspended while holding such a lock with 0xdead10cc. `DataManager`
/// is built from `YatteeApp.init()`, which also runs during prewarm launches
/// that the system suspends before `UIApplicationMain`, so the store must not
/// be opened there. On iOS `AppDelegate` opens it from
/// `didFinishLaunching` inside a background-task assertion via `openStore`.
var modelContainer: ModelContainer { openedStore().container }
/// The main-actor context of `modelContainer`, opened on first access.
var modelContext: ModelContext { openedStore().context }
@ObservationIgnored private var store: (container: ModelContainer, context: ModelContext)?
@ObservationIgnored private let isInMemory: Bool
@ObservationIgnored private let launchedAt = Date()
/// Set by `AppDelegate` once `didFinishLaunching` runs, so the open log can
/// tell a deferred open from one that still happened during launch.
static var didFinishLaunching = false
/// Weak reference to settings manager for accessing search history limit.
weak var settingsManager: SettingsManager?
@@ -34,7 +53,12 @@ final class DataManager {
/// to the sync engine — otherwise it only fetches changes made since the
/// old tokens and records that did not change since (subscriptions,
/// bookmarks) never come back.
private(set) var storeWasCreated: Bool = false
var storeWasCreated: Bool {
_ = openedStore()
return storeWasCreatedValue
}
@ObservationIgnored private var storeWasCreatedValue = false
/// Cached set of bookmarked video IDs for fast O(1) lookup.
/// Updated when bookmarks are added/removed locally or via CloudKit sync.
@@ -56,49 +80,21 @@ final class DataManager {
// MARK: - Initialization
init(inMemory: Bool = false, iCloudSyncEnabled _: Bool = false) throws {
let configuration: ModelConfiguration
// IMPORTANT: We intentionally do NOT use SwiftData's built-in CloudKit sync.
// SwiftData CloudKit sync uses internal UUIDs which causes duplicates when
// the same data is created on multiple devices (each device generates different IDs).
//
// Instead, we use CKSyncEngine directly via CloudKitSyncEngine for iCloud sync.
// This approach:
// 1. Uses business identifiers (channelID, videoID) for deduplication
// 2. Gives explicit control over sync timing and conflict resolution
// 3. Avoids the duplicate data problem inherent in SwiftData CloudKit sync
if inMemory {
configuration = ModelConfiguration(
schema: Self.schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
} else {
configuration = ModelConfiguration(
schema: Self.schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
}
let storeExisted = inMemory || FileManager.default.fileExists(atPath: configuration.url.path)
self.modelContainer = try ModelContainer(
for: Self.schema,
configurations: [configuration]
)
self.storeWasCreated = !storeExisted
self.modelContext = modelContainer.mainContext
self.modelContext.autosaveEnabled = true
self.isInMemory = inMemory
self.isCloudKitEnabled = false
LoggingService.shared.logCloudKit(
"SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)",
details: inMemory ? "in-memory" : "store: \(configuration.url.path), existed: \(storeExisted)"
)
// Initialize bookmark cache
refreshBookmarkCache()
// In-memory stores hold no file lock, so tests and previews open
// immediately and keep the synchronous behavior they rely on.
// macOS has no suspend-time lock kill and tvOS has no crash evidence,
// so both keep opening at init until there is a reason to change them.
#if os(iOS)
if inMemory {
openStore(trigger: "in-memory init")
}
#else
openStore(trigger: "init")
#endif
// Listen for bookmark changes from CloudKit sync to refresh cache
NotificationCenter.default.addObserver(
forName: .bookmarksDidChange,
@@ -111,7 +107,109 @@ final class DataManager {
}
}
}
// MARK: - Store Opening
/// Opens the store if it is not open yet. Safe to call repeatedly.
///
/// - Parameter trigger: Who asked, recorded in the open log so a store
/// opened before `didFinishLaunching` can be traced to its caller.
func openStore(trigger: String) {
guard store == nil else { return }
#if DEBUG && os(iOS)
// Enforces the lifecycle boundary in development, before SQLite takes
// any lock: anything that touches the store from `YatteeApp.init()` /
// `AppEnvironment.init` would reintroduce the pre-UIApplicationMain
// open (0xdead10cc).
if !isInMemory && !Self.didFinishLaunching {
LoggingService.shared.warning(
"SwiftData store opening before didFinishLaunching",
category: .diagnostics,
details: Thread.callStackSymbols.prefix(12).joined(separator: "\n")
)
assertionFailure("SwiftData store opening before didFinishLaunching (trigger: \(trigger))")
}
#endif
let start = Date()
let opened = Self.makeStore(inMemory: isInMemory)
store = (opened.container, opened.container.mainContext)
store?.context.autosaveEnabled = true
storeWasCreatedValue = opened.created
if !isInMemory {
let duration = Date().timeIntervalSince(start)
let sinceLaunch = start.timeIntervalSince(launchedAt)
let prewarm = ProcessInfo.processInfo.environment["ActivePrewarm"] == "1"
LoggingService.shared.info(
"SwiftData store opened",
category: .diagnostics,
details: "trigger: \(trigger), duration: \(String(format: "%.3f", duration))s, "
+ "afterInit: \(String(format: "%.3f", sinceLaunch))s, "
+ "didFinishLaunching: \(Self.didFinishLaunching), prewarm: \(prewarm), "
+ "existed: \(!opened.created), store: \(opened.url?.path ?? "in-memory")"
)
}
refreshBookmarkCache()
}
private func openedStore() -> (container: ModelContainer, context: ModelContext) {
if store == nil {
openStore(trigger: "lazy access")
}
guard let store else { preconditionFailure("openStore always sets the store") }
return store
}
/// Builds the container, falling back to in-memory storage if the
/// persistent store cannot be opened.
private static func makeStore(inMemory: Bool) -> (container: ModelContainer, created: Bool, url: URL?) {
// IMPORTANT: We intentionally do NOT use SwiftData's built-in CloudKit sync.
// SwiftData CloudKit sync uses internal UUIDs which causes duplicates when
// the same data is created on multiple devices (each device generates different IDs).
//
// Instead, we use CKSyncEngine directly via CloudKitSyncEngine for iCloud sync.
// This approach:
// 1. Uses business identifiers (channelID, videoID) for deduplication
// 2. Gives explicit control over sync timing and conflict resolution
// 3. Avoids the duplicate data problem inherent in SwiftData CloudKit sync
let memoryConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
if inMemory {
// swiftlint:disable:next force_try
let container = try! ModelContainer(for: schema, configurations: [memoryConfiguration])
return (container, false, nil)
}
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
)
let storeExisted = FileManager.default.fileExists(atPath: configuration.url.path)
do {
let container = try ModelContainer(for: schema, configurations: [configuration])
LoggingService.shared.logCloudKit(
"SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)",
details: "store: \(configuration.url.path), existed: \(storeExisted)"
)
return (container, !storeExisted, configuration.url)
} catch {
LoggingService.shared.logCloudKitError(
"Persistent store unavailable, falling back to in-memory storage",
error: error
)
// swiftlint:disable:next force_try
let container = try! ModelContainer(for: schema, configurations: [memoryConfiguration])
return (container, false, nil)
}
}
/// Refreshes the cached set of bookmarked video IDs.
/// Called at init and when bookmarks change via CloudKit sync.
private func refreshBookmarkCache() {

View File

@@ -20078,6 +20078,28 @@
}
}
},
"toast.player.hwdecFallback.subtitle" : {
"comment" : "Toast subtitle shown (only with Enable Logging on) when hardware video decoding stopped mid-playback",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Details were logged — please export logs and report it"
}
}
}
},
"toast.player.hwdecFallback.title" : {
"comment" : "Toast title shown (only with Enable Logging on) when hardware video decoding stopped mid-playback",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hardware Decoding Stopped"
}
}
}
},
"toast.player.refreshingStream.title" : {
"comment" : "Toast title shown when stream URLs are being refreshed due to expiration",
"localizations" : {

View File

@@ -104,6 +104,9 @@ final class BackgroundDiagnostics {
+ " backend=\(playerService.currentBackendType) video=\(state.currentVideo?.id.videoID ?? "none")"
+ " time=\(Int(state.currentTime))/\(Int(state.duration))"
+ " publishesTime=\(state.publishesTimeUpdates)")
if let mpvBackend = playerService.currentBackend as? MPVBackend {
lines.append("decoder: " + mpvBackend.decoderDiagnosticsSummary)
}
}
let session = AVAudioSession.sharedInstance()

View File

@@ -252,6 +252,10 @@ final class MPVClient: @unchecked Sendable {
let line = MPVLogLine(prefix: prefix, level: level, text: text)
if prefix == "ao/avfoundation", text.hasPrefix("restarting due to system notification") {
reloadAudioOutputIfPaused()
}
logBufferLock.lock()
recentLogBuffer.append(line)
if recentLogBuffer.count > recentLogBufferCapacity {
@@ -277,6 +281,22 @@ final class MPVClient: @unchecked Sendable {
}
}
/// mpv's avfoundation AO restarts its renderer on route changes and automatic
/// flushes, and the restart always starts it (synchronizer rate 1, requesting
/// data) even while paused. Paused mpv supplies no samples, so the empty
/// renderer re-requests in a tight loop on its dispatch queue (~35% CPU), and
/// the running renderer keeps iOS from suspending the app in the background.
/// Reloading the AO rebuilds it in the paused state.
private func reloadAudioOutputIfPaused() {
mpvQueue.async { [weak self] in
guard let self, let mpv = self.mpv, !self.isDestroyed else { return }
var paused: Int32 = 0
guard mpv_get_property(mpv, "pause", MPV_FORMAT_FLAG, &paused) >= 0, paused != 0 else { return }
self.logWarning("avfoundation AO restarted while paused - reloading audio output")
_ = self.commandUnsafe(["ao-reload"])
}
}
/// Snapshot the recent log buffer (most-recent-last). Thread-safe.
func recentLogLines(minimumSeverity: Int = 0) -> [MPVLogLine] {
logBufferLock.lock()
@@ -1157,6 +1177,21 @@ final class MPVClient: @unchecked Sendable {
}
}
/// Get a string property value asynchronously (non-blocking).
func getStringAsync(_ name: String) async -> String? {
await withCheckedContinuation { continuation in
mpvQueue.async { [weak self] in
guard let self, let mpv = self.mpv, !self.isDestroyed,
let cString = mpv_get_property_string(mpv, name) else {
continuation.resume(returning: nil)
return
}
defer { mpv_free(cString) }
continuation.resume(returning: String(cString: cString))
}
}
}
/// Get MPV version and build information asynchronously (non-blocking).
/// Use this from MainActor code instead of getVersionInfo() to avoid blocking the UI.
/// - Returns: The version information, or nil if unavailable or MPV is destroyed

View File

@@ -861,6 +861,9 @@ final class MPVRenderView: UIView {
private func startDisplayLink() {
MPVLogging.logDisplayLink("creating", targetFPS: videoFPS)
displayLink = CADisplayLink(target: self, selector: #selector(displayLinkFired))
// Created in the background (view re-added during a lock): stay paused
// until appDidBecomeActive, as in resumeRendering.
displayLink?.isPaused = isInBackground && !isPiPActive && !captureFramesForPiP
updateDisplayLinkFrameRate()
displayLink?.add(to: .main, forMode: .common)
MPVLogging.logDisplayLink("started", isPaused: displayLink?.isPaused, targetFPS: videoFPS)
@@ -921,6 +924,16 @@ final class MPVRenderView: UIView {
return
}
// Layout can rebuild the player's container after the app entered the
// background (e.g. a lock during a quick inactive/active bounce), which
// calls this. The display link never fires there, but left running it
// matched a TestFlight report of ~36% CPU on an unnamed thread for as
// long as the app stayed alive. appDidBecomeActive resumes it.
guard !isInBackground || isPiPActive || captureFramesForPiP else {
LoggingService.shared.info("MPV RenderView: resumeRendering ignored in background", category: .mpv)
return
}
displayLink.isPaused = false
MPVLogging.logDisplayLink("resumed", isPaused: false, reason: "resumeRendering")
LoggingService.shared.debug("MPV RenderView: displayLink resumed", category: .mpv)

View File

@@ -126,6 +126,15 @@ final class MPVBackend: PlayerBackend {
private var videoCodec: String = ""
private var hwdecCurrent: String = ""
private var hwdecInterop: String = ""
// Software-fallback detection (logged + toasted only with "Enable Logging" on).
// mpv falls back to software one-way and does not always emit a property
// event for it, so the stats loop also polls hwdec-current.
private var hwdecTrackingArmed = false
private var hwdecUsedForCurrentFile: String?
private var hwdecFallbackReported = false
private var hwdecFallbackAlertPending = false
private var lastScenePhase: ScenePhase = .active
private var lastScenePhaseChange: Date?
#if os(tvOS)
private var videoGamma: String = ""
#endif
@@ -423,7 +432,8 @@ final class MPVBackend: PlayerBackend {
LoggingService.shared.debug("MPV: Disabling EDL for live stream (live streams not compatible with EDL)", category: .mpv)
}
LoggingService.shared.logMPV("MPV loading stream", details: "\(stream.qualityLabel) - \(stream.format)\n\(stream.url.absoluteString)\nEDL: \(actualUseEDL) (requested: \(useEDL), isLive: \(stream.isLive))\nStream info: videoCodec=\(stream.videoCodec ?? "nil"), audioCodec=\(stream.audioCodec ?? "nil")")
resetHwdecFallbackTracking()
LoggingService.shared.logMPV("MPV loading stream", details:"\(stream.qualityLabel) - \(stream.format)\n\(stream.url.absoluteString)\nEDL: \(actualUseEDL) (requested: \(useEDL), isLive: \(stream.isLive))\nStream info: videoCodec=\(stream.videoCodec ?? "nil"), audioCodec=\(stream.audioCodec ?? "nil")")
if let audioStream {
LoggingService.shared.logMPV("MPV loading separate audio track", details: "\(audioStream.audioLanguage ?? "default") - \(audioStream.format)\n\(audioStream.url.absoluteString)\nAudio codec: \(audioStream.audioCodec ?? "nil")")
}
@@ -780,6 +790,7 @@ final class MPVBackend: PlayerBackend {
bufferedTime = 0
currentStream = nil
loadedStreamHasVideo = false
resetHwdecFallbackTracking()
#if os(tvOS)
videoGamma = ""
clearTVDisplayCriteria()
@@ -979,6 +990,15 @@ final class MPVBackend: PlayerBackend {
MPVLogging.logAppLifecycle("handleScenePhase(\(phase))",
isPiPActive: pipActive, isRendering: nil)
if phase != lastScenePhase {
lastScenePhase = phase
lastScenePhaseChange = Date()
}
if phase == .active, hwdecFallbackAlertPending {
hwdecFallbackAlertPending = false
delegate?.backendDidFallBackToSoftwareDecoding(self)
}
#if os(iOS)
if phase == .active {
restoreVideoAfterBackground()
@@ -1762,6 +1782,7 @@ extension MPVBackend: MPVClientDelegate {
case "hwdec-current":
if let hwdec = value as? String {
hwdecCurrent = hwdec
checkHwdecFallback(hwdec, source: "event")
}
case "hwdec-interop":
@@ -1907,6 +1928,7 @@ extension MPVBackend: MPVClientDelegate {
switch event {
case MPV_EVENT_FILE_LOADED:
LoggingService.shared.debug("MPV: File loaded", category: .mpv)
hwdecTrackingArmed = true
startPlaybackStatsLogging()
// Log hwdec diagnostics on tvOS (use cached values to avoid sync fetch)
#if os(tvOS)
@@ -2065,6 +2087,71 @@ extension MPVBackend: MPVClientDelegate {
LoggingService.shared.debug("MPV: Buffer stall detection stopped", category: .mpv)
}
// MARK: - Hardware Decoding Fallback Detection
/// Decoder state for diagnostics heartbeats, from cached values (no mpv fetch).
var decoderDiagnosticsSummary: String {
"codec=\(videoCodec.isEmpty ? "none" : videoCodec) hwdec=\(hwdecCurrent.isEmpty ? "none" : hwdecCurrent)"
+ " hwUsedThisFile=\(hwdecUsedForCurrentFile ?? "no") fallbackReported=\(hwdecFallbackReported)"
}
private func resetHwdecFallbackTracking() {
hwdecTrackingArmed = false
hwdecUsedForCurrentFile = nil
hwdecFallbackReported = false
}
/// Detects mpv dropping from hardware to software decoding mid-file.
/// Armed at FILE_LOADED so stale events from the previous file are ignored;
/// only a file that was seen hardware decoded can report a fallback.
private func checkHwdecFallback(_ hwdec: String, source: String) {
// The poll sees silent fallbacks the property event misses; keep the cache current.
if !hwdec.isEmpty { hwdecCurrent = hwdec }
guard hwdecTrackingArmed, !hwdec.isEmpty else { return }
guard hwdec == "no" else {
if hwdecFallbackReported {
LoggingService.shared.warning("MPV: Hardware decoding resumed (\(hwdec), via \(source))", category: .mpv)
hwdecFallbackReported = false
}
hwdecUsedForCurrentFile = hwdec
return
}
guard let previous = hwdecUsedForCurrentFile, !hwdecFallbackReported,
LoggingService.shared.isEnabled else { return }
hwdecFallbackReported = true
var context = [
"hwdec: \(previous) -> no (detected via \(source))",
"codec: \(videoCodec.isEmpty ? "unknown" : videoCodec) \(videoWidth)x\(videoHeight)"
+ " stream=\(currentStream?.qualityLabel ?? "?") \(currentStream?.format ?? "")",
"playback: time=\(Int(currentTime))/\(Int(duration)) playing=\(isPlaying)",
"scene: \(lastScenePhase)"
+ (lastScenePhaseChange.map { String(format: " (changed %.0fs ago)", Date().timeIntervalSince($0)) } ?? " (no change since launch)"),
"thermal: \(ProcessInfo.processInfo.thermalState.rawValue)"
+ " lowPower: \(ProcessInfo.processInfo.isLowPowerModeEnabled)",
]
#if os(iOS) || os(macOS)
context.append("pip: \(isPiPActive)")
#endif
let recentLines = mpvClient?.recentLogLines().map(\.formatted) ?? []
context.append("recent mpv log (\(recentLines.count) lines):")
context.append(contentsOf: recentLines)
LoggingService.shared.warning(
"MPV: Hardware decoding fell back to software mid-playback",
category: .mpv,
details: context.joined(separator: "\n")
)
if lastScenePhase == .active {
delegate?.backendDidFallBackToSoftwareDecoding(self)
} else {
hwdecFallbackAlertPending = true
}
}
// MARK: - Playback Stats Logging
/// Log cache/frame-drop/pacing stats every 10s while a file is loaded, at INFO level
@@ -2088,11 +2175,21 @@ extension MPVBackend: MPVClientDelegate {
// watchdog below). Elsewhere the batched mpv_get_property
// calls contend with the playback core and caused a visible
// stutter every 10s on tvOS, so skip the fetch entirely
// unless verbose logging wants the stats line.
guard MPVLogging.verboseEnabled else { continue }
// unless verbose logging wants the stats line. The single
// hwdec-current read is the fallback detector's poll.
guard MPVLogging.verboseEnabled else {
if LoggingService.shared.isEnabled, self.hwdecUsedForCurrentFile != nil,
let hwdec = await client.getStringAsync("hwdec-current") {
self.checkHwdecFallback(hwdec, source: "poll")
}
continue
}
#endif
let props = await client.getDebugPropertiesAsync()
if let hwdec = props.hwdecCurrent {
self.checkHwdecFallback(hwdec, source: "poll")
}
#if os(macOS)
// Render watchdog — runs every tick regardless of the verbose

View File

@@ -114,6 +114,9 @@ protocol PlayerBackendDelegate: AnyObject {
func backend(_ backend: any PlayerBackend, didUpdateTracks tracks: [MPVTrack])
func backendDidBecomeReady(_ backend: any PlayerBackend)
func backendDidFinishPlaying(_ backend: any PlayerBackend)
/// Hardware decoding stopped mid-playback for a file that was hardware decoded.
/// Only reported while "Enable Logging" is on, and only once the app is active.
func backendDidFallBackToSoftwareDecoding(_ backend: any PlayerBackend)
}
// MARK: - Player Backend Protocol

View File

@@ -3145,6 +3145,17 @@ extension PlayerService: PlayerBackendDelegate {
state.isFirstFrameReady = true
}
func backendDidFallBackToSoftwareDecoding(_ backend: any PlayerBackend) {
toastManager?.show(
category: .playerStatus,
title: String(localized: "toast.player.hwdecFallback.title"),
subtitle: String(localized: "toast.player.hwdecFallback.subtitle"),
icon: "cpu",
iconColor: .orange,
autoDismissDelay: 8.0
)
}
func backendDidFinishPlaying(_ backend: any PlayerBackend) {
// Mark that video ended naturally - prevents play() from saving progress again
// when switching to next video (100% is already saved below)

View File

@@ -46,13 +46,23 @@ struct YatteeApp: App {
// Ambiguous-link prompts are stored on NavigationCoordinator so they can
// be dual-hosted by the root app view (here) and the expanded player sheet.
#if !os(tvOS)
/// Configures TipKit. Called once per launch.
static func configureTips() {
try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)])
}
#endif
init() {
// Configure Nuke image loading pipeline
ImageLoadingService.shared.configure()
#if !os(tvOS)
// TipKit: one-time hints such as the video info "tap thumbnail to play" tip
try? Tips.configure([.displayFrequency(.immediate), .datastoreLocation(.applicationDefault)])
// TipKit: one-time hints such as the video info "tap thumbnail to play" tip.
// TipKit opens its own SwiftData store, so on iOS it is configured from
// `AppDelegate.didFinishLaunching` together with the app store, never
// here: this init also runs during prewarm launches (0xdead10cc).
#if os(macOS)
Self.configureTips()
#endif
// Register background tasks early — Apple requires BGTaskScheduler.register()

View File

@@ -17,6 +17,24 @@
"category": "entertainment",
"screenshots": [],
"versions": [
{
"version": "2.0.0",
"buildVersion": "278",
"date": "2026-10-31T19:49:25+00:00",
"localizedDescription": "",
"downloadURL": "https://github.com/yattee/yattee/releases/download/2.0.0-beta.278/Yattee-2.0.0-iOS.ipa",
"size": 39795855,
"minOSVersion": "18.0"
},
{
"version": "2.0.0",
"buildVersion": "276",
"date": "2026-10-28T21:58:11+00:00",
"localizedDescription": "",
"downloadURL": "https://github.com/yattee/yattee/releases/download/2.0.0-beta.276/Yattee-2.0.0-iOS.ipa",
"size": 39755120,
"minOSVersion": "18.0"
},
{
"version": "2.0.0",
"buildVersion": "275",

View File

@@ -14,6 +14,23 @@ GIT_AUTHORIZATION = ENV['GIT_AUTHORIZATION']
XCODEPROJ = "#{APP_NAME}.xcodeproj"
SCHEME = APP_NAME
# Xcode 27.2's altool logs a spurious
# "ERROR: ... Failed to create URL for 'CDUploaderStateInfoResumeFilePath'"
# line even when the upload succeeds. fastlane treats any ERROR: line as a
# failure, which aborts upload_to_testflight before changelog/distribution.
module IgnoreBenignAltoolErrors
BENIGN_ALTOOL_ERRORS = ["CDUploaderStateInfoResumeFilePath"].freeze
def parse_line(line, hide_output)
if BENIGN_ALTOOL_ERRORS.any? { |pattern| line.include?(pattern) }
UI.important("[altool] Ignoring benign error: #{line.strip}")
return
end
super
end
end
FastlaneCore::AltoolTransporterExecutor.prepend(IgnoreBenignAltoolErrors)
def delete_temp_keychain(name)
delete_keychain(
name: name