diff --git a/Demo/Sources/ContentLists/ContentListView.swift b/Demo/Sources/ContentLists/ContentListView.swift index 5a5543229..1df5e5014 100644 --- a/Demo/Sources/ContentLists/ContentListView.swift +++ b/Demo/Sources/ContentLists/ContentListView.swift @@ -78,7 +78,7 @@ private struct ContentCell: View { #if os(tvOS) MediaCardView( title: topic.title, - image: SRGDataProvider.current!.url(for: topic.image, size: .large) + imageUrl: SRGDataProvider.current!.url(for: topic.image, size: .large) ) #endif } @@ -91,7 +91,7 @@ private struct ContentCell: View { size: .init(width: 570, height: 350), title: constant(iOS: title, tvOS: media.show?.title), subtitle: constant(iOS: MediaDescription.subtitle(for: media), tvOS: media.title), - image: SRGDataProvider.current!.url(for: media.image, size: .large), + imageUrl: SRGDataProvider.current!.url(for: media.image, size: .large), type: MediaDescription.systemImage(for: media), duration: MediaDescription.duration(for: media), date: MediaDescription.date(for: media), @@ -115,7 +115,7 @@ private struct ContentCell: View { #if os(tvOS) MediaCardView( title: show.title, - image: SRGDataProvider.current!.url(for: show.image, size: .large) + imageUrl: SRGDataProvider.current!.url(for: show.image, size: .large) ) #endif } diff --git a/Demo/Sources/Examples/ExamplesView.swift b/Demo/Sources/Examples/ExamplesView.swift index 8622bcb24..75c8d4c0a 100644 --- a/Demo/Sources/Examples/ExamplesView.swift +++ b/Demo/Sources/Examples/ExamplesView.swift @@ -135,7 +135,7 @@ struct ExamplesView: View { private func section(title: String, medias: [Media]) -> some View { CustomSection(title) { ForEach(medias, id: \.self) { media in - Cell(title: media.title, subtitle: media.description, image: media.image) { + Cell(title: media.title, subtitle: media.description, imageUrl: media.imageUrl) { router.presented = .player(media: media) } } diff --git a/Demo/Sources/Examples/ExamplesViewModel.swift b/Demo/Sources/Examples/ExamplesViewModel.swift index 84e88e151..21e87bdfe 100644 --- a/Demo/Sources/Examples/ExamplesViewModel.swift +++ b/Demo/Sources/Examples/ExamplesViewModel.swift @@ -118,7 +118,7 @@ final class ExamplesViewModel: ObservableObject { Media( title: title(of: media), description: "DRM-protected video", - image: SRGDataProvider.current!.url(for: media.show?.image, size: .large), + imageUrl: SRGDataProvider.current!.url(for: media.show?.image, size: .large), type: .urn(media.urn), isMonoscopic: media.isMonoscopic ) @@ -136,7 +136,7 @@ final class ExamplesViewModel: ObservableObject { Media( title: media.title, description: "Token-protected video", - image: SRGDataProvider.current!.url(for: media.image, size: .large), + imageUrl: SRGDataProvider.current!.url(for: media.image, size: .large), type: .urn(media.urn), isMonoscopic: media.isMonoscopic ) diff --git a/Demo/Sources/Model/Media.swift b/Demo/Sources/Model/Media.swift index e002a61d6..ccdd158dd 100644 --- a/Demo/Sources/Model/Media.swift +++ b/Demo/Sources/Model/Media.swift @@ -4,10 +4,13 @@ // License information is available from the LICENSE file. // +import AVFoundation +import Combine import CoreMedia import Foundation import PillarboxCoreBusiness import PillarboxPlayer +import UIKit struct Media: Hashable { enum `Type`: Hashable { @@ -22,7 +25,8 @@ struct Media: Hashable { let title: String let description: String? - let image: URL? + let imageUrl: URL? + let image: UIImage? let type: `Type` let isMonoscopic: Bool let startTime: CMTime @@ -30,13 +34,15 @@ struct Media: Hashable { init( title: String, description: String? = nil, - image: URL? = nil, + imageUrl: URL? = nil, + image: UIImage? = nil, type: `Type`, isMonoscopic: Bool = false, startTime: CMTime = .zero ) { self.title = title self.description = description + self.imageUrl = imageUrl self.image = image self.type = type self.isMonoscopic = isMonoscopic @@ -47,7 +53,7 @@ struct Media: Hashable { self.init( title: template.title, description: template.description, - image: template.image, + imageUrl: template.imageUrl, type: template.type, isMonoscopic: template.isMonoscopic, startTime: startTime @@ -57,23 +63,11 @@ struct Media: Hashable { func playerItem() -> PlayerItem { switch type { case let .url(url): - return .simple(url: url, metadata: self, trackerAdapters: [ - DemoTracker.adapter { media in - DemoTracker.Metadata(title: media.title) - } - ]) { item in + return playerItem(for: url) { item in item.seek(at(startTime)) } case let .unbufferedUrl(url): - return .simple( - url: url, - metadata: self, - trackerAdapters: [ - DemoTracker.adapter { media in - DemoTracker.Metadata(title: media.title) - } - ] - ) { item in + return playerItem(for: url) { item in item.automaticallyPreservesTimeOffsetFromLive = true item.preferredForwardBufferDuration = 1 item.seek(at(startTime)) @@ -92,6 +86,33 @@ struct Media: Hashable { extension Media: AssetMetadata { func nowPlayingMetadata() -> NowPlayingMetadata { - .init(title: title, subtitle: description) + .init(title: title, subtitle: description, image: image) + } + + private func playerItem(for url: URL, configuration: @escaping (AVPlayerItem) -> Void = { _ in }) -> PlayerItem { + .init( + publisher: imagePublisher() + .map { image in + .simple( + url: url, + metadata: Media(title: title, description: description, image: image, type: type), + configuration: configuration + ) + }, + trackerAdapters: [ + DemoTracker.adapter { media in + DemoTracker.Metadata(title: media.title) + } + ] + ) + } + + private func imagePublisher() -> AnyPublisher { + guard let imageUrl else { return Just(nil).eraseToAnyPublisher() } + return URLSession.shared.dataTaskPublisher(for: imageUrl) + .map(\.data) + .map { UIImage(data: $0) } + .replaceError(with: nil) + .eraseToAnyPublisher() } } diff --git a/Demo/Sources/Model/Template.swift b/Demo/Sources/Model/Template.swift index 1e23df6c9..b75ab4868 100644 --- a/Demo/Sources/Model/Template.swift +++ b/Demo/Sources/Model/Template.swift @@ -6,13 +6,13 @@ import AVFoundation -private let kAppleImage = URL("https://www.apple.com/newsroom/images/default/apple-logo-og.jpg?202312141200") -private let kBitmovinImage = URL(""" +private let kAppleImageUrl = URL("https://www.apple.com/newsroom/images/default/apple-logo-og.jpg?202312141200") +private let kBitmovinImageUrl = URL(""" https://img.redbull.com/images/c_crop,w_3840,h_1920,x_0,y_0,f_auto,q_auto/c_scale,w_1200/redbullcom/tv/FO-1MR39KNMH2111/fo-1mr39knmh2111-featuremedia """) -private let kThreeSixtyImage = URL("https://www.rts.ch/2017/02/24/11/43/8414076.image/16x9") -private let kUnifiedStreamingImage1 = URL("https://mango.blender.org/wp-content/gallery/4k-renders/01_thom_celia_bridge.jpg") -private let kUnifiedStreamingImage2 = URL("https://website-storage.unified-streaming.com/images/_1200x630_crop_center-center_none/default-facebook.png") +private let kThreeSixtyImageUrl = URL("https://www.rts.ch/2017/02/24/11/43/8414076.image/16x9") +private let kUnifiedStreamingImageUrl1 = URL("https://mango.blender.org/wp-content/gallery/4k-renders/01_thom_celia_bridge.jpg") +private let kUnifiedStreamingImageUrl2 = URL("https://website-storage.unified-streaming.com/images/_1200x630_crop_center-center_none/default-facebook.png") // Apple streams are found at https://developer.apple.com/streaming/examples/ // Unified Streaming streams are found at https://demo.unified-streaming.com/k8s/features/stable/#!/hls @@ -20,108 +20,108 @@ enum URLTemplate { static let onDemandVideoHLS = Template( title: "Switzerland says sorry! The fondue invasion", description: "VOD - HLS", - image: "https://www.swissinfo.ch/srgscalableimage/47603560/16x9", + imageUrl: "https://www.swissinfo.ch/srgscalableimage/47603560/16x9", type: .url("https://swi-vod.akamaized.net/videoJson/47603186/master.m3u8") ) static let shortOnDemandVideoHLS = Template( title: "Des violents orages ont touché Ajaccio, chef-lieu de la Corse, jeudi", description: "VOD - HLS (short)", - image: "https://www.rts.ch/2022/08/18/12/38/13317144.image/16x9", + imageUrl: "https://www.rts.ch/2022/08/18/12/38/13317144.image/16x9", type: .url("https://rts-vod-amd.akamaized.net/ww/13317145/f1d49f18-f302-37ce-866c-1c1c9b76a824/master.m3u8") ) static let onDemandVideoMP4 = Template( title: "The dig", description: "VOD - MP4", // swiftlint:disable:next line_length - image: "https://www.swissinfo.ch/resource/image/47686506/landscape_ratio3x2/280/187/347ee14103b1b86184659b2fd04c69ba/8C028539EC620EFACC0BF2F61591E2F8/img_8527.jpg", + 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") ) static let liveVideoHLS = Template( title: "Couleur 3 en vidéo (live)", description: "Video livestream - HLS", - image: "https://img.rts.ch/audio/2010/image/924h3y-25865853.image?w=640&h=640", + imageUrl: "https://img.rts.ch/audio/2010/image/924h3y-25865853.image?w=640&h=640", type: .url("https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8?dw=0") ) static let dvrVideoHLS = Template( title: "Couleur 3 en vidéo (DVR)", description: "Video livestream with DVR - HLS", - image: "https://il.srgssr.ch/images/?imageUrl=https%3A%2F%2Fwww.rts.ch%2F2020%2F05%2F18%2F14%2F20%2F11333286.image%2F16x9&format=jpg&width=960", + imageUrl: "https://il.srgssr.ch/images/?imageUrl=https%3A%2F%2Fwww.rts.ch%2F2020%2F05%2F18%2F14%2F20%2F11333286.image%2F16x9&format=jpg&width=960", type: .url("https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8") ) static let liveTimestampVideoHLS = Template( title: "Tageschau", description: "Video livestream with DVR and timestamps - HLS", - image: "https://images.tagesschau.de/image/89045d82-5cd5-46ad-8f91-73911add30ee/AAABh3YLLz0/AAABibBx2rU/20x9-1280/tagesschau-logo-100.jpg", + imageUrl: "https://images.tagesschau.de/image/89045d82-5cd5-46ad-8f91-73911add30ee/AAABh3YLLz0/AAABibBx2rU/20x9-1280/tagesschau-logo-100.jpg", type: .url("https://tagesschau.akamaized.net/hls/live/2020115/tagesschau/tagesschau_1/master.m3u8") ) static let onDemandAudioMP3 = Template( title: "On en parle", description: "AOD - MP3", - image: "https://www.rts.ch/2023/09/28/17/49/11872957.image?w=624&h=351", + imageUrl: "https://www.rts.ch/2023/09/28/17/49/11872957.image?w=624&h=351", type: .url("https://rts-aod-dd.akamaized.net/ww/13306839/63cc2653-8305-3894-a448-108810b553ef.mp3") ) static let liveAudioMP3 = Template( title: "Couleur 3 (live)", description: "Audio livestream - MP3", - image: "https://img.rts.ch/articles/2017/image/cxsqgp-25867841.image?w=640&h=640", + imageUrl: "https://img.rts.ch/articles/2017/image/cxsqgp-25867841.image?w=640&h=640", type: .url("http://stream.srg-ssr.ch/m/couleur3/mp3_128") ) static let appleBasic_4_3_HLS = Template( title: "Apple Basic 4:3", description: "4x3 aspect ratio, H.264 @ 30Hz", - image: kAppleImage, + imageUrl: kAppleImageUrl, type: .url("https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8") ) static let appleBasic_16_9_TS_HLS = Template( title: "Apple Basic 16:9", description: "16x9 aspect ratio, H.264 @ 30Hz", - image: kAppleImage, + imageUrl: kAppleImageUrl, type: .url("https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8") ) static let appleAdvanced_16_9_TS_HLS = Template( title: "Apple Advanced 16:9 (TS)", description: "16x9 aspect ratio, H.264 @ 30Hz and 60Hz, Transport stream", - image: kAppleImage, + imageUrl: kAppleImageUrl, type: .url("https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8") ) static let appleAdvanced_16_9_fMP4_HLS = Template( title: "Apple Advanced 16:9 (fMP4)", description: "16x9 aspect ratio, H.264 @ 30Hz and 60Hz, Fragmented MP4", - image: kAppleImage, + imageUrl: kAppleImageUrl, type: .url("https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8") ) static let appleAdvanced_16_9_HEVC_h264_HLS = Template( title: "Apple Advanced 16:9 (HEVC/H.264)", description: "16x9 aspect ratio, H.264 and HEVC @ 30Hz and 60Hz", - image: kAppleImage, + imageUrl: kAppleImageUrl, type: .url("https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_adv_example_hevc/master.m3u8") ) static let appleWWDCKeynote2023 = Template( title: "Apple WWDC Keynote 2023", - image: "https://www.apple.com/v/apple-events/home/ac/images/overview/recent-events/gallery/jun-2023__cjqmmqlyd21y_large_2x.jpg", + imageUrl: "https://www.apple.com/v/apple-events/home/ac/images/overview/recent-events/gallery/jun-2023__cjqmmqlyd21y_large_2x.jpg", type: .url("https://events-delivery.apple.com/0105cftwpxxsfrpdwklppzjhjocakrsk/m3u8/vod_index-PQsoJoECcKHTYzphNkXohHsQWACugmET.m3u8") ) static let appleDolbyAtmos = Template( title: "Apple Dolby Atmos", - image: "https://is1-ssl.mzstatic.com/image/thumb/-6farfCY0YClFd7-z_qZbA/1000x563.jpg", + imageUrl: "https://is1-ssl.mzstatic.com/image/thumb/-6farfCY0YClFd7-z_qZbA/1000x563.jpg", type: .url("https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8") ) static let appleTvMorningShowSeason1Trailer = Template( title: "The Morning Show - My Way: Season 1", - image: "https://is1-ssl.mzstatic.com/image/thumb/cZUkXfqYmSy57DBI5TiTMg/1000x563.jpg", + imageUrl: "https://is1-ssl.mzstatic.com/image/thumb/cZUkXfqYmSy57DBI5TiTMg/1000x563.jpg", // swiftlint:disable:next line_length type: .url("https://play-edge.itunes.apple.com/WebObjects/MZPlayLocal.woa/hls/subscription/playlist.m3u8?cc=CH&svcId=tvs.vds.4021&a=1522121579&isExternal=true&brandId=tvs.sbd.4000&id=518077009&l=en-GB&aec=UHD") ) static let appleTvMorningShowSeason2Trailer = Template( title: "The Morning Show - Change: Season 2", - image: "https://is1-ssl.mzstatic.com/image/thumb/IxmmS1rQ7ouO-pKoJsVpGw/1000x563.jpg", + imageUrl: "https://is1-ssl.mzstatic.com/image/thumb/IxmmS1rQ7ouO-pKoJsVpGw/1000x563.jpg", // swiftlint:disable:next line_length type: .url("https://play-edge.itunes.apple.com/WebObjects/MZPlayLocal.woa/hls/subscription/playlist.m3u8?cc=CH&svcId=tvs.vds.4021&a=1568297173&isExternal=true&brandId=tvs.sbd.4000&id=518034010&l=en-GB&aec=UHD") ) static let uhdVideoHLS = Template( title: "Brain Farm Skate Phantom Flex", description: "4K video", - image: "https://i.ytimg.com/vi/d4_96ZWu3Vk/maxresdefault.jpg", + imageUrl: "https://i.ytimg.com/vi/d4_96ZWu3Vk/maxresdefault.jpg", type: .url("http://sample.vodobox.net/skate_phantom_flex_4k/skate_phantom_flex_4k.m3u8") ) static let onDemandVideoLocalHLS = Template( @@ -141,98 +141,98 @@ enum URLTemplate { ) static let bitmovinOnDemandMultipleTracks = Template( title: "Multiple subtitles and audio tracks", - image: "https://durian.blender.org/wp-content/uploads/2010/06/05.8b_comp_000272.jpg", + imageUrl: "https://durian.blender.org/wp-content/uploads/2010/06/05.8b_comp_000272.jpg", type: .url("https://bitmovin-a.akamaihd.net/content/sintel/hls/playlist.m3u8") ) static let bitmovinOnDemand_4K_HEVC = Template( title: "4K, HEVC", - image: "https://peach.blender.org/wp-content/uploads/bbb-splash.png", + imageUrl: "https://peach.blender.org/wp-content/uploads/bbb-splash.png", type: .url("https://cdn.bitmovin.com/content/encoding_test_dash_hls/4k/hls/4k_profile/master.m3u8") ) static let bitmovinOnDemandSingleAudio = Template( title: "VoD, single audio track", - image: kBitmovinImage, + imageUrl: kBitmovinImageUrl, type: .url("https://bitmovin-a.akamaihd.net/content/MI201109210084_1/m3u8s/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8") ) static let bitmovinOnDemandAES128 = Template( title: "AES-128", - image: kBitmovinImage, + imageUrl: kBitmovinImageUrl, type: .url("https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/m3u8s/11331.m3u8") ) static let bitmovinOnDemandProgressive = Template( title: "AVC Progressive", - image: kBitmovinImage, + imageUrl: kBitmovinImageUrl, type: .url("https://bitmovin-a.akamaihd.net/content/MI201109210084_1/MI201109210084_mpeg-4_hd_high_1080p25_10mbits.mp4") ) static let unifiedStreamingOnDemand_fMP4 = Template( title: "Fragmented MP4", - image: kUnifiedStreamingImage1, + imageUrl: kUnifiedStreamingImageUrl1, type: .url("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8") ) static let unifiedStreamingOnDemandKeyRotation = Template( title: "Key Rotation", - image: kUnifiedStreamingImage2, + imageUrl: kUnifiedStreamingImageUrl2, type: .url("https://demo.unified-streaming.com/k8s/keyrotation/stable/keyrotation/keyrotation.isml/.m3u8") ) static let unifiedStreamingOnDemandAlternateAudio = Template( title: "Alternate audio language", - image: kUnifiedStreamingImage1, + imageUrl: kUnifiedStreamingImageUrl1, type: .url("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel-multi-lang.ism/.m3u8") ) static let unifiedStreamingOnDemandAudioOnly = Template( title: "Audio only", - image: kUnifiedStreamingImage1, + imageUrl: kUnifiedStreamingImageUrl1, type: .url("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel-multi-lang.ism/.m3u8?filter=(type!=%22video%22)") ) static let unifiedStreamingOnDemandTrickplay = Template( title: "Trickplay", - image: kUnifiedStreamingImage1, + imageUrl: kUnifiedStreamingImageUrl1, type: .url("https://demo.unified-streaming.com/k8s/features/stable/no-handler-origin/tears-of-steel/tears-of-steel-trickplay.m3u8") ) static let unifiedStreamingOnDemandLimitedBandwidth = Template( title: "Limiting bandwidth use", - image: kUnifiedStreamingImage1, + imageUrl: kUnifiedStreamingImageUrl1, type: .url("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8?max_bitrate=800000") ) static let unifiedStreamingOnDemandDynamicTrackSelection = Template( title: "Dynamic Track Selection", - image: kUnifiedStreamingImage1, + imageUrl: kUnifiedStreamingImageUrl1, // swiftlint:disable:next line_length type: .url("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8?filter=%28type%3D%3D%22audio%22%26%26systemBitrate%3C100000%29%7C%7C%28type%3D%3D%22video%22%26%26systemBitrate%3C1024000%29") ) static let unifiedStreamingPureLive = Template( title: "Pure live", - image: kUnifiedStreamingImage2, + imageUrl: kUnifiedStreamingImageUrl2, type: .url("https://demo.unified-streaming.com/k8s/live/stable/live.isml/.m3u8") ) static let unifiedStreamingTimeshift = Template( title: "Timeshift (5 minutes)", - image: kUnifiedStreamingImage2, + imageUrl: kUnifiedStreamingImageUrl2, type: .url("https://demo.unified-streaming.com/k8s/live/stable/live.isml/.m3u8?time_shift=300") ) static let unifiedStreamingLiveAudio = Template( title: "Live audio", - image: kUnifiedStreamingImage2, + imageUrl: kUnifiedStreamingImageUrl2, type: .url("https://demo.unified-streaming.com/k8s/live/stable/live.isml/.m3u8?filter=(type!=%22video%22)") ) static let unifiedStreamingPureLiveScte35 = Template( title: "Pure live (scte35)", - image: kUnifiedStreamingImage2, + imageUrl: kUnifiedStreamingImageUrl2, type: .url("https://demo.unified-streaming.com/k8s/live/stable/scte35.isml/.m3u8") ) static let unifiedStreamingOnDemand_fMP4_Clear = Template( title: "fMP4, clear", - image: kUnifiedStreamingImage1, + imageUrl: kUnifiedStreamingImageUrl1, type: .url("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel-fmp4.ism/.m3u8") ) static let unifiedStreamingOnDemand_fMP4_HEVC_4K = Template( title: "fMP4, HEVC 4K", - image: kUnifiedStreamingImage1, + imageUrl: kUnifiedStreamingImageUrl1, type: .url("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel-hevc.ism/.m3u8") ) static let bitmovin_360 = Template( title: "Bitmovin 360°", - image: kThreeSixtyImage, + imageUrl: kThreeSixtyImageUrl, type: .url("https://cdn.bitmovin.com/content/assets/playhouse-vr/m3u8s/105560.m3u8"), isMonoscopic: true ) @@ -241,52 +241,52 @@ enum URLTemplate { enum URNTemplate { static let onDemandHorizontalVideo = Template( title: "Horizontal video", - image: "https://www.rts.ch/2015/05/28/20/19/6820735.image/16x9", + imageUrl: "https://www.rts.ch/2015/05/28/20/19/6820735.image/16x9", type: .urn("urn:rts:video:6820736") ) static let onDemandSquareVideo = Template( title: "Square video", - image: "https://www.rts.ch/2017/02/16/07/08/8393235.image/16x9", + imageUrl: "https://www.rts.ch/2017/02/16/07/08/8393235.image/16x9", type: .urn("urn:rts:video:8393241") ) static let onDemandVerticalVideo = Template( title: "Vertical video", - image: "https://www.rts.ch/2022/10/06/17/32/13444380.image/4x5", + imageUrl: "https://www.rts.ch/2022/10/06/17/32/13444380.image/4x5", type: .urn("urn:rts:video:13444390") ) static let onDemandVideo = Template( title: "A bon entendeur", - image: "https://www.rts.ch/2023/06/13/21/47/14071626.image/16x9", + imageUrl: "https://www.rts.ch/2023/06/13/21/47/14071626.image/16x9", type: .urn("urn:rts:video:14080915") ) static let liveVideo = Template( title: "RSI 1", description: "Live video", - image: "https://ws.srf.ch/asset/image/audio/6b100f6e-440c-4bfb-8372-89a0688c533a/EPISODE_IMAGE", + imageUrl: "https://ws.srf.ch/asset/image/audio/6b100f6e-440c-4bfb-8372-89a0688c533a/EPISODE_IMAGE", type: .urn("urn:rsi:video:livestream_La1") ) static let dvrVideo = Template( title: "RTS 1", description: "DVR video livestream", - image: "https://www.rts.ch/2023/09/06/14/43/14253742.image/16x9", + imageUrl: "https://www.rts.ch/2023/09/06/14/43/14253742.image/16x9", type: .urn("urn:rts:video:3608506") ) static let dvrAudio = Template( title: "Couleur 3 (DVR)", description: "DVR audio livestream", - image: "https://img.rts.ch/articles/2017/image/cxsqgp-25867841.image?w=640&h=640", + imageUrl: "https://img.rts.ch/articles/2017/image/cxsqgp-25867841.image?w=640&h=640", type: .urn("urn:rts:audio:3262363") ) static let gothard_360 = Template( title: "Gothard 360°", - image: kThreeSixtyImage, + imageUrl: kThreeSixtyImageUrl, type: .urn("urn:rts:video:8414077"), isMonoscopic: true ) static let expired = Template( title: "Expired URN", description: "Content that is not available anymore", - image: "https://www.rts.ch/2022/09/20/09/57/13365589.image/16x9", + imageUrl: "https://www.rts.ch/2022/09/20/09/57/13365589.image/16x9", type: .urn("urn:rts:video:13382911") ) static let unknown = Template( @@ -300,13 +300,13 @@ enum UnbufferedURLTemplate { static let liveVideo = Template( title: "Couleur 3 en direct", description: "Live video (unbuffered)", - image: "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", + imageUrl: "https://www.rts.ch/2020/05/18/14/20/11333286.image/16x9", type: .unbufferedUrl("https://rtsc3video.akamaized.net/hls/live/2042837/c3video/3/playlist.m3u8?dw=0") ) static let liveAudio = Template( title: "Couleur 3 en direct", description: "Audio livestream (unbuffered)", - image: "https://img.rts.ch/articles/2017/image/cxsqgp-25867841.image?w=320&h=320", + imageUrl: "https://img.rts.ch/articles/2017/image/cxsqgp-25867841.image?w=320&h=320", type: .unbufferedUrl("http://stream.srg-ssr.ch/m/couleur3/mp3_128") ) } @@ -314,14 +314,14 @@ enum UnbufferedURLTemplate { struct Template: Hashable { let title: String let description: String? - let image: URL? + let imageUrl: URL? let type: Media.`Type` let isMonoscopic: Bool - init(title: String, description: String? = nil, image: URL? = nil, type: Media.`Type`, isMonoscopic: Bool = false) { + init(title: String, description: String? = nil, imageUrl: URL? = nil, type: Media.`Type`, isMonoscopic: Bool = false) { self.title = title self.description = description - self.image = image + self.imageUrl = imageUrl self.type = type self.isMonoscopic = isMonoscopic } diff --git a/Demo/Sources/Search/SearchView.swift b/Demo/Sources/Search/SearchView.swift index 87af68c84..426ab810e 100644 --- a/Demo/Sources/Search/SearchView.swift +++ b/Demo/Sources/Search/SearchView.swift @@ -47,7 +47,7 @@ struct SearchView: View { size: .init(width: 520, height: 300), title: constant(iOS: MediaDescription.title(for: media), tvOS: media.show?.title), subtitle: constant(iOS: MediaDescription.subtitle(for: media), tvOS: media.title), - image: SRGDataProvider.current!.url(for: media.image, size: .large), + imageUrl: SRGDataProvider.current!.url(for: media.image, size: .large), type: MediaDescription.systemImage(for: media), duration: MediaDescription.duration(for: media), date: MediaDescription.date(for: media), diff --git a/Demo/Sources/Views/Cell.swift b/Demo/Sources/Views/Cell.swift index 9c09a989e..91b9dde34 100644 --- a/Demo/Sources/Views/Cell.swift +++ b/Demo/Sources/Views/Cell.swift @@ -11,7 +11,7 @@ struct Cell: View { let size: CGSize let title: String? let subtitle: String? - let image: URL? + let imageUrl: URL? let type: String? let duration: String? let date: String? @@ -39,7 +39,7 @@ struct Cell: View { size: size, title: title, subtitle: subtitle, - image: image, + imageUrl: imageUrl, type: type, duration: duration, date: date @@ -55,7 +55,7 @@ struct Cell: View { size: CGSize = .init(width: 450, height: 250), title: String?, subtitle: String? = nil, - image: URL? = nil, + imageUrl: URL? = nil, type: String? = nil, duration: String? = nil, date: String? = nil, @@ -65,7 +65,7 @@ struct Cell: View { self.size = size self.title = title self.subtitle = subtitle - self.image = image + self.imageUrl = imageUrl self.type = type self.duration = duration self.date = date diff --git a/Demo/Sources/Views/MediaCardView.swift b/Demo/Sources/Views/MediaCardView.swift index 4540c86cb..6371edce5 100644 --- a/Demo/Sources/Views/MediaCardView.swift +++ b/Demo/Sources/Views/MediaCardView.swift @@ -71,14 +71,14 @@ struct MediaCardView: View { let size: CGSize let title: String? let subtitle: String? - let image: URL? + let imageUrl: URL? let type: String? let duration: String? let date: String? var body: some View { ZStack(alignment: .center) { - AsyncImage(url: image) { phase in + AsyncImage(url: imageUrl) { phase in switch phase { case .success(let image): image @@ -107,7 +107,7 @@ struct MediaCardView: View { size: CGSize = .init(width: 570, height: 350), title: String?, subtitle: String? = nil, - image: URL? = nil, + imageUrl: URL? = nil, type: String? = nil, duration: String? = nil, date: String? = nil @@ -115,7 +115,7 @@ struct MediaCardView: View { self.size = size self.title = title self.subtitle = subtitle - self.image = image + self.imageUrl = imageUrl self.type = type self.duration = duration self.date = date diff --git a/Sources/Player/Asset.swift b/Sources/Player/Asset.swift index dab3f9c42..29dff68c2 100644 --- a/Sources/Player/Asset.swift +++ b/Sources/Player/Asset.swift @@ -151,8 +151,13 @@ public struct Asset: Assetable where M: AssetMetadata { func playerItem() -> AVPlayerItem { let item = resource.playerItem().withId(id) configuration(item) + update(item: item) return item } + + func update(item: AVPlayerItem) { + item.externalMetadata = Self.externalMetadata(from: metadata?.nowPlayingMetadata()) + } } public extension Asset where M == Never { @@ -244,6 +249,27 @@ extension Asset { } } +private extension Asset { + static func externalMetadata(from metadata: NowPlayingMetadata?) -> [AVMetadataItem] { + [ + metadataItem(for: .commonIdentifierTitle, value: metadata?.title), + metadataItem(for: .iTunesMetadataTrackSubTitle, value: metadata?.subtitle), + metadataItem(for: .commonIdentifierArtwork, value: metadata?.image?.pngData()), + metadataItem(for: .commonIdentifierDescription, value: metadata?.description) + ] + .compactMap { $0 } + } + + private static func metadataItem(for identifier: AVMetadataIdentifier, value: T?) -> AVMetadataItem? { + guard let value else { return nil } + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + item.extendedLanguageTag = "und" + return item.copy() as? AVMetadataItem + } +} + extension AVPlayerItem { /// An identifier for player items delivered by the same data source. var id: UUID? { diff --git a/Sources/Player/Interfaces/Assetable.swift b/Sources/Player/Interfaces/Assetable.swift index a1c49a59b..2307ddca3 100644 --- a/Sources/Player/Interfaces/Assetable.swift +++ b/Sources/Player/Interfaces/Assetable.swift @@ -17,6 +17,7 @@ protocol Assetable { func nowPlayingInfo() -> NowPlayingInfo func playerItem() -> AVPlayerItem + func update(item: AVPlayerItem) } extension Assetable { @@ -41,7 +42,9 @@ extension AVPlayerItem { ) -> [AVPlayerItem] { guard let currentItem else { return playerItems(from: currentAssets) } if let currentIndex = matchingIndex(for: currentItem, in: currentAssets) { - if findAsset(for: currentItem, in: previousAssets, equalTo: currentAssets[currentIndex]) { + let currentAsset = currentAssets[currentIndex] + if findAsset(currentAsset, in: previousAssets) { + currentAsset.update(item: currentItem) return [currentItem] + playerItems(from: Array(currentAssets.suffix(from: currentIndex + 1))) } else { @@ -73,17 +76,13 @@ extension AVPlayerItem { return matchingIndex(for: match, in: other) } - private static func matchingAsset(for item: AVPlayerItem, in assets: [any Assetable]) -> (any Assetable)? { - assets.first { $0.matches(item) } - } - private static func matchingIndex(for asset: any Assetable, in assets: [any Assetable]) -> Int? { assets.firstIndex { $0.id == asset.id } } - private static func findAsset(for item: AVPlayerItem, in assets: [any Assetable], equalTo other: any Assetable) -> Bool { - guard let match = matchingAsset(for: item, in: assets) else { return false } - return match.resource == other.resource + private static func findAsset(_ asset: any Assetable, in assets: [any Assetable]) -> Bool { + guard let match = assets.first(where: { $0.id == asset.id }) else { return false } + return match.resource == asset.resource } private static func firstCommonIndex(in assets: [any Assetable], matching other: [any Assetable], after item: AVPlayerItem) -> Int? { diff --git a/Sources/Player/Player.swift b/Sources/Player/Player.swift index a616832cb..557ab2d3a 100644 --- a/Sources/Player/Player.swift +++ b/Sources/Player/Player.swift @@ -233,10 +233,10 @@ private extension Player { func configureQueueUpdatePublisher() { assetsPublisher() .withPrevious() - .map { [queuePlayer] sources in + .map { [queuePlayer] assets in AVPlayerItem.playerItems( - for: sources.current, - replacing: sources.previous ?? [], + for: assets.current, + replacing: assets.previous ?? [], currentItem: queuePlayer.currentItem ) } diff --git a/Sources/Player/UserInterface/BasicSystemVideoView.swift b/Sources/Player/UserInterface/BasicSystemVideoView.swift index d82b1137d..ef0add5ee 100644 --- a/Sources/Player/UserInterface/BasicSystemVideoView.swift +++ b/Sources/Player/UserInterface/BasicSystemVideoView.swift @@ -20,6 +20,9 @@ struct BasicSystemVideoView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> AVPlayerViewController { let controller = AVPlayerViewController() controller.allowsPictureInPicturePlayback = false +#if os(tvOS) + controller.playbackControlsIncludeInfoViews = true +#endif return controller } diff --git a/Tests/PlayerTests/Asset/AssetCreationTests.swift b/Tests/PlayerTests/Asset/AssetCreationTests.swift index 93bd2b08b..5092ba241 100644 --- a/Tests/PlayerTests/Asset/AssetCreationTests.swift +++ b/Tests/PlayerTests/Asset/AssetCreationTests.swift @@ -59,6 +59,7 @@ final class AssetCreationTests: TestCase { expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate))) expect(asset.nowPlayingInfo()).notTo(beEmpty()) expect(asset.playerItem().preferredForwardBufferDuration).to(equal(4)) + expect(asset.playerItem().externalMetadata).toNot(beEmpty()) } func testCustomAssetWithoutConfiguration() { diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index b1f8063d7..61bc7d341 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -26,10 +26,6 @@ Most of these tools can easily be installed with [Homebrew](https://brew.sh). The project provides a demo application which can be run to test features offered by the Pillarbox package. -## Playground - -A playground can be used to visualize Swift body refreshes. - ## Unit tests Unit tests are provided with the Pillarbox package. Since Apple players [cannot play local manifests](https://developer.apple.com/forums/thread/69357?answerId=202051022#202051022) we are using Python to run a simple [web server](https://docs.python.org/3/library/http.server.html) serving various [test streams](TEST_STREAM_GENERATION.md) from a local directory.