diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa79e4e..dc3f4a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to this project will be documented in this file. * [gem] Cocoapods -> 1.10.1 * [gem] Fastlane -> 2.171.0 +## [1.7.4] +* Improvements: + * Support seek tolerance API + ## [1.7.3] * Fix: * Resume audio on device background mode diff --git a/Sources/Core/Components/PlayerCommand.swift b/Sources/Core/Components/PlayerCommand.swift index 7e133116..cae8a7db 100644 --- a/Sources/Core/Components/PlayerCommand.swift +++ b/Sources/Core/Components/PlayerCommand.swift @@ -25,11 +25,13 @@ // THE SOFTWARE. import Foundation +import CoreMedia public protocol PlayerCommand { func load(media: PlayerMedia, autostart: Bool, position: Double?) func pause() func play() func seek(position: Double) + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) func stop() } diff --git a/Sources/Core/Components/PlayerContext.swift b/Sources/Core/Components/PlayerContext.swift index 5cdae301..82336656 100644 --- a/Sources/Core/Components/PlayerContext.swift +++ b/Sources/Core/Components/PlayerContext.swift @@ -160,12 +160,32 @@ final class ModernAVPlayerContext: NSObject, PlayerContext { assertionFailure("boundedPosition should return at least value or reason") } } + + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + guard let item = currentItem + else { unaivalableCommand(reason: .loadMediaFirst); return } + + let seekService = ModernAVPlayerSeekService(preferredTimescale: config.preferredTimescale) + let seekPosition = seekService.boundedPosition(position, item: item) + if let boundedPosition = seekPosition.value { + state.seek(position: boundedPosition, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) + } else if let reason = seekPosition.reason { + unaivalableCommand(reason: reason) + } else { + assertionFailure("boundedPosition should return at least value or reason") + } + } func seek(offset: Double) { let position = currentTime + offset seek(position: position) } + func seek(offset: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + let position = currentTime + offset + seek(position: position, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) + } + func stop() { state.stop() } diff --git a/Sources/Core/ModernAVPlayer.swift b/Sources/Core/ModernAVPlayer.swift index bde2657d..05302b5c 100644 --- a/Sources/Core/ModernAVPlayer.swift +++ b/Sources/Core/ModernAVPlayer.swift @@ -109,6 +109,18 @@ public final class ModernAVPlayer: NSObject, ModernAVPlayerExposable { context.seek(position: position) } + /// + /// Sets the media current time to the specified position + /// + /// - Note: position is bounded between 0 and end time or available ranges + /// - parameter position: time to seek + /// - patameter toleranceBefore: CMTime for toleranceBefore + /// - patameter toleranceAfter: CMTime for toleranceAfter + /// + public func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + context.seek(position: position, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) + } + /// /// Apply offset to the media current time /// diff --git a/Sources/Core/State/BufferingState.swift b/Sources/Core/State/BufferingState.swift index 75438a5e..1927b23e 100644 --- a/Sources/Core/State/BufferingState.swift +++ b/Sources/Core/State/BufferingState.swift @@ -112,6 +112,16 @@ final class BufferingState: NSObject, PlayerState { strongSelf.playCommand() } } + + func seekCommand(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + context.currentItem?.cancelPendingSeeks() + let time = CMTime(seconds: position, preferredTimescale: context.config.preferredTimescale) + context.player.seek(to: time, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) { [weak self] completed in + guard completed, let strongSelf = self else { return } + strongSelf.context.delegate?.playerContext(didCurrentTimeChange: strongSelf.context.currentTime) + strongSelf.playCommand() + } + } // MARK: - Shared actions @@ -133,6 +143,10 @@ final class BufferingState: NSObject, PlayerState { func seek(position: Double) { seekCommand(position: position) } + + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + seekCommand(position: position, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) + } func stop() { changeState(StoppedState(context: context)) diff --git a/Sources/Core/State/FailedState.swift b/Sources/Core/State/FailedState.swift index f092a463..d5b3bbe4 100644 --- a/Sources/Core/State/FailedState.swift +++ b/Sources/Core/State/FailedState.swift @@ -85,6 +85,12 @@ final class FailedState: PlayerState { ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) context.delegate?.playerContext(unavailableActionReason: .loadMediaFirst) } + + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + let debug = "Unable to seek, load a media first" + ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) + context.delegate?.playerContext(unavailableActionReason: .loadMediaFirst) + } func stop() { let debug = "Unable to stop, load a media first" diff --git a/Sources/Core/State/InitState.swift b/Sources/Core/State/InitState.swift index 2230957a..201bd092 100644 --- a/Sources/Core/State/InitState.swift +++ b/Sources/Core/State/InitState.swift @@ -70,6 +70,12 @@ struct InitState: PlayerState { context.delegate?.playerContext(unavailableActionReason: .loadMediaFirst) } + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + let debug = "Load item before seeking" + ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) + context.delegate?.playerContext(unavailableActionReason: .loadMediaFirst) + } + func stop() { context.changeState(state: StoppedState(context: context)) } diff --git a/Sources/Core/State/LoadedState.swift b/Sources/Core/State/LoadedState.swift index 07d8661c..f84a4ecc 100644 --- a/Sources/Core/State/LoadedState.swift +++ b/Sources/Core/State/LoadedState.swift @@ -83,6 +83,16 @@ struct LoadedState: PlayerState { value: context.currentTime) } } + + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + let time = CMTime(seconds: position, preferredTimescale: context.config.preferredTimescale) + context.player.seek(to: time) { [context] completed in + guard completed else { return } + context.delegate?.playerContext(didCurrentTimeChange: context.currentTime) + context.nowPlaying.overrideInfoCenter(for: MPNowPlayingInfoPropertyElapsedPlaybackTime, + value: context.currentTime) + } + } func stop() { context.changeState(state: StoppedState(context: context)) diff --git a/Sources/Core/State/LoadingMediaState.swift b/Sources/Core/State/LoadingMediaState.swift index 131c29ac..056c4dab 100644 --- a/Sources/Core/State/LoadingMediaState.swift +++ b/Sources/Core/State/LoadingMediaState.swift @@ -118,6 +118,12 @@ final class LoadingMediaState: PlayerState { ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) context.delegate?.playerContext(unavailableActionReason: .waitLoadedMedia) } + + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + let debug = "Wait media to be loaded before seeking" + ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) + context.delegate?.playerContext(unavailableActionReason: .waitLoadedMedia) + } func stop() { cancelMediaLoading() diff --git a/Sources/Core/State/PausedState.swift b/Sources/Core/State/PausedState.swift index c9352466..951a4bc2 100644 --- a/Sources/Core/State/PausedState.swift +++ b/Sources/Core/State/PausedState.swift @@ -103,6 +103,16 @@ class PausedState: PlayerState { value: context.currentTime) } } + + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + let time = CMTime(seconds: position, preferredTimescale: context.config.preferredTimescale) + context.player.seek(to: time, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) { [weak self] completed in + guard completed, let context = self?.context else { return } + context.delegate?.playerContext(didCurrentTimeChange: context.currentTime) + context.nowPlaying.overrideInfoCenter(for: MPNowPlayingInfoPropertyElapsedPlaybackTime, + value: context.currentTime) + } + } func stop() { context.changeState(state: StoppedState(context: context)) diff --git a/Sources/Core/State/PlayingState.swift b/Sources/Core/State/PlayingState.swift index 9e73dacc..b1c6a930 100644 --- a/Sources/Core/State/PlayingState.swift +++ b/Sources/Core/State/PlayingState.swift @@ -119,6 +119,12 @@ final class PlayingState: PlayerState { state.seekCommand(position: position) } + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + let state = BufferingState(context: context) + changeState(state: state) + state.seekCommand(position: position, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) + } + func stop() { let state = StoppedState(context: context) changeState(state: state) diff --git a/Sources/Core/State/WaitingNetworkState.swift b/Sources/Core/State/WaitingNetworkState.swift index 4ab0764e..a7b690d4 100644 --- a/Sources/Core/State/WaitingNetworkState.swift +++ b/Sources/Core/State/WaitingNetworkState.swift @@ -116,6 +116,12 @@ final class WaitingNetworkState: PlayerState { context.delegate?.playerContext(unavailableActionReason: .waitEstablishedNetwork) } + func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + let debug = "Reload a media first before seeking" + ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) + context.delegate?.playerContext(unavailableActionReason: .waitEstablishedNetwork) + } + func stop() { context.changeState(state: StoppedState(context: context)) } diff --git a/Tests/Generated/Mock.generated.swift b/Tests/Generated/Mock.generated.swift index d6b39f27..55a01877 100644 --- a/Tests/Generated/Mock.generated.swift +++ b/Tests/Generated/Mock.generated.swift @@ -889,6 +889,12 @@ open class PlayerContextMock: PlayerContext, Mock { let perform = methodPerformValue(.m_seek__position_position(Parameter.value(`position`))) as? (Double) -> Void perform?(`position`) } + + public func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + addInvocation(.m_seek__position_position(Parameter.value(`position`))) + let perform = methodPerformValue(.m_seek__position_position(Parameter.value(`position`))) as? (Double) -> Void + perform?(`position`) + } open func stop() { addInvocation(.m_stop) @@ -2366,6 +2372,12 @@ open class PlayerStateMock: PlayerState, Mock { let perform = methodPerformValue(.m_seek__position_position(Parameter.value(`position`))) as? (Double) -> Void perform?(`position`) } + + public func seek(position: Double, toleranceBefore: CMTime, toleranceAfter: CMTime) { + addInvocation(.m_seek__position_position(Parameter.value(`position`))) + let perform = methodPerformValue(.m_seek__position_position(Parameter.value(`position`))) as? (Double) -> Void + perform?(`position`) + } open func stop() { addInvocation(.m_stop) diff --git a/Tests/PlayerContextTests.swift b/Tests/PlayerContextTests.swift index 4cd27716..b8e91bfb 100644 --- a/Tests/PlayerContextTests.swift +++ b/Tests/PlayerContextTests.swift @@ -195,6 +195,17 @@ final class PlayerContextTests: XCTestCase { // ASSERT Verify(delegate, 1, .playerContext(unavailableActionReason: .value(.loadMediaFirst))) } + + func testSeekToleranceWithNoCurrentItem() { + // ARRANGE + context.changeState(state: state) + + // ACT + context.seek(position: 0, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) + + // ASSERT + Verify(delegate, 1, .playerContext(unavailableActionReason: .value(.loadMediaFirst))) + } func testOverstepSeekPosition() { // ARRANGE @@ -209,6 +220,19 @@ final class PlayerContextTests: XCTestCase { // ASSERT Verify(delegate, 1, .playerContext(unavailableActionReason: .value(.seekOverstepPosition))) } + + func testOverstepSeekTolerancePosition() { + // ARRANGE + let seekPosition: Double = 43 + let duration = CMTime(seconds: 42, preferredTimescale: config.preferredTimescale) + player.overrideCurrentItem = MockPlayerItem(url: URL(fileURLWithPath: ""), + duration: duration, status: nil) + + // ACT + context.seek(position: seekPosition, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) + // ASSERT + Verify(delegate, 1, .playerContext(unavailableActionReason: .value(.seekOverstepPosition))) + } func testValidSeekPosition() { // ARRANGE @@ -224,6 +248,21 @@ final class PlayerContextTests: XCTestCase { // ASSERT Verify(state, 1, .seek(position: .value(seekPosition))) } + + func testValidSeekTolerancePosition() { + // ARRANGE + let seekPosition: Double = 21 + let duration = CMTime(seconds: 42, preferredTimescale: config.preferredTimescale) + player.overrideCurrentItem = MockPlayerItem(url: URL(fileURLWithPath: ""), + duration: duration, status: nil) + context.changeState(state: state) + + // ACT + context.seek(position: seekPosition, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) + // ASSERT + Verify(state, 1, .seek(position: .value(seekPosition))) + + } func testValidSeekOffset() { // ARRANGE @@ -242,6 +281,23 @@ final class PlayerContextTests: XCTestCase { let expected = seekPosition.seconds + offset Verify(state, 1, .seek(position: .value(expected))) } + + func testValidSeekToleranceOffset() { + // ARRANGE + let seekPosition = CMTime(seconds: 21, preferredTimescale: config.preferredTimescale) + let duration = CMTime(seconds: 42, preferredTimescale: config.preferredTimescale) + let offset: Double = 10 + player.overrideCurrentTime = seekPosition + player.overrideCurrentItem = MockPlayerItem(url: URL(fileURLWithPath: ""), + duration: duration, status: nil) + context.changeState(state: state) + + // ACT + context.seek(offset: offset, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) + // ASSERT + let expected = seekPosition.seconds + offset + Verify(state, 1, .seek(position: .value(expected))) + } func testLoadMedia() { // ARRANGE diff --git a/Tests/State/FailedStateTests.swift b/Tests/State/FailedStateTests.swift index 350eadba..37955c15 100644 --- a/Tests/State/FailedStateTests.swift +++ b/Tests/State/FailedStateTests.swift @@ -95,6 +95,14 @@ final class FailedStateTests: XCTestCase { // ASSERT Verify(contextDelegate, 1, .playerContext(unavailableActionReason: .value(.loadMediaFirst))) } + + func testSeekTolerance() { + // ACT + state.seek(position: 0, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) + + // ASSERT + Verify(contextDelegate, 1, .playerContext(unavailableActionReason: .value(.loadMediaFirst))) + } func testFailedUsedAVPlayerItem() { // ACT diff --git a/Tests/State/InitStateTests.swift b/Tests/State/InitStateTests.swift index 89a2c70e..a200aeb9 100644 --- a/Tests/State/InitStateTests.swift +++ b/Tests/State/InitStateTests.swift @@ -103,4 +103,12 @@ final class InitStateTests: XCTestCase { // ASSERT Verify(contextDelegate, 1, .playerContext(unavailableActionReason: .value(.loadMediaFirst))) } + + func testSeekToleranceCall() { + // ACT + state.seek(position: 42, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) + + // ASSERT + Verify(contextDelegate, 1, .playerContext(unavailableActionReason: .value(.loadMediaFirst))) + } } diff --git a/Tests/State/WaitingNetworkStateTests.swift b/Tests/State/WaitingNetworkStateTests.swift index d3a0e9d2..0ea66955 100644 --- a/Tests/State/WaitingNetworkStateTests.swift +++ b/Tests/State/WaitingNetworkStateTests.swift @@ -87,6 +87,13 @@ final class WaitingNetworkStateTest: XCTestCase { // ASSERT Verify(contextDelegate, 1, .playerContext(unavailableActionReason: .value(.waitEstablishedNetwork))) } + + func testSeekTolerance() { + // ACT + state.seek(position: 0, toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero) + // ASSERT + Verify(contextDelegate, 1, .playerContext(unavailableActionReason: .value(.waitEstablishedNetwork))) + } func testPause() { // ACT