Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for forced subtitles #533

Merged
merged 33 commits into from
Aug 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
92bf1b3
Rename media selection groups
defagos Aug 15, 2023
716c68d
Filter forced subtitles
defagos Aug 16, 2023
c1cd8e6
Generate stream with single audio
defagos Aug 16, 2023
bc3faed
Update naming scheme for generated streams
defagos Aug 16, 2023
cecc5d8
Add missing test
defagos Aug 16, 2023
46d228b
Make legible test setup more explicit
defagos Aug 16, 2023
4d053ca
Use internal access to raw media selection
defagos Aug 16, 2023
9b6700c
Prepare tests
defagos Aug 16, 2023
b699678
Provide selection information to selector implementation
defagos Aug 16, 2023
86dbd90
Prepare forced legible option extraction
defagos Aug 16, 2023
6fac89a
Flatten implementation
defagos Aug 17, 2023
dff2661
Implement rough forced subtitle selection
defagos Aug 17, 2023
327ecdf
Revisit media selection implementation
defagos Aug 17, 2023
f2db7cc
Select forced subtitles when setting to Off
defagos Aug 17, 2023
cc8a169
Wait before options are applied
defagos Aug 17, 2023
e6e850b
Support forced subtitles automatic selection
defagos Aug 17, 2023
a077e97
Cleanup superfluous APIs
defagos Aug 17, 2023
9c1b2d0
Cleanup non-relevant tests
defagos Aug 17, 2023
65ddde6
Loosen up flaky tests
defagos Aug 17, 2023
4f49368
Use consistent type for options
defagos Aug 18, 2023
7b476c9
Improve naming scheme
defagos Aug 18, 2023
e752830
Ensure forced subtitles cannot be selected
defagos Aug 18, 2023
c82acd7
Improve API
defagos Aug 18, 2023
f57ebbf
Improve unsupported option handling
defagos Aug 18, 2023
3929ab9
Improve internal API
defagos Aug 18, 2023
724d63b
Add stream packaging advice
defagos Aug 18, 2023
2e93805
Fix behavior when forced subtitles are enabled over AirPlay
defagos Aug 18, 2023
b7bdf38
Improve documentation
defagos Aug 18, 2023
d3c71f7
Move method
defagos Aug 18, 2023
2e55839
Polish documentation
defagos Aug 18, 2023
b855914
Factor out local comparison criterium
defagos Aug 18, 2023
ad674a2
Polish documentation
defagos Aug 18, 2023
6fc902e
Add Apple Dolby Atmos sample stream
defagos Aug 18, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Demo/Sources/Examples/ExamplesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
8 changes: 6 additions & 2 deletions Demo/Sources/Model/Template.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions Demo/Sources/Showcase/Playlist/PlaylistViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 23 additions & 17 deletions Scripts/test-streams.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand All @@ -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"

Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 15 additions & 4 deletions Sources/Player/Extensions/AVMediaSelectionGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
13 changes: 13 additions & 0 deletions Sources/Player/Extensions/AVMediaSelectionOption.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
16 changes: 0 additions & 16 deletions Sources/Player/Interfaces/MediaSelectionGroup.swift

This file was deleted.

31 changes: 31 additions & 0 deletions Sources/Player/Interfaces/MediaSelector.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
64 changes: 51 additions & 13 deletions Sources/Player/Player+MediaSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,39 +10,52 @@ import SwiftUI
public extension Player {
/// The set of media characteristics for which a media selection is available.
var mediaSelectionCharacteristics: Set<AVMediaCharacteristic> {
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.
Expand All @@ -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
}
}
}
10 changes: 5 additions & 5 deletions Sources/Player/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlayerItem>

Expand Down Expand Up @@ -209,7 +209,7 @@ public final class Player: ObservableObject, Equatable {
configurePresentationSizePublisher()
configureMutedPublisher()
configurePlaybackSpeedPublisher()
configureMediaSelectorPublisher()
configureMediaSelectionContextPublisher()
}

deinit {
Expand Down Expand Up @@ -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)
}
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/Player/Publishers/AVPlayerItemPublishers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@ extension AVPlayerItem {
.eraseToAnyPublisher()
}

func mediaSelectorPublisher() -> AnyPublisher<MediaSelector, Never> {
func mediaSelectionContextPublisher() -> AnyPublisher<MediaSelectionContext, Never> {
Publishers.CombineLatest(
asset.mediaSelectionGroupsPublisher(),
mediaSelectionPublisher()
)
.map { MediaSelector(groups: $0, selection: $1) }
.map { MediaSelectionContext(groups: $0, selection: $1) }
.prepend(.empty)
.eraseToAnyPublisher()
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Player/Publishers/AVPlayerPublishers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ extension AVPlayer {
.eraseToAnyPublisher()
}

func currentItemMediaSelectorPublisher() -> AnyPublisher<MediaSelector, Never> {
func currentItemMediaSelectionContextPublisher() -> AnyPublisher<MediaSelectionContext, Never> {
publisher(for: \.currentItem)
.compactMap { $0?.mediaSelectorPublisher() }
.compactMap { $0?.mediaSelectionContextPublisher() }
.switchToLatest()
.prepend(.empty)
.eraseToAnyPublisher()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading