Skip to content

Commit

Permalink
Support starting playback at a given time (#724)
Browse files Browse the repository at this point in the history
  • Loading branch information
defagos authored Jan 9, 2024
1 parent 1699fb9 commit c7285ee
Show file tree
Hide file tree
Showing 11 changed files with 126 additions and 28 deletions.
4 changes: 4 additions & 0 deletions Demo/Pillarbox-demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
6F0E5CD32B3394EA0031E313 /* PiPButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0E5CD22B3394EA0031E313 /* PiPButton.swift */; };
6F0E5CD52B33A41F0031E313 /* MonoscopicVideoView~ios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0E5CD42B33A41F0031E313 /* MonoscopicVideoView~ios.swift */; };
6F26F35E2B33B73900392ED4 /* SupportingBasicPictureInPicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F26F35D2B33B73900392ED4 /* SupportingBasicPictureInPicture.swift */; };
6F49E0442B4D4C7D00F67C8F /* StartAtGivenTimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F49E0432B4D4C7D00F67C8F /* StartAtGivenTimeView.swift */; };
6F59E87929CF31E10093E6FB /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F59E84029CF31E10093E6FB /* SearchView.swift */; };
6F59E87A29CF31E10093E6FB /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F59E84129CF31E10093E6FB /* SearchViewModel.swift */; };
6F59E87B29CF31E10093E6FB /* DemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F59E84329CF31E10093E6FB /* DemoApp.swift */; };
Expand Down Expand Up @@ -118,6 +119,7 @@
6F45DB9D2893B773008ACCE6 /* Demo.nightly.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Demo.nightly.xcconfig; sourceTree = "<group>"; };
6F45DB9E2893B773008ACCE6 /* Demo.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Demo.debug.xcconfig; sourceTree = "<group>"; };
6F45DB9F2893B773008ACCE6 /* Demo.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Demo.release.xcconfig; sourceTree = "<group>"; };
6F49E0432B4D4C7D00F67C8F /* StartAtGivenTimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartAtGivenTimeView.swift; sourceTree = "<group>"; };
6F59E84029CF31E10093E6FB /* SearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
6F59E84129CF31E10093E6FB /* SearchViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
6F59E84329CF31E10093E6FB /* DemoApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoApp.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -403,6 +405,7 @@
6F59E87029CF31E10093E6FB /* PlayerView.swift */,
0E4128BE2AFB959B00D67759 /* PlayerViewModel.swift */,
6F59E86C29CF31E10093E6FB /* SimplePlayerView.swift */,
6F49E0432B4D4C7D00F67C8F /* StartAtGivenTimeView.swift */,
6F59E86D29CF31E10093E6FB /* SystemPlayerView.swift */,
6F59E86F29CF31E10093E6FB /* VanillaPlayerView.swift */,
);
Expand Down Expand Up @@ -640,6 +643,7 @@
6F0E5CD32B3394EA0031E313 /* PiPButton.swift in Sources */,
6F59E89629CF31E20093E6FB /* Cell.swift in Sources */,
0EF2A5452B443FEF00F01804 /* WebView~ios.swift in Sources */,
6F49E0442B4D4C7D00F67C8F /* StartAtGivenTimeView.swift in Sources */,
6F59E89A29CF31E20093E6FB /* SimplePlayerView.swift in Sources */,
6F59E89329CF31E20093E6FB /* LinkView.swift in Sources */,
6F59E89529CF31E20093E6FB /* CopyButton.swift in Sources */,
Expand Down
3 changes: 0 additions & 3 deletions Demo/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,6 @@
},
"Improves playlist navigation so that it feels more natural." : {

},
"Inline system player (using Pillarbox)" : {

},
"Layouts" : {

Expand Down
30 changes: 30 additions & 0 deletions Demo/Sources/Players/StartAtGivenTimeView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// Copyright (c) SRG SSR. All rights reserved.
//
// License information is available from the LICENSE file.
//

import PillarboxCoreBusiness
import PillarboxPlayer
import SwiftUI

struct StartAtGivenTimeView: View {
@StateObject private var player = Player(
item: .urn("urn:rts:video:14608947") { item in
item.seek(at(.init(value: 1055300, timescale: 1000)))
}
)

var body: some View {
SystemVideoView(player: player)
.onAppear(perform: player.play)
}
}

extension StartAtGivenTimeView: SourceCodeViewable {
static var filePath: String { #file }
}

#Preview {
StartAtGivenTimeView()
}
6 changes: 6 additions & 0 deletions Demo/Sources/Router/RouterDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ enum RouterDestination: Identifiable, Hashable {
case simplePlayer(media: Media)
case optInPlayer(media: Media)

case startAtGivenTime

case vanillaPlayer(item: AVPlayerItem)

case blurred(media: Media)
Expand Down Expand Up @@ -41,6 +43,8 @@ enum RouterDestination: Identifiable, Hashable {
return "simplePlayer"
case .optInPlayer:
return "optInPlayer"
case .startAtGivenTime:
return "startAtGivenTime"
case .vanillaPlayer:
return "vanillaPlayer"
case .blurred:
Expand Down Expand Up @@ -92,6 +96,8 @@ enum RouterDestination: Identifiable, Hashable {
SimplePlayerView(media: media)
case let .optInPlayer(media: media):
OptInView(media: media)
case .startAtGivenTime:
StartAtGivenTimeView()
case let .vanillaPlayer(item: item):
VanillaPlayerView(item: item)
case let .blurred(media: media):
Expand Down
14 changes: 11 additions & 3 deletions Demo/Sources/Showcase/ShowcaseView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct ShowcaseView: View {
playlistsSection()
embeddingsSection()
systemPlayerSection()
inlineSystemPlayerSection()
miscellaneousPlayerFeaturesSection()
customPictureInPictureSection()
systemPictureInPictureSection()
vanillaPlayerSection()
Expand Down Expand Up @@ -205,13 +205,21 @@ struct ShowcaseView: View {
}

@ViewBuilder
private func inlineSystemPlayerSection() -> some View {
CustomSection("Inline system player (using Pillarbox)") {
private func miscellaneousPlayerFeaturesSection() -> some View {
CustomSection("Miscellaneous player features (using Pillarbox)") {
cell(
title: "Couleur 3 (DVR)",
subtitle: "Inline system playback view",
destination: .inlineSystemPlayer(media: Media(from: URLTemplate.dvrVideoHLS))
)
.sourceCode(of: InlineSystemPlayerView.self)

cell(
title: "19h30 - La nature peut enfin se reposer...",
subtitle: "Playback start at a given time",
destination: .startAtGivenTime
)
.sourceCode(of: StartAtGivenTimeView.self)
}
}

Expand Down
21 changes: 14 additions & 7 deletions Sources/CoreBusiness/PlayerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ public extension PlayerItem {
/// - Parameters:
/// - urn: The URN to play.
/// - server: The server which the URN is played from.
/// - trackerAdapters: An array of `TrackerAdapter` instances to use for tracking playback events.
/// - configuration: A closure to configure player items created from the receiver.
///
/// The item is automatically tracked according to SRG SSR analytics standards.
/// In addition the item is automatically tracked according to SRG SSR analytics standards.
static func urn(
_ urn: String,
server: Server = .production,
trackerAdapters: [TrackerAdapter<MediaMetadata>] = []
trackerAdapters: [TrackerAdapter<MediaMetadata>] = [],
configuration: @escaping (AVPlayerItem) -> Void = { _ in }
) -> Self {
let dataProvider = DataProvider(server: server)
let publisher = dataProvider.playableMediaCompositionPublisher(forUrn: urn)
Expand All @@ -35,7 +38,7 @@ public extension PlayerItem {
.prepend(nil)
.map { image in
let metadata = MediaMetadata(mediaComposition: mediaComposition, resource: resource, image: image)
return Self.asset(for: metadata)
return Self.asset(for: metadata, configuration: configuration)
}
}
.switchToLatest()
Expand All @@ -46,9 +49,9 @@ public extension PlayerItem {
] + trackerAdapters)
}

private static func asset(for metadata: MediaMetadata) -> Asset<MediaMetadata> {
private static func asset(for metadata: MediaMetadata, configuration: @escaping (AVPlayerItem) -> Void) -> Asset<MediaMetadata> {
let resource = metadata.resource
let configuration = assetConfiguration(for: resource)
let configuration = assetConfiguration(for: resource, configuration: configuration)

if let certificateUrl = resource.drms.first(where: { $0.type == .fairPlay })?.certificateUrl {
return .encrypted(
Expand All @@ -74,11 +77,15 @@ public extension PlayerItem {
}
}

private static func assetConfiguration(for resource: Resource) -> ((AVPlayerItem) -> Void) {
private static func assetConfiguration(for resource: Resource, configuration: @escaping (AVPlayerItem) -> Void) -> ((AVPlayerItem) -> Void) {
{ item in
guard resource.streamType == .live else { return }
configuration(item)

// Limit buffering and force the player to return to the live edge when re-buffering. This ensures
// livestreams cannot be paused and resumed in the past, as requested by business people.
//
// This setup is performed after any custom configuration to avoid being overridden.
guard resource.streamType == .live else { return }
item.automaticallyPreservesTimeOffsetFromLive = true
item.preferredForwardBufferDuration = 1
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Player/Asset.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ extension Asset {
}

extension AVPlayerItem {
/// An identifier to identify player items delivered by the same data source.
/// An identifier for player items delivered by the same data source.
var id: UUID? {
get {
objc_getAssociatedObject(self, &kIdKey) as? UUID
Expand All @@ -255,7 +255,7 @@ extension AVPlayerItem {
}
}

/// Assigns an identifier to identify player items delivered by the same data source.
/// Assigns an identifier for player items delivered by the same data source.
///
/// - Parameter id: The id to assign.
/// - Returns: The receiver with the id assigned to it.
Expand Down
11 changes: 11 additions & 0 deletions Sources/Player/Extensions/AVPlayerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@

import AVFoundation

public extension AVPlayerItem {
/// Seeks to a given position.
///
/// - Parameter position: The position to seek to.
///
/// This method can be used to start playback of an `AVPlayerItem` at some position.
func seek(_ position: Position) {
seek(to: position.time, toleranceBefore: position.toleranceBefore, toleranceAfter: position.toleranceAfter, completionHandler: nil)
}
}

extension AVPlayerItem {
var timeRange: CMTimeRange {
TimeProperties.timeRange(loadedTimeRanges: loadedTimeRanges, seekableTimeRanges: seekableTimeRanges)
Expand Down
45 changes: 33 additions & 12 deletions Sources/Player/Player.docc/Articles/playback/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ You create a player with or without associated items to be played. Since ``Playe

```swift
struct PlayerView: View {
@StateObject private var player = Player(item:
.simple(url: URL(string: "https://server.com/stream.m3u8")!)
@StateObject private var player = Player(
item: .simple(url: URL(string: "https://server.com/stream.m3u8")!)
)
}
```
Expand Down Expand Up @@ -76,19 +76,40 @@ The resulting player item can then be played with ``Player`` instance, possibly

A player loaded with content starts in a paused state. To actually start playback you have to call ``Player/play()``, usually when the associated view appears. For example a very basic layout able to display video content would look like:

<!-- markdownlint-disable MD046 -->
```swift
struct PlayerView: View {
@StateObject private var player = Player(item:
.simple(url: URL(string: "https://server.com/stream.m3u8")!)
)
<!-- markdownlint-disable MD034 -->
@TabNavigator {
@Tab("Start at the default position") {
```swift
struct PlayerView: View {
@StateObject private var player = Player(
item: .simple(url: URL(string: "https://server.com/stream.m3u8")!)
)

var body: some View {
VideoView(player: player)
.onAppear(perform: player.play)
}
}
```
}

var body: some View {
VideoView(player: player)
.onAppear(perform: player.play)
@Tab("Start at a given position") {
```swift
struct PlayerView: View {
@StateObject private var player = Player(
item: .simple(url: URL(string: "https://server.com/stream.m3u8")!) { item in
item.seek(at(.init(value: 10, timescale: 1)))
}
)

var body: some View {
VideoView(player: player)
.onAppear(perform: player.play)
}
}
```
}
}
```
<!-- markdownlint-restore -->

## Play video in the background
Expand Down
2 changes: 1 addition & 1 deletion Sources/Player/PlayerItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public final class PlayerItem: Equatable {
///
/// - Parameters:
/// - asset: The asset to play.
/// - configuration: A closure to configure player items created from the receiver.
/// - trackerAdapters: An array of `TrackerAdapter` instances to use for tracking playback events.
public convenience init<M>(asset: Asset<M>, trackerAdapters: [TrackerAdapter<M>] = []) where M: AssetMetadata {
self.init(publisher: Just(asset), trackerAdapters: trackerAdapters)
}
Expand Down
14 changes: 14 additions & 0 deletions Tests/PlayerTests/Player/SeekTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,18 @@ final class SeekTests: TestCase {
player.seek(near(player.seekableTimeRange.end + CMTime(value: 10, timescale: 1)))
expect(player.time).toEventually(equal(player.seekableTimeRange.end), timeout: .seconds(1))
}

func testOnDemandStartAtTime() {
let player = Player(item: .simple(url: Stream.onDemand.url) { item in
item.seek(at(.init(value: 10, timescale: 1)))
})
expect(player.time.seconds).toEventually(equal(10), timeout: .seconds(1))
}

func testDvrStartAtTime() {
let player = Player(item: .simple(url: Stream.dvr.url) { item in
item.seek(at(.init(value: 10, timescale: 1)))
})
expect(player.time.seconds).toEventually(equal(10), timeout: .seconds(1))
}
}

0 comments on commit c7285ee

Please sign in to comment.