Skip to content

Instantly share code, notes, and snippets.

@goodones-mac
Last active February 14, 2024 08:48
Show Gist options
  • Save goodones-mac/dc4f608794a5113a8b2a46b84f1d55d3 to your computer and use it in GitHub Desktop.
Save goodones-mac/dc4f608794a5113a8b2a46b84f1d55d3 to your computer and use it in GitHub Desktop.
This lets you play a HEVC-with-alpha file on repeat and put it in as a SwiftUI view as a much smaller alternative to GIF files. This code is released into the public domain with no warranties, use as you like. Due to it being a SKView, not a UIView you can hypothetically use this within MacOS & iOS without many changes.
import AVFoundation
import SpriteKit
import SwiftUI
@MainActor
class TransparentBackgroundVideoPlayerUIView: SKView {
let backgroundNode: SKSpriteNode
let videoPlayer: AVPlayer
let videoNode: SKVideoNode
let url: URL
let videoResolution: CGSize?
let keepsAspectRatio: Bool
var repeatLimit: VideoPlayerAlphaView.RepeatCount
private var notificationObserver: NSObjectProtocol?
private var repeatCount = 0
@MainActor
init(url: URL, keepsAspectRatio: Bool, repeatLimit: VideoPlayerAlphaView.RepeatCount) {
self.url = url
self.keepsAspectRatio = keepsAspectRatio
self.repeatLimit = repeatLimit
videoResolution = Self.resolutionForLocalVideo(url: url)
let scene = SKScene(size: CGSize.zero)
scene.backgroundColor = .clear
backgroundNode = SKSpriteNode(color: .clear, size: CGSize.zero)
scene.addChild(backgroundNode)
videoPlayer = AVPlayer(url: url)
videoNode = SKVideoNode(avPlayer: videoPlayer)
videoNode.name = "videoNode"
scene.addChild(videoNode)
super.init(frame: CGRect.zero)
backgroundColor = .clear
allowsTransparency = true
if videoResolution == nil {
// logger.warn("cannot load video to extract size", extra: ["url": url])
}
notificationObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime,
object: videoPlayer.currentItem, queue: nil)
{ [weak self] _ in
self?.repeatCheck()
}
presentScene(scene)
videoPlayer.play()
setNeedsLayout()
}
deinit {
if let notificationObserver {
NotificationCenter.default.removeObserver(notificationObserver, name: .AVPlayerItemDidPlayToEndTime, object: videoPlayer.currentItem)
}
}
func resetVideo() {
videoPlayer.seek(to: .zero)
videoPlayer.play()
repeatCount += 1
}
func repeatCheck() {
switch repeatLimit {
case let .constant(limit):
// people 1 index this when they think about this, but the counter is zero indexed,
// so we have to adjust for the zero indexing reality vs. the external API
if repeatCount < limit - 1 {
resetVideo()
}
case .infinite:
resetVideo()
case .never:
break
}
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let size = frame.size
let centerPoint = CGPoint(x: size.width / 2, y: size.height / 2)
scene?.size = size
backgroundNode.size = size
backgroundNode.position = centerPoint
if keepsAspectRatio, let videoResolution {
videoNode.size = videoResolution.aspectFit(into: size)
} else {
videoNode.size = size
}
videoNode.position = centerPoint
}
private static func resolutionForLocalVideo(url: URL) -> CGSize? {
guard let track = AVURLAsset(url: url).tracks(withMediaType: AVMediaType.video).first else { return nil }
let size = track.naturalSize.applying(track.preferredTransform)
return CGSize(width: abs(size.width), height: abs(size.height))
}
}
struct VideoPlayerAlphaView: UIViewRepresentable {
enum RepeatCount: Equatable {
case infinite
case constant(Int) // How many times it should play, so `.constant(7)` will play 7 times
case never // Will play only once, equivalent to `.constant(1)`
}
let url: URL
let repeatCount: RepeatCount
let keepsAspectRatio: Bool
init(url: URL, repeatCount: RepeatCount = .never, keepsAspectRatio: Bool = true) {
self.url = url
self.repeatCount = repeatCount
self.keepsAspectRatio = keepsAspectRatio
}
func makeUIView(context _: Context) -> TransparentBackgroundVideoPlayerUIView {
TransparentBackgroundVideoPlayerUIView(url: url, keepsAspectRatio: keepsAspectRatio, repeatLimit: repeatCount)
}
func updateUIView(_: TransparentBackgroundVideoPlayerUIView, context _: Context) {}
}
#Preview {
VideoPlayerAlphaView(
url: Bundle.main.url(
forResource: "how_to_use_widget-hevc",
withExtension: "mov"
)!
)
.frame(width: 300, height: 300)
.background(.green)
}
@goodones-mac
Copy link
Author

Here is a simpler non-transparent version so you can hide video controls compared to the SwiftUI version:

import AVKit
import SwiftUI
import UIKit

/// As the SwiftUI version doesn't provide support to hide the player
/// buttons, this version is bridging from UIKit and it is needed until
/// an update of the native is released
struct VideoPlayerView: UIViewControllerRepresentable {
    let url: URL
    @Binding var replayVideo: Bool

    func makeUIViewController(context _: Context) -> AVPlayerViewController {
        let playerItem = AVPlayerItem(url: url)
        let player = AVQueuePlayer(playerItem: playerItem)
        let controller = AVPlayerViewController()
        controller.player = player
        controller.showsPlaybackControls = false

        let looper = AVPlayerLooper(player: player, templateItem: playerItem)
        objc_setAssociatedObject(controller, "looper", looper, .OBJC_ASSOCIATION_RETAIN)

        player.play()
        return controller
    }

    func updateUIViewController(_ uiViewController: AVPlayerViewController, context _: Context) {
        if replayVideo {
            uiViewController.player?.play()
        }
    }
}

@VladislavSmolyanoy
Copy link

Hello, there seems to be an error with your code. in the layoutSubviews().

videoNode.size = videoResolution.aspectFit(into: size)
generates an error Value of type 'CGSize' has no member 'aspectFit'

Is there a missing helper function here?

@VladislavSmolyanoy
Copy link

This CGSize Extension https://stackoverflow.com/a/66923367/12596719 seems to do the trick 👌

@goodones-mac
Copy link
Author

Ah yes, good catch. Sorry about that. This is our extension version of that:

import Foundation

extension CGSize {
    var area: CGFloat {
        width * height
    }

    func aspectFit(into size: CGSize) -> CGSize {
        if width == 0.0 || height == 0.0 {
            return self
        }

        let widthRatio = size.width / width
        let heightRatio = size.height / height
        let aspectFitRatio = min(widthRatio, heightRatio)
        return CGSize(width: width * aspectFitRatio, height: height * aspectFitRatio)
    }

    func aspectFill(into size: CGSize) -> CGSize {
        if width == 0.0 || height == 0.0 {
            return self
        }

        let widthRatio = size.width / width
        let heightRatio = size.height / height
        let aspectFillRatio = max(widthRatio, heightRatio)
        return CGSize(width: width * aspectFillRatio, height: height * aspectFillRatio)
    }
}

@VladislavSmolyanoy
Copy link

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment