diff --git a/Demo/Pillarbox-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/Pillarbox-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4f7a5fb10..b80eea2a3 100644 --- a/Demo/Pillarbox-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/Pillarbox-demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "combineext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CombineCommunity/CombineExt.git", + "state" : { + "revision" : "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", + "version" : "1.8.1" + } + }, { "identity" : "comscore-swift-package-manager", "kind" : "remoteSourceControl", @@ -41,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CommandersAct/iOSV5.git", "state" : { - "revision" : "1350bca4163cfdd62d1508068601817d86d9f4a5", - "version" : "5.4.4" + "revision" : "c699709090afee22a2b72078d753b729218b70ba", + "version" : "5.4.5" } }, { @@ -77,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Quick/Nimble.git", "state" : { - "revision" : "f0b14e200e39921d1df720e305f11ca6acb72a17", - "version" : "13.1.1" + "revision" : "efe11bbca024b57115260709b5c05e01131470d0", + "version" : "13.2.1" } }, { @@ -122,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", - "version" : "1.0.6" + "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" } }, { diff --git a/Demo/Sources/Examples/ExamplesViewModel.swift b/Demo/Sources/Examples/ExamplesViewModel.swift index 21e87bdfe..0103ea95d 100644 --- a/Demo/Sources/Examples/ExamplesViewModel.swift +++ b/Demo/Sources/Examples/ExamplesViewModel.swift @@ -85,9 +85,11 @@ final class ExamplesViewModel: ObservableObject { ]) let cornerCaseMedias = Template.medias(from: [ - URNTemplate.expired, URNTemplate.unknown, - URLTemplate.unauthorized + URNTemplate.expired, + URLTemplate.unknown, + URLTemplate.unauthorized, + URLTemplate.unavailableMp3 ]) @Published private(set) var protectedMedias = [Media]() diff --git a/Demo/Sources/Model/MediaDescription.swift b/Demo/Sources/Model/MediaDescription.swift index 2bf62dbf1..d70618e8d 100644 --- a/Demo/Sources/Model/MediaDescription.swift +++ b/Demo/Sources/Model/MediaDescription.swift @@ -12,7 +12,7 @@ enum MediaDescription { case disabled } - private static var dateFormatter: DateFormatter = { + private static let dateFormatter = { let formatter = DateFormatter() formatter.timeZone = TimeZone(identifier: "Europe/Zurich") formatter.dateStyle = .long @@ -21,7 +21,7 @@ enum MediaDescription { return formatter }() - private static var minuteFormatter: DateComponentsFormatter = { + private static let minuteFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = .minute formatter.unitsStyle = .short diff --git a/Demo/Sources/Model/Playlist.swift b/Demo/Sources/Model/Playlist.swift index 4e6e8cfbb..988607e92 100644 --- a/Demo/Sources/Model/Playlist.swift +++ b/Demo/Sources/Model/Playlist.swift @@ -168,6 +168,12 @@ enum Playlist { URLTemplate.onDemandVideoHLS ] + static let videosWithOneFailingMp3Url: [Template] = [ + URLTemplate.shortOnDemandVideoHLS, + URLTemplate.unavailableMp3, + URLTemplate.onDemandVideoHLS + ] + static let videosWithOneFailingUrn: [Template] = [ URNTemplate.onDemandVideo, URNTemplate.unknown, @@ -188,6 +194,7 @@ enum Playlist { URNTemplate.unknown, URLTemplate.unknown, URNTemplate.expired, - URLTemplate.unauthorized + URLTemplate.unauthorized, + URLTemplate.unavailableMp3 ] } diff --git a/Demo/Sources/Model/Template.swift b/Demo/Sources/Model/Template.swift index c8f1b568b..4b2fd9f76 100644 --- a/Demo/Sources/Model/Template.swift +++ b/Demo/Sources/Model/Template.swift @@ -30,11 +30,10 @@ enum URLTemplate { type: .url("https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8") ) static let onDemandVideoMP4 = Template( - title: "The dig", + title: "Swiss wheelchair athlete wins top award", description: "VOD - MP4", - // swiftlint:disable:next line_length - imageUrl: "https://www.swissinfo.ch/resource/image/47686506/landscape_ratio3x2/280/187/347ee14103b1b86184659b2fd04c69ba/8C028539EC620EFACC0BF2F61591E2F8/img_8527.jpg", - type: .url("https://media.swissinfo.ch/media/video/dddaff93-c2cd-4b6e-bdad-55f75a519480/rendition/154a844b-de1d-4854-93c1-5c61cd07e98c.mp4") + imageUrl: "https://cdn.prod.swi-services.ch/video-delivery/images/94f5f5d1-5d53-4336-afda-9198462c45d9/_.1hAGinujJ.yERGrrGNzBGCNSxmhKZT/16x9", + type: .url("https://cdn.prod.swi-services.ch/video-projects/94f5f5d1-5d53-4336-afda-9198462c45d9/localised-videos/ENG/renditions/ENG.mp4") ) static let liveVideoHLS = Template( title: "Couleur 3 en vidéo (live)", @@ -134,6 +133,11 @@ enum URLTemplate { description: "Content that does not exist", type: .url("http://localhost:8123/simple/unavailable/master.m3u8") ) + static let unavailableMp3 = Template( + title: "Unavailable MP3", + description: "MP3 that does not exist", + type: .url("http://localhost:8123/simple/unavailable.mp3") + ) static let unauthorized = Template( title: "Unauthorized URL", description: "Content which cannot be accessed", diff --git a/Demo/Sources/Players/InlineSystemPlayerView.swift b/Demo/Sources/Players/InlineSystemPlayerView.swift index c091e8d9f..73a4c58a5 100644 --- a/Demo/Sources/Players/InlineSystemPlayerView.swift +++ b/Demo/Sources/Players/InlineSystemPlayerView.swift @@ -34,7 +34,7 @@ struct InlineSystemPlayerView: View { } extension InlineSystemPlayerView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Players/PlayerConfiguration.swift b/Demo/Sources/Players/PlayerConfiguration.swift index 5077504ae..73efd134f 100644 --- a/Demo/Sources/Players/PlayerConfiguration.swift +++ b/Demo/Sources/Players/PlayerConfiguration.swift @@ -8,19 +8,13 @@ import Foundation import PillarboxPlayer extension PlayerConfiguration { - static var standard: Self { - let userDefaults = UserDefaults.standard - return .init( - usesExternalPlaybackWhileMirroring: !userDefaults.presenterModeEnabled, - smartNavigationEnabled: userDefaults.smartNavigationEnabled - ) - } + static let standard = Self( + usesExternalPlaybackWhileMirroring: !UserDefaults.standard.presenterModeEnabled, + smartNavigationEnabled: UserDefaults.standard.smartNavigationEnabled + ) - static var externalPlaybackDisabled: Self { - let userDefaults = UserDefaults.standard - return .init( - allowsExternalPlayback: false, - smartNavigationEnabled: userDefaults.smartNavigationEnabled - ) - } + static let externalPlaybackDisabled = Self( + allowsExternalPlayback: false, + smartNavigationEnabled: UserDefaults.standard.smartNavigationEnabled + ) } diff --git a/Demo/Sources/Players/PlayerView.swift b/Demo/Sources/Players/PlayerView.swift index 98ea058b2..223583f8d 100644 --- a/Demo/Sources/Players/PlayerView.swift +++ b/Demo/Sources/Players/PlayerView.swift @@ -51,7 +51,7 @@ extension PlayerView { } extension PlayerView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Players/SimplePlayerView.swift b/Demo/Sources/Players/SimplePlayerView.swift index 4b1b5804a..e810e1a40 100644 --- a/Demo/Sources/Players/SimplePlayerView.swift +++ b/Demo/Sources/Players/SimplePlayerView.swift @@ -54,7 +54,7 @@ struct SimplePlayerView: View { } extension SimplePlayerView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Players/SystemPlayerView.swift b/Demo/Sources/Players/SystemPlayerView.swift index e72464885..259fe36ad 100644 --- a/Demo/Sources/Players/SystemPlayerView.swift +++ b/Demo/Sources/Players/SystemPlayerView.swift @@ -42,7 +42,7 @@ extension SystemPlayerView { } extension SystemPlayerView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Players/VanillaPlayerView.swift b/Demo/Sources/Players/VanillaPlayerView.swift index 42acb1a88..cadbda58f 100644 --- a/Demo/Sources/Players/VanillaPlayerView.swift +++ b/Demo/Sources/Players/VanillaPlayerView.swift @@ -26,7 +26,7 @@ struct VanillaPlayerView: View { } extension VanillaPlayerView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } // Workaround for FB13126425. Makes it possible to use `AVPlayer` as `@ObservableObject` to avoid memory leaks diff --git a/Demo/Sources/Search/SearchViewModel.swift b/Demo/Sources/Search/SearchViewModel.swift index 02b4e64e5..13fd36cda 100644 --- a/Demo/Sources/Search/SearchViewModel.swift +++ b/Demo/Sources/Search/SearchViewModel.swift @@ -36,7 +36,7 @@ final class SearchViewModel: ObservableObject, Refreshable { case loadMore } - private static var settings: SRGMediaSearchSettings = { + private static let settings = { let settings = SRGMediaSearchSettings() settings.aggregationsEnabled = false return settings diff --git a/Demo/Sources/Showcase/BlurredView.swift b/Demo/Sources/Showcase/BlurredView.swift index 7f46ab919..1908d4b9f 100644 --- a/Demo/Sources/Showcase/BlurredView.swift +++ b/Demo/Sources/Showcase/BlurredView.swift @@ -41,7 +41,7 @@ struct BlurredView: View { } extension BlurredView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Showcase/LinkView.swift b/Demo/Sources/Showcase/LinkView.swift index 5a9bf3e93..9d1255a57 100644 --- a/Demo/Sources/Showcase/LinkView.swift +++ b/Demo/Sources/Showcase/LinkView.swift @@ -45,7 +45,7 @@ struct LinkView: View { } extension LinkView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Showcase/Multi/MultiView.swift b/Demo/Sources/Showcase/Multi/MultiView.swift index 10a870419..7734384a5 100644 --- a/Demo/Sources/Showcase/Multi/MultiView.swift +++ b/Demo/Sources/Showcase/Multi/MultiView.swift @@ -65,7 +65,7 @@ struct MultiView: View { } extension MultiView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Showcase/OptInView.swift b/Demo/Sources/Showcase/OptInView.swift index 2b8e462af..91fdf80e5 100644 --- a/Demo/Sources/Showcase/OptInView.swift +++ b/Demo/Sources/Showcase/OptInView.swift @@ -72,7 +72,7 @@ struct OptInView: View { } extension OptInView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Showcase/Playlist/PlaylistView.swift b/Demo/Sources/Showcase/Playlist/PlaylistView.swift index 0d4b1f4ce..8a57f1df9 100644 --- a/Demo/Sources/Showcase/Playlist/PlaylistView.swift +++ b/Demo/Sources/Showcase/Playlist/PlaylistView.swift @@ -175,7 +175,7 @@ struct PlaylistView: View { } extension PlaylistView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift b/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift index 81503bbaa..338b60380 100644 --- a/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift +++ b/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift @@ -38,6 +38,7 @@ final class PlaylistViewModel: ObservableObject, PictureInPicturePersistable { URLTemplate.bitmovin_360, URLTemplate.unauthorized, URLTemplate.unknown, + URLTemplate.unavailableMp3, URNTemplate.expired, URNTemplate.unknown ] diff --git a/Demo/Sources/Showcase/ShowcaseView.swift b/Demo/Sources/Showcase/ShowcaseView.swift index 97702cada..1ce558175 100644 --- a/Demo/Sources/Showcase/ShowcaseView.swift +++ b/Demo/Sources/Showcase/ShowcaseView.swift @@ -75,6 +75,7 @@ struct ShowcaseView: View { @ViewBuilder private func playlistsSection() -> some View { + // swiftlint:disable:previous function_body_length // swiftlint:disable:next closure_body_length CustomSection("Playlists") { cell( @@ -101,6 +102,10 @@ struct ShowcaseView: View { title: "Videos (URLs, one failing)", destination: .playlist(templates: Playlist.videosWithOneFailingUrl) ) + cell( + title: "Videos (URLs, one failing MP3)", + destination: .playlist(templates: Playlist.videosWithOneFailingMp3Url) + ) cell( title: "Videos (URNs, one failing)", destination: .playlist(templates: Playlist.videosWithOneFailingUrn) diff --git a/Demo/Sources/Showcase/Stories/StoriesView.swift b/Demo/Sources/Showcase/Stories/StoriesView.swift index b3f04a23e..705aad6a6 100644 --- a/Demo/Sources/Showcase/Stories/StoriesView.swift +++ b/Demo/Sources/Showcase/Stories/StoriesView.swift @@ -61,7 +61,7 @@ struct StoriesView: View { } extension StoriesView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Showcase/TransitionView.swift b/Demo/Sources/Showcase/TransitionView.swift index 7bc113a3d..60210f04d 100644 --- a/Demo/Sources/Showcase/TransitionView.swift +++ b/Demo/Sources/Showcase/TransitionView.swift @@ -66,7 +66,7 @@ private extension Player { } extension TransitionView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Showcase/TwinsView.swift b/Demo/Sources/Showcase/TwinsView.swift index d1de39e8c..f5b5f5e46 100644 --- a/Demo/Sources/Showcase/TwinsView.swift +++ b/Demo/Sources/Showcase/TwinsView.swift @@ -65,7 +65,7 @@ private extension TwinsView { } extension TwinsView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Showcase/Wrapped/WrappedView.swift b/Demo/Sources/Showcase/Wrapped/WrappedView.swift index cd66215dd..ccc77797b 100644 --- a/Demo/Sources/Showcase/Wrapped/WrappedView.swift +++ b/Demo/Sources/Showcase/Wrapped/WrappedView.swift @@ -50,7 +50,7 @@ struct WrappedView: View { } extension WrappedView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } #Preview { diff --git a/Demo/Sources/Tools/Constant.swift b/Demo/Sources/Tools/Constant.swift index b186c5776..829d4fd8c 100644 --- a/Demo/Sources/Tools/Constant.swift +++ b/Demo/Sources/Tools/Constant.swift @@ -9,9 +9,7 @@ import SwiftUI let kPageSize: UInt = 50 extension Animation { - static var defaultLinear: Animation { - .linear(duration: 0.2) - } + static let defaultLinear = Self.linear(duration: 0.2) } func constant(iOS: T, tvOS: T) -> T { diff --git a/Demo/Sources/Views/WebView~ios.swift b/Demo/Sources/Views/WebView~ios.swift index 556d896bf..69ab9b172 100644 --- a/Demo/Sources/Views/WebView~ios.swift +++ b/Demo/Sources/Views/WebView~ios.swift @@ -27,5 +27,5 @@ private struct SafariWebView: UIViewControllerRepresentable { } extension WebView: SourceCodeViewable { - static var filePath: String { #file } + static let filePath = #file } diff --git a/Package.resolved b/Package.resolved index 46ec0f027..1cde3a9c1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "combineext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CombineCommunity/CombineExt.git", + "state" : { + "revision" : "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", + "version" : "1.8.1" + } + }, { "identity" : "comscore-swift-package-manager", "kind" : "remoteSourceControl", @@ -41,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CommandersAct/iOSV5.git", "state" : { - "revision" : "1350bca4163cfdd62d1508068601817d86d9f4a5", - "version" : "5.4.4" + "revision" : "c699709090afee22a2b72078d753b729218b70ba", + "version" : "5.4.5" } }, { @@ -59,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", - "version" : "1.0.6" + "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", + "version" : "1.1.0" } }, { diff --git a/Package.swift b/Package.swift index 44521f53b..006dc2344 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( ) ], dependencies: [ + .package(url: "https://github.com/CombineCommunity/CombineExt.git", .upToNextMinor(from: "1.8.1")), .package(url: "https://github.com/comScore/Comscore-Swift-Package-Manager.git", .upToNextMinor(from: "6.11.0")), .package(url: "https://github.com/CommandersAct/iOSV5.git", .upToNextMinor(from: "5.4.4")), .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.0.3")), @@ -84,6 +85,7 @@ let package = Package( name: "PillarboxPlayer", dependencies: [ .target(name: "PillarboxCore"), + .product(name: "CombineExt", package: "CombineExt"), .product(name: "DequeModule", package: "swift-collections"), .product(name: "TimelaneCombine", package: "TimelaneCombine") ], diff --git a/Scripts/test-streams.sh b/Scripts/test-streams.sh index 41e213cac..a1d13df78 100755 --- a/Scripts/test-streams.sh +++ b/Scripts/test-streams.sh @@ -71,11 +71,6 @@ function generate_simple_streams { ffmpeg -stream_loop -1 -i "$src_dir/source_360x360.mp4" -stream_loop -1 -i "$src_dir/source_audio_eng.mp4" -t 120 -vcodec copy -acodec copy \ -f hls -hls_time 4 -hls_list_size 0 -hls_flags round_durations "$on_demand_square_dir/master.m3u8" > /dev/null 2>&1 & - local on_demand_corrupt_dir="$dest_dir/on_demand_corrupt" - mkdir -p "$on_demand_corrupt_dir" - ffmpeg -stream_loop -1 -i "$src_dir/source_640x360.mp4" -stream_loop -1 -i "$src_dir/source_audio_eng.mp4" -t 2 -vcodec copy -acodec copy \ - -f hls -hls_time 4 -hls_list_size 0 -hls_flags round_durations "$on_demand_corrupt_dir/master.m3u8" > /dev/null 2>&1 && rm "$on_demand_corrupt_dir"/*.ts & - local live_dir="$dest_dir/live" mkdir -p "$live_dir" ffmpeg -stream_loop -1 -re -i "$src_dir/source_640x360.mp4" -stream_loop -1 -re -i "$src_dir/source_audio_eng.mp4" -vcodec copy -acodec copy \ diff --git a/Sources/Analytics/Analytics.swift b/Sources/Analytics/Analytics.swift index 128269e0f..1d731c020 100644 --- a/Sources/Analytics/Analytics.swift +++ b/Sources/Analytics/Analytics.swift @@ -42,12 +42,10 @@ public class Analytics { } /// The singleton instance. - public static var shared = Analytics() + public static let shared = Analytics() /// The analytics version. - public static var version: String { - PackageInfo.version - } + public static let version = PackageInfo.version var comScoreGlobals: ComScoreGlobals? { dataSource?.comScoreGlobals diff --git a/Sources/CoreBusiness/DataProvider/Errors.swift b/Sources/CoreBusiness/DataProvider/Errors.swift index bc2353dae..6a52afeee 100644 --- a/Sources/CoreBusiness/DataProvider/Errors.swift +++ b/Sources/CoreBusiness/DataProvider/Errors.swift @@ -7,9 +7,7 @@ import Foundation struct DRMError: LocalizedError { - static var missingContentKeyContext: Self { - .init(errorDescription: "The DRM license could not be retrieved") - } + static let missingContentKeyContext = Self(errorDescription: "The DRM license could not be retrieved") let errorDescription: String? } @@ -19,21 +17,17 @@ enum TokenError: Error { } struct DataError: LocalizedError { - static var noResourceAvailable: Self { - .init(errorDescription: NSLocalizedString( - "No playable resources could be found.", - bundle: .module, - comment: "Generic error message returned when no playable resources could be found" - )) - } - - static var malformedData: Self { - .init(errorDescription: NSLocalizedString( - "The data is invalid", - bundle: .module, - comment: "Generic error message returned when data is invalid" - )) - } + static let noResourceAvailable = Self(errorDescription: NSLocalizedString( + "No playable resources could be found.", + bundle: .module, + comment: "Generic error message returned when no playable resources could be found" + )) + + static let malformedData = Self(errorDescription: NSLocalizedString( + "The data is invalid", + bundle: .module, + comment: "Generic error message returned when data is invalid" + )) let errorDescription: String? diff --git a/Sources/CoreBusiness/Model/MediaMetadata.swift b/Sources/CoreBusiness/Model/MediaMetadata.swift index 9269e11be..2d693da14 100644 --- a/Sources/CoreBusiness/Model/MediaMetadata.swift +++ b/Sources/CoreBusiness/Model/MediaMetadata.swift @@ -9,7 +9,7 @@ import UIKit /// Metadata associated with content loaded from a URN. public struct MediaMetadata: AssetMetadata { - private static var dateFormatter: DateFormatter = { + private static let dateFormatter: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.timeZone = TimeZone(identifier: "Europe/Zurich") dateFormatter.dateStyle = .long diff --git a/Sources/CoreBusiness/Model/StreamingMethod.swift b/Sources/CoreBusiness/Model/StreamingMethod.swift index 5af0358d3..b3227ca42 100644 --- a/Sources/CoreBusiness/Model/StreamingMethod.swift +++ b/Sources/CoreBusiness/Model/StreamingMethod.swift @@ -34,7 +34,5 @@ public enum StreamingMethod: String, Decodable { case unknown = "UNKNOWN" /// The supported streaming methods on Apple platforms. - public static var supportedMethods: [Self] { - [.hls, .https, .http, .m3uPlaylist, .progressive] - } + public static let supportedMethods: [Self] = [.hls, .https, .http, .m3uPlaylist, .progressive] } diff --git a/Sources/Player/Asset.swift b/Sources/Player/Asset.swift index 5a5526459..4c88757b3 100644 --- a/Sources/Player/Asset.swift +++ b/Sources/Player/Asset.swift @@ -149,20 +149,22 @@ public struct Asset: Assetable where M: AssetMetadata { } } - func nowPlayingInfo() -> NowPlayingInfo? { + func nowPlayingInfo(with error: Error?) -> NowPlayingInfo { + var nowPlayingInfo = NowPlayingInfo() if let metadata = metadata?.nowPlayingMetadata() { - var nowPlayingInfo = NowPlayingInfo() nowPlayingInfo[MPMediaItemPropertyTitle] = metadata.title - nowPlayingInfo[MPMediaItemPropertyArtist] = metadata.subtitle + nowPlayingInfo[MPMediaItemPropertyArtist] = error?.localizedDescription ?? metadata.subtitle nowPlayingInfo[MPMediaItemPropertyComments] = metadata.description if let image = metadata.image { nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image } } - return nowPlayingInfo } else { - return nil + // Fill the title so that the Control Center can be enabled for the asset, even if it has no associated + // metadata. + nowPlayingInfo[MPMediaItemPropertyTitle] = error?.localizedDescription ?? "" } + return nowPlayingInfo } func playerItem(reload: Bool) -> AVPlayerItem { diff --git a/Sources/Player/Interfaces/Assetable.swift b/Sources/Player/Interfaces/Assetable.swift index d0e6e8d7c..48124c692 100644 --- a/Sources/Player/Interfaces/Assetable.swift +++ b/Sources/Player/Interfaces/Assetable.swift @@ -15,7 +15,7 @@ protocol Assetable { func updateMetadata() func disable() - func nowPlayingInfo() -> NowPlayingInfo? + func nowPlayingInfo(with error: Error?) -> NowPlayingInfo func playerItem(reload: Bool) -> AVPlayerItem func update(item: AVPlayerItem) } @@ -28,6 +28,10 @@ extension Assetable { func playerItem() -> AVPlayerItem { playerItem(reload: false) } + + func nowPlayingInfo() -> NowPlayingInfo { + nowPlayingInfo(with: nil) + } } extension AVPlayerItem { @@ -45,7 +49,6 @@ extension AVPlayerItem { currentItem: AVPlayerItem?, length: Int ) -> [AVPlayerItem] { - assert(length > 1) guard let currentItem else { return playerItems(from: Array(currentAssets.prefix(length))) } if let currentIndex = matchingIndex(for: currentItem, in: currentAssets) { let currentAsset = currentAssets[currentIndex] diff --git a/Sources/Player/Internal/QueuePlayer.swift b/Sources/Player/Internal/QueuePlayer.swift index 13787f60b..9b2967e83 100644 --- a/Sources/Player/Internal/QueuePlayer.swift +++ b/Sources/Player/Internal/QueuePlayer.swift @@ -159,6 +159,11 @@ extension AVQueuePlayer { if let firstItem = items.first { if firstItem !== self.items().first { remove(firstItem) + // TODO: Workaround to fix incorrect recovery from failed MP3 playback (FB13650115). Remove when fixed. + if self.items().first?.error != nil { + removeAllItems() + } + // End of workaround replaceCurrentItem(with: firstItem) } removeAll(from: 1) @@ -172,6 +177,7 @@ extension AVQueuePlayer { } private func removeAll(from index: Int) { + assert(index > 0, "The current item must not be removed") guard items().count > index else { return } items().suffix(from: index).forEach { remove($0) } } diff --git a/Sources/Player/MediaSelection/MediaSelectionProperties.swift b/Sources/Player/MediaSelection/MediaSelectionProperties.swift index 6dd061f10..a4839c96a 100644 --- a/Sources/Player/MediaSelection/MediaSelectionProperties.swift +++ b/Sources/Player/MediaSelection/MediaSelectionProperties.swift @@ -7,9 +7,7 @@ import AVFoundation struct MediaSelectionProperties: Equatable { - static var empty: Self { - self.init(groups: [:], selection: nil) - } + static let empty = Self(groups: [:], selection: nil) private let groups: [AVMediaCharacteristic: AVMediaSelectionGroup] let selection: AVMediaSelection? diff --git a/Sources/Player/Player+Assets.swift b/Sources/Player/Player+Assets.swift deleted file mode 100644 index 4c68aad09..000000000 --- a/Sources/Player/Player+Assets.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import Combine - -extension Player { - func assetsPublisher() -> AnyPublisher<[any Assetable], Never> { - $storedItems - .map { items in - Publishers.AccumulateLatestMany(items.map { item in - item.$asset - }) - } - .switchToLatest() - .eraseToAnyPublisher() - } -} diff --git a/Sources/Player/Player+ControlCenter.swift b/Sources/Player/Player+ControlCenter.swift index f29927901..0fdeafaaa 100644 --- a/Sources/Player/Player+ControlCenter.swift +++ b/Sources/Player/Player+ControlCenter.swift @@ -30,17 +30,14 @@ extension Player { } func nowPlayingInfoMetadataPublisher() -> AnyPublisher { - currentPublisher() - .map { current in - guard let current else { - return Just(NowPlayingInfo()).eraseToAnyPublisher() + queuePublisher + .compactMap { queue in + guard let index = queue.index else { + return NowPlayingInfo() } - return current.item.$asset - .filter { !$0.resource.isLoading } - .compactMap { $0.nowPlayingInfo() } - .eraseToAnyPublisher() + let asset = queue.elements[index].asset + return !asset.resource.isLoading ? asset.nowPlayingInfo(with: queue.error) : nil } - .switchToLatest() .removeDuplicates { lhs, rhs in // swiftlint:disable:next legacy_objc_type NSDictionary(dictionary: lhs).isEqual(to: rhs) @@ -82,8 +79,15 @@ private extension Player { func playRegistration() -> some RemoteCommandRegistrable { nowPlayingSession.remoteCommandCenter.register(command: \.playCommand) { [weak self] _ in - self?.play() - return .success + guard let self else { return .commandFailed } + if canReplay() { + replay() + return .commandFailed + } + else { + play() + return .success + } } } diff --git a/Sources/Player/Player+Current.swift b/Sources/Player/Player+Current.swift deleted file mode 100644 index b9cf85682..000000000 --- a/Sources/Player/Player+Current.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import AVFoundation -import Combine -import PillarboxCore - -extension Player { - func currentPublisher() -> AnyPublisher { - itemUpdatePublisher - .map { update in - guard let currentIndex = update.currentIndex() else { return nil } - return .init(item: update.items[currentIndex], index: currentIndex) - } - .removeDuplicates() - .eraseToAnyPublisher() - } - - func queueItemsPublisher() -> AnyPublisher<[AVPlayerItem], Never> { - Publishers.Merge( - assetsPublisher() - .map { ItemQueueUpdate.assets($0) }, - queuePlayer.itemTransitionPublisher() - .map { ItemQueueUpdate.itemTransition($0) } - ) - .scan(ItemQueue.initial) { queue, update in - queue.updated(with: update) - } - .withPrevious(ItemQueue.initial) - .compactMap { [configuration] previous, current in - switch current.itemTransition { - case let .advance(item): - return AVPlayerItem.playerItems( - for: current.assets, - replacing: previous.assets, - currentItem: item, - length: configuration.preloadedItems - ) - case let .stop(item): - return [item] - case .finish: - return nil - } - } - .eraseToAnyPublisher() - } -} diff --git a/Sources/Player/Player+Queue.swift b/Sources/Player/Player+Queue.swift new file mode 100644 index 000000000..573d26afc --- /dev/null +++ b/Sources/Player/Player+Queue.swift @@ -0,0 +1,49 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation +import Combine +import Foundation +import PillarboxCore + +extension Player { + func elementsQueueUpdatePublisher() -> AnyPublisher { + $storedItems + .map { items in + Publishers.AccumulateLatestMany(items.map { item in + item.$asset + .map { QueueElement(item: item, asset: $0) } + }) + } + .switchToLatest() + .map { .elements($0) } + .eraseToAnyPublisher() + } + + func itemStateQueueUpdatePublisher() -> AnyPublisher { + queuePlayer.itemStatePublisher() + .map { .itemState($0) } + .eraseToAnyPublisher() + } + + func queuePlayerItemsPublisher() -> AnyPublisher<[AVPlayerItem], Never> { + queuePublisher + .withPrevious(.empty) + .compactMap { [configuration] previous, current in + guard let buffer = Queue.buffer(from: previous, to: current, length: configuration.preloadedItems) else { + return nil + } + return AVPlayerItem.playerItems( + for: current.elements.map(\.asset), + replacing: previous.elements.map(\.asset), + currentItem: buffer.item, + length: buffer.length + ) + } + .removeDuplicates() + .eraseToAnyPublisher() + } +} diff --git a/Sources/Player/Player.swift b/Sources/Player/Player.swift index 3aaa727f1..a708acf12 100644 --- a/Sources/Player/Player.swift +++ b/Sources/Player/Player.swift @@ -6,6 +6,7 @@ import AVFoundation import Combine +import CombineExt import DequeModule import MediaPlayer import PillarboxCore @@ -16,12 +17,10 @@ public final class Player: ObservableObject, Equatable { private static weak var currentPlayer: Player? /// The player version. - public static var version: String { - PackageInfo.version - } + public static let version = PackageInfo.version /// The last error received by the player. - @Published public private(set) var error: (any Error)? + @Published public private(set) var error: Error? /// The index of the current item in the queue. @Published public private(set) var currentIndex: Int? @@ -70,17 +69,20 @@ public final class Player: ObservableObject, Equatable { /// fast-paced property changes into corresponding local bindings. public lazy var propertiesPublisher: AnyPublisher = { queuePlayer.propertiesPublisher() - .multicast { CurrentValueSubject(.empty) } - .autoconnect() + .share(replay: 1) .eraseToAnyPublisher() }() - lazy var itemUpdatePublisher: AnyPublisher = { - Publishers.CombineLatest($storedItems, queuePlayer.smoothCurrentItemPublisher()) - .map { ItemUpdate(items: $0, currentItem: $1) } - .multicast { CurrentValueSubject(.empty) } - .autoconnect() - .eraseToAnyPublisher() + lazy var queuePublisher: AnyPublisher = { + Publishers.Merge( + elementsQueueUpdatePublisher(), + itemStateQueueUpdatePublisher() + ) + .scan(.empty) { queue, update -> Queue in + queue.updated(with: update) + } + .share(replay: 1) + .eraseToAnyPublisher() }() /// A Boolean setting whether the audio output of the player must be muted. @@ -156,9 +158,9 @@ public final class Player: ObservableObject, Equatable { configurePlayer() - configureControlCenterPublishers() - configureQueuePlayerUpdatePublishers() configurePublishedPropertyPublishers() + configureQueuePlayerUpdatePublishers() + configureControlCenterPublishers() } /// Creates a player with a single item in its queue. @@ -207,7 +209,7 @@ public final class Player: ObservableObject, Equatable { } private func configureQueuePlayerUpdatePublishers() { - configureQueueItemsPublisher() + configureQueuePlayerItemsPublisher() configureRateUpdatePublisher() configureTextStyleRulesUpdatePublisher() } @@ -230,8 +232,8 @@ public final class Player: ObservableObject, Equatable { } private extension Player { - func configureQueueItemsPublisher() { - queueItemsPublisher() + func configureQueuePlayerItemsPublisher() { + queuePlayerItemsPublisher() .receiveOnMainThread() .sink { [queuePlayer] items in queuePlayer.replaceItems(with: items) @@ -270,34 +272,27 @@ private extension Player { } func configureErrorPublisher() { - Publishers.Merge( - queuePlayer.errorPublisher(), - resetErrorPublisher() - ) - .receiveOnMainThread() - .assign(to: &$error) - } - - private func resetErrorPublisher() -> AnyPublisher { - $storedItems - .filter { $0.isEmpty } - .map { _ in nil } - .eraseToAnyPublisher() + queuePublisher + .map(\.error) + .removeDuplicates { $0 as? NSError == $1 as? NSError } + .receiveOnMainThread() + .assign(to: &$error) } func configureCurrentIndexPublisher() { - currentPublisher() - .map(\.?.index) + queuePublisher + .slice(at: \.index) .receiveOnMainThread() .lane("player_current_index") .assign(to: &$currentIndex) } func configureCurrentTrackerPublisher() { - currentPublisher() - .map { [weak self] current in - guard let self, let current else { return nil } - return CurrentTracker(item: current.item, player: self) + queuePublisher + .slice(at: \.item) + .map { [weak self] item in + guard let self, let item else { return nil } + return CurrentTracker(item: item, player: self) } .receiveOnMainThread() .assign(to: &$currentTracker) @@ -328,18 +323,21 @@ private extension Player { func configureControlCenterRemoteCommandUpdatePublisher() { Publishers.CombineLatest( - itemUpdatePublisher, + queuePublisher, propertiesPublisher ) - .sink { [weak self] update, properties in + .sink { [weak self] queue, properties in guard let self else { return } - let areSkipsEnabled = update.items.count <= 1 && properties.streamType != .live - nowPlayingSession.remoteCommandCenter.skipBackwardCommand.isEnabled = areSkipsEnabled - nowPlayingSession.remoteCommandCenter.skipForwardCommand.isEnabled = areSkipsEnabled - - let index = update.currentIndex() - nowPlayingSession.remoteCommandCenter.previousTrackCommand.isEnabled = canReturn(before: index, in: update.items, streamType: properties.streamType) - nowPlayingSession.remoteCommandCenter.nextTrackCommand.isEnabled = canAdvance(after: index, in: update.items) + let areSkipsEnabled = queue.elements.count <= 1 && properties.streamType != .live + let hasError = queue.error != nil + nowPlayingSession.remoteCommandCenter.skipBackwardCommand.isEnabled = areSkipsEnabled && !hasError && canSkipForward() + nowPlayingSession.remoteCommandCenter.skipForwardCommand.isEnabled = areSkipsEnabled && !hasError && canSkipBackward() + nowPlayingSession.remoteCommandCenter.changePlaybackPositionCommand.isEnabled = !hasError + + let index = queue.index + let items = Deque(queue.elements.map(\.item)) + nowPlayingSession.remoteCommandCenter.previousTrackCommand.isEnabled = canReturn(before: index, in: items, streamType: properties.streamType) + nowPlayingSession.remoteCommandCenter.nextTrackCommand.isEnabled = canAdvance(after: index, in: items) } .store(in: &cancellables) } diff --git a/Sources/Player/Publishers/AVPlayerItemPublishers.swift b/Sources/Player/Publishers/AVPlayerItemPublishers.swift index a1703c471..d49ccc929 100644 --- a/Sources/Player/Publishers/AVPlayerItemPublishers.swift +++ b/Sources/Player/Publishers/AVPlayerItemPublishers.swift @@ -11,8 +11,8 @@ import MediaAccessibility extension AVPlayerItem { func propertiesPublisher() -> AnyPublisher { Publishers.CombineLatest6( - statePublisher() - .lane("player_item_state"), + statusPublisher() + .lane("player_item_status"), publisher(for: \.presentationSize), mediaSelectionPropertiesPublisher() .lane("player_item_media_selection"), @@ -20,12 +20,12 @@ extension AVPlayerItem { publisher(for: \.duration), minimumTimeOffsetFromLivePublisher() ) - .map { [weak self] state, presentationSize, mediaSelectionProperties, timeProperties, duration, minimumTimeOffsetFromLive in - let isKnown = (state != .unknown) + .map { [weak self] status, presentationSize, mediaSelectionProperties, timeProperties, duration, minimumTimeOffsetFromLive in + let isKnown = (status != .unknown) return .init( itemProperties: .init( item: self, - state: state, + status: status, duration: isKnown ? duration : .invalid, minimumTimeOffsetFromLive: minimumTimeOffsetFromLive, presentationSize: isKnown ? presentationSize : nil @@ -38,7 +38,7 @@ extension AVPlayerItem { .eraseToAnyPublisher() } - func statePublisher() -> AnyPublisher { + func statusPublisher() -> AnyPublisher { Publishers.Merge( publisher(for: \.status) .map { status in @@ -107,13 +107,11 @@ extension AVPlayerItem { } extension AVPlayerItem { - func errorPublisher() -> AnyPublisher { + func errorPublisher() -> AnyPublisher { Publishers.Merge( intrinsicErrorPublisher(), playbackErrorPublisher() ) - .map { Optional($0) } - .prepend(nil) .eraseToAnyPublisher() } @@ -122,7 +120,7 @@ extension AVPlayerItem { .filter { $0 == .failed } .weakCapture(self) .map { _, item in - ItemError.intrinsicError(for: item) + ItemError.intrinsicError(for: item) ?? PlaybackError.unknown } .eraseToAnyPublisher() } diff --git a/Sources/Player/Publishers/AVPlayerPublishers.swift b/Sources/Player/Publishers/AVPlayerPublishers.swift index 314347671..d7acea695 100644 --- a/Sources/Player/Publishers/AVPlayerPublishers.swift +++ b/Sources/Player/Publishers/AVPlayerPublishers.swift @@ -15,27 +15,36 @@ extension AVPlayer { .eraseToAnyPublisher() } - /// Publishes a stream of `AVPlayerItem` which preserves failed items. - func smoothCurrentItemPublisher() -> AnyPublisher { - itemTransitionPublisher() - .map { transition in - switch transition { - case let .advance(to: item): - return item - case let .stop(on: item): - return item - case .finish: - return nil + func itemStatePublisher() -> AnyPublisher { + currentItemPublisher() + .map { item -> AnyPublisher in + if let item { + if let error = item.error { + return Just(.init(item: item, error: error)).eraseToAnyPublisher() + } + else { + return item.errorPublisher() + .map { .init(item: item, error: $0) } + .prepend(.init(item: item, error: nil)) + .eraseToAnyPublisher() + } + } + else { + return Just(.empty).eraseToAnyPublisher() + } + } + .switchToLatest() + .withPrevious(.empty) + .map { state in + // `AVQueuePlayer` sometimes consumes failed items, transitioning to `nil`, sometimes not. We can + // make this behavior consistent by never consuming failed states. + if state.current.item == nil && state.previous.error != nil { + return state.previous + } + else { + return state.current } } - .eraseToAnyPublisher() - } - - func itemTransitionPublisher() -> AnyPublisher { - currentItemPublisher() - .withPrevious(nil) - .map { ItemTransition.transition(from: $0.previous, to: $0.current) } - .removeDuplicates() .eraseToAnyPublisher() } @@ -61,11 +70,4 @@ extension AVPlayer { .removeDuplicates() .eraseToAnyPublisher() } - - func errorPublisher() -> AnyPublisher { - currentItemPublisher() - .compactMap { $0?.errorPublisher() } - .switchToLatest() - .eraseToAnyPublisher() - } } diff --git a/Sources/Player/ResourceLoading/Resource.swift b/Sources/Player/ResourceLoading/Resource.swift index 6830b210b..54880e93a 100644 --- a/Sources/Player/ResourceLoading/Resource.swift +++ b/Sources/Player/ResourceLoading/Resource.swift @@ -64,9 +64,7 @@ extension Resource { static let loadingUrl = URL(string: "pillarbox://loading.m3u8")! static let failingUrl = URL(string: "pillarbox://failing.m3u8")! - static var loading: Self { - .custom(url: loadingUrl, delegate: LoadingResourceLoaderDelegate()) - } + static let loading = Self.custom(url: loadingUrl, delegate: LoadingResourceLoaderDelegate()) static func failing(error: Error) -> Self { .custom(url: failingUrl, delegate: FailedResourceLoaderDelegate(error: error)) diff --git a/Sources/Player/Types/CoreProperties.swift b/Sources/Player/Types/CoreProperties.swift index fc1f4c648..fd2ed5765 100644 --- a/Sources/Player/Types/CoreProperties.swift +++ b/Sources/Player/Types/CoreProperties.swift @@ -7,9 +7,7 @@ import CoreMedia struct CoreProperties: Equatable { - static var empty: Self { - .init(itemProperties: .empty, mediaSelectionProperties: .empty, playbackProperties: .empty) - } + static let empty = Self(itemProperties: .empty, mediaSelectionProperties: .empty, playbackProperties: .empty) let itemProperties: ItemProperties let mediaSelectionProperties: MediaSelectionProperties @@ -19,8 +17,8 @@ struct CoreProperties: Equatable { // MARK: ItemProperties extension CoreProperties { - var state: ItemState { - itemProperties.state + var itemStatus: ItemStatus { + itemProperties.status } var duration: CMTime { @@ -47,7 +45,7 @@ extension CoreProperties { } var playbackState: PlaybackState { - .init(itemState: itemProperties.state, rate: rate) + .init(itemStatus: itemProperties.status, rate: rate) } } diff --git a/Sources/Player/Types/ItemError.swift b/Sources/Player/Types/ItemError.swift index fbf373478..26825d561 100644 --- a/Sources/Player/Types/ItemError.swift +++ b/Sources/Player/Types/ItemError.swift @@ -8,7 +8,7 @@ import AVFoundation enum ItemError { /// Consolidates intrinsic error and error log information. - static func intrinsicError(for item: AVPlayerItem) -> Error { + static func intrinsicError(for item: AVPlayerItem) -> Error? { if let errorLog = item.errorLog(), let event = errorLog.events.last { return NSError( domain: event.errorDomain, @@ -20,7 +20,7 @@ enum ItemError { return localizedError(from: error) } else { - return PlaybackError.unknown + return nil } } diff --git a/Sources/Player/Types/ItemProperties.swift b/Sources/Player/Types/ItemProperties.swift index 87bd31358..9a7224ae4 100644 --- a/Sources/Player/Types/ItemProperties.swift +++ b/Sources/Player/Types/ItemProperties.swift @@ -8,18 +8,16 @@ import AVFoundation import CoreMedia struct ItemProperties: Equatable { - static var empty: Self { - .init( - item: nil, - state: .unknown, - duration: .invalid, - minimumTimeOffsetFromLive: .invalid, - presentationSize: nil - ) - } + static let empty = Self( + item: nil, + status: .unknown, + duration: .invalid, + minimumTimeOffsetFromLive: .invalid, + presentationSize: nil + ) let item: AVPlayerItem? - let state: ItemState + let status: ItemStatus let duration: CMTime let minimumTimeOffsetFromLive: CMTime diff --git a/Sources/Player/Types/ItemQueue.swift b/Sources/Player/Types/ItemQueue.swift deleted file mode 100644 index fe0bd917f..000000000 --- a/Sources/Player/Types/ItemQueue.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import AVFoundation - -enum ItemQueueUpdate { - case assets([any Assetable]) - case itemTransition(ItemTransition) -} - -struct ItemQueue { - static var initial: Self { - .init(assets: [], itemTransition: .advance(to: nil)) - } - - let assets: [any Assetable] - let itemTransition: ItemTransition - - init(assets: [any Assetable], itemTransition: ItemTransition) { - self.assets = assets - self.itemTransition = !assets.isEmpty ? itemTransition : .advance(to: nil) - } - - func updated(with update: ItemQueueUpdate) -> Self { - switch update { - case let .assets(assets): - return .init(assets: assets, itemTransition: itemTransition) - case let .itemTransition(itemTransition): - return .init(assets: assets, itemTransition: itemTransition) - } - } -} diff --git a/Sources/Player/Types/ItemState.swift b/Sources/Player/Types/ItemState.swift index 376dcc2db..92de041b6 100644 --- a/Sources/Player/Types/ItemState.swift +++ b/Sources/Player/Types/ItemState.swift @@ -4,8 +4,22 @@ // License information is available from the LICENSE file. // -enum ItemState: Equatable { - case unknown - case readyToPlay - case ended +import AVFoundation + +struct ItemState { + static let empty = Self(item: nil, error: nil) + + let item: AVPlayerItem? + let error: Error? + + init(item: AVPlayerItem?, error: Error?) { + self.item = item + + if let error, item != nil { + self.error = ItemError.localizedError(from: error) + } + else { + self.error = nil + } + } } diff --git a/Sources/Player/Types/ItemStatus.swift b/Sources/Player/Types/ItemStatus.swift new file mode 100644 index 000000000..b4926d245 --- /dev/null +++ b/Sources/Player/Types/ItemStatus.swift @@ -0,0 +1,11 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +enum ItemStatus: Equatable { + case unknown + case readyToPlay + case ended +} diff --git a/Sources/Player/Types/ItemTransition.swift b/Sources/Player/Types/ItemTransition.swift deleted file mode 100644 index 6c96da854..000000000 --- a/Sources/Player/Types/ItemTransition.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import AVFoundation - -/// A transition between items in a playlist. -enum ItemTransition: Equatable { - /// Advance to the provided item (or the beginning of the playlist if `nil`). - case advance(to: AVPlayerItem?) - /// Stop on the provided item. - case stop(on: AVPlayerItem) - /// Finish playing all items. - case finish - - static func transition(from previousItem: AVPlayerItem?, to currentItem: AVPlayerItem?) -> Self { - if let previousItem, previousItem.error != nil { - return .stop(on: previousItem) - } - else if let currentItem, currentItem.error != nil { - return .stop(on: currentItem) - } - else if let currentItem { - return .advance(to: currentItem) - } - else if previousItem != nil { - return .finish - } - else { - return .advance(to: nil) - } - } -} diff --git a/Sources/Player/Types/ItemUpdate.swift b/Sources/Player/Types/ItemUpdate.swift deleted file mode 100644 index 8c11ff823..000000000 --- a/Sources/Player/Types/ItemUpdate.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import AVFoundation -import DequeModule - -struct ItemUpdate { - static var empty: Self { - .init(items: [], currentItem: nil) - } - - let items: Deque - let currentItem: AVPlayerItem? - - func currentIndex() -> Int? { - items.firstIndex { $0.matches(currentItem) } - } -} diff --git a/Sources/Player/Types/PlaybackProperties.swift b/Sources/Player/Types/PlaybackProperties.swift index 7bdd3c1ec..d5f3fa3d3 100644 --- a/Sources/Player/Types/PlaybackProperties.swift +++ b/Sources/Player/Types/PlaybackProperties.swift @@ -5,9 +5,7 @@ // struct PlaybackProperties: Equatable { - static var empty: Self { - .init(rate: 0, isExternalPlaybackActive: false, isMuted: false) - } + static let empty = Self(rate: 0, isExternalPlaybackActive: false, isMuted: false) let rate: Float let isExternalPlaybackActive: Bool diff --git a/Sources/Player/Types/PlaybackSpeed.swift b/Sources/Player/Types/PlaybackSpeed.swift index 4849596f1..f610ac732 100644 --- a/Sources/Player/Types/PlaybackSpeed.swift +++ b/Sources/Player/Types/PlaybackSpeed.swift @@ -4,15 +4,8 @@ // License information is available from the LICENSE file. // -enum PlaybackSpeedUpdate: Equatable { - case value(Float) - case range(ClosedRange?) -} - struct PlaybackSpeed: Equatable { - static var indefinite: Self { - .init(value: 1, range: nil) - } + static let indefinite = Self(value: 1, range: nil) let value: Float let range: ClosedRange? diff --git a/Sources/Player/Types/PlaybackSpeedUpdate.swift b/Sources/Player/Types/PlaybackSpeedUpdate.swift new file mode 100644 index 000000000..d0f9ef370 --- /dev/null +++ b/Sources/Player/Types/PlaybackSpeedUpdate.swift @@ -0,0 +1,12 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import Foundation + +enum PlaybackSpeedUpdate: Equatable { + case value(Float) + case range(ClosedRange?) +} diff --git a/Sources/Player/Types/PlaybackState.swift b/Sources/Player/Types/PlaybackState.swift index 3995dc304..474c4cb28 100644 --- a/Sources/Player/Types/PlaybackState.swift +++ b/Sources/Player/Types/PlaybackState.swift @@ -15,8 +15,8 @@ public enum PlaybackState: Equatable { /// The player ended playback of an item. case ended - init(itemState: ItemState, rate: Float) { - switch itemState { + init(itemStatus: ItemStatus, rate: Float) { + switch itemStatus { case .readyToPlay: self = (rate == 0) ? .paused : .playing case .ended: diff --git a/Sources/Player/Types/PlayerItemProperties.swift b/Sources/Player/Types/PlayerItemProperties.swift index 2360b1971..6fa1b237e 100644 --- a/Sources/Player/Types/PlayerItemProperties.swift +++ b/Sources/Player/Types/PlayerItemProperties.swift @@ -5,14 +5,12 @@ // struct PlayerItemProperties: Equatable { - static var empty: Self { - .init( - itemProperties: .empty, - mediaSelectionProperties: .empty, - timeProperties: .empty, - isEmpty: true - ) - } + static let empty = Self( + itemProperties: .empty, + mediaSelectionProperties: .empty, + timeProperties: .empty, + isEmpty: true + ) let itemProperties: ItemProperties let mediaSelectionProperties: MediaSelectionProperties diff --git a/Sources/Player/Types/PlayerProperties.swift b/Sources/Player/Types/PlayerProperties.swift index 51e9725c9..ceccc8847 100644 --- a/Sources/Player/Types/PlayerProperties.swift +++ b/Sources/Player/Types/PlayerProperties.swift @@ -8,14 +8,12 @@ import CoreMedia /// A type describing player properties. public struct PlayerProperties: Equatable { - static var empty: Self { - .init( - coreProperties: .empty, - timeProperties: .empty, - isEmpty: true, - seekTime: nil - ) - } + static let empty = Self( + coreProperties: .empty, + timeProperties: .empty, + isEmpty: true, + seekTime: nil + ) let coreProperties: CoreProperties private let timeProperties: TimeProperties @@ -111,8 +109,8 @@ extension PlayerProperties { coreProperties.mediaSelectionProperties } - var state: ItemState { - coreProperties.state + var itemStatus: ItemStatus { + coreProperties.itemStatus } var duration: CMTime { diff --git a/Sources/Player/Types/Queue.swift b/Sources/Player/Types/Queue.swift new file mode 100644 index 000000000..c794d4117 --- /dev/null +++ b/Sources/Player/Types/Queue.swift @@ -0,0 +1,63 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +struct Queue { + static let empty = Self(elements: [], itemState: .empty) + + let elements: [QueueElement] + let itemState: ItemState + + var index: Int? { + elements.firstIndex { $0.matches(itemState.item) } + } + + var item: PlayerItem? { + guard let index else { return nil } + return elements[index].item + } + + var error: Error? { + itemState.error + } + + private var playerItem: AVPlayerItem? { + itemState.item + } + + init(elements: [QueueElement], itemState: ItemState) { + self.elements = elements + self.itemState = !elements.isEmpty ? itemState : .empty + } + + static func buffer(from previous: Self, to current: Self, length: Int) -> QueueBuffer? { + if let previousItem = previous.playerItem, previous.error != nil, previous.index != nil { + return .init(item: previousItem, length: 1) + } + else if let currentItem = current.playerItem, current.error != nil { + return .init(item: currentItem, length: 1) + } + else if let currentItem = current.playerItem { + return .init(item: currentItem, length: length) + } + else if previous.playerItem != nil, !current.elements.isEmpty { + return nil + } + else { + return .init(item: nil, length: length) + } + } + + func updated(with update: QueueUpdate) -> Self { + switch update { + case let .elements(elements): + return .init(elements: elements, itemState: itemState) + case let .itemState(itemState): + return .init(elements: elements, itemState: itemState) + } + } +} diff --git a/Sources/Player/Types/QueueBuffer.swift b/Sources/Player/Types/QueueBuffer.swift new file mode 100644 index 000000000..ce19b07b1 --- /dev/null +++ b/Sources/Player/Types/QueueBuffer.swift @@ -0,0 +1,12 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +struct QueueBuffer { + let item: AVPlayerItem? + let length: Int +} diff --git a/Sources/Player/Types/QueueElement.swift b/Sources/Player/Types/QueueElement.swift new file mode 100644 index 000000000..5cacb4007 --- /dev/null +++ b/Sources/Player/Types/QueueElement.swift @@ -0,0 +1,22 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +struct QueueElement { + let item: PlayerItem + let asset: any Assetable + + init(item: PlayerItem, asset: any Assetable) { + assert(item.id == asset.id) + self.item = item + self.asset = asset + } + + func matches(_ playerItem: AVPlayerItem?) -> Bool { + item.matches(playerItem) + } +} diff --git a/Sources/Player/Types/Current.swift b/Sources/Player/Types/QueueUpdate.swift similarity index 62% rename from Sources/Player/Types/Current.swift rename to Sources/Player/Types/QueueUpdate.swift index 041e95523..b8bcf751a 100644 --- a/Sources/Player/Types/Current.swift +++ b/Sources/Player/Types/QueueUpdate.swift @@ -6,7 +6,7 @@ import Foundation -struct Current: Equatable { - let item: PlayerItem - let index: Int +enum QueueUpdate { + case elements([QueueElement]) + case itemState(ItemState) } diff --git a/Sources/Player/Types/TimeProperties.swift b/Sources/Player/Types/TimeProperties.swift index c9b21709f..97fe8e007 100644 --- a/Sources/Player/Types/TimeProperties.swift +++ b/Sources/Player/Types/TimeProperties.swift @@ -7,9 +7,7 @@ import CoreMedia struct TimeProperties: Equatable { - static var empty: Self { - .init(loadedTimeRanges: [], seekableTimeRanges: [], isPlaybackLikelyToKeepUp: false) - } + static let empty = Self(loadedTimeRanges: [], seekableTimeRanges: [], isPlaybackLikelyToKeepUp: false) let loadedTimeRanges: [NSValue] let seekableTimeRanges: [NSValue] diff --git a/Sources/Player/UserInterface/LayoutReader.swift b/Sources/Player/UserInterface/LayoutReader.swift index e3bb58a5b..e21326c79 100644 --- a/Sources/Player/UserInterface/LayoutReader.swift +++ b/Sources/Player/UserInterface/LayoutReader.swift @@ -9,9 +9,7 @@ import SwiftUI /// Layout information. public struct LayoutInfo { /// A placeholder for unfilled layout information. - public static var none: Self { - .init(isOverCurrentContext: false, isFullScreen: false) - } + public static let none = Self(isOverCurrentContext: false, isFullScreen: false) /// A Boolean describing whether the view covers its current context. public let isOverCurrentContext: Bool diff --git a/Sources/Streams/Stream.swift b/Sources/Streams/Stream.swift index 2b215668c..1700c23a5 100644 --- a/Sources/Streams/Stream.swift +++ b/Sources/Streams/Stream.swift @@ -40,12 +40,6 @@ public extension Stream { duration: CMTime(value: 120, timescale: 1) ) - /// A corrupt on-demand stream. - static let corruptOnDemand: Self = .init( - url: URL(string: "http://localhost:8123/simple/on_demand_corrupt/master.m3u8")!, - duration: CMTime(value: 2, timescale: 1) - ) - /// A live stream. static let live: Self = .init( url: URL(string: "http://localhost:8123/simple/live/master.m3u8")!, @@ -107,6 +101,12 @@ public extension Stream { url: Bundle.module.url(forResource: "silence", withExtension: "mp3")!, duration: CMTime(value: 5, timescale: 1) ) + + /// An unavailable MP3 stream. + static let unavailableMp3: Self = .init( + url: URL(string: "http://localhost:8123/unavailable.mp3")!, + duration: .indefinite + ) } public extension Stream { diff --git a/Tests/AnalyticsTests/Extensions/Dictionary.swift b/Tests/AnalyticsTests/Extensions/Dictionary.swift index 8d71f01b4..7a81bfee2 100644 --- a/Tests/AnalyticsTests/Extensions/Dictionary.swift +++ b/Tests/AnalyticsTests/Extensions/Dictionary.swift @@ -5,7 +5,5 @@ // extension [String: String] { - static var test: Self { - ["media_title": "name"] - } + static let test = ["media_title": "name"] } diff --git a/Tests/PlayerTests/Asset/AssetCreationTests.swift b/Tests/PlayerTests/Asset/AssetCreationTests.swift index c2a6043b3..b403f92b5 100644 --- a/Tests/PlayerTests/Asset/AssetCreationTests.swift +++ b/Tests/PlayerTests/Asset/AssetCreationTests.swift @@ -30,20 +30,18 @@ final class AssetCreationTests: TestCase { } func testSimpleAssetWithoutMetadata() { - let asset = Asset.simple( - url: Stream.onDemand.url - ) { item in + let asset = Asset.simple(url: Stream.onDemand.url) { item in item.preferredForwardBufferDuration = 4 } expect(asset.resource).to(equal(.simple(url: Stream.onDemand.url))) - expect(asset.nowPlayingInfo()).to(beNil()) + expect(asset.nowPlayingInfo()).notTo(beEmpty()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } func testSimpleAssetWithoutMetadataAndConfiguration() { let asset = Asset.simple(url: Stream.onDemand.url) expect(asset.resource).to(equal(.simple(url: Stream.onDemand.url))) - expect(asset.nowPlayingInfo()).to(beNil()) + expect(asset.nowPlayingInfo()).notTo(beEmpty()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } @@ -79,7 +77,7 @@ final class AssetCreationTests: TestCase { item.preferredForwardBufferDuration = 4 } expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).to(beNil()) + expect(asset.nowPlayingInfo()).notTo(beEmpty()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } @@ -87,7 +85,7 @@ final class AssetCreationTests: TestCase { let delegate = ResourceLoaderDelegateMock() let asset = Asset.custom(url: Stream.onDemand.url, delegate: delegate) expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).to(beNil()) + expect(asset.nowPlayingInfo()).notTo(beEmpty()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } @@ -122,7 +120,7 @@ final class AssetCreationTests: TestCase { item.preferredForwardBufferDuration = 4 } expect(asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).to(beNil()) + expect(asset.nowPlayingInfo()).notTo(beEmpty()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } @@ -130,7 +128,7 @@ final class AssetCreationTests: TestCase { let delegate = ContentKeySessionDelegateMock() let asset = Asset.encrypted(url: Stream.onDemand.url, delegate: delegate) expect(asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(asset.nowPlayingInfo()).to(beNil()) + expect(asset.nowPlayingInfo()).notTo(beEmpty()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } } diff --git a/Tests/PlayerTests/Asset/AssetPlayerItemTests.swift b/Tests/PlayerTests/Asset/AssetPlayerItemTests.swift index 4146aaa49..53ab37519 100644 --- a/Tests/PlayerTests/Asset/AssetPlayerItemTests.swift +++ b/Tests/PlayerTests/Asset/AssetPlayerItemTests.swift @@ -33,10 +33,8 @@ final class AssetPlayerItemTests: TestCase { let item = EmptyAsset.failed(error: StructError()).playerItem() _ = AVPlayer(playerItem: item) expectEqualPublished( - values: [ - .unknown - ], - from: item.statePublisher(), + values: [.unknown], + from: item.statusPublisher(), during: .seconds(1) ) } diff --git a/Tests/PlayerTests/Extensions/Player.swift b/Tests/PlayerTests/Extensions/Player.swift new file mode 100644 index 000000000..667f97151 --- /dev/null +++ b/Tests/PlayerTests/Extensions/Player.swift @@ -0,0 +1,15 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import Foundation + +extension Player { + var urls: [URL] { + queuePlayer.items().compactMap(\.url) + } +} diff --git a/Tests/PlayerTests/Player/ErrorTests.swift b/Tests/PlayerTests/Player/ErrorTests.swift new file mode 100644 index 000000000..e0ac705ad --- /dev/null +++ b/Tests/PlayerTests/Player/ErrorTests.swift @@ -0,0 +1,50 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import AVFoundation +import Combine +import Nimble +import PillarboxCircumspect +import PillarboxStreams + +final class ErrorTests: TestCase { + private static func errorCodePublisher(for player: Player) -> AnyPublisher { + player.$error + .map { error in + guard let error else { return nil } + return .init(rawValue: (error as NSError).code) + } + .eraseToAnyPublisher() + } + + func testNoStream() { + let player = Player() + expectNothingPublishedNext(from: player.$error, during: .milliseconds(500)) + } + + func testValidStream() { + let player = Player(item: .simple(url: Stream.onDemand.url)) + expectNothingPublishedNext(from: player.$error, during: .milliseconds(500)) + } + + func testInvalidStream() { + let player = Player(item: .simple(url: Stream.unavailable.url)) + expectEqualPublishedNext( + values: [.init(rawValue: -12938)], + from: Self.errorCodePublisher(for: player), + during: .milliseconds(500) + ) + } + + func testReset() { + let player = Player(item: .simple(url: Stream.unavailable.url)) + expect(player.error).toEventuallyNot(beNil()) + player.removeAllItems() + expect(player.error).toEventually(beNil()) + } +} diff --git a/Tests/PlayerTests/Player/QueueTests.swift b/Tests/PlayerTests/Player/QueueTests.swift new file mode 100644 index 000000000..84d292201 --- /dev/null +++ b/Tests/PlayerTests/Player/QueueTests.swift @@ -0,0 +1,210 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import AVFoundation +import Combine +import Nimble +import PillarboxStreams + +final class QueueTests: TestCase { + func testWhenEmpty() { + let player = Player() + expect(player.urls).to(beEmpty()) + expect(player.currentIndex).to(beNil()) + } + + func testPlayableItem() { + let player = Player(item: .simple(url: Stream.shortOnDemand.url)) + expect(player.urls).toEventually(equal([ + Stream.shortOnDemand.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testEntirePlayback() { + let player = Player(item: .simple(url: Stream.shortOnDemand.url)) + player.play() + expect(player.urls).toEventually(beEmpty()) + expect(player.currentIndex).to(beNil()) + } + + func testFailingUnavailableItem() { + let player = Player(items: [ + .simple(url: Stream.unavailable.url) + ]) + // Item is consumed by `AVQueuePlayer` for some reason. + expect(player.urls).toEventually(beEmpty()) + expect(player.currentIndex).to(equal(0)) + } + + func testFailingUnauthorizedItem() { + let player = Player(items: [ + .simple(url: Stream.unauthorized.url) + ]) + expect(player.urls).toEventually(equal([ + Stream.unauthorized.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testFailingMp3Item() { + let player = Player(items: [ + .simple(url: Stream.unavailableMp3.url) + ]) + expect(player.urls).toEventually(equal([ + Stream.unavailableMp3.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testBetweenPlayableItems() { + let player = Player(items: [ + .simple(url: Stream.shortOnDemand.url), + .simple(url: Stream.onDemand.url) + ]) + player.play() + + expect(player.urls).toEventually(equal([ + Stream.shortOnDemand.url, + Stream.onDemand.url + ])) + expect(player.currentIndex).to(equal(0)) + + expect(player.urls).toEventually(equal([ + Stream.onDemand.url + ])) + expect(player.currentIndex).to(equal(1)) + } + + func testFailingUnavailableItemFollowedByPlayableItem() { + let player = Player(items: [ + .simple(url: Stream.unavailable.url), + .simple(url: Stream.onDemand.url) + ]) + // Item is consumed by `AVQueuePlayer` for some reason. + expect(player.urls).toEventually(beEmpty()) + expect(player.currentIndex).to(equal(0)) + } + + func testFailingUnauthorizedItemFollowedByPlayableItem() { + let player = Player(items: [ + .simple(url: Stream.unauthorized.url), + .simple(url: Stream.onDemand.url) + ]) + expect(player.urls).toEventually(equal([ + Stream.unauthorized.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testFailingMp3ItemFollowedByPlayableItem() { + let player = Player(items: [ + .simple(url: Stream.unavailableMp3.url), + .simple(url: Stream.onDemand.url) + ]) + expect(player.urls).toEventually(equal([ + Stream.unavailableMp3.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testFailingItemUnavailableBetweenPlayableItems() { + let player = Player(items: [ + .simple(url: Stream.shortOnDemand.url), + .simple(url: Stream.unavailable.url), + .simple(url: Stream.onDemand.url) + ]) + player.play() + expect(player.urls).toEventually(beEmpty()) + expect(player.currentIndex).to(equal(1)) + } + + func testFailingUnauthorizedItemBetweenPlayableItems() { + let player = Player(items: [ + .simple(url: Stream.shortOnDemand.url), + .simple(url: Stream.unauthorized.url), + .simple(url: Stream.onDemand.url) + ]) + player.play() + expect(player.urls).toEventually(beEmpty()) + expect(player.currentIndex).to(equal(1)) + } + + func testFailingMp3ItemBetweenPlayableItems() { + let player = Player(items: [ + .simple(url: Stream.shortOnDemand.url), + .simple(url: Stream.unavailableMp3.url), + .simple(url: Stream.onDemand.url) + ]) + player.play() + expect(player.urls).toEventually(beEmpty()) + expect(player.currentIndex).to(equal(1)) + } + + func testPlayableItemReplacingFailingUnavailableItem() { + let player = Player(items: [ + .simple(url: Stream.unavailable.url) + ]) + player.items = [.simple(url: Stream.onDemand.url)] + expect(player.urls).toEventually(equal([ + Stream.onDemand.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testPlayableItemReplacingFailingUnauthorizedItem() { + let player = Player(items: [ + .simple(url: Stream.unauthorized.url) + ]) + player.items = [.simple(url: Stream.onDemand.url)] + expect(player.urls).toEventually(equal([ + Stream.onDemand.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testPlayableItemReplacingFailingMp3Item() { + let player = Player(items: [ + .simple(url: Stream.unavailableMp3.url) + ]) + player.items = [.simple(url: Stream.onDemand.url)] + expect(player.urls).toEventually(equal([ + Stream.onDemand.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testReplaceCurrentItem() { + let player = Player(items: [ + .simple(url: Stream.shortOnDemand.url) + ]) + player.items = [.simple(url: Stream.onDemand.url)] + expect(player.urls).toEventually(equal([ + Stream.onDemand.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testRemoveCurrentItemFollowedByPlayableItem() { + let player = Player(items: [ + .simple(url: Stream.shortOnDemand.url), + .simple(url: Stream.onDemand.url) + ]) + player.remove(player.items.first!) + expect(player.urls).toEventually(equal([ + Stream.onDemand.url + ])) + expect(player.currentIndex).to(equal(0)) + } + + func testRemoveAllItems() { + let player = Player(item: .simple(url: Stream.shortOnDemand.url)) + player.removeAllItems() + expect(player.urls).to(beEmpty()) + } +} diff --git a/Tests/PlayerTests/Player/TextStyleRulesTests.swift b/Tests/PlayerTests/Player/TextStyleRulesTests.swift index 2b87878a0..8507ce404 100644 --- a/Tests/PlayerTests/Player/TextStyleRulesTests.swift +++ b/Tests/PlayerTests/Player/TextStyleRulesTests.swift @@ -11,14 +11,12 @@ import Nimble import PillarboxStreams final class TextStyleRulesTests: TestCase { - private static var textStyleRules: [AVTextStyleRule] = { - [ - .init(textMarkupAttributes: [ - kCMTextMarkupAttribute_ForegroundColorARGB: [1, 1, 0, 0], - kCMTextMarkupAttribute_ItalicStyle: true - ]) - ] - }() + private static let textStyleRules = [ + AVTextStyleRule(textMarkupAttributes: [ + kCMTextMarkupAttribute_ForegroundColorARGB: [1, 1, 0, 0], + kCMTextMarkupAttribute_ItalicStyle: true + ]) + ] func testDefaultWithEmptyPlayer() { let player = Player() diff --git a/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift b/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift index 7b733787f..fc0e7c21b 100644 --- a/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift +++ b/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift @@ -39,7 +39,7 @@ final class PlayerItemTests: TestCase { } PlayerItem.load(for: item.id) expect(item.asset.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) - expect(item.asset.nowPlayingInfo()).to(beNil()) + expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } @@ -47,7 +47,7 @@ final class PlayerItemTests: TestCase { let item = PlayerItem.simple(url: Stream.onDemand.url) PlayerItem.load(for: item.id) expect(item.asset.resource).toEventually(equal(.simple(url: Stream.onDemand.url))) - expect(item.asset.nowPlayingInfo()).to(beNil()) + expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } @@ -85,7 +85,7 @@ final class PlayerItemTests: TestCase { } PlayerItem.load(for: item.id) expect(item.asset.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).to(beNil()) + expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } @@ -94,7 +94,7 @@ final class PlayerItemTests: TestCase { let item = PlayerItem.custom(url: Stream.onDemand.url, delegate: delegate) PlayerItem.load(for: item.id) expect(item.asset.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).to(beNil()) + expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } @@ -132,7 +132,7 @@ final class PlayerItemTests: TestCase { } PlayerItem.load(for: item.id) expect(item.asset.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).to(beNil()) + expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(4)) } @@ -141,7 +141,7 @@ final class PlayerItemTests: TestCase { let item = PlayerItem.encrypted(url: Stream.onDemand.url, delegate: delegate) PlayerItem.load(for: item.id) expect(item.asset.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate))) - expect(item.asset.nowPlayingInfo()).to(beNil()) + expect(item.asset.nowPlayingInfo()).notTo(beEmpty()) expect(item.asset.playerItem().preferredForwardBufferDuration).to(equal(0)) } } diff --git a/Tests/PlayerTests/Playlist/CurrentIndexTests.swift b/Tests/PlayerTests/Playlist/CurrentIndexTests.swift index 5934102ca..89a0e35f5 100644 --- a/Tests/PlayerTests/Playlist/CurrentIndexTests.swift +++ b/Tests/PlayerTests/Playlist/CurrentIndexTests.swift @@ -49,6 +49,27 @@ final class CurrentIndexTests: TestCase { } } + func testCurrentIndexWithFirstItemRemoved() { + let item1 = PlayerItem.simple(url: Stream.unavailable.url) + let item2 = PlayerItem.simple(url: Stream.onDemand.url) + let player = Player(items: [item1, item2]) + expect(player.error).toEventuallyNot(beNil()) + player.remove(item1) + expect(player.currentIndex).toAlways(equal(0), until: .seconds(1)) + } + + func testCurrentIndexWithSecondItemRemoved() { + let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url) + let item2 = PlayerItem.simple(url: Stream.unavailable.url) + let item3 = PlayerItem.simple(url: Stream.onDemand.url) + let player = Player(items: [item1, item2, item3]) + player.advanceToNextItem() + expect(player.currentIndex).toEventually(equal(1)) + expect(player.error).toEventuallyNot(beNil()) + player.remove(item2) + expect(player.currentIndex).toAlways(equal(1), until: .seconds(1)) + } + func testCurrentIndexWithFailedItem() { let player = Player(item: .simple(url: Stream.unavailable.url)) expectEqualPublished(values: [0], from: player.$currentIndex, during: .milliseconds(500)) diff --git a/Tests/PlayerTests/Playlist/ItemsTests.swift b/Tests/PlayerTests/Playlist/ItemsTests.swift index 848e4bd31..4d004072c 100644 --- a/Tests/PlayerTests/Playlist/ItemsTests.swift +++ b/Tests/PlayerTests/Playlist/ItemsTests.swift @@ -59,7 +59,7 @@ final class ItemsTests: TestCase { expect(player.currentIndex).to(beNil()) } - func testAppendAfterAfterRemoveAll() { + func testAppendAfterRemoveAll() { let player = Player(item: .simple(url: Stream.shortOnDemand.url)) player.removeAllItems() player.append(.simple(url: Stream.onDemand.url)) diff --git a/Tests/PlayerTests/Publishers/AVPlayerErrorPublisherTests.swift b/Tests/PlayerTests/Publishers/AVPlayerErrorPublisherTests.swift deleted file mode 100644 index de149a748..000000000 --- a/Tests/PlayerTests/Publishers/AVPlayerErrorPublisherTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -@testable import PillarboxPlayer - -import AVFoundation -import Combine -import PillarboxStreams - -final class AVPlayerErrorPublisherTests: TestCase { - private static func errorPublisher(for player: AVPlayer) -> AnyPublisher { - player.errorPublisher() - .removeDuplicates { $0 as? NSError == $1 as? NSError } - .eraseToAnyPublisher() - } - - func testWhenEmpty() { - let player = AVQueuePlayer() - expectNothingPublished(from: Self.errorPublisher(for: player), during: .milliseconds(100)) - } - - func testError() { - let player = AVQueuePlayer( - playerItem: .init(url: Stream.unavailable.url) - ) - expectAtLeastPublished( - values: [nil, PlayerError.resourceNotFound], - from: Self.errorPublisher(for: player), - to: beEqual - ) - } -} diff --git a/Tests/PlayerTests/Publishers/AVPlayerItemErrorPublisherTests.swift b/Tests/PlayerTests/Publishers/AVPlayerItemErrorPublisherTests.swift new file mode 100644 index 000000000..0240d286a --- /dev/null +++ b/Tests/PlayerTests/Publishers/AVPlayerItemErrorPublisherTests.swift @@ -0,0 +1,41 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +@testable import PillarboxPlayer + +import AVFoundation +import Combine +import PillarboxStreams + +final class AVPlayerItemErrorPublisherTests: TestCase { + private static func errorCodePublisher(for item: AVPlayerItem) -> AnyPublisher { + item.errorPublisher() + .map { .init(rawValue: ($0 as NSError).code) } + .eraseToAnyPublisher() + } + + func testNoError() { + let item = AVPlayerItem(url: Stream.onDemand.url) + _ = AVPlayer(playerItem: item) + expectNothingPublished(from: item.errorPublisher(), during: .milliseconds(500)) + } + + func testM3u8Error() { + let item = AVPlayerItem(url: Stream.unavailable.url) + _ = AVPlayer(playerItem: item) + expectAtLeastEqualPublished(values: [ + URLError.fileDoesNotExist + ], from: Self.errorCodePublisher(for: item)) + } + + func testMp3Error() { + let item = AVPlayerItem(url: Stream.unavailableMp3.url) + _ = AVPlayer(playerItem: item) + expectAtLeastEqualPublished(values: [ + URLError.fileDoesNotExist + ], from: Self.errorCodePublisher(for: item)) + } +} diff --git a/Tests/PlayerTests/Publishers/AVPlayerItemTransitionPublisherTests.swift b/Tests/PlayerTests/Publishers/AVPlayerItemTransitionPublisherTests.swift deleted file mode 100644 index f8d3fa645..000000000 --- a/Tests/PlayerTests/Publishers/AVPlayerItemTransitionPublisherTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -@testable import PillarboxPlayer - -import AVFoundation -import Combine -import PillarboxStreams - -final class AVPlayerItemTransitionPublisherTests: TestCase { - func testWhenEmpty() { - let player = AVQueuePlayer() - expectEqualPublished( - values: [.advance(to: nil)], - from: player.itemTransitionPublisher(), - during: .milliseconds(100) - ) - } - - func testDuringEntirePlayback() { - let item = AVPlayerItem(url: Stream.shortOnDemand.url) - let player = AVQueuePlayer(playerItem: item) - expectEqualPublished( - values: [.advance(to: item), .finish], - from: player.itemTransitionPublisher(), - during: .seconds(2) - ) { - player.play() - } - } - - func testBetweenPlayableItems() { - let item1 = AVPlayerItem(url: Stream.shortOnDemand.url) - let item2 = AVPlayerItem(url: Stream.onDemand.url) - let player = AVQueuePlayer(items: [item1, item2]) - expectEqualPublished( - values: [.advance(to: item1), .advance(to: item2)], - from: player.itemTransitionPublisher(), - during: .seconds(2) - ) { - player.play() - } - } - - func testFailingItemFollowedByPlayableItem() { - let item1 = AVPlayerItem(url: Stream.unavailable.url) - let item2 = AVPlayerItem(url: Stream.onDemand.url) - let player = AVQueuePlayer(items: [item1, item2]) - expectEqualPublished( - values: [.advance(to: item1), .stop(on: item1)], - from: player.itemTransitionPublisher(), - during: .seconds(2) - ) { - player.play() - } - } - - func testFailingItemBetweenPlayableItems() { - let item1 = AVPlayerItem(url: Stream.shortOnDemand.url) - let item2 = AVPlayerItem(url: Stream.unavailable.url) - let item3 = AVPlayerItem(url: Stream.onDemand.url) - let player = AVQueuePlayer(items: [item1, item2, item3]) - expectEqualPublished( - values: [.advance(to: item1), .stop(on: item2)], - from: player.itemTransitionPublisher(), - during: .seconds(2) - ) { - player.play() - } - } - - func testReplaceCurrentItem() { - let item1 = AVPlayerItem(url: Stream.onDemand.url) - let item2 = AVPlayerItem(url: Stream.shortOnDemand.url) - let player = AVQueuePlayer(playerItem: item1) - expectEqualPublished( - values: [.advance(to: item1), .advance(to: item2)], - from: player.itemTransitionPublisher(), - during: .milliseconds(100) - ) { - player.replaceCurrentItem(with: item2) - } - } - - func testRemoveCurrentItemFollowedByPlayableItem() { - let item1 = AVPlayerItem(url: Stream.shortOnDemand.url) - let item2 = AVPlayerItem(url: Stream.onDemand.url) - let player = AVQueuePlayer(items: [item1, item2]) - expectEqualPublished( - values: [.advance(to: item1), .advance(to: item2)], - from: player.itemTransitionPublisher(), - during: .milliseconds(100) - ) { - player.remove(item1) - } - } - - func testRemoveAllItems() { - let item = AVPlayerItem(url: Stream.shortOnDemand.url) - let player = AVQueuePlayer(playerItem: item) - expectEqualPublished( - values: [.advance(to: item), .finish], - from: player.itemTransitionPublisher(), - during: .milliseconds(100) - ) { - player.removeAllItems() - } - } -} diff --git a/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift b/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift index 3e1b53d4f..5757d4617 100644 --- a/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift +++ b/Tests/PlayerTests/Publishers/NowPlayingInfoMetadataPublisherTests.swift @@ -21,7 +21,10 @@ final class NowPlayingInfoMetadataPublisherTests: TestCase { func testImmediatelyAvailableWithoutMetadata() { let player = Player(item: .simple(url: Stream.onDemand.url)) - expectNothingPublished(from: player.nowPlayingInfoMetadataPublisher(), during: .seconds(1)) + expectAtLeastSimilarPublished( + values: [[MPMediaItemPropertyTitle: ""]], + from: player.nowPlayingInfoMetadataPublisher() + ) } func testAvailableAfterDelay() { @@ -104,7 +107,25 @@ final class NowPlayingInfoMetadataPublisherTests: TestCase { func testEntirePlayback() { let player = Player(item: .simple(url: Stream.shortOnDemand.url, metadata: AssetMetadataMock(title: "title"))) expectAtLeastSimilarPublished( - values: [[MPMediaItemPropertyTitle: "title"]], + values: [[MPMediaItemPropertyTitle: "title"], [:]], + from: player.nowPlayingInfoMetadataPublisher() + ) { + player.play() + } + } + + func testError() { + let player = Player(item: .simple(url: Stream.unavailable.url, metadata: AssetMetadataMock(title: "title"))) + expectAtLeastSimilarPublished( + values: [ + [ + MPMediaItemPropertyTitle: "title" + ], + [ + MPMediaItemPropertyTitle: "title", + MPMediaItemPropertyArtist: "HTTP 404: File Not Found" + ] + ], from: player.nowPlayingInfoMetadataPublisher() ) { player.play() diff --git a/Tests/PlayerTests/Publishers/QueuePlayerPublisherTests.swift b/Tests/PlayerTests/Publishers/QueuePlayerPublisherTests.swift index ea9e7f89c..f198402dc 100644 --- a/Tests/PlayerTests/Publishers/QueuePlayerPublisherTests.swift +++ b/Tests/PlayerTests/Publishers/QueuePlayerPublisherTests.swift @@ -24,9 +24,9 @@ final class QueuePlayerPublisherTests: TestCase { .eraseToAnyPublisher() } - private static func statePublisher(for player: QueuePlayer) -> AnyPublisher { + private static func itemStatusPublisher(for player: QueuePlayer) -> AnyPublisher { player.propertiesPublisher() - .slice(at: \.state) + .slice(at: \.itemStatus) .eraseToAnyPublisher() } @@ -78,21 +78,21 @@ final class QueuePlayerPublisherTests: TestCase { } } - func testStateEmpty() { + func testItemStatusEmpty() { let player = QueuePlayer() expectAtLeastEqualPublished( values: [.unknown], - from: Self.statePublisher(for: player) + from: Self.itemStatusPublisher(for: player) ) } - func testStateLifeCycle() { + func testItemStatusLifeCycle() { let player = QueuePlayer( playerItem: .init(url: Stream.shortOnDemand.url) ) expectAtLeastEqualPublished( values: [.unknown, .readyToPlay, .ended, .unknown], - from: Self.statePublisher(for: player) + from: Self.itemStatusPublisher(for: player) ) { player.play() } diff --git a/Tests/PlayerTests/Tools/FailingResourceLoaderDelegate.swift b/Tests/PlayerTests/Tools/FailingResourceLoaderDelegate.swift deleted file mode 100644 index 53afc3b55..000000000 --- a/Tests/PlayerTests/Tools/FailingResourceLoaderDelegate.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import AVFoundation - -struct ResourceLoaderError: LocalizedError { - let errorDescription: String? -} - -final class FailingResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate { - func resourceLoader( - _ resourceLoader: AVAssetResourceLoader, - shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest - ) -> Bool { - let error = ResourceLoaderError(errorDescription: "Failed to load the resource (custom message)") - loadingRequest.finishLoadingReliably(with: error) - return true - } - - func resourceLoader( - _ resourceLoader: AVAssetResourceLoader, - shouldWaitForRenewalOfRequestedResource renewalRequest: AVAssetResourceRenewalRequest - ) -> Bool { - let error = ResourceLoaderError(errorDescription: "Failed to renew the resource (custom message)") - renewalRequest.finishLoadingReliably(with: error) - return true - } -} diff --git a/Tests/PlayerTests/Tools/Tools.swift b/Tests/PlayerTests/Tools/Tools.swift index 1e4db4358..a745e1400 100644 --- a/Tests/PlayerTests/Tools/Tools.swift +++ b/Tests/PlayerTests/Tools/Tools.swift @@ -16,25 +16,6 @@ struct StructError: LocalizedError { } } -enum PlayerError { - static var resourceNotFound: NSError { - NSError( - domain: URLError.errorDomain, - code: URLError.fileDoesNotExist.rawValue, - userInfo: [ - NSLocalizedDescriptionKey: "The requested URL was not found on this server.", - NSUnderlyingErrorKey: NSError( - domain: "CoreMediaErrorDomain", - code: -12938, - userInfo: [ - "NSDescription": "HTTP 404: File Not Found" - ] - ) - ] - ) - } -} - extension UUID { init(_ char: Character) { self.init( @@ -48,7 +29,3 @@ extension UUID { )! } } - -func beEqual(_ lhsError: Error?, _ rhsError: Error?) -> Bool { - lhsError as? NSError == rhsError as? NSError -} diff --git a/Tests/PlayerTests/Tracking/PlayerItemTrackerLifeCycleTests.swift b/Tests/PlayerTests/Tracking/PlayerItemTrackerLifeCycleTests.swift index 4a975aebf..12c6b6501 100644 --- a/Tests/PlayerTests/Tracking/PlayerItemTrackerLifeCycleTests.swift +++ b/Tests/PlayerTests/Tracking/PlayerItemTrackerLifeCycleTests.swift @@ -69,4 +69,19 @@ final class PlayerItemTrackerLifeCycleTests: TestCase { player.play() } } + + func testMoveCurrentItem() { + let publisher = TrackerLifeCycleMock.StatePublisher() + let player = Player() + expectAtLeastEqualPublished(values: [.initialized, .enabled], from: publisher) { + player.append(.simple( + url: Stream.onDemand.url, + trackerAdapters: [TrackerLifeCycleMock.adapter(statePublisher: publisher)] + )) + player.play() + } + expectNothingPublished(from: publisher, during: .seconds(1)) { + player.prepend(.simple(url: Stream.onDemand.url)) + } + } } diff --git a/Tests/PlayerTests/Types/PlaybackStateTests.swift b/Tests/PlayerTests/Types/PlaybackStateTests.swift index ddc913f17..0d3ece451 100644 --- a/Tests/PlayerTests/Types/PlaybackStateTests.swift +++ b/Tests/PlayerTests/Types/PlaybackStateTests.swift @@ -10,11 +10,11 @@ import Nimble final class PlaybackStateTests: TestCase { func testAllCases() { - expect(PlaybackState(itemState: .unknown, rate: 0)).to(equal(.idle)) - expect(PlaybackState(itemState: .unknown, rate: 1)).to(equal(.idle)) - expect(PlaybackState(itemState: .readyToPlay, rate: 0)).to(equal(.paused)) - expect(PlaybackState(itemState: .readyToPlay, rate: 1)).to(equal(.playing)) - expect(PlaybackState(itemState: .ended, rate: 0)).to(equal(.ended)) - expect(PlaybackState(itemState: .ended, rate: 1)).to(equal(.ended)) + expect(PlaybackState(itemStatus: .unknown, rate: 0)).to(equal(.idle)) + expect(PlaybackState(itemStatus: .unknown, rate: 1)).to(equal(.idle)) + expect(PlaybackState(itemStatus: .readyToPlay, rate: 0)).to(equal(.paused)) + expect(PlaybackState(itemStatus: .readyToPlay, rate: 1)).to(equal(.playing)) + expect(PlaybackState(itemStatus: .ended, rate: 0)).to(equal(.ended)) + expect(PlaybackState(itemStatus: .ended, rate: 1)).to(equal(.ended)) } } diff --git a/Tests/PlayerTests/Types/StreamTypeTests.swift b/Tests/PlayerTests/Types/StreamTypeTests.swift index 190d4c36b..4e7ef6ac1 100644 --- a/Tests/PlayerTests/Types/StreamTypeTests.swift +++ b/Tests/PlayerTests/Types/StreamTypeTests.swift @@ -19,7 +19,5 @@ final class StreamTypeTests: TestCase { } private extension CMTimeRange { - static var finite: Self { - .init(start: .zero, duration: .init(value: 1, timescale: 1)) - } + static let finite = Self(start: .zero, duration: .init(value: 1, timescale: 1)) } diff --git a/Tests/PlayerTests/Types/TimePropertiesTests.swift b/Tests/PlayerTests/Types/TimePropertiesTests.swift index 8596de1b7..d8f92d496 100644 --- a/Tests/PlayerTests/Types/TimePropertiesTests.swift +++ b/Tests/PlayerTests/Types/TimePropertiesTests.swift @@ -65,7 +65,5 @@ final class TimePropertiesTests: TestCase { } private extension CMTimeRange { - static var finite: Self { - .init(start: .zero, duration: .init(value: 1, timescale: 1)) - } + static let finite = Self(start: .zero, duration: .init(value: 1, timescale: 1)) }