mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
870 lines
31 KiB
Swift
870 lines
31 KiB
Swift
import AVFoundation
|
|
import AVKit
|
|
import KSPlayer
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
protocol SfPlayerWrapperDelegate: AnyObject {
|
|
func player(_ player: SfPlayerWrapper, didUpdatePosition position: Double, duration: Double)
|
|
func player(_ player: SfPlayerWrapper, didChangePause isPaused: Bool)
|
|
func player(_ player: SfPlayerWrapper, didChangeLoading isLoading: Bool)
|
|
func player(_ player: SfPlayerWrapper, didBecomeReadyToSeek: Bool)
|
|
func player(_ player: SfPlayerWrapper, didBecomeTracksReady: Bool)
|
|
func player(_ player: SfPlayerWrapper, didEncounterError error: String)
|
|
func player(_ player: SfPlayerWrapper, didChangePictureInPicture isActive: Bool)
|
|
}
|
|
|
|
/// Configuration for loading a video
|
|
struct VideoLoadConfig {
|
|
let url: URL
|
|
var headers: [String: String]?
|
|
var externalSubtitles: [String]?
|
|
var startPosition: Double?
|
|
var autoplay: Bool
|
|
var initialSubtitleId: Int?
|
|
var initialAudioId: Int?
|
|
|
|
init(
|
|
url: URL,
|
|
headers: [String: String]? = nil,
|
|
externalSubtitles: [String]? = nil,
|
|
startPosition: Double? = nil,
|
|
autoplay: Bool = true,
|
|
initialSubtitleId: Int? = nil,
|
|
initialAudioId: Int? = nil
|
|
) {
|
|
self.url = url
|
|
self.headers = headers
|
|
self.externalSubtitles = externalSubtitles
|
|
self.startPosition = startPosition
|
|
self.autoplay = autoplay
|
|
self.initialSubtitleId = initialSubtitleId
|
|
self.initialAudioId = initialAudioId
|
|
}
|
|
}
|
|
|
|
final class SfPlayerWrapper: NSObject {
|
|
|
|
// MARK: - Properties
|
|
|
|
private var playerView: IOSVideoPlayerView?
|
|
private var containerView: UIView?
|
|
|
|
private var cachedPosition: Double = 0
|
|
private var cachedDuration: Double = 0
|
|
private var isPaused: Bool = true
|
|
private var isLoading: Bool = false
|
|
private var currentURL: URL?
|
|
private var pendingExternalSubtitles: [String] = []
|
|
private var initialSubtitleId: Int?
|
|
private var initialAudioId: Int?
|
|
private var pendingStartPosition: Double?
|
|
|
|
private var progressTimer: Timer?
|
|
private var pipController: AVPictureInPictureController?
|
|
|
|
/// Scale factor for image-based subtitles (PGS, VOBSUB)
|
|
/// Default 1.0 = no scaling; setSubtitleFontSize derives scale from font size
|
|
private var subtitleScale: CGFloat = 1.0
|
|
/// When true, setSubtitleFontSize won't override the scale (user set explicit value)
|
|
private var isScaleExplicitlySet: Bool = false
|
|
/// Optional override for subtitle font family
|
|
private var subtitleFontName: String?
|
|
|
|
weak var delegate: SfPlayerWrapperDelegate?
|
|
|
|
var view: UIView? { containerView }
|
|
|
|
// MARK: - Initialization
|
|
|
|
override init() {
|
|
super.init()
|
|
setupPlayer()
|
|
}
|
|
|
|
deinit {
|
|
stopProgressTimer()
|
|
playerView?.pause()
|
|
playerView = nil
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupPlayer() {
|
|
// Configure KSPlayer options for hardware acceleration
|
|
KSOptions.canBackgroundPlay = true
|
|
KSOptions.isAutoPlay = false
|
|
KSOptions.isSecondOpen = true
|
|
KSOptions.isAccurateSeek = true
|
|
KSOptions.hardwareDecode = true
|
|
|
|
// Create container view
|
|
let container = UIView()
|
|
container.backgroundColor = .black
|
|
container.clipsToBounds = true
|
|
containerView = container
|
|
}
|
|
|
|
private func createPlayerView(frame: CGRect) -> IOSVideoPlayerView {
|
|
let player = IOSVideoPlayerView()
|
|
player.frame = frame
|
|
player.delegate = self
|
|
|
|
// Hide ALL KSPlayer UI elements - we use our own JS controls
|
|
player.toolBar.isHidden = true
|
|
player.navigationBar.isHidden = true
|
|
player.topMaskView.isHidden = true
|
|
player.bottomMaskView.isHidden = true
|
|
player.loadingIndector.isHidden = false
|
|
player.seekToView.isHidden = true
|
|
player.replayButton.isHidden = true
|
|
player.lockButton.isHidden = true
|
|
player.controllerView.isHidden = true
|
|
player.titleLabel.isHidden = true
|
|
|
|
// Ensure subtitle views are visible for rendering
|
|
player.subtitleBackView.isHidden = false
|
|
player.subtitleLabel.isHidden = false
|
|
|
|
// Disable all gestures - handled in JS
|
|
player.tapGesture.isEnabled = false
|
|
player.doubleTapGesture.isEnabled = false
|
|
player.panGesture.isEnabled = false
|
|
|
|
// Disable interaction on hidden elements
|
|
player.controllerView.isUserInteractionEnabled = false
|
|
applySubtitleFont()
|
|
return player
|
|
}
|
|
|
|
// MARK: - Progress Timer
|
|
|
|
private func startProgressTimer() {
|
|
stopProgressTimer()
|
|
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
|
self?.updateProgress()
|
|
}
|
|
}
|
|
|
|
private func stopProgressTimer() {
|
|
progressTimer?.invalidate()
|
|
progressTimer = nil
|
|
}
|
|
|
|
private func updateProgress() {
|
|
guard let player = playerView?.playerLayer?.player else { return }
|
|
|
|
let position = player.currentPlaybackTime
|
|
let duration = player.duration
|
|
|
|
if position != cachedPosition || duration != cachedDuration {
|
|
cachedPosition = position
|
|
cachedDuration = duration
|
|
delegate?.player(self, didUpdatePosition: position, duration: duration)
|
|
}
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
func load(config: VideoLoadConfig) {
|
|
guard config.url != currentURL else { return }
|
|
|
|
currentURL = config.url
|
|
pendingExternalSubtitles = config.externalSubtitles ?? []
|
|
initialSubtitleId = config.initialSubtitleId
|
|
initialAudioId = config.initialAudioId
|
|
|
|
// Store start position to seek after video is ready
|
|
if let startPos = config.startPosition, startPos > 0 {
|
|
pendingStartPosition = startPos
|
|
} else {
|
|
pendingStartPosition = nil
|
|
}
|
|
|
|
isLoading = true
|
|
delegate?.player(self, didChangeLoading: true)
|
|
|
|
// Create or reset player view
|
|
if playerView == nil, let container = containerView {
|
|
let player = createPlayerView(frame: container.bounds)
|
|
player.translatesAutoresizingMaskIntoConstraints = false
|
|
container.addSubview(player)
|
|
|
|
// Pin player to all edges of container
|
|
NSLayoutConstraint.activate([
|
|
player.topAnchor.constraint(equalTo: container.topAnchor),
|
|
player.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
player.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
player.bottomAnchor.constraint(equalTo: container.bottomAnchor)
|
|
])
|
|
|
|
playerView = player
|
|
}
|
|
|
|
// Configure options for this media
|
|
let options = KSOptions()
|
|
|
|
// Set HTTP headers if provided
|
|
if let headers = config.headers, !headers.isEmpty {
|
|
for (key, value) in headers {
|
|
options.appendHeader(["key": key, "value": value])
|
|
}
|
|
}
|
|
|
|
// Note: startPosition is handled via explicit seek in readyToPlay callback
|
|
// because KSPlayer's options.startPlayTime doesn't work reliably
|
|
|
|
// Set the URL with options
|
|
playerView?.set(url: config.url, options: options)
|
|
|
|
if config.autoplay {
|
|
play()
|
|
}
|
|
}
|
|
|
|
func play() {
|
|
isPaused = false
|
|
playerView?.play()
|
|
startProgressTimer()
|
|
delegate?.player(self, didChangePause: false)
|
|
}
|
|
|
|
func pause() {
|
|
isPaused = true
|
|
playerView?.pause()
|
|
delegate?.player(self, didChangePause: true)
|
|
}
|
|
|
|
func seek(to seconds: Double) {
|
|
let time = max(0, seconds)
|
|
let wasPaused = isPaused
|
|
cachedPosition = time
|
|
playerView?.seek(time: time) { [weak self] finished in
|
|
guard let self, finished else { return }
|
|
// KSPlayer may auto-resume after seeking, so enforce the intended state
|
|
if wasPaused {
|
|
self.pause()
|
|
}
|
|
self.updateProgress()
|
|
}
|
|
}
|
|
|
|
func seek(by seconds: Double) {
|
|
let newPosition = max(0, cachedPosition + seconds)
|
|
seek(to: newPosition)
|
|
}
|
|
|
|
func setSpeed(_ speed: Double) {
|
|
playerView?.playerLayer?.player.playbackRate = Float(speed)
|
|
}
|
|
|
|
func getSpeed() -> Double {
|
|
return Double(playerView?.playerLayer?.player.playbackRate ?? 1.0)
|
|
}
|
|
|
|
func getCurrentPosition() -> Double {
|
|
return cachedPosition
|
|
}
|
|
|
|
func getDuration() -> Double {
|
|
return cachedDuration
|
|
}
|
|
|
|
func getIsPaused() -> Bool {
|
|
return isPaused
|
|
}
|
|
|
|
// MARK: - Picture in Picture
|
|
|
|
private func setupPictureInPicture() {
|
|
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
|
|
|
|
// Get the PiP controller from KSPlayer
|
|
guard let pip = playerView?.playerLayer?.player.pipController else { return }
|
|
|
|
pipController = pip
|
|
pip.delegate = self
|
|
|
|
// Enable automatic PiP when app goes to background (swipe up to home)
|
|
if #available(iOS 14.2, *) {
|
|
pip.canStartPictureInPictureAutomaticallyFromInline = true
|
|
}
|
|
}
|
|
|
|
func startPictureInPicture() {
|
|
pipController?.startPictureInPicture()
|
|
}
|
|
|
|
func stopPictureInPicture() {
|
|
pipController?.stopPictureInPicture()
|
|
}
|
|
|
|
func isPictureInPictureSupported() -> Bool {
|
|
return AVPictureInPictureController.isPictureInPictureSupported()
|
|
}
|
|
|
|
func isPictureInPictureActive() -> Bool {
|
|
return pipController?.isPictureInPictureActive ?? false
|
|
}
|
|
|
|
func setAutoPipEnabled(_ enabled: Bool) {
|
|
if #available(iOS 14.2, *) {
|
|
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
|
|
}
|
|
}
|
|
|
|
// MARK: - Subtitle Controls
|
|
|
|
func getSubtitleTracks() -> [[String: Any]] {
|
|
var tracks: [[String: Any]] = []
|
|
|
|
// srtControl.subtitleInfos should contain ALL subtitles KSPlayer knows about
|
|
// (both embedded that were auto-detected and external that were added)
|
|
if let srtControl = playerView?.srtControl {
|
|
let allSubtitles = srtControl.subtitleInfos
|
|
let selectedInfo = srtControl.selectedSubtitleInfo
|
|
|
|
print("[SfPlayer] getSubtitleTracks - srtControl has \(allSubtitles.count) subtitles")
|
|
|
|
for (index, info) in allSubtitles.enumerated() {
|
|
let isSelected = selectedInfo?.subtitleID == info.subtitleID
|
|
let trackInfo: [String: Any] = [
|
|
"id": index + 1, // 1-based ID
|
|
"selected": isSelected,
|
|
"title": info.name,
|
|
"lang": "",
|
|
"source": "srtControl"
|
|
]
|
|
tracks.append(trackInfo)
|
|
print("[SfPlayer] [\(index + 1)]: \(info.name) (selected: \(isSelected))")
|
|
}
|
|
}
|
|
|
|
// Also log embedded tracks from player for debugging
|
|
if let player = playerView?.playerLayer?.player {
|
|
let embeddedTracks = player.tracks(mediaType: .subtitle)
|
|
print("[SfPlayer] getSubtitleTracks - player.tracks has \(embeddedTracks.count) embedded tracks")
|
|
for (i, track) in embeddedTracks.enumerated() {
|
|
print("[SfPlayer] embedded[\(i)]: \(track.name) (enabled: \(track.isEnabled))")
|
|
}
|
|
}
|
|
|
|
return tracks
|
|
}
|
|
|
|
func setSubtitleTrack(_ trackId: Int) {
|
|
print("[SfPlayer] setSubtitleTrack called with trackId: \(trackId)")
|
|
|
|
// Handle disable case
|
|
if trackId < 0 {
|
|
print("[SfPlayer] Disabling subtitles (trackId < 0)")
|
|
disableSubtitles()
|
|
return
|
|
}
|
|
|
|
guard let player = playerView?.playerLayer?.player,
|
|
let srtControl = playerView?.srtControl else {
|
|
print("[SfPlayer] setSubtitleTrack - player or srtControl not available")
|
|
return
|
|
}
|
|
|
|
let embeddedTracks = player.tracks(mediaType: .subtitle)
|
|
let index = trackId - 1 // Convert to 0-based
|
|
|
|
print("[SfPlayer] setSubtitleTrack - embedded tracks: \(embeddedTracks.count), srtControl.subtitleInfos: \(srtControl.subtitleInfos.count), index: \(index)")
|
|
|
|
// Log all available subtitles for debugging
|
|
print("[SfPlayer] Available in srtControl:")
|
|
for (i, info) in srtControl.subtitleInfos.enumerated() {
|
|
print("[SfPlayer] [\(i)]: \(info.name)")
|
|
}
|
|
|
|
// KSPlayer's srtControl might contain all subtitles (embedded + external)
|
|
// Try to find and select the subtitle at the given index in srtControl
|
|
let allSubtitles = srtControl.subtitleInfos
|
|
if index >= 0 && index < allSubtitles.count {
|
|
let subtitleInfo = allSubtitles[index]
|
|
srtControl.selectedSubtitleInfo = subtitleInfo
|
|
playerView?.updateSrt()
|
|
print("[SfPlayer] Selected subtitle from srtControl: \(subtitleInfo.name)")
|
|
return
|
|
}
|
|
|
|
// Fallback: try selecting embedded track directly via player.select()
|
|
// This handles cases where srtControl doesn't have all embedded tracks
|
|
if index >= 0 && index < embeddedTracks.count {
|
|
let track = embeddedTracks[index]
|
|
player.select(track: track)
|
|
print("[SfPlayer] Fallback: Selected embedded track via player.select(): \(track.name)")
|
|
return
|
|
}
|
|
|
|
print("[SfPlayer] WARNING: index \(index) out of range")
|
|
}
|
|
|
|
func disableSubtitles() {
|
|
print("[SfPlayer] disableSubtitles called")
|
|
|
|
// Clear srtControl selection (handles both embedded and external via srtControl)
|
|
playerView?.srtControl.selectedSubtitleInfo = nil
|
|
playerView?.updateSrt()
|
|
|
|
// Also disable any embedded tracks selected via player.select()
|
|
if let player = playerView?.playerLayer?.player {
|
|
let subtitleTracks = player.tracks(mediaType: .subtitle)
|
|
for track in subtitleTracks {
|
|
if track.isEnabled {
|
|
// KSPlayer doesn't have a direct "disable" - selecting a different track would disable this one
|
|
print("[SfPlayer] Note: embedded track '\(track.name)' is still enabled at decoder level")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func getCurrentSubtitleTrack() -> Int {
|
|
guard let srtControl = playerView?.srtControl,
|
|
let selectedInfo = srtControl.selectedSubtitleInfo else {
|
|
return 0 // No subtitle selected
|
|
}
|
|
|
|
// Find the selected subtitle in srtControl.subtitleInfos
|
|
let allSubtitles = srtControl.subtitleInfos
|
|
for (index, info) in allSubtitles.enumerated() {
|
|
if info.subtitleID == selectedInfo.subtitleID {
|
|
return index + 1 // 1-based ID
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func addSubtitleFile(url: String, select: Bool) {
|
|
print("[SfPlayer] addSubtitleFile called with url: \(url), select: \(select)")
|
|
guard let subUrl = URL(string: url) else {
|
|
print("[SfPlayer] Failed to create URL from string")
|
|
return
|
|
}
|
|
|
|
// If player is ready, add directly via srtControl
|
|
if let srtControl = playerView?.srtControl {
|
|
let subtitleInfo = URLSubtitleInfo(url: subUrl)
|
|
srtControl.addSubtitle(info: subtitleInfo)
|
|
print("[SfPlayer] Added subtitle via srtControl: \(subtitleInfo.name)")
|
|
if select {
|
|
srtControl.selectedSubtitleInfo = subtitleInfo
|
|
playerView?.updateSrt()
|
|
print("[SfPlayer] Selected subtitle: \(subtitleInfo.name)")
|
|
}
|
|
} else {
|
|
// Player not ready yet, queue for later
|
|
print("[SfPlayer] Player not ready, queuing subtitle")
|
|
pendingExternalSubtitles.append(url)
|
|
}
|
|
}
|
|
|
|
// MARK: - Subtitle Positioning
|
|
|
|
func setSubtitlePosition(_ position: Int) {
|
|
// KSPlayer subtitle positioning through options
|
|
}
|
|
|
|
func setSubtitleScale(_ scale: Double) {
|
|
subtitleScale = CGFloat(scale)
|
|
isScaleExplicitlySet = true
|
|
applySubtitleScale()
|
|
}
|
|
|
|
private func applySubtitleScale() {
|
|
guard let subtitleBackView = playerView?.subtitleBackView else { return }
|
|
|
|
// Apply scale transform to subtitle view
|
|
// This scales both text and image-based subtitles (PGS, VOBSUB)
|
|
subtitleBackView.transform = CGAffineTransform(scaleX: subtitleScale, y: subtitleScale)
|
|
}
|
|
|
|
func setSubtitleMarginY(_ margin: Int) {
|
|
var position = SubtitleModel.textPosition
|
|
position.verticalMargin = CGFloat(margin)
|
|
SubtitleModel.textPosition = position
|
|
playerView?.updateSrt()
|
|
}
|
|
|
|
func setSubtitleAlignX(_ alignment: String) {
|
|
var position = SubtitleModel.textPosition
|
|
switch alignment.lowercased() {
|
|
case "left":
|
|
position.horizontalAlign = .leading
|
|
case "right":
|
|
position.horizontalAlign = .trailing
|
|
default:
|
|
position.horizontalAlign = .center
|
|
}
|
|
SubtitleModel.textPosition = position
|
|
playerView?.updateSrt()
|
|
}
|
|
|
|
func setSubtitleAlignY(_ alignment: String) {
|
|
var position = SubtitleModel.textPosition
|
|
switch alignment.lowercased() {
|
|
case "top":
|
|
position.verticalAlign = .top
|
|
case "center":
|
|
position.verticalAlign = .center
|
|
default:
|
|
position.verticalAlign = .bottom
|
|
}
|
|
SubtitleModel.textPosition = position
|
|
playerView?.updateSrt()
|
|
}
|
|
|
|
func setSubtitleFontSize(_ size: Int) {
|
|
// Size is now a scale value * 100 (e.g., 100 = 1.0, 60 = 0.6)
|
|
// Convert to actual scale for both text and image subtitles
|
|
let scale = CGFloat(size) / 100.0
|
|
|
|
// Set font size for text-based subtitles (SRT, ASS, VTT)
|
|
// Base font size ~50pt, scaled by user preference
|
|
SubtitleModel.textFontSize = 50.0 * scale
|
|
|
|
// Apply scale for image-based subtitles (PGS, VOBSUB)
|
|
// Only if scale wasn't explicitly set via setSubtitleScale
|
|
if !isScaleExplicitlySet {
|
|
subtitleScale = min(max(scale, 0.3), 1.5) // Clamp to 0.3-1.5
|
|
applySubtitleScale()
|
|
}
|
|
|
|
playerView?.updateSrt()
|
|
}
|
|
|
|
func setSubtitleFontName(_ name: String?) {
|
|
subtitleFontName = name
|
|
applySubtitleFont()
|
|
}
|
|
|
|
func setSubtitleColor(_ hexColor: String) {
|
|
if let color = UIColor(hex: hexColor) {
|
|
SubtitleModel.textColor = Color(color)
|
|
playerView?.subtitleLabel.textColor = color
|
|
playerView?.updateSrt()
|
|
}
|
|
}
|
|
|
|
func setSubtitleBackgroundColor(_ hexColor: String) {
|
|
if let color = UIColor(hex: hexColor) {
|
|
SubtitleModel.textBackgroundColor = Color(color)
|
|
playerView?.subtitleBackView.backgroundColor = color
|
|
playerView?.updateSrt()
|
|
}
|
|
}
|
|
|
|
// MARK: - Hardware Decode
|
|
|
|
static func setHardwareDecode(_ enabled: Bool) {
|
|
KSOptions.hardwareDecode = enabled
|
|
}
|
|
|
|
static func getHardwareDecode() -> Bool {
|
|
return KSOptions.hardwareDecode
|
|
}
|
|
|
|
// MARK: - Private helpers
|
|
|
|
private func applySubtitleFont() {
|
|
guard let playerView else { return }
|
|
let currentSize = playerView.subtitleLabel.font.pointSize
|
|
|
|
let baseFont: UIFont
|
|
if let subtitleFontName,
|
|
!subtitleFontName.isEmpty,
|
|
subtitleFontName.lowercased() != "system",
|
|
let customFont = UIFont(name: subtitleFontName, size: currentSize) {
|
|
baseFont = customFont
|
|
} else {
|
|
baseFont = UIFont.systemFont(ofSize: currentSize)
|
|
}
|
|
|
|
// Remove any implicit italic trait to avoid overly slanted rendering
|
|
let nonItalicDescriptor = baseFont.fontDescriptor
|
|
.withSymbolicTraits(baseFont.fontDescriptor.symbolicTraits.subtracting(.traitItalic))
|
|
?? baseFont.fontDescriptor
|
|
let finalFont = UIFont(descriptor: nonItalicDescriptor, size: currentSize)
|
|
|
|
playerView.subtitleLabel.font = finalFont
|
|
playerView.updateSrt()
|
|
}
|
|
|
|
// MARK: - Audio Controls
|
|
|
|
func getAudioTracks() -> [[String: Any]] {
|
|
guard let player = playerView?.playerLayer?.player else { return [] }
|
|
|
|
var tracks: [[String: Any]] = []
|
|
let audioTracks = player.tracks(mediaType: .audio)
|
|
|
|
for (index, track) in audioTracks.enumerated() {
|
|
let trackInfo: [String: Any] = [
|
|
"id": index + 1,
|
|
"selected": track.isEnabled,
|
|
"title": track.name,
|
|
"lang": track.language ?? ""
|
|
]
|
|
tracks.append(trackInfo)
|
|
}
|
|
|
|
return tracks
|
|
}
|
|
|
|
func setAudioTrack(_ trackId: Int) {
|
|
guard let player = playerView?.playerLayer?.player else { return }
|
|
|
|
let audioTracks = player.tracks(mediaType: .audio)
|
|
let index = trackId - 1
|
|
|
|
if index >= 0 && index < audioTracks.count {
|
|
let track = audioTracks[index]
|
|
player.select(track: track)
|
|
}
|
|
}
|
|
|
|
func getCurrentAudioTrack() -> Int {
|
|
guard let player = playerView?.playerLayer?.player else { return 0 }
|
|
|
|
let audioTracks = player.tracks(mediaType: .audio)
|
|
for (index, track) in audioTracks.enumerated() {
|
|
if track.isEnabled {
|
|
return index + 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// MARK: - Video Zoom
|
|
|
|
func setVideoZoomToFill(_ enabled: Bool) {
|
|
// Toggle between fit (black bars) and fill (crop to fill screen)
|
|
let contentMode: UIView.ContentMode = enabled ? .scaleAspectFill : .scaleAspectFit
|
|
playerView?.playerLayer?.player.view?.contentMode = contentMode
|
|
}
|
|
|
|
func getVideoZoomToFill() -> Bool {
|
|
return playerView?.playerLayer?.player.view?.contentMode == .scaleAspectFill
|
|
}
|
|
|
|
// MARK: - Layout
|
|
|
|
func updateLayout(bounds: CGRect) {
|
|
containerView?.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
// MARK: - PlayerControllerDelegate
|
|
|
|
extension SfPlayerWrapper: PlayerControllerDelegate {
|
|
func playerController(state: KSPlayerState) {
|
|
switch state {
|
|
case .initialized:
|
|
break
|
|
|
|
case .preparing:
|
|
isLoading = true
|
|
delegate?.player(self, didChangeLoading: true)
|
|
|
|
case .readyToPlay:
|
|
isLoading = false
|
|
delegate?.player(self, didChangeLoading: false)
|
|
delegate?.player(self, didBecomeReadyToSeek: true)
|
|
delegate?.player(self, didBecomeTracksReady: true)
|
|
|
|
// Seek to pending start position if set
|
|
// Pause first, seek, then resume to avoid showing video at wrong position
|
|
if let startPos = pendingStartPosition, startPos > 0 {
|
|
let capturedStartPos = startPos
|
|
let wasPlaying = !isPaused
|
|
pendingStartPosition = nil
|
|
|
|
// Pause to prevent showing frames at wrong position
|
|
playerView?.pause()
|
|
|
|
// Small delay then seek
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
guard let self else { return }
|
|
self.playerView?.seek(time: capturedStartPos) { [weak self] finished in
|
|
guard let self else { return }
|
|
if finished && wasPlaying {
|
|
self.play()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Center video content - KSAVPlayerView maps contentMode to videoGravity
|
|
playerView?.playerLayer?.player.view?.contentMode = .scaleAspectFit
|
|
|
|
// Setup PiP controller with delegate
|
|
setupPictureInPicture()
|
|
|
|
// Add embedded subtitles from player to srtControl
|
|
// This makes them available for selection and rendering via srtControl
|
|
if let player = playerView?.playerLayer?.player,
|
|
let subtitleDataSource = player.subtitleDataSouce {
|
|
print("[SfPlayer] Adding embedded subtitles from player.subtitleDataSouce")
|
|
playerView?.srtControl.addSubtitle(dataSouce: subtitleDataSource)
|
|
}
|
|
|
|
// Load pending external subtitles via srtControl
|
|
print("[SfPlayer] readyToPlay - Loading \(pendingExternalSubtitles.count) external subtitles")
|
|
for subUrlString in pendingExternalSubtitles {
|
|
print("[SfPlayer] Adding external subtitle: \(subUrlString)")
|
|
if let subUrl = URL(string: subUrlString) {
|
|
let subtitleInfo = URLSubtitleInfo(url: subUrl)
|
|
playerView?.srtControl.addSubtitle(info: subtitleInfo)
|
|
print("[SfPlayer] Added subtitle info: \(subtitleInfo.name)")
|
|
} else {
|
|
print("[SfPlayer] Failed to create URL from: \(subUrlString)")
|
|
}
|
|
}
|
|
pendingExternalSubtitles.removeAll()
|
|
|
|
// Log all available subtitles in srtControl
|
|
let allSubtitles = playerView?.srtControl.subtitleInfos ?? []
|
|
print("[SfPlayer] srtControl now has \(allSubtitles.count) subtitles:")
|
|
for (i, info) in allSubtitles.enumerated() {
|
|
print("[SfPlayer] [\(i)]: \(info.name)")
|
|
}
|
|
|
|
// Also log embedded tracks from player for reference
|
|
let embeddedTracks = playerView?.playerLayer?.player.tracks(mediaType: .subtitle) ?? []
|
|
print("[SfPlayer] player.tracks has \(embeddedTracks.count) embedded tracks")
|
|
|
|
// Apply initial track selection
|
|
print("[SfPlayer] Applying initial track selections - subId: \(String(describing: initialSubtitleId)), audioId: \(String(describing: initialAudioId))")
|
|
if let subId = initialSubtitleId {
|
|
if subId < 0 {
|
|
print("[SfPlayer] Disabling subtitles (subId < 0)")
|
|
disableSubtitles()
|
|
} else {
|
|
print("[SfPlayer] Setting subtitle track to: \(subId)")
|
|
setSubtitleTrack(subId)
|
|
}
|
|
}
|
|
if let audioId = initialAudioId {
|
|
print("[SfPlayer] Setting audio track to: \(audioId)")
|
|
setAudioTrack(audioId)
|
|
}
|
|
|
|
// Debug: Check selected subtitle after applying
|
|
if let selectedSub = playerView?.srtControl.selectedSubtitleInfo {
|
|
print("[SfPlayer] Currently selected subtitle: \(selectedSub.name)")
|
|
} else {
|
|
print("[SfPlayer] No subtitle currently selected in srtControl")
|
|
}
|
|
|
|
case .buffering:
|
|
isLoading = true
|
|
delegate?.player(self, didChangeLoading: true)
|
|
|
|
case .bufferFinished:
|
|
isLoading = false
|
|
delegate?.player(self, didChangeLoading: false)
|
|
|
|
case .paused:
|
|
isPaused = true
|
|
delegate?.player(self, didChangePause: true)
|
|
|
|
case .playedToTheEnd:
|
|
isPaused = true
|
|
delegate?.player(self, didChangePause: true)
|
|
stopProgressTimer()
|
|
|
|
case .error:
|
|
delegate?.player(self, didEncounterError: "Playback error occurred")
|
|
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func playerController(currentTime: TimeInterval, totalTime: TimeInterval) {
|
|
cachedPosition = currentTime
|
|
cachedDuration = totalTime
|
|
delegate?.player(self, didUpdatePosition: currentTime, duration: totalTime)
|
|
}
|
|
|
|
func playerController(finish error: Error?) {
|
|
if let error = error {
|
|
delegate?.player(self, didEncounterError: error.localizedDescription)
|
|
}
|
|
stopProgressTimer()
|
|
}
|
|
|
|
func playerController(maskShow: Bool) {
|
|
// UI mask visibility changed
|
|
}
|
|
|
|
func playerController(action: PlayerButtonType) {
|
|
// Button action handled
|
|
}
|
|
|
|
func playerController(bufferedCount: Int, consumeTime: TimeInterval) {
|
|
// Buffering progress
|
|
}
|
|
|
|
func playerController(seek: TimeInterval) {
|
|
// Seek completed
|
|
}
|
|
}
|
|
|
|
// MARK: - AVPictureInPictureControllerDelegate
|
|
|
|
extension SfPlayerWrapper: AVPictureInPictureControllerDelegate {
|
|
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
delegate?.player(self, didChangePictureInPicture: true)
|
|
}
|
|
|
|
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
delegate?.player(self, didChangePictureInPicture: false)
|
|
}
|
|
|
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
|
delegate?.player(self, didEncounterError: "PiP failed: \(error.localizedDescription)")
|
|
}
|
|
|
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
|
// Called when user taps to restore from PiP - return true to allow restoration
|
|
completionHandler(true)
|
|
}
|
|
}
|
|
|
|
// MARK: - UIColor Hex Extension
|
|
|
|
extension UIColor {
|
|
convenience init?(hex: String) {
|
|
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
|
|
|
var rgb: UInt64 = 0
|
|
var r: CGFloat = 0.0
|
|
var g: CGFloat = 0.0
|
|
var b: CGFloat = 0.0
|
|
var a: CGFloat = 1.0
|
|
|
|
let length = hexSanitized.count
|
|
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
|
|
|
|
if length == 6 {
|
|
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
|
|
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
|
|
b = CGFloat(rgb & 0x0000FF) / 255.0
|
|
} else if length == 8 {
|
|
r = CGFloat((rgb & 0xFF000000) >> 24) / 255.0
|
|
g = CGFloat((rgb & 0x00FF0000) >> 16) / 255.0
|
|
b = CGFloat((rgb & 0x0000FF00) >> 8) / 255.0
|
|
a = CGFloat(rgb & 0x000000FF) / 255.0
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
self.init(red: r, green: g, blue: b, alpha: a)
|
|
}
|
|
}
|