diff --git a/Sources/YouTubeKit/YouTube+PlayerItem.swift b/Sources/YouTubeKit/YouTube+PlayerItem.swift new file mode 100644 index 0000000..6b3fc7b --- /dev/null +++ b/Sources/YouTubeKit/YouTube+PlayerItem.swift @@ -0,0 +1,46 @@ +// +// YouTube+PlayerItem.swift +// YouTubeKit +// +// Created by Alexander Eichhorn on 15.10.2024. +// + +import Foundation +import AVFoundation + +@available(iOS 13.0, watchOS 6.0, tvOS 13.0, macOS 10.15, *) +extension YouTube { + + /// Returns `AVPlayerItem` for the highest resolution stream that is natively playable with potentially audio and video automatically combined. + /// - Parameter maxResolution: The maximum resolution of the video stream. If `nil`, the highest resolution stream is used. + @MainActor + @available(iOS 15.0, watchOS 8.0, tvOS 15.0, macOS 12.0, *) + public func playerItem(maxResolution: Int? = nil) async throws -> AVPlayerItem { + let streams = try await streams + + let composition = AVMutableComposition() + + guard let videoStream = streams.filter({ $0.isNativelyPlayable }).filterVideoOnly().filter(byResolution: { ($0 ?? .max) <= (maxResolution ?? .max) }).highestResolutionStream(), + let audioStream = streams.filter({ $0.isNativelyPlayable }).filterAudioOnly().highestAudioBitrateStream() else { + throw YouTubeKitError.extractError + } + + // Add video track + let videoAsset = AVURLAsset(url: videoStream.url) + let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) + let videoAssetTrack = try await videoAsset.loadTracks(withMediaType: .video).first + let videoTimeRange = try await videoAsset.load(.duration) + try videoTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: videoTimeRange), of: videoAssetTrack!, at: .zero) + + // Add audio track + let audioAsset = AVURLAsset(url: audioStream.url) + let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) + let audioAssetTrack = try await audioAsset.load(.tracks).first { $0.mediaType == .audio } + let audioTimeRange = try await audioAsset.load(.duration) + try audioTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: audioTimeRange), of: audioAssetTrack!, at: .zero) + + let playerItem = AVPlayerItem(asset: composition) + return playerItem + } + +} diff --git a/Tests/YouTubeKitTests/PlayabilityTests.swift b/Tests/YouTubeKitTests/PlayabilityTests.swift index 6121ec6..5c8b5b0 100644 --- a/Tests/YouTubeKitTests/PlayabilityTests.swift +++ b/Tests/YouTubeKitTests/PlayabilityTests.swift @@ -60,4 +60,22 @@ final class PlayabilityTests: XCTestCase { } } + + func testAutoCombinedPlayerItemPlayability() async throws { + + let videoID = "njX2bu-_Vw4" + let youtubeLocal = YouTube(videoID: videoID, methods: [.local]) + let youtubeRemote = YouTube(videoID: videoID, methods: [.remote]) + + let localPlayerItem = try await youtubeLocal.playerItem() + let remotePlayerItem = try await youtubeRemote.playerItem() + + let localIsPlayable = try await localPlayerItem.asset.load(.isPlayable) + let remoteIsPlayable = try await remotePlayerItem.asset.load(.isPlayable) + + XCTAssert(localIsPlayable, "Local player item should be playable") + XCTAssert(remoteIsPlayable, "Remote player item should be playable") + + } + }