Skip to content

Instantly share code, notes, and snippets.

@kuglee
Created February 11, 2024 18:20
Show Gist options
  • Save kuglee/821d881d712b5c339057113361271247 to your computer and use it in GitHub Desktop.
Save kuglee/821d881d712b5c339057113361271247 to your computer and use it in GitHub Desktop.
Using AVPlayer with TCA
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
}
}
}
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
}
}
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