diff --git a/CHANGELOG.md b/CHANGELOG.md index 49443bbe..5b27134f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. --- +## [X.X.X] +* Feature: + * Accurate seek api + ## [1.7.3] * Fix: * Resume audio on device background mode diff --git a/Example/RemoteCommandExample.swift b/Example/RemoteCommandExample.swift index 693ec92a..67c0d31d 100644 --- a/Example/RemoteCommandExample.swift +++ b/Example/RemoteCommandExample.swift @@ -126,7 +126,7 @@ public struct RemoteCommandFactoryExample { guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } let position = e.positionTime - self.player.seek(position: position) + self.player.seek(position: position, isAccurate: false) return .success } command.addTarget(handler: handler) @@ -145,7 +145,7 @@ public struct RemoteCommandFactoryExample { else { return .commandFailed } let position = max(self.player.currentTime - skipTime, 0) - self.player.seek(position: position) + self.player.seek(position: position, isAccurate: false) return .success } command.addTarget(handler: handler) @@ -164,7 +164,7 @@ public struct RemoteCommandFactoryExample { else { return .commandFailed } let position = self.player.currentTime + skipTime - self.player.seek(position: position) + self.player.seek(position: position, isAccurate: false) return .success } command.addTarget(handler: handler) diff --git a/Sources/Core/Components/PlayerCommand.swift b/Sources/Core/Components/PlayerCommand.swift index 7e133116..8071c304 100644 --- a/Sources/Core/Components/PlayerCommand.swift +++ b/Sources/Core/Components/PlayerCommand.swift @@ -30,6 +30,6 @@ public protocol PlayerCommand { func load(media: PlayerMedia, autostart: Bool, position: Double?) func pause() func play() - func seek(position: Double) + func seek(position: Double, isAccurate: Bool) func stop() } diff --git a/Sources/Core/Components/PlayerContext.swift b/Sources/Core/Components/PlayerContext.swift index 5cdae301..bb58700c 100644 --- a/Sources/Core/Components/PlayerContext.swift +++ b/Sources/Core/Components/PlayerContext.swift @@ -146,14 +146,14 @@ final class ModernAVPlayerContext: NSObject, PlayerContext { state.pause() } - func seek(position: Double) { + func seek(position: Double, isAccurate: Bool) { 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) + state.seek(position: boundedPosition, isAccurate: isAccurate) } else if let reason = seekPosition.reason { unaivalableCommand(reason: reason) } else { @@ -161,9 +161,9 @@ final class ModernAVPlayerContext: NSObject, PlayerContext { } } - func seek(offset: Double) { + func seek(offset: Double, isAccurate: Bool) { let position = currentTime + offset - seek(position: position) + seek(position: position, isAccurate: isAccurate) } func stop() { diff --git a/Sources/Core/Components/RemoteCommand/ModernAVPlayerRemoteCommandFactory.swift b/Sources/Core/Components/RemoteCommand/ModernAVPlayerRemoteCommandFactory.swift index 002dfa75..0b9fd134 100644 --- a/Sources/Core/Components/RemoteCommand/ModernAVPlayerRemoteCommandFactory.swift +++ b/Sources/Core/Components/RemoteCommand/ModernAVPlayerRemoteCommandFactory.swift @@ -165,7 +165,7 @@ public class ModernAVPlayerRemoteCommandFactory { let position = e.positionTime ModernAVPlayerLogger.instance.log(message: "Remote command: seek to \(position)", domain: .service) - self.player.seek(position: position) + self.player.seek(position: position, isAccurate: false) return .success } command.addTarget(handler: handler) @@ -191,7 +191,7 @@ public class ModernAVPlayerRemoteCommandFactory { ModernAVPlayerLogger.instance.log(message: "Remote command: skipBackward", domain: .service) let position = max(self.player.currentTime - skipTime, 0) - self.player.seek(position: position) + self.player.seek(position: position, isAccurate: false) return .success } command.addTarget(handler: handler) @@ -217,7 +217,7 @@ public class ModernAVPlayerRemoteCommandFactory { ModernAVPlayerLogger.instance.log(message: "Remote command: skipForward", domain: .service) let position = self.player.currentTime + skipTime - self.player.seek(position: position) + self.player.seek(position: position, isAccurate: false) return .success } command.addTarget(handler: handler) diff --git a/Sources/Core/ModernAVPlayer.swift b/Sources/Core/ModernAVPlayer.swift index bde2657d..a55e7775 100644 --- a/Sources/Core/ModernAVPlayer.swift +++ b/Sources/Core/ModernAVPlayer.swift @@ -102,21 +102,47 @@ public final class ModernAVPlayer: NSObject, ModernAVPlayerExposable { /// /// Sets the media current time to the specified position /// - /// - Note: position is bounded between 0 and end time or available ranges + /// - Note: position is bounded between 0 and end time or available ranges, resulting seek position may differ slightly for efficiency /// - parameter position: time to seek /// + @available(*, deprecated, message: "use func seek(position: Double, isAccurate: Bool = false) instead") public func seek(position: Double) { - context.seek(position: position) + context.seek(position: position, isAccurate: false) + } + + /// + /// 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 + /// - parameter isAccurate: pass true if you desire presice seeking, may incur additional decoding delay + /// which can impact seeking performance, default is false + /// + public func seek(position: Double, isAccurate: Bool = false) { + context.seek(position: position, isAccurate: isAccurate) } /// /// Apply offset to the media current time /// - /// - Note: this method compute position then call then seek(position:) + /// - Note: this method compute position then call then seek(position:), resulting seek position may differ slightly for efficiency /// - parameter offset: offset to apply /// + @available(*, deprecated, message: "use func seek(offset: Double, isAccurate: Bool = false) instead") public func seek(offset: Double) { - context.seek(offset: offset) + context.seek(offset: offset, isAccurate: false) + } + + /// + /// Apply offset to the media current time + /// + /// - Note: this method compute position then call then seek(position:), resulting seek position may differ slightly for efficiency + /// - parameter offset: offset to apply + /// - parameter isAccurate: pass true if you desire presice seeking, + /// may incur additional decoding delay which can impact seeking performance, default is false + /// + public func seek(offset: Double, isAccurate: Bool = false) { + context.seek(offset: offset, isAccurate: isAccurate) } /// Stops playback of the current item then seek to 0 diff --git a/Sources/Core/State/BufferingState.swift b/Sources/Core/State/BufferingState.swift index 75438a5e..9151149b 100644 --- a/Sources/Core/State/BufferingState.swift +++ b/Sources/Core/State/BufferingState.swift @@ -103,10 +103,11 @@ final class BufferingState: NSObject, PlayerState { context.player.play() } - func seekCommand(position: Double) { + func seekCommand(position: Double, isAccurate: Bool) { context.currentItem?.cancelPendingSeeks() let time = CMTime(seconds: position, preferredTimescale: context.config.preferredTimescale) - context.player.seek(to: time) { [weak self] completed in + let tolerance: CMTime = isAccurate ? .zero : .positiveInfinity + context.player.seek(to: time, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] completed in guard completed, let strongSelf = self else { return } strongSelf.context.delegate?.playerContext(didCurrentTimeChange: strongSelf.context.currentTime) strongSelf.playCommand() @@ -130,8 +131,8 @@ final class BufferingState: NSObject, PlayerState { context.delegate?.playerContext(unavailableActionReason: .alreadyTryingToPlay) } - func seek(position: Double) { - seekCommand(position: position) + func seek(position: Double, isAccurate: Bool) { + seekCommand(position: position, isAccurate: isAccurate) } func stop() { diff --git a/Sources/Core/State/FailedState.swift b/Sources/Core/State/FailedState.swift index f092a463..18d4cc75 100644 --- a/Sources/Core/State/FailedState.swift +++ b/Sources/Core/State/FailedState.swift @@ -80,7 +80,7 @@ final class FailedState: PlayerState { context.changeState(state: state) } - func seek(position: Double) { + func seek(position: Double, isAccurate: Bool) { let debug = "Unable to seek, load a media first" ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) context.delegate?.playerContext(unavailableActionReason: .loadMediaFirst) diff --git a/Sources/Core/State/InitState.swift b/Sources/Core/State/InitState.swift index 2230957a..597667e6 100644 --- a/Sources/Core/State/InitState.swift +++ b/Sources/Core/State/InitState.swift @@ -64,7 +64,7 @@ struct InitState: PlayerState { context.delegate?.playerContext(unavailableActionReason: .loadMediaFirst) } - func seek(position: Double) { + func seek(position: Double, isAccurate: Bool) { let debug = "Load item before seeking" ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) context.delegate?.playerContext(unavailableActionReason: .loadMediaFirst) diff --git a/Sources/Core/State/LoadedState.swift b/Sources/Core/State/LoadedState.swift index 07d8661c..0712621c 100644 --- a/Sources/Core/State/LoadedState.swift +++ b/Sources/Core/State/LoadedState.swift @@ -74,9 +74,10 @@ struct LoadedState: PlayerState { state.playCommand() } - func seek(position: Double) { + func seek(position: Double, isAccurate: Bool) { let time = CMTime(seconds: position, preferredTimescale: context.config.preferredTimescale) - context.player.seek(to: time) { [context] completed in + let tolerance: CMTime = isAccurate ? .zero : .positiveInfinity + context.player.seek(to: time, toleranceBefore: tolerance, toleranceAfter: tolerance) { [context] completed in guard completed else { return } context.delegate?.playerContext(didCurrentTimeChange: context.currentTime) context.nowPlaying.overrideInfoCenter(for: MPNowPlayingInfoPropertyElapsedPlaybackTime, diff --git a/Sources/Core/State/LoadingMediaState.swift b/Sources/Core/State/LoadingMediaState.swift index 131c29ac..306c157d 100644 --- a/Sources/Core/State/LoadingMediaState.swift +++ b/Sources/Core/State/LoadingMediaState.swift @@ -113,7 +113,7 @@ final class LoadingMediaState: PlayerState { context.delegate?.playerContext(unavailableActionReason: .waitLoadedMedia) } - func seek(position: Double) { + func seek(position: Double, isAccurate: Bool) { let debug = "Wait media to be loaded before seeking" ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) context.delegate?.playerContext(unavailableActionReason: .waitLoadedMedia) diff --git a/Sources/Core/State/PausedState.swift b/Sources/Core/State/PausedState.swift index c9352466..2b581335 100644 --- a/Sources/Core/State/PausedState.swift +++ b/Sources/Core/State/PausedState.swift @@ -94,9 +94,10 @@ class PausedState: PlayerState { } } - func seek(position: Double) { + func seek(position: Double, isAccurate: Bool) { let time = CMTime(seconds: position, preferredTimescale: context.config.preferredTimescale) - context.player.seek(to: time) { [weak self] completed in + let tolerance: CMTime = isAccurate ? .zero : .positiveInfinity + context.player.seek(to: time, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] completed in guard completed, let context = self?.context else { return } context.delegate?.playerContext(didCurrentTimeChange: context.currentTime) context.nowPlaying.overrideInfoCenter(for: MPNowPlayingInfoPropertyElapsedPlaybackTime, diff --git a/Sources/Core/State/PlayingState.swift b/Sources/Core/State/PlayingState.swift index 9e73dacc..30c7665f 100644 --- a/Sources/Core/State/PlayingState.swift +++ b/Sources/Core/State/PlayingState.swift @@ -113,10 +113,10 @@ final class PlayingState: PlayerState { context.delegate?.playerContext(unavailableActionReason: .alreadyPlaying) } - func seek(position: Double) { + func seek(position: Double, isAccurate: Bool) { let state = BufferingState(context: context) changeState(state: state) - state.seekCommand(position: position) + state.seekCommand(position: position, isAccurate: isAccurate) } func stop() { @@ -142,7 +142,7 @@ final class PlayingState: PlayerState { $0.didItemPlayToEndTime(media: media, endTime: strongSelf.context.currentTime) } if strongSelf.context.loopMode { - strongSelf.seek(position: 0) + strongSelf.seek(position: 0, isAccurate: false) } else { strongSelf.stop() } diff --git a/Sources/Core/State/StoppedState.swift b/Sources/Core/State/StoppedState.swift index 68f260fd..6be1f3e4 100644 --- a/Sources/Core/State/StoppedState.swift +++ b/Sources/Core/State/StoppedState.swift @@ -34,7 +34,7 @@ final class StoppedState: PausedState { init(context: PlayerContext) { super.init(context: context, type: .stopped) - seek(position: 0) + seek(position: 0, isAccurate: false) } override func contextUpdated() { diff --git a/Sources/Core/State/WaitingNetworkState.swift b/Sources/Core/State/WaitingNetworkState.swift index 4ab0764e..d432be73 100644 --- a/Sources/Core/State/WaitingNetworkState.swift +++ b/Sources/Core/State/WaitingNetworkState.swift @@ -110,7 +110,7 @@ final class WaitingNetworkState: PlayerState { context.delegate?.playerContext(unavailableActionReason: .waitEstablishedNetwork) } - func seek(position: Double) { + func seek(position: Double, isAccurate: Bool) { let debug = "Reload a media first before seeking" ModernAVPlayerLogger.instance.log(message: debug, domain: .unavailableCommand) context.delegate?.playerContext(unavailableActionReason: .waitEstablishedNetwork) diff --git a/Tests/PlayerContextTests.swift b/Tests/PlayerContextTests.swift index 4cd27716..f7325aef 100644 --- a/Tests/PlayerContextTests.swift +++ b/Tests/PlayerContextTests.swift @@ -190,7 +190,7 @@ final class PlayerContextTests: XCTestCase { context.changeState(state: state) // ACT - context.seek(position: 0) + context.seek(position: 0, isAccurate: false) // ASSERT Verify(delegate, 1, .playerContext(unavailableActionReason: .value(.loadMediaFirst))) @@ -204,7 +204,7 @@ final class PlayerContextTests: XCTestCase { duration: duration, status: nil) // ACT - context.seek(position: seekPosition) + context.seek(position: seekPosition, isAccurate: false) // ASSERT Verify(delegate, 1, .playerContext(unavailableActionReason: .value(.seekOverstepPosition))) @@ -219,10 +219,10 @@ final class PlayerContextTests: XCTestCase { context.changeState(state: state) // ACT - context.seek(position: seekPosition) + context.seek(position: seekPosition, isAccurate: false) // ASSERT - Verify(state, 1, .seek(position: .value(seekPosition))) + Verify(state, 1, .seek(position: .value(seekPosition), isAccurate: false)) } func testValidSeekOffset() { @@ -236,11 +236,11 @@ final class PlayerContextTests: XCTestCase { context.changeState(state: state) // ACT - context.seek(offset: offset) + context.seek(offset: offset, isAccurate: false) // ASSERT let expected = seekPosition.seconds + offset - Verify(state, 1, .seek(position: .value(expected))) + Verify(state, 1, .seek(position: .value(expected), isAccurate: false)) } func testLoadMedia() { diff --git a/Tests/State/BufferingStateSpecs.swift b/Tests/State/BufferingStateSpecs.swift index 264cbfd1..4d9b32ca 100644 --- a/Tests/State/BufferingStateSpecs.swift +++ b/Tests/State/BufferingStateSpecs.swift @@ -145,7 +145,7 @@ final class BufferingStateSpecs: QuickSpec { self.mockPlayer.seekCompletionHandlerReturn = true // ACT - self.bufferingState.seekCommand(position: 42) + self.bufferingState.seekCommand(position: 42, isAccurate: false) } it("should call didCurrentTimeChange delegate method") { @@ -168,7 +168,7 @@ final class BufferingStateSpecs: QuickSpec { self.mockPlayer.seekCompletionHandlerReturn = false // ACT - self.bufferingState.seekCommand(position: 42) + self.bufferingState.seekCommand(position: 42, isAccurate: false) } it("should not call didCurrentTimeChange delegate method") { @@ -246,7 +246,7 @@ final class BufferingStateSpecs: QuickSpec { it("should cancel pending seek") { // ACT - self.bufferingState.seek(position: CMTime.zero.seconds) + self.bufferingState.seek(position: CMTime.zero.seconds, isAccurate: false) // ASSERT expect(self.item.cancelPendingSeeksCallCount).to(equal(1)) @@ -255,7 +255,7 @@ final class BufferingStateSpecs: QuickSpec { it("should call player seek command") { // ACT - self.bufferingState.seek(position: CMTime.zero.seconds) + self.bufferingState.seek(position: CMTime.zero.seconds, isAccurate: false) // ASSERT expect(self.mockPlayer.seekCompletionCallCount).to(equal(1)) diff --git a/Tests/State/FailedStateTests.swift b/Tests/State/FailedStateTests.swift index 350eadba..5196f0f0 100644 --- a/Tests/State/FailedStateTests.swift +++ b/Tests/State/FailedStateTests.swift @@ -90,7 +90,7 @@ final class FailedStateTests: XCTestCase { func testSeek() { // ACT - state.seek(position: 0) + state.seek(position: 0, isAccurate: false) // ASSERT Verify(contextDelegate, 1, .playerContext(unavailableActionReason: .value(.loadMediaFirst))) diff --git a/Tests/State/InitStateTests.swift b/Tests/State/InitStateTests.swift index 89a2c70e..58668d15 100644 --- a/Tests/State/InitStateTests.swift +++ b/Tests/State/InitStateTests.swift @@ -98,7 +98,7 @@ final class InitStateTests: XCTestCase { func testSeekCall() { // ACT - state.seek(position: 42) + state.seek(position: 42, isAccurate: false) // ASSERT Verify(contextDelegate, 1, .playerContext(unavailableActionReason: .value(.loadMediaFirst))) diff --git a/Tests/State/LoadedStateSpecs.swift b/Tests/State/LoadedStateSpecs.swift index cc6f276e..0e9b98b9 100644 --- a/Tests/State/LoadedStateSpecs.swift +++ b/Tests/State/LoadedStateSpecs.swift @@ -118,7 +118,7 @@ final class LoadedStateSpecs: QuickSpec { self.mockPlayer.seekCompletionHandlerReturn = true // ACT - self.loadedState.seek(position: 42) + self.loadedState.seek(position: 42, isAccurate: false) } it("should call seek on player") { @@ -140,7 +140,7 @@ final class LoadedStateSpecs: QuickSpec { self.mockPlayer.seekCompletionHandlerReturn = false // ACT - self.loadedState.seek(position: 42) + self.loadedState.seek(position: 42, isAccurate: false) } it("should call seek on player") { diff --git a/Tests/State/LoadingMediaStateSpecs.swift b/Tests/State/LoadingMediaStateSpecs.swift index 2e399f52..05358836 100644 --- a/Tests/State/LoadingMediaStateSpecs.swift +++ b/Tests/State/LoadingMediaStateSpecs.swift @@ -173,7 +173,7 @@ final class LoadingMediaStateTests: XCTestCase { func testSeekCall() { // ACT - state.seek(position: 42) + state.seek(position: 42, isAccurate: false) // ASSERT Verify(contextDelegate, .once, .playerContext(unavailableActionReason: .value(.waitLoadedMedia))) diff --git a/Tests/State/PausedStateSpecs.swift b/Tests/State/PausedStateSpecs.swift index 0ad50cb7..0136bae7 100644 --- a/Tests/State/PausedStateSpecs.swift +++ b/Tests/State/PausedStateSpecs.swift @@ -170,7 +170,7 @@ final class PausedStateSpecs: QuickSpec { self.mockPlayer.seekCompletionHandlerReturn = true // ACT - self.tested.seek(position: self.position.seconds) + self.tested.seek(position: self.position.seconds, isAccurate: false) } it("should call player seek") { @@ -203,7 +203,7 @@ final class PausedStateSpecs: QuickSpec { self.mockPlayer.seekCompletionHandlerReturn = false // ACT - self.tested.seek(position: self.position.seconds) + self.tested.seek(position: self.position.seconds, isAccurate: false) } it("should call player seek") { diff --git a/Tests/State/PlayingStateSpecs.swift b/Tests/State/PlayingStateSpecs.swift index c2d53cb0..8058440c 100644 --- a/Tests/State/PlayingStateSpecs.swift +++ b/Tests/State/PlayingStateSpecs.swift @@ -121,7 +121,7 @@ final class PlayingStateSpecs: QuickSpec { let position: Double = 42 // ACT - self.playingState.seek(position: position) + self.playingState.seek(position: position, isAccurate: false) // ASSERT expect(self.tested.state).to(beAnInstanceOf(BufferingState.self)) diff --git a/Tests/State/StoppedStateSpecs.swift b/Tests/State/StoppedStateSpecs.swift index ee074faf..3dc940b3 100644 --- a/Tests/State/StoppedStateSpecs.swift +++ b/Tests/State/StoppedStateSpecs.swift @@ -191,7 +191,7 @@ final class StoppedStateSpecs: QuickSpec { self.mockPlayer.seekCompletionHandlerReturn = true // ACT - self.tested.seek(position: self.position.seconds) + self.tested.seek(position: self.position.seconds, isAccurate: false) } it("should call player seek") { @@ -226,7 +226,7 @@ final class StoppedStateSpecs: QuickSpec { self.mockPlayer.seekCompletionHandlerReturn = false // ACT - self.tested.seek(position: self.position.seconds) + self.tested.seek(position: self.position.seconds, isAccurate: false) } it("should call player seek") { diff --git a/Tests/State/WaitingNetworkStateTests.swift b/Tests/State/WaitingNetworkStateTests.swift index d3a0e9d2..6caf64c6 100644 --- a/Tests/State/WaitingNetworkStateTests.swift +++ b/Tests/State/WaitingNetworkStateTests.swift @@ -82,7 +82,7 @@ final class WaitingNetworkStateTest: XCTestCase { func testSeek() { // ACT - state.seek(position: 0) + state.seek(position: 0, isAccurate: false) // ASSERT Verify(contextDelegate, 1, .playerContext(unavailableActionReason: .value(.waitEstablishedNetwork)))