diff --git a/Demo/Sources/Examples/ExamplesViewModel.swift b/Demo/Sources/Examples/ExamplesViewModel.swift index 4863226ba..2bb096391 100644 --- a/Demo/Sources/Examples/ExamplesViewModel.swift +++ b/Demo/Sources/Examples/ExamplesViewModel.swift @@ -45,7 +45,8 @@ final class ExamplesViewModel: ObservableObject { URLTemplate.appleAdvanced_16_9_TS_HLS, URLTemplate.appleAdvanced_16_9_fMP4_HLS, URLTemplate.appleAdvanced_16_9_HEVC_h264_HLS, - URLTemplate.appleWWDCKeynote2023 + URLTemplate.appleWWDCKeynote2023, + URLTemplate.appleDolbyAtmos ]) let thirdPartyMedias = Template.medias(from: [ diff --git a/Demo/Sources/Model/Template.swift b/Demo/Sources/Model/Template.swift index 43a98a912..79937c5d2 100644 --- a/Demo/Sources/Model/Template.swift +++ b/Demo/Sources/Model/Template.swift @@ -85,6 +85,10 @@ enum URLTemplate { title: "Apple WWDC Keynote 2023", type: .url("https://events-delivery.apple.com/0105cftwpxxsfrpdwklppzjhjocakrsk/m3u8/vod_index-PQsoJoECcKHTYzphNkXohHsQWACugmET.m3u8") ) + static let appleDolbyAtmos = Template( + title: "Apple Dolby Atmos", + type: .url("https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8") + ) static let uhdVideoHLS = Template( title: "Brain Farm Skate Phantom Flex", description: "4K video", @@ -93,11 +97,11 @@ enum URLTemplate { static let onDemandVideoLocalHLS = Template( title: "Test video pattern", description: "Stream served locally", - type: .url("http://localhost:8123/single/on_demand/master.m3u8") + type: .url("http://localhost:8123/simple/on_demand/master.m3u8") ) static let unknown = Template( title: "Unknown URL", - type: .url("http://localhost:8123/single/unavailable/master.m3u8") + type: .url("http://localhost:8123/simple/unavailable/master.m3u8") ) static let bitmovinOnDemandMultipleTracks = Template( title: "Multiple subtitles and audio tracks", diff --git a/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift b/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift index 53f6178a2..1044d2ad1 100644 --- a/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift +++ b/Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift @@ -31,6 +31,7 @@ final class PlaylistViewModel: ObservableObject { URLTemplate.appleAdvanced_16_9_fMP4_HLS, URLTemplate.appleAdvanced_16_9_HEVC_h264_HLS, URLTemplate.appleWWDCKeynote2023, + URLTemplate.appleDolbyAtmos, URLTemplate.uhdVideoHLS, URNTemplate.expired, URNTemplate.unknown diff --git a/Scripts/test-streams.sh b/Scripts/test-streams.sh index 4b800e31e..41e213cac 100755 --- a/Scripts/test-streams.sh +++ b/Scripts/test-streams.sh @@ -29,8 +29,8 @@ function serve_test_streams { local sources_dir="$dest_dir/sources" generate_sources "$sources_dir" - generate_single_variant_streams "$sources_dir" "$dest_dir/single" - generate_multi_variant_streams "$sources_dir" "$dest_dir/multi" + generate_simple_streams "$sources_dir" "$dest_dir/simple" + generate_packaged_streams "$sources_dir" "$dest_dir/packaged" serve_directory "$dest_dir" } @@ -47,7 +47,7 @@ function generate_sources { ffmpeg -f lavfi -i anullsrc=duration=4:channel_layout=stereo:sample_rate=44100 -metadata:s:a:0 language=fre "$dest_dir/source_audio_fre.mp4" > /dev/null 2>&1 } -function generate_single_variant_streams { +function generate_simple_streams { local src_dir="$1" local dest_dir="$2" @@ -87,27 +87,33 @@ function generate_single_variant_streams { -f hls -hls_time 1 -hls_list_size 20 -hls_flags delete_segments+round_durations "$dvr_dir/master.m3u8" > /dev/null 2>&1 & } -function generate_multi_variant_streams { +function generate_packaged_streams { local src_dir="$1" local dest_dir="$2" mkdir -p "$dest_dir" - local on_demand_with_tracks_dir="$dest_dir/on_demand_with_tracks" + local on_demand_with_options_dir="$dest_dir/on_demand_with_options" shaka-packager \ - "in=$src_dir/source_640x360.mp4,stream=video,segment_template=$on_demand_with_tracks_dir/640x360/\$Number\$.ts" \ - "in=$src_dir/source_audio_eng.mp4,stream=audio,segment_template=$on_demand_with_tracks_dir/audio_eng/\$Number\$.ts,lang=en,hls_name=English" \ - "in=$src_dir/source_audio_fre.mp4,stream=audio,segment_template=$on_demand_with_tracks_dir/audio_fre/\$Number\$.ts,lang=fr,hls_name=Français" \ - "in=$src_dir/source_audio_eng.mp4,stream=audio,segment_template=$on_demand_with_tracks_dir/audio_eng_ad/\$Number\$.ts,lang=en,hls_name=English (AD),hls_characteristics=public.accessibility.describes-video" \ - "in=$SUBTITLES_DIR/subtitles_en.webvtt,stream=text,segment_template=$on_demand_with_tracks_dir/subtitles_en/\$Number\$.vtt,lang=en,hls_name=English" \ - "in=$SUBTITLES_DIR/subtitles_fr.webvtt,stream=text,segment_template=$on_demand_with_tracks_dir/subtitles_fr/\$Number\$.vtt,lang=fr,hls_name=Français" \ - "in=$SUBTITLES_DIR/subtitles_ja.webvtt,stream=text,segment_template=$on_demand_with_tracks_dir/subtitles_ja/\$Number\$.vtt,lang=ja,hls_name=日本語" \ - --hls_master_playlist_output "$on_demand_with_tracks_dir/master.m3u8" > /dev/null 2>&1 - - local on_demand_without_tracks_dir="$dest_dir/on_demand_without_tracks" + "in=$src_dir/source_640x360.mp4,stream=video,segment_template=$on_demand_with_options_dir/640x360/\$Number\$.ts" \ + "in=$src_dir/source_audio_eng.mp4,stream=audio,segment_template=$on_demand_with_options_dir/audio_eng/\$Number\$.ts,lang=en,hls_name=English" \ + "in=$src_dir/source_audio_fre.mp4,stream=audio,segment_template=$on_demand_with_options_dir/audio_fre/\$Number\$.ts,lang=fr,hls_name=Français" \ + "in=$src_dir/source_audio_eng.mp4,stream=audio,segment_template=$on_demand_with_options_dir/audio_eng_ad/\$Number\$.ts,lang=en,hls_name=English (AD),hls_characteristics=public.accessibility.describes-video" \ + "in=$SUBTITLES_DIR/subtitles_en.webvtt,stream=text,segment_template=$on_demand_with_options_dir/subtitles_en/\$Number\$.vtt,lang=en,hls_name=English" \ + "in=$SUBTITLES_DIR/subtitles_fr.webvtt,stream=text,segment_template=$on_demand_with_options_dir/subtitles_fr/\$Number\$.vtt,lang=fr,hls_name=Français" \ + "in=$SUBTITLES_DIR/subtitles_ja.webvtt,stream=text,segment_template=$on_demand_with_options_dir/subtitles_ja/\$Number\$.vtt,lang=ja,hls_name=日本語" \ + --hls_master_playlist_output "$on_demand_with_options_dir/master.m3u8" > /dev/null 2>&1 + + local on_demand_without_options_dir="$dest_dir/on_demand_without_options" shaka-packager \ - "in=$src_dir/source_640x360.mp4,stream=video,segment_template=$on_demand_without_tracks_dir/640x360/\$Number\$.ts" \ - --hls_master_playlist_output "$on_demand_without_tracks_dir/master.m3u8" > /dev/null 2>&1 + "in=$src_dir/source_640x360.mp4,stream=video,segment_template=$on_demand_without_options_dir/640x360/\$Number\$.ts" \ + --hls_master_playlist_output "$on_demand_without_options_dir/master.m3u8" > /dev/null 2>&1 + + local on_demand_with_single_audible_option_dir="$dest_dir/on_demand_with_single_audible_option" + shaka-packager \ + "in=$src_dir/source_640x360.mp4,stream=video,segment_template=$on_demand_with_single_audible_option_dir/640x360/\$Number\$.ts" \ + "in=$src_dir/source_audio_eng.mp4,stream=audio,segment_template=$on_demand_with_single_audible_option_dir/audio_eng/\$Number\$.ts,lang=en,hls_name=English" \ + --hls_master_playlist_output "$on_demand_with_single_audible_option_dir/master.m3u8" > /dev/null 2>&1 } function serve_directory { diff --git a/Sources/Player/Extensions/AVMediaSelectionGroup.swift b/Sources/Player/Extensions/AVMediaSelectionGroup.swift index e18fe3264..cbfae60f5 100644 --- a/Sources/Player/Extensions/AVMediaSelectionGroup.swift +++ b/Sources/Player/Extensions/AVMediaSelectionGroup.swift @@ -7,9 +7,20 @@ import AVFoundation extension AVMediaSelectionGroup { - var sortedOptions: [AVMediaSelectionOption] { - options.sorted { lhsOption, rhsOption in - lhsOption.displayName.localizedCaseInsensitiveCompare(rhsOption.displayName) == .orderedAscending - } + static func sortedMediaSelectionOptions(from options: [AVMediaSelectionOption]) -> [AVMediaSelectionOption] { + options.sorted(by: <) + } + + static func sortedMediaSelectionOptions( + from options: [AVMediaSelectionOption], + withoutMediaCharacteristics characteristics: [AVMediaCharacteristic] + ) -> [AVMediaSelectionOption] { + mediaSelectionOptions(from: options, withoutMediaCharacteristics: characteristics).sorted(by: <) + } +} + +private extension AVMediaSelectionOption { + static func < (_ lhs: AVMediaSelectionOption, _ rhs: AVMediaSelectionOption) -> Bool { + lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending } } diff --git a/Sources/Player/Extensions/AVMediaSelectionOption.swift b/Sources/Player/Extensions/AVMediaSelectionOption.swift new file mode 100644 index 000000000..2ab7bca85 --- /dev/null +++ b/Sources/Player/Extensions/AVMediaSelectionOption.swift @@ -0,0 +1,13 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +extension AVMediaSelectionOption { + var languageCode: String? { + locale?.language.languageCode?.identifier + } +} diff --git a/Sources/Player/Interfaces/MediaSelectionGroup.swift b/Sources/Player/Interfaces/MediaSelectionGroup.swift deleted file mode 100644 index fec464f06..000000000 --- a/Sources/Player/Interfaces/MediaSelectionGroup.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -import AVFoundation - -protocol MediaSelectionGroup { - var options: [MediaSelectionOption] { get } - - init(group: AVMediaSelectionGroup) - - func selectedMediaOption(in selection: AVMediaSelection) -> MediaSelectionOption - func select(mediaOption: MediaSelectionOption, in item: AVPlayerItem) -} diff --git a/Sources/Player/Interfaces/MediaSelector.swift b/Sources/Player/Interfaces/MediaSelector.swift new file mode 100644 index 000000000..e793d7eec --- /dev/null +++ b/Sources/Player/Interfaces/MediaSelector.swift @@ -0,0 +1,31 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation + +/// A protocol for media selection logic. +protocol MediaSelector { + /// The associated selection group. + var group: AVMediaSelectionGroup { get } + + /// Creates the selector. + init(group: AVMediaSelectionGroup) + + /// The available options. + func mediaSelectionOptions() -> [MediaSelectionOption] + + /// The available media option matching the provided selection. + func selectedMediaOption(in selection: AVMediaSelection) -> MediaSelectionOption + + /// Selects the provided option, applying it on the specified item. + func select(mediaOption: MediaSelectionOption, on item: AVPlayerItem) +} + +extension MediaSelector { + func supports(mediaSelectionOption: MediaSelectionOption) -> Bool { + mediaSelectionOptions().contains(mediaSelectionOption) + } +} diff --git a/Sources/Player/Player+MediaSelection.swift b/Sources/Player/Player+MediaSelection.swift index 5331acff1..26d23da9d 100644 --- a/Sources/Player/Player+MediaSelection.swift +++ b/Sources/Player/Player+MediaSelection.swift @@ -10,39 +10,52 @@ import SwiftUI public extension Player { /// The set of media characteristics for which a media selection is available. var mediaSelectionCharacteristics: Set { - mediaSelector.characteristics + mediaSelectionContext.characteristics } /// The list of media options associated with a characteristic. /// - /// Use `mediaCharacteristics` to retrieve available characteristics. - /// /// - Parameter characteristic: The characteristic. - /// - Returns: The list of options associated with the characteristic of an empty array if none. + /// - Returns: The list of options associated with the characteristic. + /// + /// Use `mediaCharacteristics` to retrieve available characteristics. func mediaSelectionOptions(for characteristic: AVMediaCharacteristic) -> [MediaSelectionOption] { - mediaSelector.options(for: characteristic) + guard let selector = mediaSelector(for: characteristic) else { return [] } + return selector.mediaSelectionOptions() } /// The currently selected media option for a characteristic. /// - /// Use `mediaCharacteristics` to retrieve available characteristics. - /// /// - Parameter characteristic: The characteristic. - /// - Returns: The selected option or `nil` if none. + /// - Returns: The selected option. + /// + /// Returns the selection based on [Media Accessibility](https://developer.apple.com/documentation/mediaaccessibility). + /// + /// You can use `mediaCharacteristics` to retrieve available characteristics. func selectedMediaOption(for characteristic: AVMediaCharacteristic) -> MediaSelectionOption { - mediaSelector.selectedMediaOption(for: characteristic) + guard let selection = mediaSelectionContext.selection, let selector = mediaSelector(for: characteristic) else { + return .off + } + let option = selector.selectedMediaOption(in: selection) + return selector.supports(mediaSelectionOption: option) ? option : .off } /// Selects a media option for a characteristic. /// - /// This method does nothing if the provided option is not associated with the characteristic. Use `mediaCharacteristics` - /// to retrieve available characteristics. - /// /// - Parameters: /// - mediaOption: The option to select. /// - characteristic: The characteristic. + /// + /// Sets the selection using [Media Accessibility](https://developer.apple.com/documentation/mediaaccessibility). + /// + /// You can use `mediaCharacteristics` to retrieve available characteristics. This method does nothing if attempting + /// to set an option that is not supported. func select(mediaOption: MediaSelectionOption, for characteristic: AVMediaCharacteristic) { - mediaSelector.select(mediaOption: mediaOption, for: characteristic, in: queuePlayer.currentItem) + guard let item = queuePlayer.currentItem, let selector = mediaSelector(for: characteristic), + selector.supports(mediaSelectionOption: mediaOption) else { + return + } + selector.select(mediaOption: mediaOption, on: item) } /// A binding to read and write the current media selection for a characteristic. @@ -56,4 +69,29 @@ public extension Player { self.select(mediaOption: newValue, for: characteristic) } } + + /// The current media option for a characteristic. + /// + /// - Parameter characteristic: The characteristic. + /// - Returns: The current option or `nil` if none. + /// + /// Unlike `selectedMediaOption(for:)` this method provides the currently applied option. This method can + /// be useful if you need to access the actual selection made by `select(mediaOption:for:)` for `.automatic` + /// and `.off` options. Forced options might be returned where applicable. + func currentMediaOption(for characteristic: AVMediaCharacteristic) -> MediaSelectionOption { + guard let option = mediaSelectionContext.selectedOption(for: characteristic) else { return .off } + return .on(option) + } + + private func mediaSelector(for characteristic: AVMediaCharacteristic) -> MediaSelector? { + guard let group = mediaSelectionContext.group(for: characteristic) else { return nil } + switch characteristic { + case .audible: + return AudibleMediaSelector(group: group) + case .legible: + return LegibleMediaSelector(group: group) + default: + return nil + } + } } diff --git a/Sources/Player/Player.swift b/Sources/Player/Player.swift index 85bd62bbc..3ffee8457 100644 --- a/Sources/Player/Player.swift +++ b/Sources/Player/Player.swift @@ -54,7 +54,7 @@ public final class Player: ObservableObject, Equatable { } @Published var _playbackSpeed: PlaybackSpeed = .indefinite - @Published var mediaSelector: MediaSelector = .empty + @Published var mediaSelectionContext: MediaSelectionContext = .empty @Published var currentItem: CurrentItem = .good(nil) @Published var storedItems: Deque @@ -209,7 +209,7 @@ public final class Player: ObservableObject, Equatable { configurePresentationSizePublisher() configureMutedPublisher() configurePlaybackSpeedPublisher() - configureMediaSelectorPublisher() + configureMediaSelectionContextPublisher() } deinit { @@ -328,10 +328,10 @@ private extension Player { .assign(to: &$_playbackSpeed) } - func configureMediaSelectorPublisher() { - queuePlayer.currentItemMediaSelectorPublisher() + func configureMediaSelectionContextPublisher() { + queuePlayer.currentItemMediaSelectionContextPublisher() .receiveOnMainThread() - .assign(to: &$mediaSelector) + .assign(to: &$mediaSelectionContext) } } diff --git a/Sources/Player/Publishers/AVPlayerItemPublishers.swift b/Sources/Player/Publishers/AVPlayerItemPublishers.swift index 6cd22818f..24dbc6025 100644 --- a/Sources/Player/Publishers/AVPlayerItemPublishers.swift +++ b/Sources/Player/Publishers/AVPlayerItemPublishers.swift @@ -93,12 +93,12 @@ extension AVPlayerItem { .eraseToAnyPublisher() } - func mediaSelectorPublisher() -> AnyPublisher { + func mediaSelectionContextPublisher() -> AnyPublisher { Publishers.CombineLatest( asset.mediaSelectionGroupsPublisher(), mediaSelectionPublisher() ) - .map { MediaSelector(groups: $0, selection: $1) } + .map { MediaSelectionContext(groups: $0, selection: $1) } .prepend(.empty) .eraseToAnyPublisher() } diff --git a/Sources/Player/Publishers/AVPlayerPublishers.swift b/Sources/Player/Publishers/AVPlayerPublishers.swift index 1ade95033..07e9df2ed 100644 --- a/Sources/Player/Publishers/AVPlayerPublishers.swift +++ b/Sources/Player/Publishers/AVPlayerPublishers.swift @@ -78,9 +78,9 @@ extension AVPlayer { .eraseToAnyPublisher() } - func currentItemMediaSelectorPublisher() -> AnyPublisher { + func currentItemMediaSelectionContextPublisher() -> AnyPublisher { publisher(for: \.currentItem) - .compactMap { $0?.mediaSelectorPublisher() } + .compactMap { $0?.mediaSelectionContextPublisher() } .switchToLatest() .prepend(.empty) .eraseToAnyPublisher() diff --git a/Sources/Player/Types/AudibleSelectionGroup.swift b/Sources/Player/Types/AudibleMediaSelector.swift similarity index 53% rename from Sources/Player/Types/AudibleSelectionGroup.swift rename to Sources/Player/Types/AudibleMediaSelector.swift index a957f3bf4..3c14f8b2a 100644 --- a/Sources/Player/Types/AudibleSelectionGroup.swift +++ b/Sources/Player/Types/AudibleMediaSelector.swift @@ -6,26 +6,27 @@ import AVFoundation -struct AudibleSelectionGroup: MediaSelectionGroup { +/// The default selector for audible options. +struct AudibleMediaSelector: MediaSelector { let group: AVMediaSelectionGroup - var options: [MediaSelectionOption] { - guard group.options.count > 1 else { return [] } - return group.sortedOptions.map { .enabled($0) } + func mediaSelectionOptions() -> [MediaSelectionOption] { + let options = AVMediaSelectionGroup.sortedMediaSelectionOptions(from: group.options) + return options.count > 1 ? options.map { .on($0) } : [] } func selectedMediaOption(in selection: AVMediaSelection) -> MediaSelectionOption { if let option = selection.selectedMediaOption(in: group) { - return .enabled(option) + return .on(option) } else { - return .disabled + return .off } } - func select(mediaOption: MediaSelectionOption, in item: AVPlayerItem) { + func select(mediaOption: MediaSelectionOption, on item: AVPlayerItem) { switch mediaOption { - case let .enabled(option): + case let .on(option): item.select(option, in: group) default: break diff --git a/Sources/Player/Types/LegibleSelectionGroup.swift b/Sources/Player/Types/LegibleMediaSelector.swift similarity index 56% rename from Sources/Player/Types/LegibleSelectionGroup.swift rename to Sources/Player/Types/LegibleMediaSelector.swift index 22d1f5991..a82242442 100644 --- a/Sources/Player/Types/LegibleSelectionGroup.swift +++ b/Sources/Player/Types/LegibleMediaSelector.swift @@ -7,12 +7,19 @@ import AVFoundation import MediaAccessibility -struct LegibleSelectionGroup: MediaSelectionGroup { +/// The default selector for legible options. +struct LegibleMediaSelector: MediaSelector { let group: AVMediaSelectionGroup - var options: [MediaSelectionOption] { - var options: [MediaSelectionOption] = [.automatic, .disabled] - options.append(contentsOf: group.sortedOptions.map { .enabled($0) }) + func mediaSelectionOptions() -> [MediaSelectionOption] { + var options: [MediaSelectionOption] = [.automatic, .off] + options.append( + contentsOf: AVMediaSelectionGroup.sortedMediaSelectionOptions( + from: group.options, + withoutMediaCharacteristics: [.containsOnlyForcedSubtitles] + ) + .map { .on($0) } + ) return options } @@ -20,30 +27,30 @@ struct LegibleSelectionGroup: MediaSelectionGroup { switch MACaptionAppearanceGetDisplayType(.user) { case .alwaysOn: if let option = selection.selectedMediaOption(in: group) { - return .enabled(option) + return .on(option) } else { - return .disabled + return .off } case .automatic: return .automatic default: - return .disabled + return .off } } - func select(mediaOption: MediaSelectionOption, in item: AVPlayerItem) { + func select(mediaOption: MediaSelectionOption, on item: AVPlayerItem) { switch mediaOption { case .automatic: MACaptionAppearanceSetDisplayType(.user, .automatic) item.selectMediaOptionAutomatically(in: group) - case .disabled: + case .off: MACaptionAppearanceSetDisplayType(.user, .forcedOnly) - item.select(nil, in: group) - case let .enabled(option): + item.selectMediaOptionAutomatically(in: group) + case let .on(option): MACaptionAppearanceSetDisplayType(.user, .alwaysOn) - if let languageCode = option.locale?.language.languageCode { - MACaptionAppearanceAddSelectedLanguage(.user, languageCode.identifier as CFString) + if let languageCode = option.languageCode { + MACaptionAppearanceAddSelectedLanguage(.user, languageCode as CFString) } item.select(option, in: group) } diff --git a/Sources/Player/Types/MediaSelectionContext.swift b/Sources/Player/Types/MediaSelectionContext.swift new file mode 100644 index 000000000..4d875f9aa --- /dev/null +++ b/Sources/Player/Types/MediaSelectionContext.swift @@ -0,0 +1,35 @@ +// +// Copyright (c) SRG SSR. All rights reserved. +// +// License information is available from the LICENSE file. +// + +import AVFoundation +import MediaAccessibility + +struct MediaSelectionContext { + static var empty: Self { + self.init(groups: [:], selection: nil) + } + + private let groups: [AVMediaCharacteristic: AVMediaSelectionGroup] + let selection: AVMediaSelection? + + var characteristics: Set { + Set(groups.keys) + } + + init(groups: [AVMediaCharacteristic: AVMediaSelectionGroup], selection: AVMediaSelection?) { + self.groups = groups + self.selection = selection + } + + func group(for characteristic: AVMediaCharacteristic) -> AVMediaSelectionGroup? { + groups[characteristic] + } + + func selectedOption(for characteristic: AVMediaCharacteristic) -> AVMediaSelectionOption? { + guard let selection, let group = groups[characteristic] else { return nil } + return selection.selectedMediaOption(in: group) + } +} diff --git a/Sources/Player/Types/MediaSelectionOption.swift b/Sources/Player/Types/MediaSelectionOption.swift index 201e7e72d..9726ea1bd 100644 --- a/Sources/Player/Types/MediaSelectionOption.swift +++ b/Sources/Player/Types/MediaSelectionOption.swift @@ -6,18 +6,29 @@ import AVFoundation +/// An option for media selection (audible, legible, etc.). public enum MediaSelectionOption: Hashable { + /// Automatic selection based on system language and accessibility settings. case automatic - case disabled - case enabled(AVMediaSelectionOption) + /// Disabled. + /// + /// Options might still be forced where applicable. + case off + + /// Enabled. + /// + /// You can extract `AVMediaSelectionOption` characteristics for display purposes. + case on(AVMediaSelectionOption) + + /// A name suitable for display. public var displayName: String { switch self { case .automatic: return NSLocalizedString("Auto (Recommended)", comment: "Subtitle selection option") - case .disabled: + case .off: return NSLocalizedString("Off", comment: "Subtitle selection option") - case let .enabled(option): + case let .on(option): return option.displayName } } diff --git a/Sources/Player/Types/MediaSelector.swift b/Sources/Player/Types/MediaSelector.swift deleted file mode 100644 index f274c4f58..000000000 --- a/Sources/Player/Types/MediaSelector.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// -import AVFoundation -import MediaAccessibility - -/// Manages the available media selections as well as the currently selected one. -struct MediaSelector { - static var empty: Self { - self.init(groups: [:], selection: nil) - } - - let groups: [AVMediaCharacteristic: any MediaSelectionGroup] - let selection: AVMediaSelection? - - var characteristics: Set { - Set(groups.keys) - } - - init(groups: [AVMediaCharacteristic: AVMediaSelectionGroup], selection: AVMediaSelection?) { - self.groups = .init(uniqueKeysWithValues: groups.compactMap { characteristic, group in - switch characteristic { - case .legible: - return (characteristic, LegibleSelectionGroup(group: group)) - case .audible: - return (characteristic, AudibleSelectionGroup(group: group)) - default: - return nil - } - }) - self.selection = selection - } - - func options(for characteristic: AVMediaCharacteristic) -> [MediaSelectionOption] { - guard let group = groups[characteristic] else { return [] } - return group.options - } - - func selectedMediaOption(for characteristic: AVMediaCharacteristic) -> MediaSelectionOption { - guard let selection, let group = groups[characteristic] else { return .disabled } - return group.selectedMediaOption(in: selection) - } - - func select(mediaOption: MediaSelectionOption, for characteristic: AVMediaCharacteristic, in item: AVPlayerItem?) { - guard let item, let group = groups[characteristic] else { return } - group.select(mediaOption: mediaOption, in: item) - } -} - -extension MediaSelector: CustomDebugStringConvertible { - var debugDescription: String { - groups - .map { characteristic, _ in - let option = selectedMediaOption(for: characteristic) - return "\(characteristic.rawValue) = \(option.displayName)" - } - .joined(separator: ", ") - } -} diff --git a/Sources/Streams/Stream.swift b/Sources/Streams/Stream.swift index dea4b0a3b..7f77b1b3d 100644 --- a/Sources/Streams/Stream.swift +++ b/Sources/Streams/Stream.swift @@ -30,59 +30,72 @@ public struct Stream { public extension Stream { /// An on-demand stream. static let onDemand: Self = .init( - url: URL(string: "http://localhost:8123/single/on_demand/master.m3u8")!, + url: URL(string: "http://localhost:8123/simple/on_demand/master.m3u8")!, duration: CMTime(value: 120, timescale: 1) ) /// A short on-demand stream. static let shortOnDemand: Self = .init( - url: URL(string: "http://localhost:8123/single/on_demand_short/master.m3u8")!, + url: URL(string: "http://localhost:8123/simple/on_demand_short/master.m3u8")!, duration: CMTime(value: 1, timescale: 1) ) /// A medium-sized on-demand stream. static let mediumOnDemand: Self = .init( - url: URL(string: "http://localhost:8123/single/on_demand_medium/master.m3u8")!, + url: URL(string: "http://localhost:8123/simple/on_demand_medium/master.m3u8")!, duration: CMTime(value: 5, timescale: 1) ) /// A square on-demand stream. static let squareOnDemand: Self = .init( - url: URL(string: "http://localhost:8123/single/on_demand_square/master.m3u8")!, + url: URL(string: "http://localhost:8123/simple/on_demand_square/master.m3u8")!, duration: CMTime(value: 120, timescale: 1) ) /// A corrupt on-demand stream. static let corruptOnDemand: Self = .init( - url: URL(string: "http://localhost:8123/single/on_demand_corrupt/master.m3u8")!, + 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/single/live/master.m3u8")!, + url: URL(string: "http://localhost:8123/simple/live/master.m3u8")!, duration: .zero ) /// A DVR stream. static let dvr: Self = .init( - url: URL(string: "http://localhost:8123/single/dvr/master.m3u8")!, + url: URL(string: "http://localhost:8123/simple/dvr/master.m3u8")!, duration: CMTime(value: 17 /* 20 - 3 * 1 (chunk) */, timescale: 1) ) } public extension Stream { - /// An on-demand stream with several subtitles and audio tracks. - static let onDemandWithTracks: Self = .init( - url: URL(string: "http://localhost:8123/multi/on_demand_with_tracks/master.m3u8")!, + /// An on-demand stream with several audible and legible options. + static let onDemandWithOptions: Self = .init( + url: URL(string: "http://localhost:8123/packaged/on_demand_with_options/master.m3u8")!, duration: CMTime(value: 4, timescale: 1) ) - /// An on-demand stream without subtitles and audio tracks. - static let onDemandWithoutTracks: Self = .init( - url: URL(string: "http://localhost:8123/multi/on_demand_without_tracks/master.m3u8")!, + /// An on-demand stream without audible or legible options. + static let onDemandWithoutOptions: Self = .init( + url: URL(string: "http://localhost:8123/packaged/on_demand_without_options/master.m3u8")!, duration: CMTime(value: 4, timescale: 1) ) + + /// An on-demand stream with a single audible option. + static let onDemandWithSingleAudibleOption: Self = .init( + url: URL(string: "http://localhost:8123/packaged/on_demand_with_single_audible_option/master.m3u8")!, + duration: CMTime(value: 4, timescale: 1) + ) + + /// An on-demand stream with forced and unforced legible options. + static let onDemandWithForcedAndUnforcedLegibleOptions: Self = .init( + // TODO: Should update to a local stream when forced subtitles are supported by Shaka Packager. + url: URL(string: "https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8")!, + duration: CMTime(value: 1800, timescale: 1) + ) } public extension Stream { diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift index 8c18adcaf..ffb693c6d 100644 --- a/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift +++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift @@ -90,7 +90,7 @@ final class ComScoreTrackerDvrPropertiesTests: ComScoreTestCase { expect(labels.ns_st_ldw).to(equal(Stream.dvr.duration.seconds)) }, .play { labels in - expect(labels.ns_st_ldo).to(beCloseTo(4, within: 1)) + expect(labels.ns_st_ldo).to(beCloseTo(4, within: 2)) expect(labels.ns_st_ldw).to(equal(Stream.dvr.duration.seconds)) } ) { diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift index 3aa7de1ee..da889c932 100644 --- a/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift +++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift @@ -53,7 +53,7 @@ final class ComScoreTrackerTests: ComScoreTestCase { expectAtLeastHits( .play { labels in - expect(labels.ns_st_po).to(beCloseTo(0, within: 1)) + expect(labels.ns_st_po).to(beCloseTo(0, within: 2)) } ) { player.play() diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift index 0f5bbffbb..c0df79f88 100644 --- a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift +++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift @@ -95,7 +95,7 @@ final class CommandersActTrackerDvrPropertiesTests: CommandersActTestCase { expect(labels.media_timeshift).to(equal(0)) }, .play { labels in - expect(labels.media_timeshift).to(beCloseTo(4, within: 1)) + expect(labels.media_timeshift).to(beCloseTo(4, within: 2)) } ) { player.seek(at(player.timeRange.end - CMTime(value: 4, timescale: 1))) diff --git a/Tests/PlayerTests/Player/MediaSelectionTests.swift b/Tests/PlayerTests/Player/MediaSelectionTests.swift index 234dedd6e..546b251df 100644 --- a/Tests/PlayerTests/Player/MediaSelectionTests.swift +++ b/Tests/PlayerTests/Player/MediaSelectionTests.swift @@ -14,252 +14,247 @@ import XCTest private enum MediaAccessibilityDisplayType { case automatic - case disabled - case enabled(languageCode: String) + case forcedOnly + case alwaysOn(languageCode: String) } final class MediaSelectionTests: TestCase { - private static func setupMediaAccessibilityType(_ type: MediaAccessibilityDisplayType) { + private func setupAccessibilityDisplayType(_ type: MediaAccessibilityDisplayType) { switch type { case .automatic: MACaptionAppearanceSetDisplayType(.user, .automatic) - case .disabled: + case .forcedOnly: MACaptionAppearanceSetDisplayType(.user, .forcedOnly) - case let .enabled(languageCode: languageCode): + case let .alwaysOn(languageCode: languageCode): MACaptionAppearanceSetDisplayType(.user, .alwaysOn) MACaptionAppearanceAddSelectedLanguage(.user, languageCode as CFString) } } - private static func selectedMediaOption( - forMediaCharacteristic characteristic: AVMediaCharacteristic, - player: Player - ) async throws -> AVMediaSelectionOption? { - guard let item = player.queuePlayer.currentItem, - let group = try await item.asset.loadMediaSelectionGroup(for: characteristic) else { - return nil - } - return item.currentMediaSelection.selectedMediaOption(in: group) - } - - override func setUp() { - super.setUp() - Self.setupMediaAccessibilityType(.disabled) - } - - @MainActor - func testCharacteristicsAndOptionsWhenEmpty() async throws { + func testCharacteristicsAndOptionsWhenEmpty() { let player = Player() - await expect(player.mediaSelectionCharacteristics).toAlways(beEmpty(), until: .seconds(2)) + expect(player.mediaSelectionCharacteristics).toAlways(beEmpty(), until: .seconds(2)) expect(player.mediaSelectionOptions(for: .audible)).to(beEmpty()) expect(player.mediaSelectionOptions(for: .legible)).to(beEmpty()) expect(player.mediaSelectionOptions(for: .visual)).to(beEmpty()) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .audible, player: player) - }.to(beNil()) } - @MainActor - func testCharacteristicsAndOptionsWhenAvailable() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.mediaSelectionCharacteristics).toEventually(equal([.audible, .legible])) + func testCharacteristicsAndOptionsWhenAvailable() { + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.mediaSelectionCharacteristics).toEventually(equal([.audible, .legible])) expect(player.mediaSelectionOptions(for: .audible)).notTo(beEmpty()) expect(player.mediaSelectionOptions(for: .legible)).notTo(beEmpty()) expect(player.mediaSelectionOptions(for: .visual)).to(beEmpty()) - expect(player.selectedMediaOption(for: .audible)).to(haveLanguageIdentifier("en")) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .audible, player: player) - }.to(haveLanguageIdentifier("en")) } - @MainActor - func testCharacteristicsAndOptionsWhenFailed() async throws { + func testCharacteristicsAndOptionsWhenFailed() { let player = Player(item: .simple(url: Stream.unavailable.url)) - await expect(player.mediaSelectionCharacteristics).toAlways(beEmpty(), until: .seconds(2)) + expect(player.mediaSelectionCharacteristics).toAlways(beEmpty(), until: .seconds(2)) expect(player.mediaSelectionOptions(for: .audible)).to(beEmpty()) expect(player.mediaSelectionOptions(for: .legible)).to(beEmpty()) expect(player.mediaSelectionOptions(for: .visual)).to(beEmpty()) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .audible, player: player) - }.to(beNil()) } - @MainActor - func testWithoutCharacteristicsAndOptions() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithoutTracks.url)) - await expect(player.mediaSelectionCharacteristics).toAlways(beEmpty(), until: .seconds(2)) + func testWithoutCharacteristicsAndOptions() { + let player = Player(item: .simple(url: Stream.onDemandWithoutOptions.url)) + expect(player.mediaSelectionCharacteristics).toAlways(beEmpty(), until: .seconds(2)) expect(player.mediaSelectionOptions(for: .audible)).to(beEmpty()) expect(player.mediaSelectionOptions(for: .legible)).to(beEmpty()) expect(player.mediaSelectionOptions(for: .visual)).to(beEmpty()) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .audible, player: player) - }.to(beNil()) } - @MainActor - func testCharacteristicsAndOptionsWhenAdvancingToNextItem() async throws { + func testCharacteristicsAndOptionsWhenAdvancingToNextItem() { let player = Player(items: [ - .simple(url: Stream.onDemandWithTracks.url), - .simple(url: Stream.onDemandWithoutTracks.url) + .simple(url: Stream.onDemandWithOptions.url), + .simple(url: Stream.onDemandWithoutOptions.url) ]) - await expect(player.mediaSelectionCharacteristics).toEventuallyNot(beEmpty()) + expect(player.mediaSelectionCharacteristics).toEventuallyNot(beEmpty()) player.advanceToNextItem() - await expect(player.mediaSelectionCharacteristics).toEventually(beEmpty()) + expect(player.mediaSelectionCharacteristics).toEventually(beEmpty()) } - @MainActor - func testInitiallySelectedEnabledAudibleOption() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en")) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .audible, player: player) - }.to(haveLanguageIdentifier("en")) + func testSingleAudibleOptionIsNeverReturned() { + let player = Player(item: .simple(url: Stream.onDemandWithSingleAudibleOption.url)) + expect(player.mediaSelectionCharacteristics).toEventually(equal([.audible])) + expect(player.mediaSelectionOptions(for: .audible)).to(beEmpty()) } - @MainActor - func testInitiallySelectedEnabledLegibleOption() async throws { - Self.setupMediaAccessibilityType(.enabled(languageCode: "ja")) - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("ja")) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .legible, player: player) - }.to(haveLanguageIdentifier("ja")) + func testLegibleOptionsMustNotContainForcedSubtitles() { + let player = Player(item: .simple(url: Stream.onDemandWithForcedAndUnforcedLegibleOptions.url)) + expect(player.mediaSelectionCharacteristics).toEventually(equal([.audible, .legible])) + expect(player.mediaSelectionOptions(for: .legible).count).to(equal(6)) } - @MainActor - func testInitiallySelectedAutomaticLegibleOption() async throws { - Self.setupMediaAccessibilityType(.automatic) - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.automatic)) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .legible, player: player) - }.to(beNil()) + func testInitialAudibleOption() { + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en")) + expect(player.currentMediaOption(for: .audible)).to(haveLanguageIdentifier("en")) } - @MainActor - func testInitiallySelectedOptionWithoutAudibleOptions() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithoutTracks.url)) - await expect(player.selectedMediaOption(for: .audible)).toAlways(equal(.disabled), until: .seconds(2)) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .audible, player: player) - }.to(beNil()) + func testInitialLegibleOptionWithAlwaysOnAccessibilityDisplayType() { + setupAccessibilityDisplayType(.alwaysOn(languageCode: "ja")) + + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("ja")) + expect(player.currentMediaOption(for: .legible)).to(haveLanguageIdentifier("ja")) } - @MainActor - func testInitiallySelectedOptionWithoutLegibleOptions() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithoutTracks.url)) - await expect(player.selectedMediaOption(for: .legible)).toAlways(equal(.disabled), until: .seconds(2)) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .legible, player: player) - }.to(beNil()) + func testInitialLegibleOptionWithAutomaticAccessibilityDisplayType() { + setupAccessibilityDisplayType(.automatic) + + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.automatic)) + expect(player.currentMediaOption(for: .legible)).to(equal(.off)) } - @MainActor - func testInitiallySelectedDisabledLegibleOption() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.disabled)) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .legible, player: player) - }.to(beNil()) + func testInitialLegibleOptionWithForcedOnlyAccessibilityDisplayType() { + setupAccessibilityDisplayType(.forcedOnly) + + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.off)) + expect(player.currentMediaOption(for: .legible)).to(equal(.off)) } - @MainActor - func testSelectedAudibleOptionWhenAdvancingToNextItem() async throws { + func testInitialAudibleOptionWithoutAvailableOptions() { + let player = Player(item: .simple(url: Stream.onDemandWithoutOptions.url)) + expect(player.selectedMediaOption(for: .audible)).toAlways(equal(.off), until: .seconds(2)) + expect(player.currentMediaOption(for: .audible)).to(equal(.off)) + } + + func testInitialLegibleOptionWithoutAvailableOptions() { + setupAccessibilityDisplayType(.forcedOnly) + + let player = Player(item: .simple(url: Stream.onDemandWithoutOptions.url)) + expect(player.selectedMediaOption(for: .legible)).toAlways(equal(.off), until: .seconds(2)) + expect(player.currentMediaOption(for: .legible)).to(equal(.off)) + } + + func testAudibleOptionUpdateWhenAdvancingToNextItem() { let player = Player(items: [ - .simple(url: Stream.onDemandWithTracks.url), - .simple(url: Stream.onDemandWithoutTracks.url) + .simple(url: Stream.onDemandWithOptions.url), + .simple(url: Stream.onDemandWithoutOptions.url) ]) - await expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en")) + expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en")) player.advanceToNextItem() - await expect(player.selectedMediaOption(for: .audible)).toEventually(equal(.disabled)) + expect(player.selectedMediaOption(for: .audible)).toEventually(equal(.off)) } - @MainActor - func testSelectedLegibleOptionWhenAdvancingToNextItem() async throws { - Self.setupMediaAccessibilityType(.enabled(languageCode: "fr")) + func testLegibleOptionUpdateWhenAdvancingToNextItem() { + setupAccessibilityDisplayType(.alwaysOn(languageCode: "fr")) + let player = Player(items: [ - .simple(url: Stream.onDemandWithTracks.url), - .simple(url: Stream.onDemandWithoutTracks.url) + .simple(url: Stream.onDemandWithOptions.url), + .simple(url: Stream.onDemandWithoutOptions.url) ]) - await expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("fr")) + expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("fr")) player.advanceToNextItem() - await expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.disabled)) + expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.off)) } + // When using AirPlay the receiver might offer forced subtitle selection, thus changing subtitles externally. In + // this case the perceived selected option must be `.off`. @MainActor - func testSelectEnabledAudibleOption() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty()) + func testLegibleOptionStaysOffEvenIfForcedSubtitlesAreEnabledExternally() async throws { + setupAccessibilityDisplayType(.alwaysOn(languageCode: "ja")) + + let player = Player(item: .simple(url: Stream.onDemandWithForcedAndUnforcedLegibleOptions.url)) + await expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty()) + + let group = try await player.group(for: .legible)! + let option = AVMediaSelectionGroup.mediaSelectionOptions( + from: group.options, + withMediaCharacteristics: [.containsOnlyForcedSubtitles] + ) + .first { option in + option.languageIdentifier == "ja" + }! + + // Simulates an external change using the low-level player API directly. + player.systemPlayer.currentItem?.select(option, in: group) + + await expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.off)) + } + + func testSelectAudibleOnOption() { + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty()) player.select(mediaOption: player.mediaSelectionOptions(for: .audible).first { option in option.languageIdentifier == "fr" }!, for: .audible) - await expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("fr")) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .audible, player: player) - }.to(haveLanguageIdentifier("fr")) + expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("fr")) + expect(player.currentMediaOption(for: .audible)).to(haveLanguageIdentifier("fr")) } - @MainActor - func testSelectAutomaticAudibleOptionDoesNothing() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty()) + func testSelectAudibleAutomaticOptionDoesNothing() { + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty()) player.select(mediaOption: .automatic, for: .audible) - await expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en")) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .audible, player: player) - }.to(haveLanguageIdentifier("en")) + expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en")) + expect(player.currentMediaOption(for: .audible)).to(haveLanguageIdentifier("en")) } - @MainActor - func testSelectDisabledAudibleOptionDoesNothing() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty()) - - player.select(mediaOption: .disabled, for: .audible) - await expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en")) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .audible, player: player) - }.to(haveLanguageIdentifier("en")) + func testSelectAudibleOffOptionDoesNothing() { + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty()) + + player.select(mediaOption: .off, for: .audible) + expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en")) + expect(player.currentMediaOption(for: .audible)).to(haveLanguageIdentifier("en")) } - @MainActor - func testSelectEnabledLegibleOption() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty()) + func testSelectLegibleOnOption() { + setupAccessibilityDisplayType(.forcedOnly) + + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty()) player.select(mediaOption: player.mediaSelectionOptions(for: .legible).first { option in option.languageIdentifier == "ja" }!, for: .legible) - await expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("ja")) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .legible, player: player) - }.to(haveLanguageIdentifier("ja")) + expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("ja")) + expect(player.currentMediaOption(for: .legible)).to(haveLanguageIdentifier("ja")) } - @MainActor - func testSelectAutomaticLegibleOption() async throws { - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty()) + func testSelectLegibleAutomaticOption() { + setupAccessibilityDisplayType(.forcedOnly) + + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty()) player.select(mediaOption: .automatic, for: .legible) - await expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.automatic)) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .legible, player: player) - }.to(beNil()) + expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.automatic)) + expect(player.currentMediaOption(for: .legible)).to(equal(.off)) } - @MainActor - func testSelectDisabledLegibleOption() async throws { - Self.setupMediaAccessibilityType(.automatic) - let player = Player(item: .simple(url: Stream.onDemandWithTracks.url)) - await expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty()) + func testSelectLegibleOffOption() { + setupAccessibilityDisplayType(.automatic) + + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty()) + + player.select(mediaOption: .off, for: .legible) + expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.off)) + expect(player.currentMediaOption(for: .legible)).to(equal(.off)) + } + + func testSelectIncompatibleOptionDoesNothing() { + setupAccessibilityDisplayType(.forcedOnly) + + let player = Player(item: .simple(url: Stream.onDemandWithOptions.url)) + expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty()) + + let firstAudibleOption = player.mediaSelectionOptions(for: .audible).first! + player.select(mediaOption: firstAudibleOption, for: .legible) + expect(player.selectedMediaOption(for: .legible)).toAlways(equal(.off), until: .seconds(2)) + expect(player.currentMediaOption(for: .legible)).to(equal(.off)) + } +} - player.select(mediaOption: .disabled, for: .legible) - await expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.disabled)) - await expect { - try await Self.selectedMediaOption(forMediaCharacteristic: .legible, player: player) - }.to(beNil()) +private extension Player { + func group(for characteristic: AVMediaCharacteristic) async throws -> AVMediaSelectionGroup? { + guard let item = systemPlayer.currentItem else { return nil } + return try await item.asset.loadMediaSelectionGroup(for: characteristic) } } diff --git a/Tests/PlayerTests/Publishers/AVAsset/AVAssetMediaSelectionGroupsPublisherTests.swift b/Tests/PlayerTests/Publishers/AVAsset/AVAssetMediaSelectionGroupsPublisherTests.swift index 90d16f48a..9d84c066d 100644 --- a/Tests/PlayerTests/Publishers/AVAsset/AVAssetMediaSelectionGroupsPublisherTests.swift +++ b/Tests/PlayerTests/Publishers/AVAsset/AVAssetMediaSelectionGroupsPublisherTests.swift @@ -14,20 +14,20 @@ import XCTest // swiftlint:disable:next type_name final class AVAssetMediaSelectionGroupsPublisherTests: TestCase { func testFetch() throws { - let asset = AVURLAsset(url: Stream.onDemandWithTracks.url) + let asset = AVURLAsset(url: Stream.onDemandWithOptions.url) let groups = try waitForSingleOutput(from: asset.mediaSelectionGroupsPublisher()) expect(groups[.audible]).notTo(beNil()) expect(groups[.legible]).notTo(beNil()) } func testFetchWithoutSelectionAvailable() throws { - let asset = AVURLAsset(url: Stream.onDemandWithoutTracks.url) + let asset = AVURLAsset(url: Stream.onDemandWithoutOptions.url) let groups = try waitForSingleOutput(from: asset.mediaSelectionGroupsPublisher()) expect(groups).to(beEmpty()) } func testRepeatedFetch() throws { - let asset = AVURLAsset(url: Stream.onDemandWithTracks.url) + let asset = AVURLAsset(url: Stream.onDemandWithOptions.url) let groups1 = try waitForSingleOutput(from: asset.mediaSelectionGroupsPublisher()) expect(groups1).notTo(beEmpty()) diff --git a/Tests/PlayerTests/Types/LanguageIdentifiable.swift b/Tests/PlayerTests/Types/LanguageIdentifiable.swift index 62cd9ac81..8728473d4 100644 --- a/Tests/PlayerTests/Types/LanguageIdentifiable.swift +++ b/Tests/PlayerTests/Types/LanguageIdentifiable.swift @@ -14,8 +14,8 @@ protocol LanguageIdentifiable { extension MediaSelectionOption: LanguageIdentifiable { var languageIdentifier: String? { switch self { - case let .enabled(option): - return option.locale?.language.languageCode?.identifier + case let .on(option): + return option.languageIdentifier default: return nil } diff --git a/Tests/PlayerTests/Types/MediaSelectorTests.swift b/Tests/PlayerTests/Types/MediaSelectorTests.swift deleted file mode 100644 index 0bf85c577..000000000 --- a/Tests/PlayerTests/Types/MediaSelectorTests.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Copyright (c) SRG SSR. All rights reserved. -// -// License information is available from the LICENSE file. -// - -@testable import Player - -import AVFoundation -import Nimble -import Streams -import XCTest - -final class MediaSelectorTests: TestCase { - func testEmpty() { - let selector = MediaSelector.empty - expect(selector.characteristics).to(beEmpty()) - expect(selector.options(for: .legible)).to(beEmpty()) - expect(selector.selectedMediaOption(for: .legible)).to(equal(.disabled)) - } -} diff --git a/docs/README.md b/docs/README.md index a56395ff8..ffb44ab7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,7 @@ To learn more how integration of Pillarbox into your project please have a look Follow the links below for further documentation: +- [Stream packaging advice](STREAM_PACKAGING_ADVICE.md) - [Known issues](KNOWN_ISSUES.md) - [Development setup](DEVELOPMENT_SETUP.md) - [Continuous integration](CONTINUOUS_INTEGRATION.md) diff --git a/docs/STREAM_PACKAGING_ADVICE.md b/docs/STREAM_PACKAGING_ADVICE.md new file mode 100644 index 000000000..dbaa8744d --- /dev/null +++ b/docs/STREAM_PACKAGING_ADVICE.md @@ -0,0 +1,21 @@ +# Stream packaging advice + +This article discusses how streams should be packaged for optimal compatibility with Pillarbox player. + +## Automatic media option selection + +Pillarbox player supports automatic media option selection based on: + +- System language and accessibility settings (e.g. unforced subtitles and audio description). +- Content language (e.g. forced subtitles). + +For automatic selection to work with legible [renditions](https://datatracker.ietf.org/doc/html/rfc8216#section-4.3.4.1): + +- Unforced `SUBTITLES` and `CLOSED-CAPTIONS` renditions must have their `AUTOSELECT` attribute set to `YES` so that the player can choose among them in _Automatic_ mode. +- Forced `SUBTITLES` renditions must have their `AUTOSELECT` attribute set to `YES`. Note that Pillarbox player follows [Apple recommendations](https://developer.apple.com/library/archive/releasenotes/AudioVideo/RN-AVFoundation/index.html#//apple_ref/doc/uid/TP40010717-CH1-DontLinkElementID_3) and never returns forced subtitles for selection. + +## Trick mode / Trick play + +Pillarbox player supports [trick mode](https://en.wikipedia.org/wiki/Trick_mode) (aka trick play) which requires dedicated I-frame playlists to be delivered in video master playlists. Please refer to the [HTTP Live Streaming (HLS) authoring specification for Apple devices](https://developer.apple.com/documentation/http-live-streaming/hls-authoring-specification-for-apple-devices?language=objc) for more information. + +The player still attempts to provide the best possible seek experience when I-frame playlists are not available. In this case the player performs seek requests in sequence, avoiding pending request interruption and unnecessary seeks (an approach called _smooth seeking_). Note that this experience is an order of magnitude slower than the one obtained with trick mode, though.