Created
February 11, 2024 18:20
-
-
Save kuglee/821d881d712b5c339057113361271247 to your computer and use it in GitHub Desktop.
Using AVPlayer with TCA
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import AVFoundation | |
import Combine | |
// from: https://gist.github.com/MaximKotliar/c8b628ff9c7644b596711152594e1024 | |
public class AVVideoPlayer: AVPlayer { | |
public var delegate: AVVideoPlayerDelegate? { didSet { self.setupObservers() } } | |
private var cancellables: Set<AnyCancellable> = [] | |
public func stop() { | |
self.pause() | |
notificationNames.forEach { name in | |
NotificationCenter.default.removeObserver(self, name: name, object: nil) | |
} | |
self.cancellables = [] | |
} | |
private func setupObservers() { | |
let notificationCenter = NotificationCenter.default | |
notificationNames.forEach { | |
guard self.delegate != nil else { | |
notificationCenter.removeObserver(self, name: $0, object: nil) | |
return | |
} | |
notificationCenter.addObserver( | |
self, | |
selector: #selector(handleNotification(_:)), | |
name: $0, | |
object: nil | |
) | |
} | |
switch self.delegate { | |
case .some: | |
self.publisher(for: \.currentItem).sink { self.delegate?.playerDidChangeCurrentItem?(to: $0) } | |
.store(in: &self.cancellables) | |
self.publisher(for: \.rate) | |
.sink { | |
if $0 == 0 { | |
self.delegate?.playerDidPausePlayback?(self) | |
} else { | |
self.delegate?.playerDidStartPlayback?(self) | |
} | |
} | |
.store(in: &self.cancellables) | |
self.publisher(for: \.timeControlStatus) | |
.sink { self.delegate?.playerDidChangeTimeControlStatus?(self, timeControlStatus: $0) } | |
.store(in: &self.cancellables) | |
self.periodicTimePublisher().sink { self.delegate?.playerTimeChanged?(self, time: $0) } | |
.store(in: &self.cancellables) | |
case .none: self.cancellables = [] | |
} | |
} | |
@objc private func handleNotification(_ notification: Notification) { | |
guard let item = notification.object as? AVPlayerItem else { return } | |
guard item == currentItem else { return } | |
switch notification.name { | |
case AVPlayerItem.timeJumpedNotification: self.delegate?.playerItemTimeJumped?(item) | |
case AVPlayerItem.playbackStalledNotification: self.delegate?.playerItemPlaybackStalled?(item) | |
case AVPlayerItem.didPlayToEndTimeNotification: self.delegate?.playerItemDidPlayToEndTime?(item) | |
case AVPlayerItem.failedToPlayToEndTimeNotification: | |
self.delegate?.playerItemPlayToEndTimeFailed?(item) | |
default: return | |
} | |
} | |
} | |
@objc public protocol AVVideoPlayerDelegate: AnyObject { | |
@objc optional func playerDidStartPlayback(_ player: AVVideoPlayer) | |
@objc optional func playerDidPausePlayback(_ player: AVVideoPlayer) | |
@objc optional func playerDidChangeTimeControlStatus( | |
_ player: AVVideoPlayer, | |
timeControlStatus: AVPlayer.TimeControlStatus | |
) | |
@objc optional func playerTimeChanged(_ player: AVVideoPlayer, time: CMTime) | |
@objc optional func playerDidChangeCurrentItem(to item: AVPlayerItem?) | |
@objc optional func playerItemTimeJumped(_ item: AVPlayerItem) | |
@objc optional func playerItemPlaybackStalled(_ item: AVPlayerItem) | |
@objc optional func playerItemDidPlayToEndTime(_ item: AVPlayerItem) | |
@objc optional func playerItemPlayToEndTimeFailed(_ item: AVPlayerItem) | |
} | |
private let notificationNames: [Notification.Name] = [ | |
AVPlayerItem.timeJumpedNotification, AVPlayerItem.playbackStalledNotification, | |
AVPlayerItem.didPlayToEndTimeNotification, AVPlayerItem.failedToPlayToEndTimeNotification, | |
] | |
// from: https://gist.github.com/kshivang/4c213ec85adf911d30f1305722e7129d | |
extension AVPlayer { | |
func periodicTimePublisher( | |
forInterval interval: CMTime = CMTime( | |
seconds: 0.5, | |
preferredTimescale: CMTimeScale(NSEC_PER_SEC) | |
) | |
) -> AnyPublisher<CMTime, Never> { Publisher(self, forInterval: interval).eraseToAnyPublisher() } | |
} | |
extension AVPlayer { | |
private struct Publisher: Combine.Publisher { | |
typealias Output = CMTime | |
typealias Failure = Never | |
var player: AVPlayer | |
var interval: CMTime | |
init(_ player: AVPlayer, forInterval interval: CMTime) { | |
self.player = player | |
self.interval = interval | |
} | |
func receive<S: Sendable>(subscriber: S) | |
where S: Subscriber, Publisher.Failure == S.Failure, Publisher.Output == S.Input { | |
let subscription = CMTime.Subscription( | |
subscriber: subscriber, | |
player: player, | |
forInterval: interval | |
) | |
subscriber.receive(subscription: subscription) | |
} | |
} | |
} | |
extension CMTime { | |
fileprivate final class Subscription<SubscriberType: Subscriber & Sendable>: Combine.Subscription | |
where SubscriberType.Input == CMTime, SubscriberType.Failure == Never { | |
var player: AVPlayer? = nil | |
var observer: Any? = nil | |
init(subscriber: SubscriberType, player: AVPlayer, forInterval interval: CMTime) { | |
self.player = player | |
observer = player.addPeriodicTimeObserver(forInterval: interval, queue: nil) { time in | |
_ = subscriber.receive(time) | |
} | |
} | |
func request(_ demand: Subscribers.Demand) { | |
// We do nothing here as we only want to send events when they occur. | |
// See, for more info: https://developer.apple.com/documentation/combine/subscribers/demand | |
} | |
func cancel() { | |
if let observer = observer { player?.removeTimeObserver(observer) } | |
observer = nil | |
player = nil | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import MediaPlayer | |
extension MPRemoteCommandCenter { | |
func set(player: AVPlayer) { | |
self.changePlaybackPositionCommand.addTarget { event in | |
guard let seconds = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime else { | |
return .commandFailed | |
} | |
let time = CMTime(seconds: seconds, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) | |
player.seek(to: time) | |
return .success | |
} | |
self.playCommand.addTarget { _ in | |
player.play() | |
return .success | |
} | |
self.pauseCommand.addTarget { _ in | |
player.pause() | |
return .success | |
} | |
self.togglePlayPauseCommand.addTarget { _ in | |
switch player.timeControlStatus { | |
case .playing: player.pause() | |
case .paused: player.play() | |
case .waitingToPlayAtSpecifiedRate: return .noActionableNowPlayingItem | |
@unknown default: return .noActionableNowPlayingItem | |
} | |
return .success | |
} | |
self.skipBackwardCommand.addTarget { event in | |
guard let interval = (event as? MPSkipIntervalCommandEvent)?.interval else { | |
return .commandFailed | |
} | |
let seconds = player.currentTime().seconds - interval | |
let time = CMTime(seconds: seconds, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) | |
player.seek(to: time) | |
return .success | |
} | |
self.skipForwardCommand.addTarget { event in | |
guard let interval = (event as? MPSkipIntervalCommandEvent)?.interval else { | |
return .commandFailed | |
} | |
let seconds = player.currentTime().seconds + interval | |
let time = CMTime(seconds: seconds, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) | |
player.seek(to: time) | |
return .success | |
} | |
#if os(macOS) | |
self.skipBackwardCommand.preferredIntervals = [15] | |
self.skipForwardCommand.preferredIntervals = [15] | |
#endif | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import AVKit | |
import ComposableArchitecture | |
import MediaPlayer | |
import SwiftUI | |
@Reducer public struct VideoPlayerFeature { | |
public init() {} | |
@ObservableState public struct State: Equatable { | |
let player: AVVideoPlayer | |
let posterUrl: URL? | |
var videoPlayerPlayButtonTapped: Bool = false | |
public init(videoUrl: URL?, posterUrl: URL?) { | |
self.player = AVVideoPlayer(url: videoUrl ?? URL(string: "/")!) | |
self.posterUrl = posterUrl | |
} | |
} | |
public enum Action: Sendable { | |
case onDisappear | |
case playButtonTapped | |
} | |
public var body: some ReducerOf<Self> { | |
Reduce { state, action in | |
switch action { | |
case .onDisappear: | |
state.player.stop() | |
return .none | |
case .playButtonTapped: | |
state.videoPlayerPlayButtonTapped = true | |
state.player.play() | |
return .none | |
} | |
} | |
} | |
} | |
public struct VideoPlayerView: View { | |
@Environment(\.imagePipeline) var imagePipeline | |
let store: StoreOf<VideoPlayerFeature> | |
public init(store: StoreOf<VideoPlayerFeature>) { self.store = store } | |
public var body: some View { | |
VStack { | |
if self.store.state.videoPlayerPlayButtonTapped { | |
CustomVideoPlayer(player: self.store.player) | |
} else { | |
if !self.store.videoPlayerPlayButtonTapped { | |
AsyncImage(url: self.store.posterUrl) { image in | |
image.resizable().interpolation(Image.Interpolation.high).scaledToFit() | |
} placeholder: { | |
Rectangle().fill(.tertiary) | |
} | |
.overlay { | |
#if os(macOS) | |
Image(systemName: "play.circle.fill").symbolRenderingMode(.palette) | |
.foregroundStyle(.white, Color(nsColor: .darkGray)).font(.system(size: 70)) | |
#else | |
Image(systemName: "play.fill").foregroundStyle(.white).font(.system(size: 58)) | |
.offset(x: 0, y: 2) | |
#endif | |
} | |
.onTapGesture { self.store.send(.playButtonTapped) } | |
} | |
} | |
} | |
.onDisappear { self.store.send(.onDisappear) } | |
} | |
} | |
#if canImport(UIKit) | |
public struct CustomVideoPlayer: UIViewControllerRepresentable { | |
var player: AVVideoPlayer | |
public init(player: AVVideoPlayer) { | |
self.player = player | |
self.player.delegate = NowPlayingAVVideoPlayerDelegate() | |
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) | |
} | |
public func makeUIViewController(context: Context) -> AVPlayerViewController { | |
let playerViewController = AVPlayerViewController() | |
playerViewController.player = player | |
playerViewController.updatesNowPlayingInfoCenter = false | |
playerViewController.allowsPictureInPicturePlayback = true | |
playerViewController.canStartPictureInPictureAutomaticallyFromInline = true | |
playerViewController.perform( | |
Selector(("flashPlaybackControlsWithDuration:")), | |
with: CDouble(3) | |
) | |
return playerViewController | |
} | |
public func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) | |
{} | |
} | |
#else | |
public struct CustomVideoPlayer: NSViewRepresentable { | |
class OverrideControlsView: NSView { | |
let playerView: AVPlayerView | |
init(playerView: AVPlayerView) { | |
self.playerView = playerView | |
super.init(frame: .zero) | |
self.autoresizingMask = [.width, .height] | |
} | |
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } | |
override func mouseUp(with event: NSEvent) { | |
if let player = playerView.player { | |
switch player.timeControlStatus { | |
case .playing: player.pause() | |
case .paused: player.play() | |
case .waitingToPlayAtSpecifiedRate: break | |
@unknown default: break | |
} | |
} | |
} | |
override func scrollWheel(with event: NSEvent) { | |
self.nextResponder?.scrollWheel(with: event) | |
} | |
} | |
var player: AVVideoPlayer | |
public init(player: AVVideoPlayer) { | |
self.player = player | |
self.player.delegate = NowPlayingAVVideoPlayerDelegate() | |
} | |
public func makeNSView(context: Context) -> AVPlayerView { | |
let playerView = AVPlayerView() | |
playerView.player = player | |
playerView.updatesNowPlayingInfoCenter = false | |
playerView.allowsPictureInPicturePlayback = true | |
playerView.showsFullScreenToggleButton = true | |
playerView.autoresizesSubviews = true | |
playerView.addSubview(OverrideControlsView(playerView: playerView)) | |
return playerView | |
} | |
public func updateNSView(_ nsView: AVPlayerView, context: Context) {} | |
} | |
#endif | |
class NowPlayingAVVideoPlayerDelegate: AVVideoPlayerDelegate { | |
func playerDidStartPlayback(_ player: AVVideoPlayer) { | |
guard let currentPlayerItem = player.currentItem else { return } | |
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() | |
nowPlayingInfoCenter.playbackState = .playing | |
nowPlayingInfoCenter.nowPlayingInfo = [ | |
MPMediaItemPropertyTitle: "<YourApp>", | |
MPMediaItemPropertyPlaybackDuration: currentPlayerItem.duration.seconds, | |
] | |
MPRemoteCommandCenter.shared().set(player: player) | |
} | |
func playerDidChangeTimeControlStatus( | |
_ player: AVVideoPlayer, | |
timeControlStatus: AVPlayer.TimeControlStatus | |
) { | |
MPNowPlayingInfoCenter.default().playbackState = | |
if timeControlStatus == .playing { .playing } else { .paused } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment