This commit is contained in:
Jin
2026-05-14 17:50:06 +08:00
parent 63e29be346
commit fdde6c51ce
25 changed files with 2970 additions and 295 deletions
+251 -12
View File
@@ -26,7 +26,7 @@ private struct MacOSPlayerView: NSViewRepresentable {
func makeNSView(context: Context) -> AVPlayerView {
let view = AVPlayerView()
view.controlsStyle = .none //
view.controlsStyle = .none
view.showsFullScreenToggleButton = false
view.videoGravity = .resizeAspect
view.player = player
@@ -61,10 +61,17 @@ final class SystemPlayerSessionController: ObservableObject {
}
func stop() {
let stoppingPlayer = player
player?.pause()
player?.replaceCurrentItem(with: nil)
player = nil
mediaURLString = nil
// AVPlayer.replaceCurrentItem(with: nil) 线
// AVPlayer dealloc
if let stoppingPlayer {
DispatchQueue.global(qos: .utility).async {
stoppingPlayer.replaceCurrentItem(with: nil)
}
}
}
}
@@ -165,6 +172,7 @@ struct AVPlayerContentView: View {
@State private var volume: Double = 1.0
@State private var rate: Float = 1.0
@State private var isPreparing = true
@State private var playbackError: String?
@State private var showControls = true
@State private var controlsTimer: Timer?
@State private var osdIcon: String?
@@ -174,11 +182,16 @@ struct AVPlayerContentView: View {
@State private var draggingSeconds: Double = 0
@State private var playerObservers: [NSKeyValueObservation] = []
@State private var videoZoomScale: CGFloat = 1.0
var body: some View {
ZStack {
Group {
if let player = player {
PlatformVideoPlayer(player: player)
#if os(iOS)
.scaleEffect(videoZoomScale)
#endif
} else {
ZStack {
Color.black
@@ -187,17 +200,36 @@ struct AVPlayerContentView: View {
}
}
}
#if !os(iOS)
.onTapGesture(count: 2) {
onToggleFullScreen?()
}
.onTapGesture(count: 1) {
togglePlayPauseWithOSD()
}
#endif
if isPreparing {
ProgressView()
.tint(.white)
}
if let error = playbackError {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 36))
.foregroundColor(.yellow)
Text("播放失败")
.font(.headline)
.foregroundColor(.white)
Text(error)
.font(.caption)
.foregroundColor(.white.opacity(0.7))
.multilineTextAlignment(.center)
.lineLimit(3)
}
.padding()
}
if let osdIcon = osdIcon {
Image(systemName: osdIcon)
@@ -210,6 +242,22 @@ struct AVPlayerContentView: View {
.allowsHitTesting(false)
}
}
#if os(iOS)
.overlay {
PlayerGestureLayer(
onSeek: { offset in seek(by: offset) },
onTogglePlayPause: { togglePlayPauseWithOSD() },
onToggleControls: { wakeUpControls() },
onZoomChanged: { scale in
withAnimation(.easeInOut(duration: 0.2)) {
videoZoomScale = scale
}
},
currentTime: currentTime,
duration: duration
)
}
#endif
.overlay(alignment: .bottom) {
GeometryReader { proxy in
if player != nil {
@@ -257,15 +305,35 @@ struct AVPlayerContentView: View {
}
private func setupPlayer() {
guard let url = URL(string: urlString) else { return }
guard let url = Self.sanitizedURL(from: urlString) else {
print("[AVPlayer] URL sanitization failed for: \(urlString)")
return
}
let targetURLString = url.absoluteString
let preferredRate = normalizedSavedPlaybackRate
rate = preferredRate
playbackError = nil
if let sharedController,
sharedController.mediaURLString == targetURLString,
let sharedPlayer = sharedController.player {
cleanupPlayer(keepSharedPlayer: true)
// AVPlayerItem退 VideoPlayer
// SwiftUI.VideoPlayer AVPlayer
// replaceCurrentItem layer
#if os(iOS)
if let currentItem = sharedPlayer.currentItem {
let currentTime = sharedPlayer.currentTime()
let wasPlaying = sharedPlayer.rate != 0
sharedPlayer.replaceCurrentItem(with: nil)
sharedPlayer.replaceCurrentItem(with: currentItem)
sharedPlayer.seek(to: currentTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in
if wasPlaying {
sharedPlayer.playImmediately(atRate: self.normalizedSavedPlaybackRate)
}
}
}
#endif
player = sharedPlayer
applyPreferredPlaybackRate(to: sharedPlayer)
bindPlayerObservers(for: sharedPlayer)
@@ -276,7 +344,11 @@ struct AVPlayerContentView: View {
//
cleanupPlayer()
let playerItem = AVPlayerItem(url: url)
// 使 AVURLAsset HTTP CDN User-Agent
let asset = AVURLAsset(url: url)
asset.resourceLoader.setDelegate(nil, queue: nil)
let playerItem = AVPlayerItem(asset: asset)
playerItem.preferredForwardBufferDuration = 0
let newPlayer = AVPlayer(playerItem: playerItem)
newPlayer.defaultRate = preferredRate
if let sharedController {
@@ -288,6 +360,20 @@ struct AVPlayerContentView: View {
startPlayback(for: newPlayer)
}
/// URL URL
private static func sanitizedURL(from urlString: String) -> URL? {
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed) {
return url
}
//
if let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: encoded) {
return url
}
return nil
}
private func bindPlayerObservers(for player: AVPlayer) {
playerObservers = [
player.observe(\.timeControlStatus, options: [.new]) { p, _ in
@@ -314,6 +400,23 @@ struct AVPlayerContentView: View {
}
}
]
// AVPlayerItem
if let item = player.currentItem {
let itemObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
if observedItem.status == .failed {
let errorDesc = observedItem.error?.localizedDescription ?? "未知错误"
DispatchQueue.main.async {
isPreparing = false
playbackError = errorDesc
}
} else if observedItem.status == .readyToPlay {
DispatchQueue.main.async {
playbackError = nil
}
}
}
playerObservers.append(itemObserver)
}
observePlaybackProgress(for: player)
observePlaybackEnd(for: player)
isPlaying = player.timeControlStatus == .playing
@@ -346,12 +449,15 @@ struct AVPlayerContentView: View {
}
currentPlayer.pause()
currentPlayer.replaceCurrentItem(with: nil)
if sharedController?.player === currentPlayer {
sharedController?.player = nil
sharedController?.mediaURLString = nil
}
player = nil
// replaceCurrentItem(with: nil) 线
DispatchQueue.global(qos: .utility).async {
currentPlayer.replaceCurrentItem(with: nil)
}
}
private func startPlayback(for player: AVPlayer) {
@@ -451,8 +557,129 @@ struct AVPlayerContentView: View {
}
private func playbackControls(containerWidth: CGFloat) -> some View {
VStack(spacing: 8) {
//
#if os(iOS)
let controlWidth = containerWidth * 1.0
#else
let controlWidth = containerWidth * 0.7
#endif
return VStack(spacing: 0) {
#if os(iOS)
// iOS:
//
HStack(spacing: 8) {
Text(currentTime.durationString)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.8))
.lineLimit(1)
Slider(
value: Binding(
get: { isDraggingProgress ? draggingSeconds : currentTime },
set: {
draggingSeconds = $0
wakeUpControls()
}
),
in: 0...progressUpperBound,
onEditingChanged: { editing in
isDraggingProgress = editing
wakeUpControls()
if !editing {
seek(to: draggingSeconds)
}
}
)
.accentColor(.white)
.disabled(duration <= 0)
Text(duration.durationString)
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 4)
//
HStack(spacing: 0) {
//
playbackRateMenu
.frame(minWidth: 36, alignment: .leading)
Spacer()
//
HStack(spacing: 20) {
Button {
wakeUpControls()
seek(by: -seekStep)
showOSD(icon: "gobackward.\(Int(seekStep))")
} label: {
Image(systemName: "gobackward.\(Int(seekStep))")
.font(.system(size: 16, weight: .medium))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
Button {
wakeUpControls()
togglePlayPauseWithOSD()
} label: {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 20, weight: .bold))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
Button {
wakeUpControls()
seek(by: seekStep)
showOSD(icon: "goforward.\(Int(seekStep))")
} label: {
Image(systemName: "goforward.\(Int(seekStep))")
.font(.system(size: 16, weight: .medium))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
if let onPlayNext {
Button {
guard canPlayNext else { return }
wakeUpControls()
onPlayNext()
showOSD(icon: "forward.end.fill")
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 16, weight: .medium))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
.disabled(!canPlayNext)
.opacity(canPlayNext ? 1 : 0.4)
}
}
Spacer()
//
if let onToggleFullScreen {
Button {
wakeUpControls()
onToggleFullScreen()
} label: {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 14, weight: .bold))
.frame(minWidth: 36, minHeight: 36)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 6)
#else
// macOS:
HStack(spacing: 12) {
Text(currentTime.durationString)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
@@ -488,9 +715,7 @@ struct AVPlayerContentView: View {
}
.padding(.horizontal, 4)
//
HStack(spacing: 0) {
//
HStack(spacing: 16) {
playbackRateMenu
}
@@ -498,7 +723,6 @@ struct AVPlayerContentView: View {
Spacer()
// (退)
HStack(spacing: 24) {
Button {
wakeUpControls()
@@ -553,7 +777,6 @@ struct AVPlayerContentView: View {
Spacer()
//
HStack(spacing: 14) {
HStack(spacing: 6) {
Button {
@@ -595,14 +818,30 @@ struct AVPlayerContentView: View {
}
.frame(width: 150, alignment: .trailing)
}
.padding(.top, 8)
#endif
}
#if os(iOS)
.padding(.vertical, 2)
.foregroundColor(.white)
.background(
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .top,
endPoint: .bottom
)
)
.padding(.bottom, 0)
.frame(width: controlWidth)
#else
.padding(.horizontal, 24)
.padding(.vertical, 10)
.foregroundColor(.white)
.glassCard(cornerRadius: 18)
.padding(.horizontal, 20)
.padding(.bottom, 6)
.frame(width: containerWidth * 0.7)
.frame(width: controlWidth)
#endif
.environment(\.colorScheme, .dark)
}