This commit is contained in:
Alex Kim
2025-12-06 04:56:48 +11:00
parent 36655bba43
commit c76d7eb877
47 changed files with 458 additions and 3442 deletions

View File

@@ -1,10 +1,3 @@
//
// Logging.swift
// Sora
//
// Created by seiike on 16/01/2025.
//
import Foundation
class Logger {

View File

@@ -5,6 +5,7 @@
// Created by Francesco on 28/09/25.
//
import UIKit
import Libmpv
import CoreMedia
import CoreVideo
@@ -44,7 +45,7 @@ final class MPVSoftwareRenderer {
private var poolWidth: Int = 0
private var poolHeight: Int = 0
private var preAllocatedBuffers: [CVPixelBuffer] = []
private let maxPreAllocatedBuffers = 6
private let maxPreAllocatedBuffers = 12
private var currentPreset: PlayerPreset?
private var currentURL: URL?
@@ -64,15 +65,26 @@ final class MPVSoftwareRenderer {
private var isLoading: Bool = false
private var isRenderScheduled = false
private var lastRenderTime: CFTimeInterval = 0
private let minRenderInterval: CFTimeInterval = 1.0 / 120.0
private var minRenderInterval: CFTimeInterval
private var isReadyToSeek: Bool = false
private var lastRenderDimensions: CGSize = .zero
var isPausedState: Bool {
return isPaused
}
init(displayLayer: AVSampleBufferDisplayLayer) {
guard
let screen = UIApplication.shared.connectedScenes
.compactMap({ ($0 as? UIWindowScene)?.screen })
.first
else {
fatalError("⚠️ No active screen found — app may not have a visible window yet.")
}
self.displayLayer = displayLayer
let maxFPS = screen.maximumFramesPerSecond
let cappedFPS = min(maxFPS, 60)
self.minRenderInterval = 1.0 / CFTimeInterval(cappedFPS)
renderQueue.setSpecific(key: renderQueueKey, value: ())
}
@@ -96,11 +108,16 @@ final class MPVSoftwareRenderer {
setOption(name: "gpu-context", value: "metal")
setOption(name: "demuxer-thread", value: "yes")
setOption(name: "ytdl", value: "yes")
setOption(name: "profile", value: "fast")
setOption(name: "vd-lavc-threads", value: "8")
setOption(name: "cache", value: "yes")
setOption(name: "demuxer-max-bytes", value: "150M")
setOption(name: "demuxer-readahead-secs", value: "20")
// Subtitle rendering options
setOption(name: "subs-match-os-language", value: "yes")
setOption(name: "subs-fallback", value: "yes")
setOption(name: "sub-auto", value: "no")
// Subtitle options - blend into video for software renderer
setOption(name: "blend-subtitles", value: "video")
setOption(name: "sub-visibility", value: "yes")
setOption(name: "osd-level", value: "0")
let initStatus = mpv_initialize(handle)
guard initStatus >= 0 else {
@@ -144,6 +161,7 @@ final class MPVSoftwareRenderer {
self.pixelBufferPool = nil
self.poolWidth = 0
self.poolHeight = 0
self.lastRenderDimensions = .zero
}
eventQueueGroup.wait()
@@ -162,6 +180,7 @@ final class MPVSoftwareRenderer {
self.formatDescription = nil
self.poolWidth = 0
self.poolHeight = 0
self.lastRenderDimensions = .zero
self.disposeBag.forEach { $0() }
self.disposeBag.removeAll()
@@ -169,7 +188,11 @@ final class MPVSoftwareRenderer {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.displayLayer.flushAndRemoveImage()
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
} else {
self.displayLayer.flushAndRemoveImage()
}
}
isStopping = false
@@ -198,18 +221,12 @@ final class MPVSoftwareRenderer {
self.command(handle, ["stop"])
self.updateHTTPHeaders(headers)
// Handle file URLs - use path, otherwise use absolute string
let target: String
if url.isFileURL {
// For file URLs, use the path (removes file:// prefix)
target = url.path
Logger.shared.log("Loading file: \(target)", type: "Info")
} else {
// For network URLs, use absolute string
target = url.absoluteString
Logger.shared.log("Loading URL: \(target)", type: "Info")
var finalURL = url
if !url.isFileURL {
finalURL = url
}
let target = finalURL.isFileURL ? finalURL.path : finalURL.absoluteString
self.command(handle, ["loadfile", target, "replace"])
}
}
@@ -339,7 +356,18 @@ final class MPVSoftwareRenderer {
guard let self, self.isRunning, !self.isStopping else { return }
let currentTime = CACurrentMediaTime()
if self.isRenderScheduled && (currentTime - self.lastRenderTime) < self.minRenderInterval {
let timeSinceLastRender = currentTime - self.lastRenderTime
if timeSinceLastRender < self.minRenderInterval {
let remaining = self.minRenderInterval - timeSinceLastRender
if self.isRenderScheduled { return }
self.isRenderScheduled = true
self.renderQueue.asyncAfter(deadline: .now() + remaining) { [weak self] in
guard let self else { return }
self.lastRenderTime = CACurrentMediaTime()
self.performRenderUpdate()
self.isRenderScheduled = false
}
return
}
@@ -367,11 +395,21 @@ final class MPVSoftwareRenderer {
private func renderFrame() {
guard let context = renderContext else { return }
let size = currentVideoSize()
guard size.width > 0, size.height > 0 else { return }
let videoSize = currentVideoSize()
guard videoSize.width > 0, videoSize.height > 0 else { return }
let width = Int(size.width)
let height = Int(size.height)
let targetSize = targetRenderSize(for: videoSize)
let width = Int(targetSize.width)
let height = Int(targetSize.height)
guard width > 0, height > 0 else { return }
if lastRenderDimensions != targetSize {
lastRenderDimensions = targetSize
if targetSize != videoSize {
Logger.shared.log("Rendering scaled output at \(width)x\(height) (source \(Int(videoSize.width))x\(Int(videoSize.height)))", type: "Info")
} else {
Logger.shared.log("Rendering output at native size \(width)x\(height)", type: "Info")
}
}
if poolWidth != width || poolHeight != height {
recreatePixelBufferPool(width: width, height: height)
@@ -451,15 +489,40 @@ final class MPVSoftwareRenderer {
}
CVPixelBufferUnlockBaseAddress(buffer, [])
enqueue(buffer: buffer)
if preAllocatedBuffers.count < 2 {
if preAllocatedBuffers.count < 4 {
renderQueue.async { [weak self] in
self?.preAllocateBuffers()
}
}
}
private func targetRenderSize(for videoSize: CGSize) -> CGSize {
guard videoSize.width > 0, videoSize.height > 0 else { return videoSize }
guard
let screen = UIApplication.shared.connectedScenes
.compactMap({ ($0 as? UIWindowScene)?.screen })
.first
else {
fatalError("⚠️ No active screen found — app may not have a visible window yet.")
}
var scale = screen.scale
if scale <= 0 { scale = 1 }
let maxWidth = max(screen.bounds.width * scale, 1.0)
let maxHeight = max(screen.bounds.height * scale, 1.0)
if maxWidth <= 0 || maxHeight <= 0 {
return videoSize
}
let widthRatio = videoSize.width / maxWidth
let heightRatio = videoSize.height / maxHeight
let ratio = max(widthRatio, heightRatio, 1)
let targetWidth = max(1, Int(videoSize.width / ratio))
let targetHeight = max(1, Int(videoSize.height / ratio))
return CGSize(width: CGFloat(targetWidth), height: CGFloat(targetHeight))
}
private func createPixelBufferPool(width: Int, height: Int) {
let pixelFormat = kCVPixelFormatType_32BGRA
@@ -479,7 +542,7 @@ final class MPVSoftwareRenderer {
]
let auxAttrs: [CFString: Any] = [
kCVPixelBufferPoolAllocationThresholdKey: 4
kCVPixelBufferPoolAllocationThresholdKey: 8
]
var pool: CVPixelBufferPool?
@@ -522,7 +585,7 @@ final class MPVSoftwareRenderer {
guard let pool = pixelBufferPool else { return }
let targetCount = min(maxPreAllocatedBuffers, 4)
let targetCount = min(maxPreAllocatedBuffers, 8)
let currentCount = preAllocatedBuffers.count
guard currentCount < targetCount else { return }
@@ -597,19 +660,43 @@ final class MPVSoftwareRenderer {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if self.displayLayer.status == .failed {
if let error = self.displayLayer.error {
let (status, error): (AVQueuedSampleBufferRenderingStatus?, Error?) = {
if #available(iOS 18.0, *) {
return (
self.displayLayer.sampleBufferRenderer.status,
self.displayLayer.sampleBufferRenderer.error
)
} else {
return (
self.displayLayer.status,
self.displayLayer.error
)
}
}()
if status == .failed {
if let error = error {
Logger.shared.log("Display layer in failed state: \(error.localizedDescription)", type: "Error")
}
self.displayLayer.flushAndRemoveImage()
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
} else {
self.displayLayer.flushAndRemoveImage()
}
}
if needsFlush {
self.displayLayer.flushAndRemoveImage()
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil)
} else {
self.displayLayer.flushAndRemoveImage()
}
self.didFlushForFormatChange = true
} else if self.didFlushForFormatChange {
self.displayLayer.flush()
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: false, completionHandler: nil)
} else {
self.displayLayer.flush()
}
self.didFlushForFormatChange = false
}
@@ -624,14 +711,14 @@ final class MPVSoftwareRenderer {
}
}
if !self.displayLayer.isReadyForMoreMediaData {
Logger.shared.log("Display layer not ready for more media data", type: "Warn")
}
if shouldNotifyLoadingEnd {
self.delegate?.renderer(self, didChangeLoading: false)
}
self.displayLayer.enqueue(sample)
if #available(iOS 18.0, *) {
self.displayLayer.sampleBufferRenderer.enqueue(sample)
} else {
self.displayLayer.enqueue(sample)
}
}
}
@@ -890,70 +977,40 @@ final class MPVSoftwareRenderer {
}
// MARK: - Subtitle Controls
func getSubtitleTracks() -> [[String: Any]] {
guard let handle = mpv else { return [] }
var node = mpv_node()
let status = "track-list".withCString { pointer in
mpv_get_property(handle, pointer, MPV_FORMAT_NODE, &node)
}
guard status >= 0 else { return [] }
defer { mpv_free_node_contents(&node) }
var tracks: [[String: Any]] = []
if node.format == MPV_FORMAT_NODE_ARRAY {
let array = node.u.list.pointee
for i in 0..<Int(array.num) {
guard let values = array.values else { continue }
let trackNode = values[i]
if trackNode.format == MPV_FORMAT_NODE_MAP {
let map = trackNode.u.list.pointee
var trackInfo: [String: Any] = [:]
for j in 0..<Int(map.num) {
guard let keys = map.keys, let vals = map.values else { continue }
let key = String(cString: keys[j]!)
let val = vals[j]
switch key {
case "id":
if val.format == MPV_FORMAT_INT64 {
trackInfo["id"] = Int(val.u.int64)
}
case "type":
if val.format == MPV_FORMAT_STRING, let str = val.u.string {
trackInfo["type"] = String(cString: str)
}
case "title":
if val.format == MPV_FORMAT_STRING, let str = val.u.string {
trackInfo["title"] = String(cString: str)
}
case "lang":
if val.format == MPV_FORMAT_STRING, let str = val.u.string {
trackInfo["lang"] = String(cString: str)
}
case "selected":
if val.format == MPV_FORMAT_FLAG {
trackInfo["selected"] = val.u.flag == 1
}
default:
break
}
}
if trackInfo["type"] as? String == "sub" {
tracks.append(trackInfo)
}
}
}
}
var trackCount: Int64 = 0
getProperty(handle: handle, name: "track-list/count", format: MPV_FORMAT_INT64, value: &trackCount)
print("MPV: Found \(tracks.count) subtitle tracks")
for track in tracks {
print("MPV: Subtitle track - ID: \(track["id"] ?? "?"), Title: \(track["title"] ?? "?"), Lang: \(track["lang"] ?? "?"), Selected: \(track["selected"] ?? false)")
for i in 0..<trackCount {
var trackType: String?
if let typeStr = getStringProperty(handle: handle, name: "track-list/\(i)/type") {
trackType = typeStr
}
guard trackType == "sub" else { continue }
var trackId: Int64 = 0
getProperty(handle: handle, name: "track-list/\(i)/id", format: MPV_FORMAT_INT64, value: &trackId)
var track: [String: Any] = ["id": Int(trackId)]
if let title = getStringProperty(handle: handle, name: "track-list/\(i)/title") {
track["title"] = title
}
if let lang = getStringProperty(handle: handle, name: "track-list/\(i)/lang") {
track["lang"] = lang
}
var selected: Int32 = 0
getProperty(handle: handle, name: "track-list/\(i)/selected", format: MPV_FORMAT_FLAG, value: &selected)
track["selected"] = selected != 0
tracks.append(track)
}
return tracks
@@ -961,20 +1018,17 @@ final class MPVSoftwareRenderer {
func setSubtitleTrack(_ trackId: Int) {
setProperty(name: "sid", value: String(trackId))
print("MPV: Set subtitle track to \(trackId)")
}
func disableSubtitles() {
setProperty(name: "sid", value: "no")
print("MPV: Disabled subtitles")
}
func getCurrentSubtitleTrack() -> Int {
guard let handle = mpv else { return 0 }
var trackId: Int64 = 0
let status = getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &trackId)
print("MPV: Current subtitle track is \(trackId), status: \(status)")
return status >= 0 ? Int(trackId) : 0
var sid: Int64 = 0
getProperty(handle: handle, name: "sid", format: MPV_FORMAT_INT64, value: &sid)
return Int(sid)
}
func addSubtitleFile(url: String) {
@@ -984,36 +1038,27 @@ final class MPVSoftwareRenderer {
// MARK: - Subtitle Positioning
/// Set subtitle vertical position (0-100, where 100 is bottom)
func setSubtitlePosition(_ position: Int) {
let clampedPosition = max(0, min(100, position))
setProperty(name: "sub-pos", value: String(clampedPosition))
setProperty(name: "sub-pos", value: String(position))
}
/// Set subtitle scale (1.0 is normal size)
func setSubtitleScale(_ scale: Double) {
let clampedScale = max(0.1, min(10.0, scale))
setProperty(name: "sub-scale", value: String(clampedScale))
setProperty(name: "sub-scale", value: String(scale))
}
/// Set subtitle vertical margin in pixels
func setSubtitleMarginY(_ margin: Int) {
setProperty(name: "sub-margin-y", value: String(margin))
}
/// Set subtitle horizontal alignment: "left", "center", "right"
func setSubtitleAlignX(_ alignment: String) {
setProperty(name: "sub-align-x", value: alignment)
}
/// Set subtitle vertical alignment: "top", "center", "bottom"
func setSubtitleAlignY(_ alignment: String) {
setProperty(name: "sub-align-y", value: alignment)
}
/// Set subtitle font size
func setSubtitleFontSize(_ size: Int) {
let clampedSize = max(10, min(200, size))
setProperty(name: "sub-font-size", value: String(clampedSize))
setProperty(name: "sub-font-size", value: String(size))
}
}

View File

@@ -18,6 +18,10 @@ Pod::Spec.new do |s|
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
# Strip debug symbols to avoid DWARF errors from MPVKit
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
'STRIP_INSTALLED_PRODUCT' => 'YES',
'DEPLOYMENT_POSTPROCESSING' => 'YES',
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"

View File

@@ -208,37 +208,49 @@ extension MpvPlayerView: MPVSoftwareRendererDelegate {
cachedPosition = position
cachedDuration = duration
// Only update PiP state when PiP is active (like the working code does)
if pipController?.isPictureInPictureActive == true {
pipController?.updatePlaybackState()
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Only update PiP state when PiP is active
if self.pipController?.isPictureInPictureActive == true {
self.pipController?.updatePlaybackState()
}
self.onProgress([
"position": position,
"duration": duration,
"progress": duration > 0 ? position / duration : 0,
])
}
onProgress([
"position": position,
"duration": duration,
"progress": duration > 0 ? position / duration : 0,
])
}
func renderer(_: MPVSoftwareRenderer, didChangePause isPaused: Bool) {
onPlaybackStateChange([
"isPaused": isPaused,
"isPlaying": !isPaused,
])
// Update PiP state when playback changes (direct call, like working code)
pipController?.updatePlaybackState()
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.onPlaybackStateChange([
"isPaused": isPaused,
"isPlaying": !isPaused,
])
// Update PiP state when playback changes
self.pipController?.updatePlaybackState()
}
}
func renderer(_: MPVSoftwareRenderer, didChangeLoading isLoading: Bool) {
onPlaybackStateChange([
"isLoading": isLoading,
])
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.onPlaybackStateChange([
"isLoading": isLoading,
])
}
}
func renderer(_: MPVSoftwareRenderer, didBecomeReadyToSeek: Bool) {
onPlaybackStateChange([
"isReadyToSeek": didBecomeReadyToSeek,
])
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.onPlaybackStateChange([
"isReadyToSeek": didBecomeReadyToSeek,
])
}
}
}

View File

@@ -1,10 +1,3 @@
//
// PiPController.swift
// test
//
// Created by Francesco on 30/09/25.
//
import AVKit
import AVFoundation
@@ -46,7 +39,8 @@ final class PiPController: NSObject {
}
private func setupPictureInPicture() {
guard isPictureInPictureSupported, let displayLayer = sampleBufferDisplayLayer else {
guard isPictureInPictureSupported,
let displayLayer = sampleBufferDisplayLayer else {
return
}
@@ -58,7 +52,9 @@ final class PiPController: NSObject {
pipController = AVPictureInPictureController(contentSource: contentSource)
pipController?.delegate = self
pipController?.requiresLinearPlayback = false
#if !os(tvOS)
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
#endif
}
func startPictureInPicture() {
@@ -75,11 +71,23 @@ final class PiPController: NSObject {
}
func invalidate() {
pipController?.invalidatePlaybackState()
if Thread.isMainThread {
pipController?.invalidatePlaybackState()
} else {
DispatchQueue.main.async { [weak self] in
self?.pipController?.invalidatePlaybackState()
}
}
}
func updatePlaybackState() {
pipController?.invalidatePlaybackState()
if Thread.isMainThread {
pipController?.invalidatePlaybackState()
} else {
DispatchQueue.main.async { [weak self] in
self?.pipController?.invalidatePlaybackState()
}
}
}
}
@@ -161,4 +169,4 @@ extension PiPController: AVPictureInPictureSampleBufferPlaybackDelegate {
}
completion()
}
}
}

View File

@@ -1,10 +1,3 @@
//
// PlayerPreset.swift
// test
//
// Created by Francesco on 28/09/25.
//
import Foundation
struct PlayerPreset: Identifiable, Hashable {
@@ -41,7 +34,7 @@ struct PlayerPreset: Identifiable, Hashable {
let commands: [[String]]
static var presets: [PlayerPreset] {
var list: [PlayerPreset] = []
let list: [PlayerPreset] = []
return list
}
}
}

View File

@@ -1,10 +1,3 @@
//
// SampleBufferDisplayView.swift
// test
//
// Created by Francesco on 28/09/25.
//
import UIKit
import AVFoundation
@@ -36,9 +29,18 @@ final class SampleBufferDisplayView: UIView {
private func commonInit() {
backgroundColor = .black
displayLayer.videoGravity = .resizeAspect
if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#if !os(tvOS)
#if compiler(>=6.0)
if #available(iOS 26.0, *) {
displayLayer.preferredDynamicRange = .automatic
} else if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#endif
if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#endif
setupPictureInPicture()
}

View File

@@ -69,7 +69,6 @@ export interface MpvPlayerViewRef {
setSubtitleAlignY: (alignment: "top" | "center" | "bottom") => Promise<void>;
setSubtitleFontSize: (size: number) => Promise<void>;
}
export type SubtitleTrack = {
id: number;
title?: string;