diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5772415..c42c9018 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/update-altstore.yml b/.github/workflows/update-altstore.yml index bb5b2e39..c87f11b5 100644 --- a/.github/workflows/update-altstore.yml +++ b/.github/workflows/update-altstore.yml @@ -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" diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 6e5de775..ab603e8a 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -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 */ diff --git a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b8b000a4..0ba9bb61 100644 --- a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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" } }, { diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index f5562cc4..f18daa65 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Services/Logging/BackgroundDiagnostics.swift b/Yattee/Services/Logging/BackgroundDiagnostics.swift index e141b891..360af60e 100644 --- a/Yattee/Services/Logging/BackgroundDiagnostics.swift +++ b/Yattee/Services/Logging/BackgroundDiagnostics.swift @@ -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() diff --git a/Yattee/Services/Player/MPV/MPVClient.swift b/Yattee/Services/Player/MPV/MPVClient.swift index 1d5f452a..4891228e 100644 --- a/Yattee/Services/Player/MPV/MPVClient.swift +++ b/Yattee/Services/Player/MPV/MPVClient.swift @@ -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 diff --git a/Yattee/Services/Player/MPV/MPVRenderView.swift b/Yattee/Services/Player/MPV/MPVRenderView.swift index 3c71d5f1..4253d9bb 100644 --- a/Yattee/Services/Player/MPV/MPVRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVRenderView.swift @@ -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) diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 516089a1..f06be504 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -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 diff --git a/Yattee/Services/Player/PlayerBackend.swift b/Yattee/Services/Player/PlayerBackend.swift index ccb653a4..bd1c6b25 100644 --- a/Yattee/Services/Player/PlayerBackend.swift +++ b/Yattee/Services/Player/PlayerBackend.swift @@ -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 diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 566b9c0e..77881ce9 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -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)