Merge remote-tracking branch 'origin/main'

This commit is contained in:
Arkadiusz Fal
2026-10-31 19:50:57 +01:00
11 changed files with 209 additions and 25 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

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

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