mirror of
https://github.com/yattee/yattee.git
synced 2026-11-06 11:57:18 +00:00
Compare commits
13 Commits
2.0.0-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10ea2798ee | ||
|
|
71b3b2cce5 | ||
|
|
dd1213c5a8 | ||
|
|
f0578e99cd | ||
|
|
4487dd14cd | ||
|
|
42dac052bf | ||
|
|
8ee5d81a99 | ||
|
|
63d5e8bbe6 | ||
|
|
27a6ddd61f | ||
|
|
913b61980d | ||
|
|
c44edd23e6 | ||
|
|
29163b7989 | ||
|
|
03599d33b4 |
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/update-altstore.yml
vendored
2
.github/workflows/update-altstore.yml
vendored
@@ -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"
|
||||
|
||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -14,8 +14,27 @@ 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,48 +80,20 @@ 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(
|
||||
@@ -112,6 +108,108 @@ 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() {
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user