diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 1b615bc..5354be1 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -23,6 +23,6 @@ jobs: steps: - uses: actions/checkout@v2 - name: Danger - uses: docker://ghcr.io/danger/danger-swift-with-swiftlint:3.12.3 + uses: docker://ghcr.io/danger/danger-swift-with-swiftlint:3.15.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/swift-build.yml b/.github/workflows/swift-build.yml index 2824ad4..3fdc9c8 100644 --- a/.github/workflows/swift-build.yml +++ b/.github/workflows/swift-build.yml @@ -32,4 +32,6 @@ jobs: with: xcode-version: '${{ matrix.ios.xcode }}' - name: Build and Test (iOS ${{ matrix.ios.version }}) - run: xcodebuild build -scheme GreedyKit -destination 'platform=iOS Simulator,OS=${{ matrix.ios.version }},name=${{ matrix.ios.device }}' test | xcbeautify + run: | + set -o pipefail + xcodebuild build -scheme GreedyKit -destination 'platform=iOS Simulator,OS=${{ matrix.ios.version }},name=${{ matrix.ios.device }}' test | xcbeautify diff --git a/.swiftlint.yml b/.swiftlint.yml index c7b1c2d..c9b5aeb 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -18,6 +18,7 @@ opt_in_rules: - comment_spacing - force_unwrapping - sorted_imports + - void_function_in_ternary line_length: 120 warning_threshold: 3 diff --git a/Package.swift b/Package.swift index a643813..c851edf 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,10 @@ let package = Package( ), .testTarget( name: "GreedyKitTests", - dependencies: ["GreedyKit"] + dependencies: ["GreedyKit"], + resources: [ + .process("Resources") + ] ) ] ) diff --git a/Sources/GreedyKit/Core/Rendering/VideoRenderActor.swift b/Sources/GreedyKit/Core/Rendering/VideoRenderer.swift similarity index 93% rename from Sources/GreedyKit/Core/Rendering/VideoRenderActor.swift rename to Sources/GreedyKit/Core/Rendering/VideoRenderer.swift index a7a7f83..049172f 100644 --- a/Sources/GreedyKit/Core/Rendering/VideoRenderActor.swift +++ b/Sources/GreedyKit/Core/Rendering/VideoRenderer.swift @@ -1,5 +1,5 @@ // -// VideoRenderActor.swift +// VideoRenderer.swift // GreedyKit // // Created by Igor Belov on 30.06.2025. @@ -8,7 +8,7 @@ import AVFoundation import CoreImage -final actor VideoRenderActor { +final actor VideoRenderer: VideoRendererProtocol { private lazy var sampleBufferFactory = SampleBufferFactory() private lazy var videoOutput = AVPlayerItemVideoOutput( diff --git a/Sources/GreedyKit/Core/Utils/BackedRenderView.swift b/Sources/GreedyKit/Core/Utils/BackedRenderView.swift index f794213..73083b7 100644 --- a/Sources/GreedyKit/Core/Utils/BackedRenderView.swift +++ b/Sources/GreedyKit/Core/Utils/BackedRenderView.swift @@ -8,7 +8,7 @@ import AVFoundation import UIKit -final class BackedRenderView: UIView { +final class BackedRenderView: UIView, RenderViewProtocol { // MARK: - Internal API diff --git a/Sources/GreedyKit/Extensions/ConcurrencyShims.swift b/Sources/GreedyKit/Extensions/ConcurrencyShims.swift index 02022bf..1a3b89d 100644 --- a/Sources/GreedyKit/Extensions/ConcurrencyShims.swift +++ b/Sources/GreedyKit/Extensions/ConcurrencyShims.swift @@ -7,8 +7,8 @@ import AVFoundation -/// Safe because after attachment to an `AVPlayerItem`, we only access the output's -/// read-only methods from a single actor. +/// Safe because after attachment to an `AVPlayerItem`, +/// we only access the output's read-only methods from a single actor. /// No concurrent mutation occurs in our usage. extension AVPlayerItemVideoOutput: @unchecked @retroactive Sendable {} diff --git a/Sources/GreedyKit/Extensions/BackedRenderView+Extensions.swift b/Sources/GreedyKit/Extensions/RenderViewProtocol+Extensions.swift similarity index 89% rename from Sources/GreedyKit/Extensions/BackedRenderView+Extensions.swift rename to Sources/GreedyKit/Extensions/RenderViewProtocol+Extensions.swift index 5993141..8dba383 100644 --- a/Sources/GreedyKit/Extensions/BackedRenderView+Extensions.swift +++ b/Sources/GreedyKit/Extensions/RenderViewProtocol+Extensions.swift @@ -1,5 +1,5 @@ // -// UIView+Extensions.swift +// RenderViewProtocol+Extensions.swift // GreedyKit // // Created by Igor Belov on 05.09.2022. @@ -7,7 +7,7 @@ import UIKit -extension BackedRenderView { +extension RenderViewProtocol { func configure(in superview: UIView) { translatesAutoresizingMaskIntoConstraints = false diff --git a/Sources/GreedyKit/Helpers/DisplayLinkProtocol.swift b/Sources/GreedyKit/Helpers/DisplayLinkProtocol.swift new file mode 100644 index 0000000..3bb20a0 --- /dev/null +++ b/Sources/GreedyKit/Helpers/DisplayLinkProtocol.swift @@ -0,0 +1,17 @@ +// +// DisplayLinkProtocol.swift +// GreedyKit +// +// Created by Igor Belov on 04.07.2025. +// + +import UIKit + +protocol DisplayLinkProtocol: AnyObject { + var isPaused: Bool { get set } + + func add(to runLoop: RunLoop, forMode: RunLoop.Mode) + func invalidate() +} + +extension CADisplayLink: DisplayLinkProtocol {} diff --git a/Sources/GreedyKit/Helpers/RenderViewProtocol.swift b/Sources/GreedyKit/Helpers/RenderViewProtocol.swift new file mode 100644 index 0000000..9b2bc64 --- /dev/null +++ b/Sources/GreedyKit/Helpers/RenderViewProtocol.swift @@ -0,0 +1,17 @@ +// +// RenderViewProtocol.swift +// GreedyKit +// +// Created by Igor Belov on 04.07.2025. +// + +import AVFoundation +import UIKit + +protocol RenderViewProtocol: UIView { + var preventsCapture: Bool { get set } + var contentGravity: AVLayerVideoGravity { get set } + + func enqueueBuffer(_ buffer: CMSampleBuffer) async + func clearLayer() async +} diff --git a/Sources/GreedyKit/Helpers/VideoRendererProtocol.swift b/Sources/GreedyKit/Helpers/VideoRendererProtocol.swift new file mode 100644 index 0000000..a3fab63 --- /dev/null +++ b/Sources/GreedyKit/Helpers/VideoRendererProtocol.swift @@ -0,0 +1,13 @@ +// +// VideoRendererProtocol.swift +// GreedyKit +// +// Created by Igor Belov on 04.07.2025. +// + +import AVFoundation + +protocol VideoRendererProtocol: Sendable { + func attach(to item: AVPlayerItem) async + func frame(at time: CMTime) async -> CMSampleBuffer? +} diff --git a/Sources/GreedyKit/UIKit/GreedyPlayerView.swift b/Sources/GreedyKit/UIKit/GreedyPlayerView.swift index c6c40c4..56d4bf2 100644 --- a/Sources/GreedyKit/UIKit/GreedyPlayerView.swift +++ b/Sources/GreedyKit/UIKit/GreedyPlayerView.swift @@ -48,51 +48,75 @@ public final class GreedyPlayerView: UIView { // MARK: - Properties - private lazy var renderer = VideoRenderActor() + typealias DisplayLinkFactory = ( + _ target: Any, + _ selector: Selector + ) -> DisplayLinkProtocol - private let renderView = BackedRenderView() - private var playerItemObserver: AnyCancellable? + private var displayLink: DisplayLinkProtocol? + private let renderer: VideoRendererProtocol + private let renderView: RenderViewProtocol - private lazy var displayLink = CADisplayLink( - target: self, - selector: #selector(displayLinkDidRefresh(link:)) - ) + private let displayLinkFactory: DisplayLinkFactory + private var playerItemObserver: AnyCancellable? // MARK: - Lifecycle - public override init(frame: CGRect) { + init( + frame: CGRect = .zero, + renderer: VideoRendererProtocol, + renderView: RenderViewProtocol, + displayLinkFactory: @escaping DisplayLinkFactory + ) { + self.renderer = renderer + self.renderView = renderView + self.displayLinkFactory = displayLinkFactory super.init(frame: frame) + renderView.configure(in: self) } + public override convenience init(frame: CGRect) { + self.init( + frame: frame, + renderer: VideoRenderer(), + renderView: BackedRenderView(), + displayLinkFactory: { target, selector in + CADisplayLink(target: target, selector: selector) + } + ) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - public override func willMove(toSuperview newSuperview: UIView?) { - if newSuperview == nil { - dismantle() - } - } + public override func didMoveToWindow() { + super.didMoveToWindow() - public override func didMoveToSuperview() { - super.didMoveToSuperview() - initializeDisplayLink() + if window == nil { + pauseRendering() + } else { + resumeRendering() + } } // MARK: - Private Methods - private func dismantle() { - displayLink.invalidate() - playerItemObserver?.cancel() + private func resumeRendering() { + if displayLink == nil { + displayLink = displayLinkFactory(self, #selector(displayLinkDidRefresh)) + displayLink?.add(to: .current, forMode: .common) + } + displayLink?.isPaused = (player?.currentItem == nil) } - private func initializeDisplayLink() { - displayLink.add(to: .current, forMode: .common) - displayLink.isPaused = true + private func pauseRendering() { + displayLink?.invalidate() + displayLink = nil } - @objc private func displayLinkDidRefresh(link: CADisplayLink) { + @objc private func displayLinkDidRefresh() { guard let player else { return } let itemTime = player.currentTime() @@ -104,17 +128,24 @@ public final class GreedyPlayerView: UIView { } } + private func attachAndResumeIfNeeded(_ item: AVPlayerItem) async { + if window != nil { + displayLink?.isPaused = false + } + await renderer.attach(to: item) + } + private func addPlayerItemObserver() { - guard let player else { return } + guard let player else { + displayLink?.isPaused = true + return + } playerItemObserver = player.publisher(for: \.currentItem) .compactMap { $0 } .sink { [weak self] item in guard let self else { return } - Task { - await self.renderer.attach(to: item) - self.displayLink.isPaused = false - } + Task { await attachAndResumeIfNeeded(item) } } } } diff --git a/Tests/GreedyKitTests/GreedyPlayerViewDeinitTests.swift b/Tests/GreedyKitTests/GreedyPlayerViewDeinitTests.swift deleted file mode 100644 index 12a2ccf..0000000 --- a/Tests/GreedyKitTests/GreedyPlayerViewDeinitTests.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// GreedyPlayerViewDeinitTests.swift -// GreedyKit -// -// Created by Igor Belov on 30.06.2025. -// - -import Testing -import UIKit - -@testable import GreedyKit - -struct GreedyPlayerViewDeinitTests { - - @Test - @MainActor - func playerViewIsDeallocatedWhenOrphan() throws { - weak var weakRef: GreedyPlayerView? - - autoreleasepool { - let view = GreedyPlayerView() - weakRef = view - } - - RunLoop.current.run(until: Date()) - #expect( - weakRef == nil, - "GreedyPlayerView should be released when no strong refs remain" - ) - } - - @Test - @MainActor - func playerViewIsDeallocatedWhenRemovedFromSuperview() throws { - let container = UIView() - weak var weakRef: GreedyPlayerView? - - autoreleasepool { - let view = GreedyPlayerView() - weakRef = view - - container.addSubview(view) - view.removeFromSuperview() - } - - RunLoop.current.run(until: Date()) - #expect( - weakRef == nil, - "GreedyPlayerView should be released after removal from superview" - ) - } -} diff --git a/Tests/GreedyKitTests/Helpers/Mocks.swift b/Tests/GreedyKitTests/Helpers/Mocks.swift new file mode 100644 index 0000000..db3b2bf --- /dev/null +++ b/Tests/GreedyKitTests/Helpers/Mocks.swift @@ -0,0 +1,86 @@ +// +// Mocks.swift +// GreedyKit +// +// Created by Igor Belov on 04.07.2025. +// + +import AVFoundation +import CoreMedia +import UIKit + +@testable import GreedyKit + +// MARK: - VideoRenderer + +@MainActor +final class MockVideoRenderer: VideoRendererProtocol { + private(set) var attached: AVPlayerItem? + private(set) var calls: [CMTime] = [] + + func attach(to item: AVPlayerItem) async { + attached = item + } + + func frame(at time: CMTime) async -> CMSampleBuffer? { + calls.append(time) + return nil + } +} + +// MARK: - MockRenderView + +final class MockRenderView: UIView, RenderViewProtocol { + var preventsCapture: Bool = false { + didSet { + preventsLog.append(preventsCapture) + } + } + + var contentGravity: AVLayerVideoGravity = .resizeAspect { + didSet { + gravityLog.append(contentGravity) + } + } + + private(set) var preventsLog: [Bool] = [] + private(set) var gravityLog: [AVLayerVideoGravity] = [] + private(set) var buffers: [CMSampleBuffer] = [] + + func enqueueBuffer(_ buffer: CMSampleBuffer) async { + buffers.append(buffer) + } + + func clearLayer() async { + buffers.removeAll() + } +} + +// MARK: - DisplayLink + +final class MockDisplayLink: DisplayLinkProtocol { + var isPaused = true + + private(set) var isAddedToRunLoop = false + private(set) var isInvalidated = false + + private var tick: (() -> Void)? + + init() {} + + func add(to runLoop: RunLoop, forMode: RunLoop.Mode) { + isAddedToRunLoop = true + } + + func invalidate() { + isInvalidated = true + } + + func setup(_ handler: @escaping () -> Void) { + tick = handler + } + + func fire() { + tick?() + } +} diff --git a/Tests/GreedyKitTests/Helpers/TestUtils.swift b/Tests/GreedyKitTests/Helpers/TestUtils.swift new file mode 100644 index 0000000..9c4a4b4 --- /dev/null +++ b/Tests/GreedyKitTests/Helpers/TestUtils.swift @@ -0,0 +1,22 @@ +// +// TestUtils.swift +// GreedyKit +// +// Created by Igor Belov on 06.07.2025. +// + +import AVFoundation +import Foundation + +enum TestUtils { + static func makePlayer() -> AVPlayer { + AVPlayer(playerItem: makePlayerItem()) + } + + static func makePlayerItem() -> AVPlayerItem { + guard let url = Bundle.module.url(forResource: "sample", withExtension: "mp4") else { + fatalError("Missing test sample file") + } + return AVPlayerItem(url: url) + } +} diff --git a/Tests/GreedyKitTests/Rendering/BackedRenderViewTests.swift b/Tests/GreedyKitTests/Rendering/BackedRenderViewTests.swift new file mode 100644 index 0000000..25bd671 --- /dev/null +++ b/Tests/GreedyKitTests/Rendering/BackedRenderViewTests.swift @@ -0,0 +1,34 @@ +// +// BackedRenderViewTests.swift +// GreedyKit +// +// Created by Igor Belov on 06.07.2025. +// + +import AVFoundation +import Testing + +@testable import GreedyKit + +@MainActor @Suite struct BackedRenderViewTests { + + @Test("layerClass is AVSampleBufferDisplayLayer") + func testLayerClass() { + #expect(BackedRenderView.layerClass is AVSampleBufferDisplayLayer.Type) + } + + @Test("Property proxies modify underlying layer") + func testProxies() { + let sut = BackedRenderView() + + sut.preventsCapture = true + sut.contentGravity = .resizeAspectFill + #expect(sut.layer.preventsCapture == true) + #expect(sut.layer.videoGravity == .resizeAspectFill) + + sut.preventsCapture = false + sut.contentGravity = .resize + #expect(sut.layer.preventsCapture == false) + #expect(sut.layer.videoGravity == .resize) + } +} diff --git a/Tests/GreedyKitTests/Resources/sample.mp4 b/Tests/GreedyKitTests/Resources/sample.mp4 new file mode 100644 index 0000000..a73e032 Binary files /dev/null and b/Tests/GreedyKitTests/Resources/sample.mp4 differ diff --git a/Tests/GreedyKitTests/Video/GreedyPlayerViewTests.swift b/Tests/GreedyKitTests/Video/GreedyPlayerViewTests.swift new file mode 100644 index 0000000..3d0fcab --- /dev/null +++ b/Tests/GreedyKitTests/Video/GreedyPlayerViewTests.swift @@ -0,0 +1,187 @@ +// +// GreedyPlayerViewTests.swift +// GreedyKit +// +// Created by Igor Belov on 04.07.2025. +// + +import AVFoundation +import Testing +import UIKit + +@testable import GreedyKit + +@MainActor @Suite final class GreedyPlayerViewTests { + + // MARK: - Fixtures + + private let window = UIWindow() + private var producedLinks: [MockDisplayLink] = [] + + private lazy var renderActor = MockVideoRenderer() + private lazy var renderView = MockRenderView() + private lazy var sut = makeSUT() + + private func makeSUT() -> GreedyPlayerView { + GreedyPlayerView( + renderer: renderActor, + renderView: renderView, + displayLinkFactory: { [unowned self] target, selector in + let link = MockDisplayLink() + if let object = target as? NSObjectProtocol { + link.setup { [weak object] in object?.perform(selector) } + } + self.producedLinks.append(link) + return link + } + ) + } + + // MARK: - Tests + + @Test("Rendering starts only when view is on-screen") + func testAttachAndLinkAfterWindow() async throws { + let player = TestUtils.makePlayer() + + sut.player = player + + await Task.yield() + try #require(await renderActor.attached === player.currentItem) + #expect(producedLinks.isEmpty) + + window.addSubview(sut) + await Task.yield() + + #expect(producedLinks.count == 1) + #expect(producedLinks[0].isPaused == false) + } + + @Test("preventsCapture is forwarded") + func testPreventsCaptureForwarding() { + sut.preventsCapture = true + sut.preventsCapture = false + #expect(renderView.preventsLog == [true, false]) + } + + @Test("contentGravity is forwarded") + func testContentGravityForwarding() { + sut.contentGravity = .fill + sut.contentGravity = .stretch + sut.contentGravity = .fit + #expect(renderView.gravityLog == [.resizeAspectFill, .resize, .resizeAspect]) + } + + @Test("displayLink tick triggers renderer.frame when visible") + func testDisplayLinkTickTriggersRenderer() async throws { + sut.player = TestUtils.makePlayer() + window.addSubview(sut) + + await Task.yield() + let link = try #require(producedLinks.first) + link.isPaused = false + link.fire() + + await Task.yield() + #expect(await renderActor.calls.count == 1) + } + + @Test("Removal from window pauses and releases link") + func testPauseAndInvalidateOnWindowRemoval() async throws { + window.addSubview(sut) + + await Task.yield() + let firstLink = try #require(producedLinks.first) + + sut.removeFromSuperview() + await Task.yield() + + #expect(firstLink.isInvalidated == true) + #expect(sut.window == nil) + } + + @Test("Re‑adding view creates new displayLink instance") + func testReaddingCreatesNewDisplayLink() async throws { + window.addSubview(sut) + + await Task.yield() + let link1 = try #require(producedLinks.first) + + sut.removeFromSuperview() + await Task.yield() + #expect(link1.isInvalidated) + + window.addSubview(sut) + await Task.yield() + + #expect(producedLinks.count == 2) + #expect(producedLinks.last !== link1) + } + + @Test("Setting player to nil pauses rendering") + func testPlayerNilPausesDisplayLink() async throws { + sut.player = TestUtils.makePlayer() + window.addSubview(sut) + + await Task.yield() + + let link = try #require(producedLinks.last) + link.isPaused = false + + sut.player = nil + await Task.yield() + + #expect(link.isPaused == true) + } + + @Test("Changing player.currentItem triggers re-attach") + func testChangingPlayerItemTriggersAttach() async throws { + let player = TestUtils.makePlayer() + sut.player = player + window.addSubview(sut) + + await Task.yield() + #expect(await renderActor.attached === player.currentItem) + + let newItem = TestUtils.makePlayerItem() + player.replaceCurrentItem(with: newItem) + + await Task.yield() + #expect(await renderActor.attached === newItem) + } + + @Test("View is deallocated when no strong references remain") + func playerViewIsDeallocatedWhenOrphan() throws { + weak var weakRef: GreedyPlayerView? + + autoreleasepool { + let view = GreedyPlayerView() + weakRef = view + } + + RunLoop.current.run(until: Date()) + #expect( + weakRef == nil, + "GreedyPlayerView should be released when no strong refs remain" + ) + } + + @Test("View is deallocated when removed from superview") + func playerViewIsDeallocatedWhenRemovedFromSuperview() throws { + let container = UIView() + weak var weakRef: GreedyPlayerView? + + autoreleasepool { + let view = GreedyPlayerView() + weakRef = view + + container.addSubview(view) + view.removeFromSuperview() + } + + RunLoop.current.run(until: Date()) + #expect( + weakRef == nil, + "GreedyPlayerView should be released after removal from superview" + ) + } +}